This commit is contained in:
Andras Schmelczer 2026-04-29 19:51:49 +01:00
parent 0d9aebf900
commit 7198639db4
9 changed files with 636 additions and 252 deletions

View file

@ -94,6 +94,10 @@ import { localUpdateSurvivesRemoteRenameTest } from "./tests/local-update-surviv
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 { disableSyncMidCreateNoDuplicateTest } from "./tests/disable-sync-mid-create-no-duplicate.test";
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-create-conflict": renameCreateConflictTest,
@ -212,5 +216,13 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"conflict-uuid-stash-cleared-after-rename-deconflict":
conflictUuidStashClearedAfterRenameDeconflictTest,
"catchup-create-and-update-not-skipped":
catchupCreateAndUpdateNotSkippedTest
catchupCreateAndUpdateNotSkippedTest,
"local-rename-survives-remote-rename":
localRenameSurvivesRemoteRenameTest,
"rename-chain-during-pending-create":
renameChainDuringPendingCreateTest,
"disable-sync-mid-create-no-duplicate":
disableSyncMidCreateNoDuplicateTest,
"remote-rename-collides-with-pending-local-create":
remoteRenameCollidesWithPendingLocalCreateTest
};

View file

@ -0,0 +1,64 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const disableSyncMidCreateNoDuplicateTest: TestDefinition = {
description:
"Client 0 creates `doc.md`. While the create's HTTP roundtrip is in " +
"flight (server paused), the user disables sync. Pre-fix: " +
"`SyncClient.pause()` calls `fetchController.startReset()`, which " +
"rejects the in-flight fetch with `SyncResetError`. The server " +
"still commits the document, but the client never learns the " +
"docId. The user then renames the file offline (`doc.md` -> " +
"`renamed.md`) and re-enables sync. The offline scan sees " +
"`renamed.md` as an untracked new file (the create's record was " +
"never settled) and creates a SECOND document on the server with " +
"the same content. WS catch-up then downloads the orphaned first " +
"doc back to `doc.md`, leaving the client with two files holding " +
"identical content. Post-fix: `pause({abortInFlight: false})` " +
"lets the create's HTTP land, the docId is captured into " +
"`queue.documents`, and the offline scan recognises `renamed.md` " +
"as a rename of an already-tracked doc — only one server doc " +
"exists.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Pause the server so the upcoming create's HTTP buffers at the
// OS level. The fetch is in flight from the client's perspective
// — exactly the state where pre-fix `startReset` would discard
// its eventual response.
{ type: "pause-server" },
{ type: "create", client: 0, path: "doc.md", content: "marker\n" },
// Disable sync. Pre-fix: aborts the in-flight create with
// SyncResetError; the server commits but the client forgets.
// Post-fix: blocks until the in-flight HTTP lands; queue
// records the doc.
{ type: "resume-server" },
{ type: "disable-sync", client: 0 },
// User renames the file while offline.
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
// Re-enable sync. Post-fix: offline scan sees `renamed.md` as a
// rename of the tracked doc and PUTs a rename to the server.
// Pre-fix: scan sees `renamed.md` as new (queue.documents is
// empty for it) and CREATEs a second doc; WS catch-up later
// re-downloads the orphaned first doc to `doc.md`.
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(1);
state.assertFileExists("renamed.md");
state.assertFileNotExists("doc.md");
state.assertContent("renamed.md", "marker\n");
}
}
]
};

View file

