eh
This commit is contained in:
parent
1163da826e
commit
5776a37dc9
13 changed files with 652 additions and 181 deletions
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue