This commit is contained in:
Andras Schmelczer 2026-04-28 22:20:31 +01:00
parent 1163da826e
commit 5776a37dc9
13 changed files with 652 additions and 181 deletions

View file

@ -91,6 +91,9 @@ import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-
import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test";
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";
export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-create-conflict": renameCreateConflictTest,
@ -203,5 +206,11 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
"remote-update-resurrects-deleted-doc": remoteUpdateResurrectsDeletedDocTest,
"local-update-survives-remote-rename":
localUpdateSurvivesRemoteRenameTest
localUpdateSurvivesRemoteRenameTest,
"merging-update-response-survives-user-rename":
mergingUpdateResponseSurvivesUserRenameTest,
"conflict-uuid-stash-cleared-after-rename-deconflict":
conflictUuidStashClearedAfterRenameDeconflictTest,
"catchup-create-and-update-not-skipped":
catchupCreateAndUpdateNotSkippedTest
};

View file

@ -0,0 +1,66 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const catchupCreateAndUpdateNotSkippedTest: TestDefinition = {
description:
"Client 1 disconnects (sync disabled). Client 0 creates a doc and " +
"then updates it. When Client 1 reconnects, the server's catch-up " +
"stream sends only the doc's *latest* version (the update), not the " +
"full history. Pre-fix the wire's `is_new_file` was set to " +
"`creation == latest_version`, so the catch-up flagged the doc as " +
"non-new even though Client 1 had never seen its creation. Client " +
"1's `processRemoteChange` then dropped it as a 'stale RemoteChange " +
"for untracked, non-new document' and the doc was silently lost. " +
"Post-fix `is_new_file` in the catch-up stream means 'new relative " +
"to the recipient's watermark' (`creation > last_seen_vault_update_id`).",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Establish a baseline so Client 1's last_seen is non-zero before
// we take it offline. This makes the bug genuinely about catch-up
// missing the create rather than just an empty-vault first sync.
{ type: "create", client: 0, path: "warmup.md", content: "w\n" },
{ type: "barrier" },
// Client 1 goes offline.
{ type: "disable-sync", client: 1 },
// Client 0 creates the doc (vault_update_id v_C, after Client 1's
// watermark). Client 1 doesn't see this because it's offline.
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
// Wait for the create's HTTP to land before the update; otherwise
// both writes are coalesced into a single POST and the server
// never sees the doc as "create followed by update".
{ type: "sync", client: 0 },
// Client 0 updates the doc (vault_update_id v_X > v_C). The
// server's `latest_document_versions` view now returns the
// *update* row — its `creation_vault_update_id != vault_update_id`.
{
type: "update",
client: 0,
path: "doc.md",
content: "v1\nupdate\n"
},
{ type: "sync", client: 0 },
// Client 1 reconnects. Server's catch-up replays docs with
// `vault_update_id > last_seen`. For doc.md it sends v_X with
// `is_new_file` derived from `creation_vault_update_id >
// last_seen_vault_update_id` (post-fix) — so Client 1 treats it
// as a fresh create and downloads the latest content.
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(2);
state.assertFileExists("doc.md");
state.assertContent("doc.md", "v1\nupdate\n");
state.assertContent("warmup.md", "w\n");
}
}
]
};

View file

@ -0,0 +1,100 @@
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");
}
}
]
};

View file

@ -0,0 +1,77 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mergingUpdateResponseSurvivesUserRenameTest: TestDefinition = {
description:
"Client 1 sends a content update with a stale `parent_version_id` " +
"(its WebSocket is paused, so it hasn't seen Client 0's intervening " +
"edit). The server merges and replies with `MergingUpdate` carrying " +
"the merged text. Before the response lands, the user renames the " +
"doc on Client 1, vacating the disk path the in-flight " +
"`processLocalUpdate` captured. Pre-fix: " +
"`handleMaybeMergingResponse`'s `operations.write(diskPath, …)` " +
"hits the `we wont recreate it` early-return inside `write`, " +
"silently dropping the server-merged content — Client 0's edit is " +
"lost on Client 1's disk, and Client 1's next local-update PUT " +
"(rebased on the now-untracked merged version) deletes Client 0's " +
"edit on the server too. Post-fix: the response is written to the " +
"doc's current tracked disk path, preserving both edits.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "doc.md", content: "0\n" },
{ type: "barrier" },
// Stop Client 1 from seeing Client 0's next edit, so its next
// outbound PUT carries a stale `parent_version_id` and the server
// is forced to merge.
{ type: "pause-websocket", client: 1 },
// Server now holds v_b = "0\nA\n". Client 1's tracked parent
// version stays at v_a = "0\n".
{ type: "update", client: 0, path: "doc.md", content: "0\nA\n" },
{ type: "sync", client: 0 },
// Pause the server. Subsequent HTTP PUTs from Client 1 buffer at
// the OS layer until resume. This guarantees the merge response
// for Client 1's update is still in flight when the rename below
// mutates `queue.documents`.
{ type: "pause-server" },
// Client 1 edits doc.md with "B". The drain pops the LocalUpdate,
// captures `diskPath = "doc.md"`, reads the file, and sends the
// HTTP PUT — which buffers because the server is SIGSTOPped.
{ type: "update", client: 1, path: "doc.md", content: "0\nB\n" },
// User renames the file while the previous PUT is still in flight.
// `queue.enqueue`'s rename branch updates `documents` to point at
// `renamed.md` synchronously, but `processLocalUpdate`'s captured
// `diskPath` ("doc.md") is a local — it can't be retargeted.
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "renamed.md" },
// Resume the server. It reconciles parent=v_a, latest=v_b,
// new="0\nB\n" → v_c with both edits, replies `MergingUpdate`.
// Pre-fix: write("doc.md", …) sees no file at that path
// (renamed.md now holds the data) and bails out without ever
// writing the merged bytes. Post-fix: the merged bytes land at
// the tracked path (renamed.md).
{ type: "resume-server" },
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(1);
state.assertFileExists("renamed.md");
state.assertFileNotExists("doc.md");
// Both edits survive: Client 0's "A" and Client 1's "B".
// The reconcile may interleave them either way; assert
// both tokens are present in the converged content.
state.assertContains("renamed.md", "A", "B");
}
}
]
};