@ -0,0 +1,80 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const localRenameSurvivesRemoteRenameTest: TestDefinition = {
description:
"Drain processes a RemoteChange (remote rename for doc D) while a " +
"LocalUpdate (user rename of D) is also queued behind it. " +
"`processRemoteUpdate` moves the disk file and, because there is a " +
"pending LocalUpdate, takes the else branch — but its setDocument " +
"uses the stale `record.path` (= the user-rename target) instead of " +
"the actualPath the file just moved to. The queued LocalUpdate then " +
"reads from `record.path`, throws FileNotFoundError, and is " +
"silently dropped. Setup pins the queue order: a sentinel " +
"LocalUpdate keeps drain busy on a SIGSTOPped HTTP roundtrip while " +
"we resume client 0's WebSocket (enqueues RemoteChange) and then " +
"user-rename D (enqueues LocalUpdate after the RemoteChange). On " +
"server resume the drain pops the sentinel, then RemoteChange, then " +
"LocalUpdate — exactly the order that triggers the bug.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
{ type: "create", client: 0, path: "sentinel.md", content: "s\n" },
{ type: "barrier" },
// Pause client 0's WebSocket so the upcoming remote rename buffers.
{ type: "pause-websocket", client: 0 },
// Server applies remote rename of doc.md -> remote.md. Broadcast
// is buffered on client 0's WebSocket.
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" },
{ type: "sync", client: 1 },
// Pause the server BEFORE arming the sentinel, so the sentinel's
// HTTP request will buffer at the kernel and keep drain occupied.
{ type: "pause-server" },
// Sentinel: a LocalUpdate on a *different* doc that drain pops
// first. Its HTTP roundtrip stalls on SIGSTOP, freezing drain
// until we resume the server. While drain is frozen we can grow
// the queue with additional events whose order we control.
{
type: "update",
client: 0,
path: "sentinel.md",
content: "s\nedit\n"
},
// Resume the WebSocket — buffered remote rename enqueues as a
// RemoteChange. Drain is still stuck on the sentinel HTTP.
{ type: "resume-websocket", client: 0 },
// User renames doc.md -> local.md on client 0. queue.enqueue
// mutates the doc's record.path to "local.md" and pushes a
// LocalUpdate(rename) onto the tail of the queue. Queue is now
// [sentinel-update (in-flight), RemoteChange, LocalUpdate-rename].
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "local.md" },
// Resume the server. Drain pops sentinel-update (succeeds), then
// RemoteChange. Pre-fix: processRemoteUpdate moves disk
// local.md -> remote.md, takes the else branch, and
// setDocument(record.path = "local.md", …) leaves record.path
// stale. Drain pops the LocalUpdate-rename and reads from the
// stale record.path, hits FileNotFoundError, silent skip.
// Post-fix: when a local event is pending, we re-queue the
// remote update without touching disk or record, so the local
// rename drains first and both ends converge.
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(2);
}
}
]
};

View file

@ -0,0 +1,72 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
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.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 1, path: "original.md", content: "v1\n" },
{ type: "barrier" },
// Pause client 0's WS so the upcoming remote rename buffers and
// we can stage a colliding local create before the rename
// drains on client 0.
{ type: "pause-websocket", client: 0 },
// Client 1 renames the doc. Server commits, broadcasts to
// client 0 (buffered).
{ 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
// creates a NEW file at `target.md` (an unrelated untracked
// doc). Disk on client 0 now has both `original.md` (the
// tracked doc) and `target.md` (the new untracked file).
{ 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`.
{ type: "resume-websocket", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(2);
for (const path of state.files.keys()) {
if (path.startsWith("conflict-")) {
throw new Error(
`Unexpected conflict-uuid stash on a converged client: ${path}`
);
}
}
state.assertFileExists("target.md");
state.assertContent("target.md", "v1\n");
// The local create gets server-deconflicted to a
// sibling path (e.g. `target (1).md`).
}
}
]
};

View file

@ -0,0 +1,54 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameChainDuringPendingCreateTest: TestDefinition = {
description:
"User creates a doc, then renames it twice while the LocalCreate's " +
"HTTP roundtrip is still in flight (server paused). Each rename " +
"pushes a LocalUpdate whose `documentId` is the create's Promise " +
"(see `pendingDocumentId` in `SyncEventQueue.enqueue`). After the " +
"create resolves, the first rename drains successfully and " +
"`setDocument` walks `events[]` to retarget queued LocalUpdates' " +
"`event.path` to the new disk location — but the comparison " +
"`e.documentId === record.documentId` mismatches the still-Promise " +
"references, so the second rename's `event.path` stays at the " +
"vacated previous slot. On the next drain step `skipIfOversized`'s " +
"`getFileSize(event.path)` throws FileNotFoundError, which " +
"`processEvent` swallows as 'Skipping sync event ... because the " +
"file no longer exists' — losing the user's final rename. " +
"Post-fix: `resolveCreate` (and the displacement-merge branch in " +
"`processCreate`) swap the Promise references for the resolved id " +
"before `setDocument` runs, so retarget works.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Pause the server so client 0's create stalls on the HTTP PUT
// while we queue rename events behind it.
{ 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" },
// Resume — drain pops LocalCreate (now resolves), then the two
// queued LocalUpdates. Pre-fix: only the first rename's
// file-system effect lands; the second is silently dropped.
// The server ends up with the doc at second.md, leaving
// client 0's local third.md untracked / out-of-sync.
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(1);
state.assertFileExists("third.md");
state.assertContent("third.md", "v1\n");
}
}
]
};