vault-link/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts
Andras Schmelczer a33e4bbcb9 Add deterministic-tests workspace
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>
2026-05-08 22:11:16 +01:00

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);
}
}
]
};