Scripted multi-client harness against a real server (~110 scenario tests, server-control, managed-websocket, test-runner). Wires the new package into frontend/package.json workspaces and the lint script. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 lines
3.8 KiB
TypeScript
80 lines
3.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
]
|
|
};
|