actually works
Some checks failed
Check / build (pull_request) Failing after 7s
E2E tests / build (pull_request) Failing after 6s
Publish CLI / publish-docker (pull_request) Failing after 4m59s
Publish server Docker image / publish-docker (pull_request) Failing after 35m13s

This commit is contained in:
Andras Schmelczer 2026-05-08 16:40:32 +01:00
parent fb71622e40
commit f2337dbbd0
7 changed files with 238 additions and 8 deletions

View file

@ -105,6 +105,7 @@ import { queuedCreateDeleteDoesNotHijackReusedPathTest } from "./tests/queued-cr
import { renamedPendingCreateReusedPathThenDeleteTest } from "./tests/renamed-pending-create-reused-path-then-delete.test";
import { renamePendingCreateOntoPendingDeletePathTest } from "./tests/rename-pending-create-onto-pending-delete-path.test";
import { remoteQuickWriteRenameBeforeRecordTest } from "./tests/remote-quick-write-rename-before-record.test";
import { selfMergePendingRenameAliasesSecondCreateTest } from "./tests/self-merge-pending-rename-aliases-second-create.test";
export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-create-conflict": renameCreateConflictTest,
@ -242,5 +243,7 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"queued-create-delete-does-not-hijack-reused-path":
queuedCreateDeleteDoesNotHijackReusedPathTest,
"remote-quick-write-rename-before-record":
remoteQuickWriteRenameBeforeRecordTest
remoteQuickWriteRenameBeforeRecordTest,
"self-merge-pending-rename-aliases-second-create":
selfMergePendingRenameAliasesSecondCreateTest
};

View file

@ -0,0 +1,152 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const selfMergePendingRenameAliasesSecondCreateTest: TestDefinition = {
description:
"Single client makes two distinct creates that briefly share a path. " +
"Client 0 POSTs the first create at primary.md while the server is " +
"paused. While that POST is in flight: a second create is queued at " +
"staging.md, primary.md is renamed to moved.md (rewriting the in- " +
"flight create's event.path to moved.md and pushing a rename " +
"LocalUpdate at the queue tail), and staging.md is renamed onto the " +
"now-vacated primary.md slot (rewriting the second create's " +
"event.path to primary.md and pushing another rename LocalUpdate). " +
"Client 0's WS is paused throughout, so its watermark stays at 0. " +
"On resume the first POST commits Doc-X at primary.md (creation_vuid " +
"= N). The drain then processes the second LocalCreate (POST " +
"relativePath=primary.md, last_seen=0); the server's path-based " +
"dedup sees N > 0 and merges the second create into Doc-X " +
"(MergingUpdate). The buggy behaviour: processCreate's resolveCreate " +
"calls upsertRecord with localPath=primary.md, but the existing " +
"record (from the first create) already holds localPath=moved.md, " +
"and upsertRecord's `existing.localPath !== undefined` guard " +
"silently drops the new claim. The file at primary.md is left " +
"orphaned: tracked by no record, never broadcast, never deleted. " +
"After the user's renames the expected user-visible state is two " +
"distinct files at moved.md and primary.md — both clients must " +
"converge to that.",
clients: 2,
steps: [
// Both clients online so the WS connection is established before
// the test starts pausing things.
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Pause client 0's WS so its MinCovered watermark stays at 0
// through the whole bug sequence. The merge condition the
// server is going to fire is `creation_vuid > last_seen`; with
// a non-zero gap the same-device second create gets merged
// into the same-device first create.
{ type: "pause-websocket", client: 0 },
// Client 1 commits a doc to push the server's vuid above 0.
// Without this filler, Doc-X's create vuid could be 1 and
// client 0's last_seen.add(1) would advance min to 1, killing
// the watermark gap that triggers the merge.
{
type: "create",
client: 1,
path: "filler.md",
content: "filler-content "
},
{ type: "sync", client: 1 },
// Pause the server so client 0's first create POST hangs in
// flight, giving us a deterministic window in which to enqueue
// the second create and the renames.
{ type: "pause-server" },
// First create — Doc-X. The wire-loop drains it, captures
// requestPath = event.path = "primary.md", reads the bytes,
// sends the POST, and stalls on the response.
{
type: "create",
client: 0,
path: "primary.md",
content: "primary content "
},
// Make sure the POST is actually on the wire with
// relativePath="primary.md" before we rewrite event.path.
// Without this delay the rename can win the race, the POST
// goes out with relativePath="moved.md", and the server-side
// path-collision merge never fires.
{ type: "sleep", ms: 100 },
// Second create at a staging path. The wire-loop is still
// blocked on Doc-X's POST, so this LocalCreate just queues at
// index 1.
{
type: "create",
client: 0,
path: "staging.md",
content: "secondary content "
},
// Rename Doc-X's path. enqueue's pending-create branch
// rewrites Doc-X's event.path in place (moved.md) and pushes
// a LocalUpdate(rename, originalPath=moved.md) at the END of
// the queue. Note the ordering: this LocalUpdate is enqueued
// AFTER the staging LocalCreate above. That ordering is
// load-bearing — it is what makes the second create's POST
// drain (and trigger the server-side merge) before Doc-X's
// rename PUT moves the doc away from primary.md on the
// server.
{
type: "rename",
client: 0,
oldPath: "primary.md",
newPath: "moved.md"
},
// Rename the staging file onto Doc-X's now-vacated primary.md
// slot. enqueue rewrites the staging LocalCreate's event.path
// to primary.md and pushes a LocalUpdate(rename,
// originalPath=primary.md) at the queue tail. After this the
// disk has: moved.md = Doc-X's bytes, primary.md = Doc-Y's
// bytes.
{
type: "rename",
client: 0,
oldPath: "staging.md",
newPath: "primary.md"
},
// Let everything fly: server processes the queued POSTs;
// client 0 catches up on broadcasts.
{ type: "resume-server" },
{ type: "resume-websocket", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
// The user did two distinct creates (Doc-X and Doc-Y);
// both contents must survive on both clients.
state.assertFileCount(3);
state.assertFileExists("filler.md");
state.assertFileExists("moved.md");
state.assertFileExists("primary.md");
// After the renames the user expects:
// - moved.md = the file that was originally created
// at primary.md (Doc-X's content).
// - primary.md = the file that was originally created
// at staging.md (Doc-Y's content).
state.assertContains("moved.md", "primary content");
state.assertContains("primary.md", "secondary content");
// No content cross-contamination: each contribution
// should land in exactly one of the user-visible
// files. Under the bug, the orphan at primary.md
// carries Doc-X's content (because Doc-Y's PUT was
// aliased onto Doc-X's record and read Doc-X's bytes
// from moved.md), so this catches the leak too.
state.assertContentInAtMostOneFile("primary content");
state.assertContentInAtMostOneFile("secondary content");
}
}
]
};