loop
This commit is contained in:
parent
0d9aebf900
commit
7198639db4
9 changed files with 636 additions and 252 deletions
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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`).
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue