claude
This commit is contained in:
parent
39c5591d36
commit
35877b69da
94 changed files with 3157 additions and 1859 deletions
|
|
@ -8,7 +8,9 @@ export function parseConcurrency(): number {
|
|||
i + 1 < args.length
|
||||
) {
|
||||
const n = parseInt(args[i + 1], 10);
|
||||
if (!isNaN(n) && n > 0) {return n;}
|
||||
if (!isNaN(n) && n > 0) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.cpus().length;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ export class ServerManager {
|
|||
}
|
||||
|
||||
public async stopAll(): Promise<void> {
|
||||
if (this.isShuttingDown) {return;}
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
this.isShuttingDown = true;
|
||||
|
||||
const servers = Array.from(this.activeServers);
|
||||
|
|
|
|||
|
|
@ -92,14 +92,12 @@ import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-ma
|
|||
import { remoteUpdateResurrectsDeletedDocTest } from "./tests/remote-update-resurrects-deleted-doc.test";
|
||||
import { localUpdateSurvivesRemoteRenameTest } from "./tests/local-update-survives-remote-rename.test";
|
||||
import { mergingUpdateResponseSurvivesUserRenameTest } from "./tests/merging-update-response-survives-user-rename.test";
|
||||
import { conflictUuidStashClearedAfterRenameDeconflictTest } from "./tests/conflict-uuid-stash-cleared-after-rename-deconflict.test";
|
||||
import { catchupCreateAndUpdateNotSkippedTest } from "./tests/catchup-create-and-update-not-skipped.test";
|
||||
import { localRenameSurvivesRemoteRenameTest } from "./tests/local-rename-survives-remote-rename.test";
|
||||
import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-pending-create.test";
|
||||
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
|
||||
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
|
||||
import { orphanStashOnCreateMergeTest } from "./tests/orphan-stash-on-create-merge.test";
|
||||
import { orphanStashOnCreateDedupeMergeTest } from "./tests/orphan-stash-on-create-dedupe-merge.test";
|
||||
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
|
||||
|
||||
export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||
"rename-create-conflict": renameCreateConflictTest,
|
||||
|
|
@ -210,25 +208,18 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
|||
"online-create-update-while-other-creates-same-path":
|
||||
onlineCreateUpdateWhileOtherCreatesSamePathTest,
|
||||
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
|
||||
"remote-update-resurrects-deleted-doc": remoteUpdateResurrectsDeletedDocTest,
|
||||
"local-update-survives-remote-rename":
|
||||
localUpdateSurvivesRemoteRenameTest,
|
||||
"remote-update-resurrects-deleted-doc":
|
||||
remoteUpdateResurrectsDeletedDocTest,
|
||||
"local-update-survives-remote-rename": localUpdateSurvivesRemoteRenameTest,
|
||||
"merging-update-response-survives-user-rename":
|
||||
mergingUpdateResponseSurvivesUserRenameTest,
|
||||
"conflict-uuid-stash-cleared-after-rename-deconflict":
|
||||
conflictUuidStashClearedAfterRenameDeconflictTest,
|
||||
"catchup-create-and-update-not-skipped":
|
||||
catchupCreateAndUpdateNotSkippedTest,
|
||||
"local-rename-survives-remote-rename":
|
||||
localRenameSurvivesRemoteRenameTest,
|
||||
"rename-chain-during-pending-create":
|
||||
renameChainDuringPendingCreateTest,
|
||||
"local-rename-survives-remote-rename": localRenameSurvivesRemoteRenameTest,
|
||||
"rename-chain-during-pending-create": renameChainDuringPendingCreateTest,
|
||||
"remote-rename-collides-with-pending-local-create":
|
||||
remoteRenameCollidesWithPendingLocalCreateTest,
|
||||
"remote-update-survives-user-rename":
|
||||
remoteUpdateSurvivesUserRenameTest,
|
||||
"orphan-stash-on-create-merge":
|
||||
orphanStashOnCreateMergeTest,
|
||||
"orphan-stash-on-create-dedupe-merge":
|
||||
orphanStashOnCreateDedupeMergeTest
|
||||
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
|
||||
"same-doc-id-collapse-on-local-create-after-remote-create":
|
||||
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { TestDefinition, TestResult, TestStep } from "./test-definition";
|
|||
import { DeterministicAgent } from "./deterministic-agent";
|
||||
import type { ServerControl } from "./server-control";
|
||||
import type { SyncSettings, Logger } from "sync-client";
|
||||
import { CONFLICT_PATH_REGEX } from "sync-client";
|
||||
import { assert } from "./utils/assert";
|
||||
import { AssertableState } from "./utils/assertable-state";
|
||||
import { sleep } from "./utils/sleep";
|
||||
|
|
@ -15,10 +14,6 @@ import {
|
|||
} from "./consts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
class ConflictFilesDetectedError extends Error {
|
||||
public override readonly name = "ConflictFilesDetectedError";
|
||||
}
|
||||
|
||||
export class TestRunner {
|
||||
private agents: DeterministicAgent[] = [];
|
||||
private readonly serverControl: ServerControl;
|
||||
|
|
@ -236,9 +231,6 @@ export class TestRunner {
|
|||
this.logger.info("Barrier complete: all clients converged");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictFilesDetectedError) {
|
||||
throw error;
|
||||
}
|
||||
lastError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.info("Barrier: not yet converged, retrying...");
|
||||
|
|
@ -304,25 +296,6 @@ export class TestRunner {
|
|||
clientFiles.push(fileMap);
|
||||
}
|
||||
|
||||
const conflictsByClient = clientFiles.map((files) =>
|
||||
Array.from(files.keys()).filter((path) =>
|
||||
CONFLICT_PATH_REGEX.test(path)
|
||||
)
|
||||
);
|
||||
if (conflictsByClient.some((conflicts) => conflicts.length > 0)) {
|
||||
const summary = conflictsByClient
|
||||
.map((conflicts, i) =>
|
||||
conflicts.length > 0
|
||||
? `client ${i}: [${conflicts.join(", ")}]`
|
||||
: null
|
||||
)
|
||||
.filter((s): s is string => s !== null)
|
||||
.join("; ");
|
||||
throw new ConflictFilesDetectedError(
|
||||
`Found local conflict file(s): ${summary}`
|
||||
);
|
||||
}
|
||||
|
||||
const referenceFiles = Array.from(clientFiles[0].keys());
|
||||
|
||||
this.logger.info(
|
||||
|
|
|
|||
|
|
@ -40,13 +40,12 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
|||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileNotExists("X.md")
|
||||
.assertFileExists(
|
||||
"Y.md",
|
||||
)
|
||||
.assertFileExists(
|
||||
"Y (1).md",
|
||||
)
|
||||
.assertAnyFileContains("original file X", "brand new Y content")
|
||||
.assertFileExists("Y.md")
|
||||
.assertFileExists("Y (1).md")
|
||||
.assertAnyFileContains(
|
||||
"original file X",
|
||||
"brand new Y content"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -51,8 +51,10 @@ export const concurrentRenameFirstWinsTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md").
|
||||
assertFileCount(2).assertContent("B.md", "edit from 0\nline 2\nline 3").assertContent("C.md", "line 1\nline 2\nedit from 1");
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(2)
|
||||
.assertContent("B.md", "edit from 0\nline 2\nline 3")
|
||||
.assertContent("C.md", "line 1\nline 2\nedit from 1");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const conflictUuidStashClearedAfterRenameDeconflictTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"A `RemoteChange` for a brand-new doc D2 at `target.md` reaches " +
|
||||
"Client 1's queue *before* Client 1's user-rename of D1 → " +
|
||||
"`target.md`. The rename's `queue.enqueue` mutates " +
|
||||
"`documents` synchronously, so by the time the drain processes " +
|
||||
"the buffered broadcast, `target.md` is already tracked by D1 " +
|
||||
"with a high `parentVersionId`. " +
|
||||
"`processRemoteCreateForNewDocument`'s version comparison " +
|
||||
"(`parentVersionId < remoteVaultUpdateId`) takes the " +
|
||||
"`MoveOnConflict.NEW` branch and stashes D2 at " +
|
||||
"`conflict-<uuid>-target.md`. The rename's `LocalUpdate` then " +
|
||||
"drains, the server deconflicts D1 to `target (1).md`, freeing " +
|
||||
"the `target.md` slot locally — but D2 is left orphaned at the " +
|
||||
"`conflict-<uuid>-` path forever, diverging from Client 0 which " +
|
||||
"has D2 at `target.md`.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
// Both clients have D1 at `original.md`.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "original.md",
|
||||
content: "D1 v1\n"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
// Buffer Client 1's WebSocket so D2's broadcast doesn't land
|
||||
// until we're ready to enqueue it ahead of the rename.
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
// Client 0 creates D2 at target.md. Server stores it; broadcast
|
||||
// is buffered at Client 1.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "target.md",
|
||||
content: "D2 v1\n"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Pause the server. Now Client 1's next HTTP PUT will buffer in
|
||||
// TCP and the drain will sit on `await sendUpdate`.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Issue an update to D1. The drain pops the LocalUpdate and
|
||||
// suspends on the HTTP PUT (server is SIGSTOPped). The drain is
|
||||
// now busy and won't pop further events until resume-server.
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "original.md",
|
||||
content: "D1 v2\n"
|
||||
},
|
||||
|
||||
// Replay the buffered D2 broadcast. It enqueues as a
|
||||
// RemoteChange BEHIND the in-flight LocalUpdate but AHEAD of
|
||||
// the rename event we're about to push.
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
// User renames D1 onto target.md. `queue.enqueue` synchronously
|
||||
// updates `documents` so target.md → D1. The rename's
|
||||
// LocalUpdate is pushed to the END of the queue, *after* the
|
||||
// buffered RemoteChange.
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "original.md",
|
||||
newPath: "target.md"
|
||||
},
|
||||
|
||||
// Resume the server. Drain order: (1) finish the v2 update PUT
|
||||
// → D1.parentVersionId bumps above D2's vaultUpdateId. (2)
|
||||
// process the RemoteChange for D2 — sees `documents.get(target.md)
|
||||
// = D1` with parentVersionId > vaultUpdateId → MoveOnConflict.NEW
|
||||
// → stashes D2 at `conflict-<uuid>-target.md`. (3) process the
|
||||
// rename's LocalUpdate — server deconflicts to target (1).md;
|
||||
// local file moves there.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
state.assertFileExists("target.md");
|
||||
state.assertFileExists("target (1).md");
|
||||
state.assertContent("target.md", "D2 v1\n");
|
||||
state.assertContent("target (1).md", "D1 v2\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -62,10 +62,7 @@ export const localUpdateSurvivesRemoteRenameTest: TestDefinition = {
|
|||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("renamed.md");
|
||||
state.assertContent(
|
||||
"renamed.md",
|
||||
"v1\nclient 0 edit\n"
|
||||
);
|
||||
state.assertContent("renamed.md", "v1\nclient 0 edit\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("renamed.md", "updated by client 1");
|
||||
s.assertFileCount(1).assertContent(
|
||||
"renamed.md",
|
||||
"updated by client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s
|
||||
.assertFileCount(0);
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const orphanStashOnCreateDedupeMergeTest: TestDefinition = {
|
||||
description:
|
||||
"When the server's create endpoint dedupe-merges a client's local " +
|
||||
"create into an existing fresh remote doc that the client has " +
|
||||
"already tracked at a `conflict-<uuid>-` stash (because the " +
|
||||
"remote create's broadcast displaced its content there), " +
|
||||
"`processCreate`'s response handler relocates the doc's record " +
|
||||
"onto the canonical path via `setDocument` but the stash file on " +
|
||||
"disk is left behind — outliving its tracking record and " +
|
||||
"diverging from every other client. Reproducing the merge half " +
|
||||
"of the dedupe is delicate: the server's merge gate requires the " +
|
||||
"POST's `last_seen_vault_update_id` to be *strictly less than* " +
|
||||
"the existing doc's `creation_vault_update_id`. A normal sync " +
|
||||
"advances the watermark contiguously, so on the canonical " +
|
||||
"create-vs-create race the watermark would already include the " +
|
||||
"remote doc's create when the local POST ships, the merge gate " +
|
||||
"fails, and the server deconflicts to `(1)`. This test pokes a " +
|
||||
"permanent gap into the watermark via the catch-up replay's " +
|
||||
"latest-only semantics: a tempdoc created and deleted while " +
|
||||
"Client 0 is offline lives in catch-up only as the delete event, " +
|
||||
"which the client processes (advancing the watermark to the " +
|
||||
"delete) without ever filling the create's update id — so the " +
|
||||
"watermark's contiguous-prefix min stays below the next doc's " +
|
||||
"creation. With that gap in place, Client 0's post-displacement " +
|
||||
"POST satisfies the server's merge gate, the server returns the " +
|
||||
"existing docId, and `processCreate` walks straight into the " +
|
||||
"orphan-stash bug. Pre-fix: `Files from agent-0 missing in " +
|
||||
"agent-1` (the `conflict-<uuid>-` stash). Post-fix: cleaned up.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline. Its watermark is saved at v=0.
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Tempdoc that lives only as a delete in Client 0's catch-up
|
||||
// (the create at v=1 is collapsed away by latest-only replay).
|
||||
// The processed delete advances the watermark to v=2 but leaves
|
||||
// v=1 unfilled, parking the contiguous-prefix min at 0.
|
||||
{ type: "create", client: 1, path: "tempdoc.md", content: "x\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "delete", client: 1, path: "tempdoc.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// The doc whose dedupe-merge we want to trigger — fresh
|
||||
// (creation == latest), mergeable text. Its creation v=3 is
|
||||
// strictly greater than Client 0's stuck min of 0, so the
|
||||
// server's merge gate will fire.
|
||||
{ type: "create", client: 1, path: "file.md", content: "from-1\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Re-arm the WS pause for the new socket Client 0 is about to
|
||||
// create on enable-sync, so the catch-up broadcast is buffered
|
||||
// until we explicitly release it. Without sticky pause across
|
||||
// factory `constructorFn` calls this would silently miss the
|
||||
// catch-up.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
// Server pause arrives before the buffered catch-up is released
|
||||
// so the resume below parks Client 0's drain on the GET for
|
||||
// file.md's content (the only fetching event in the catch-up;
|
||||
// the tempdoc delete needs no fetch and runs through quickly,
|
||||
// leaving the watermark gap intact).
|
||||
{ type: "pause-server" },
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// Yield so the drain has time to traverse the WS handler →
|
||||
// listener → enqueue → drain → processRemoteCreateForNewDocument
|
||||
// → fetch hops before the local create runs.
|
||||
{ type: "sleep", ms: 100 },
|
||||
|
||||
// Client 0 creates file.md locally while the GET is parked. The
|
||||
// file occupies the canonical slot, so when the GET returns the
|
||||
// remote create displaces D's bytes to `conflict-<uuid>-file.md`
|
||||
// and tracks D there with `intendedPath=file.md`. The
|
||||
// LocalCreate enqueues behind the in-flight RemoteChange.
|
||||
{ type: "create", client: 0, path: "file.md", content: "from-0\n" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
s.assertFileExists("file.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const orphanStashOnCreateMergeTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 creates file.md (server doc D). Client 0's WebSocket is " +
|
||||
"paused, so the broadcast is buffered. The server is paused, then " +
|
||||
"the WebSocket released — Client 0 enters " +
|
||||
"`processRemoteCreateForNewDocument` and parks on the GET for D's " +
|
||||
"content. While parked, Client 0 creates file.md locally. The GET " +
|
||||
"returns and the remote create displaces to " +
|
||||
"`conflict-<uuid>-file.md` (slot occupied), tracking D there with " +
|
||||
"`intendedPath=file.md`. Client 0's LocalCreate POST then drains " +
|
||||
"and the server deconflicts (because Client 0's lastSeenVaultUpdateId " +
|
||||
"now equals D's creation, so the merge condition fails) — creating " +
|
||||
"a sibling doc D' at `file (1).md`. The convergence path then " +
|
||||
"needs `unwindReadyStashes` to slide D off the conflict-uuid stash " +
|
||||
"back to file.md once Client 0's local file moves to file (1).md, " +
|
||||
"leaving both clients with [file.md, file (1).md]. Documents the " +
|
||||
"displacement-then-deconflict-then-unwind path the fix to " +
|
||||
"`processCreate`'s same-docId orphan cleanup must not regress.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
{ type: "create", client: 1, path: "file.md", content: "from-1\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// Yield long enough for the drain to traverse all the microtask
|
||||
// hops between the WS handler and the GET, so the request is
|
||||
// queued at the (paused) server before the local create runs.
|
||||
{ type: "sleep", ms: 50 },
|
||||
|
||||
{ type: "create", client: 0, path: "file.md", content: "from-0\n" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2);
|
||||
s.assertFileExists("file.md");
|
||||
s.assertFileExists("file (1).md");
|
||||
s.assertAnyFileContains("from-0");
|
||||
s.assertAnyFileContains("from-1");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -2,23 +2,23 @@ import type { AssertableState } from "../utils/assertable-state";
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
||||
// TODO(refactor): the failure mode described below is the
|
||||
// pre-refactor "deflect-to-conflict-uuid" path that no longer
|
||||
// exists. Under the new model the wire loop never moves files for
|
||||
// path placement, so the remote rename can't deflect anywhere; the
|
||||
// reconciler waits for the slot to free. Convergence assertion is
|
||||
// still valid (no conflict-uuid stashes, both files present, the
|
||||
// local create lands at a server-deconflicted sibling).
|
||||
description:
|
||||
"Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " +
|
||||
"and renames it to `target.md` server-side. Before client 0's " +
|
||||
"drain processes the WS broadcast for E, the user creates a new " +
|
||||
"local file `target.md` (a different doc, untracked). When the " +
|
||||
"buffered RemoteChange for E drains, `processRemoteUpdate` " +
|
||||
"tries to move client 0's tracked file from its old slot onto " +
|
||||
"`target.md`. Pre-fix: `MoveOnConflict.NEW` deflects the remote " +
|
||||
"rename to a `conflict-<uuid>-target.md` stash on client 0, " +
|
||||
"leaving a permanent local-only divergence (client 1 has no " +
|
||||
"such stash). Post-fix: when the slot is held by a non-tracked " +
|
||||
"file (typically the agent's own pending LocalCreate), " +
|
||||
"`processRemoteUpdate` uses `MoveOnConflict.EXISTING` to " +
|
||||
"displace it; `updatePendingCreatePath` retargets the displaced " +
|
||||
"create's `event.path`, so its drain reads the file from the " +
|
||||
"new location and the server's deconflict on its create lands " +
|
||||
"the new doc at a clean path.",
|
||||
"buffered RemoteChange for E drains, the engine has to reconcile " +
|
||||
"doc E onto `target.md` even though the slot is held by client " +
|
||||
"0's pending LocalCreate. Convergence requires both clients end " +
|
||||
"up with [target.md = E] and the local create lands at a " +
|
||||
"server-deconflicted sibling (e.g. `target (1).md`).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
|
@ -34,7 +34,12 @@ export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
|||
|
||||
// Client 1 renames the doc. Server commits, broadcasts to
|
||||
// client 0 (buffered).
|
||||
{ type: "rename", client: 1, oldPath: "original.md", newPath: "target.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "original.md",
|
||||
newPath: "target.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 still believes the doc is at `original.md`. The user
|
||||
|
|
@ -44,9 +49,8 @@ export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
|||
{ type: "create", client: 0, path: "target.md", content: "extra\n" },
|
||||
|
||||
// Resume client 0's WS. The buffered RemoteChange drains.
|
||||
// Pre-fix: `MoveOnConflict.NEW` deflects the rename of the
|
||||
// tracked doc into `conflict-<uuid>-target.md`, with
|
||||
// `intendedPath=target.md`.
|
||||
// The reconciler must converge without ever leaving a
|
||||
// conflict-uuid stash on disk.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
|
|
|||
|
|
@ -30,8 +30,18 @@ export const renameChainDuringPendingCreateTest: TestDefinition = {
|
|||
{ type: "pause-server" },
|
||||
|
||||
{ type: "create", client: 0, path: "first.md", content: "v1\n" },
|
||||
{ type: "rename", client: 0, oldPath: "first.md", newPath: "second.md" },
|
||||
{ type: "rename", client: 0, oldPath: "second.md", newPath: "third.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "first.md",
|
||||
newPath: "second.md"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "second.md",
|
||||
newPath: "third.md"
|
||||
},
|
||||
|
||||
// Resume — drain pops LocalCreate (now resolves), then the two
|
||||
// queued LocalUpdates. Pre-fix: only the first rename's
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ export const renameCreateConflictTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2).assertContent("B.md", "hi").assertContent("B (1).md", "hi");
|
||||
s.assertFileCount(2)
|
||||
.assertContent("B.md", "hi")
|
||||
.assertContent("B (1).md", "hi");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"Client B creates X with content C2; the server commits and " +
|
||||
"broadcasts. Client A's WS is paused so the RemoteCreate buffers. " +
|
||||
"Server is then paused so A's about-to-POST LocalCreate will " +
|
||||
"hang. A creates X with content C1: file lands on disk, " +
|
||||
"LocalCreate enqueues, drain starts the POST, the POST stalls " +
|
||||
"at the paused server. A's WS is resumed: the buffered " +
|
||||
"RemoteCreate for doc-X is delivered to A and enqueues behind " +
|
||||
"the in-flight LocalCreate. Per the lazy-paths model, when " +
|
||||
"the RemoteCreate is processed it observes that path X is " +
|
||||
"occupied locally by A's pending-create bytes, so it tracks " +
|
||||
"doc-X with `localPath = undefined` / `remoteRelativePath = " +
|
||||
"X` and does NOT fetch content. The server is then resumed: " +
|
||||
"A's LocalCreate POST returns. The server, finding X already " +
|
||||
"taken by doc-X, replies with doc-X's existing documentId " +
|
||||
"(typically a MergingUpdate carrying the merged bytes). A's " +
|
||||
"processCreate handler detects that response.documentId " +
|
||||
"matches the no-localPath record built from the RemoteCreate " +
|
||||
"and collapses the two: it sets localPath = X on that " +
|
||||
"record, writes the merged bytes, and resolves the pending " +
|
||||
"create promise. Final state: exactly one file at X on both " +
|
||||
"clients, both pointing at doc-X's documentId, content " +
|
||||
"carrying both contributions, and no conflict-<uuid>- " +
|
||||
"stash anywhere.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Buffer broadcasts to client 0 (A) so client 1's create
|
||||
// doesn't reach A's WS handler until we say so.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Client 1 (B) commits doc-X at path X with content C2.
|
||||
// The server commits, broadcasts (broadcast queued at A's
|
||||
// paused WS).
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "from-client-1 "
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Pause the server so A's upcoming LocalCreate POST hangs.
|
||||
// This holds A's drain on the in-flight POST while we
|
||||
// release the WS so the RemoteCreate enqueues behind it.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 (A) creates X locally with content C1. The
|
||||
// file lands on A's disk; LocalCreate enqueues; drain
|
||||
// starts the POST; POST stalls at the paused server.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "from-client-0 "
|
||||
},
|
||||
|
||||
// Release A's WS. The buffered RemoteCreate for doc-X is
|
||||
// delivered to A and enqueues behind the in-flight
|
||||
// LocalCreate. Whichever of (RemoteCreate processed first
|
||||
// → no-localPath record, then LocalCreate POST returns
|
||||
// with merging response that collapses) or (LocalCreate
|
||||
// POST returns first with merging response that creates
|
||||
// the canonical record, then RemoteCreate finds the doc
|
||||
// already tracked by id and no-ops) actually plays out
|
||||
// depends on the fine-grained interleaving the runtime
|
||||
// produces, but both paths are required to converge to
|
||||
// the same single-record same-docId state.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// Resume the server: A's LocalCreate POST completes.
|
||||
// Server returns doc-X's existing documentId (MergingUpdate
|
||||
// with merged content). processCreate runs the collapse
|
||||
// path.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("X.md");
|
||||
// Server-side merge of the two text creates must
|
||||
// carry both contributions through to the
|
||||
// converged file.
|
||||
state.assertContains(
|
||||
"X.md",
|
||||
"from-client-0",
|
||||
"from-client-1"
|
||||
);
|
||||
// The lazy-paths collapse path must not leave a
|
||||
// conflict-<uuid>- stash on either client.
|
||||
for (const path of state.files.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a converged client: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const perClient of state.clientFiles) {
|
||||
for (const path of perClient.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a per-client view: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue