feat(drift-trio): phase C — scenarios S2–S8 + re-resolve-on-fiber hooks

drift-trio-scenarios.spec.ts: disjoint ping-pong, same-item interleave (no
settle between bursts), conflict pairs (move-vs-delete / value-vs-value /
move-vs-move — winner is CRDT policy, asserted only as convergence + drift
silence), undo storm, 12-edit burst churn, late-joiner adopt, and Ctrl+S mid
peer burst; per-tool adapters, marker-waits before every sweep (finding #7).
S4 exposed finding #9: mutation hooks resolved item pointers at call time and
committed later on the fiber — a remote remove in between frees the pointer
(doApplyItems) and the commit resurrects the deleted item. Phase-B mutation
hooks now re-resolve by uuid ON the fiber; a vanished item makes the mutation
lose silently. applyDeltaToY's concurrent update/delete verified coherent
(full resurrect or full remove) — no shared change needed. trio.ts: TabSet
oracles + exported startV2 for duo/late-join composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5cAM9M6q34n5X4dbrfVvi
This commit is contained in:
Gergő Törcsvári 2026-07-21 11:02:41 +02:00
commit 23b0f43e73
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
4 changed files with 456 additions and 31 deletions

View file

@ -0,0 +1,390 @@
import { execSync } from "node:child_process";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import {
TRIO_PCB,
TRIO_SCH,
type TabSet,
type ToolCfg,
type Trio,
SYM1,
FP1,
VIA1,
WIRE1,
bootOpen,
callHook,
closeTrio,
getPos,
hasAbort,
modelText,
openTrio,
oracleSweep,
settleConverged,
startV2,
undoDepth,
} from "./utils/trio";
/**
* Drift trio harness scenarios S2S8 (standalone-hardening 0008 §6, phase C).
*
* S1 (per-action baseline + full catalogs) lives in drift-trio.spec.ts; this
* file drives the CONCURRENT shapes: interleaved bursts with no settle between
* them, conflicting edits on the same item, undo storms, churn, late joiners,
* and save interplay. Assertions for conflicts are convergence + drift silence
* the winner is whatever the CRDT resolves, never a specific outcome.
*
* Concurrency pattern: fire both actors' sequences via Promise.all (each
* actor's hooks run sequentially on its own tab), then wait for MARKER content
* to land on every tab before settling settleConverged alone can pass on the
* pre-action state (finding #7), and in concurrent scenarios there is no
* single actor save to gate on.
*/
test.beforeAll(() => {
execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" });
});
function skipFirefox(): void {
test.skip(
test.info().project.name.includes("firefox"),
"three heavy wasm tabs exceed Firefox's per-process wasm budget",
);
}
/** Poll until every tab's silent save contains `marker`. */
async function waitAllContain(set: TabSet, cfg: ToolCfg, marker: string, timeout = 30000): Promise<void> {
for (const [label, page] of set.tabs) {
await expect
.poll(async () => (await modelText(page, cfg)).includes(marker), {
timeout,
intervals: [400],
message: `${label} must receive "${marker}"`,
})
.toBe(true);
}
}
/** Per-tool adapter for the scenario scripts. */
interface ToolOps {
cfg: ToolCfg;
label: string;
/** The symbol / footprint — the property-carrying primary item. */
primary: string;
/** A second fixture item (wire / via) for disjoint + move-move conflicts. */
secondary: string;
mm: number; // IU per mm
move(page: Page, uuid: string, dxIU: number): Promise<boolean>;
setValue(page: Page, text: string): Promise<boolean>;
/** Add a text-ish marker item carrying `text`; returns its uuid. */
addMarker(page: Page, text: string, slot: number): Promise<string>;
}
const SCH_OPS: ToolOps = {
cfg: TRIO_SCH,
label: "eeschema",
primary: SYM1,
secondary: WIRE1,
mm: 10000,
move: (p, uuid, dx) => callHook<boolean>(p, "kicadCollabTestMoveSchItem", uuid, dx, 0),
setValue: (p, text) => callHook<boolean>(p, "kicadCollabTestSetFieldText", SYM1, text),
addMarker: (p, text, slot) =>
callHook<string>(p, "kicadCollabTestAddLabel", "label", text, 400000 + slot * 60000, 400000),
};
const PCB_OPS: ToolOps = {
cfg: TRIO_PCB,
label: "pcbnew",
primary: FP1,
secondary: VIA1,
mm: 1000000,
move: (p, uuid, dx) => callHook<boolean>(p, "kicadCollabTestMoveBoardItem", uuid, dx, 0),
setValue: (p, text) => callHook<boolean>(p, "kicadCollabTestSetFootprintField", FP1, "Value", text),
addMarker: (p, text, slot) =>
callHook<string>(p, "kicadCollabTestAddBoardText", text, 20000000 + slot * 15000000, 130000000, "F.SilkS"),
};
for (const ops of [SCH_OPS, PCB_OPS]) {
const { cfg, label } = ops;
test.describe(`drift trio scenarios — ${label}`, () => {
test.describe.configure({ timeout: 900000 });
// ── S2: disjoint ping-pong ───────────────────────────────────────────────
test(`${label} S2: A and B alternate disjoint edits, 3 rounds`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s2-${label}-${test.info().workerIndex}`);
for (let round = 0; round < 3; round++) {
await test.step(`round ${round + 1}`, async () => {
// Landed-gate per actor (finding #7), then settle covers cross-tab.
const beforeA = await getPos(trio.A, ops.primary);
const beforeB = await getPos(trio.B, ops.secondary);
await Promise.all([
ops.move(trio.A, ops.primary, ops.mm),
ops.move(trio.B, ops.secondary, ops.mm),
]);
await expect
.poll(() => getPos(trio.A, ops.primary), { timeout: 15000, intervals: [300] })
.not.toBe(beforeA);
await expect
.poll(() => getPos(trio.B, ops.secondary), { timeout: 15000, intervals: [300] })
.not.toBe(beforeB);
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
});
}
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S3: same-item interleave (the 0008 headline scenario) ────────────────
test(`${label} S3: A creates an item; B moves+renames it while A keeps editing around it`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s3-${label}-${test.info().workerIndex}`);
// A creates the contested item and everyone sees it.
let target: string;
if (label === "eeschema") {
target = await callHook<string>(trio.A, "kicadCollabTestAddSymbol", "Device:R", 800000, 800000, "R9");
} else {
target = await callHook<string>(trio.A, "kicadCollabTestDuplicateBoardItem", FP1, 15000000, 0);
}
expect(target).toMatch(/[0-9a-f-]{36}/);
await waitAllContain(trio, cfg, target);
// Interleaved bursts, NO settle in between: B moves the new item and
// renames the primary's value while A wires/tracks around the new item.
await Promise.all([
(async () => {
if (label === "eeschema") {
await callHook<string>(trio.A, "kicadCollabTestAddWire", 760000, 800000, 800000, 800000);
await callHook<string>(trio.A, "kicadCollabTestAddWire", 800000, 838100, 800000, 880000);
} else {
await callHook<string>(trio.A, "kicadCollabTestAddTrack", 110000000, 100000000, 115000000, 100000000, 300000, "F.Cu");
await callHook<string>(trio.A, "kicadCollabTestAddVia", 113000000, 100000000, 800000, 400000);
}
})(),
(async () => {
await ops.move(trio.B, target, 2 * ops.mm);
await ops.setValue(trio.B, "s3-renamed");
})(),
]);
// Marker-wait on the rename (last B op) + settle covers A's adds.
await waitAllContain(trio, cfg, "s3-renamed");
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S4: conflict pairs on the SAME item ──────────────────────────────────
test(`${label} S4: move-vs-delete, value-vs-value, move-vs-move`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s4-${label}-${test.info().workerIndex}`);
// Victim for move-vs-delete: a marker item A adds and everyone holds.
const victim = await ops.addMarker(trio.A, "s4-victim", 0);
expect(victim).toMatch(/[0-9a-f-]{36}/);
await waitAllContain(trio, cfg, victim);
await test.step("move-vs-delete", async () => {
// Winner is CRDT policy, not asserted; both hooks may race the item
// away from under each other, so their return values are not asserted
// either — convergence + silence is the contract.
await Promise.all([
ops.move(trio.A, victim, ops.mm),
callHook<boolean>(trio.B, "kicadCollabTestRemoveItem", victim),
]);
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
});
await test.step("value-vs-value", async () => {
await Promise.all([ops.setValue(trio.A, "s4-from-A"), ops.setValue(trio.B, "s4-from-B")]);
// One of the two values won everywhere; poll until every tab carries
// SOME s4 value, then settle on byte equality.
for (const [tabLabel, p] of trio.tabs) {
await expect
.poll(async () => /s4-from-[AB]/.test(await modelText(p, cfg)), {
timeout: 20000,
intervals: [400],
message: `${tabLabel} must receive one of the racing values`,
})
.toBe(true);
}
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
});
await test.step("move-vs-move", async () => {
await Promise.all([
ops.move(trio.A, ops.secondary, 2 * ops.mm),
ops.move(trio.B, ops.secondary, -2 * ops.mm),
]);
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
});
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S5: undo storm ───────────────────────────────────────────────────────
test(`${label} S5: A undoes its own ops while B keeps editing`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s5-${label}-${test.info().workerIndex}`);
// A's undoable ops, settled so peers hold them.
expect(await ops.move(trio.A, ops.primary, ops.mm)).toBe(true);
expect(await ops.setValue(trio.A, "s5-tmp")).toBe(true);
await waitAllContain(trio, cfg, "s5-tmp");
await settleConverged(trio, cfg);
// A undoes both while B lands fresh edits.
await Promise.all([
(async () => {
expect(await callHook<boolean>(trio.A, "kicadCollabTestUndo")).toBe(true);
expect(await callHook<boolean>(trio.A, "kicadCollabTestUndo")).toBe(true);
})(),
(async () => {
await ops.move(trio.B, ops.secondary, ops.mm);
await ops.addMarker(trio.B, "s5-b-survives", 1);
})(),
]);
// B's edits survive A's undos; the undone value is gone everywhere.
await waitAllContain(trio, cfg, "s5-b-survives");
for (const [tabLabel, p] of trio.tabs) {
await expect
.poll(async () => (await modelText(p, cfg)).includes("s5-tmp"), {
timeout: 20000,
intervals: [400],
message: `${tabLabel} must lose the undone value`,
})
.toBe(false);
}
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
expect(await undoDepth(trio.C), "observer undo stack").toBe(0);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S6: burst churn ──────────────────────────────────────────────────────
test(`${label} S6: 12-edit bursts from both sides, one settle at the end`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s6-${label}-${test.info().workerIndex}`);
await Promise.all([
(async () => {
for (let i = 0; i < 12; i++) await ops.move(trio.A, ops.primary, Math.round(ops.mm / 5));
await ops.addMarker(trio.A, "s6-done-a", 2);
})(),
(async () => {
for (let i = 0; i < 12; i++) await ops.move(trio.B, ops.secondary, Math.round(ops.mm / 5));
await ops.addMarker(trio.B, "s6-done-b", 3);
})(),
]);
await waitAllContain(trio, cfg, "s6-done-a", 45000);
await waitAllContain(trio, cfg, "s6-done-b", 45000);
await settleConverged(trio, cfg, 45000);
await oracleSweep(trio, cfg);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S7: late joiner adopts a room that evolved without it ────────────────
test(`${label} S7: C joins after A+B edited; adopt converges drift-silent`, async ({
context,
testLogger,
}) => {
skipFirefox();
const room = `s7-${label}-${test.info().workerIndex}`;
const A = await context.newPage();
const B = await context.newPage();
await bootOpen(A, cfg);
await bootOpen(B, cfg);
await startV2(A, { room, seedText: cfg.fixture });
await startV2(B, { room, editorMatchesDoc: true });
const duo: TabSet = { tabs: [["A", A], ["B", B]] };
await settleConverged(duo, cfg);
// The room evolves before C exists.
await ops.move(A, ops.primary, ops.mm);
await ops.addMarker(B, "s7-early", 4);
await waitAllContain(duo, cfg, "s7-early");
await settleConverged(duo, cfg);
// C opened the ORIGINAL fixture file, so its editor does NOT match the
// doc — it must take the ADOPT branch (no editorMatchesDoc, no seed).
const C = await context.newPage();
await bootOpen(C, cfg);
await startV2(C, { room });
const trio: Trio = { A, B, C, tabs: [["A", A], ["B", B], ["C", C]] };
await waitAllContain(trio, cfg, "s7-early");
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
});
}
// ── S8: user-save during a peer's burst (pcbnew) ─────────────────────────────
// Ctrl+S drives the FULL save flow (the real writer + the C++→JS onSave
// notification chokepoint) while remote applies land — asyncify contention
// between the save fiber and the apply fibers is exactly the surface.
test.describe("drift trio scenarios — pcbnew S8 save interplay", () => {
test.describe.configure({ timeout: 900000 });
test("pcbnew S8: A saves (Ctrl+S) mid B-burst", async ({ context, testLogger }) => {
skipFirefox();
const trio = await openTrio(context, TRIO_PCB, `s8-pcb-${test.info().workerIndex}`);
await Promise.all([
(async () => {
for (let i = 0; i < 6; i++)
await callHook<boolean>(trio.B, "kicadCollabTestMoveBoardItem", VIA1, 300000, 0);
await callHook<string>(trio.B, "kicadCollabTestAddBoardText", "s8-done", 20000000, 140000000, "F.SilkS");
})(),
(async () => {
// Focus the canvas on an empty margin, then user-save twice mid-burst.
await trio.A.locator("#canvas").click({ position: { x: 30, y: 300 } });
await trio.A.keyboard.press("Control+s");
await trio.A.keyboard.press("Control+s");
})(),
]);
await waitAllContain(trio, TRIO_PCB, "s8-done");
await settleConverged(trio, TRIO_PCB);
await oracleSweep(trio, TRIO_PCB);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
});

View file

@ -261,7 +261,7 @@ export async function bootOpen(page: Page, cfg: ToolCfg): Promise<void> {
await page.addScriptTag({ path: BUNDLE });
}
function startV2(
export function startV2(
page: Page,
opts: { room: string; settleMs?: number; seedText?: string; editorMatchesDoc?: boolean },
): Promise<void> {
@ -274,12 +274,15 @@ function startV2(
}, opts);
}
export interface Trio {
/** Anything with labeled tabs — the oracles work on trios, duos, quads alike. */
export interface TabSet {
tabs: ReadonlyArray<readonly [label: string, page: Page]>;
}
export interface Trio extends TabSet {
A: Page;
B: Page;
C: Page;
/** Sweep/settle order: seeder first, observer last. */
tabs: ReadonlyArray<readonly [label: string, page: Page]>;
}
/**
@ -404,7 +407,7 @@ function firstDiff(a: string, b: string): string {
* Poll until every tab's silent save is byte-identical. The convergence gate
* between scenario steps bounded poll, no blind sleeps (tests/TESTING.md).
*/
export async function settleConverged(trio: Trio, cfg: ToolCfg, timeout = 30000): Promise<void> {
export async function settleConverged(trio: TabSet, cfg: ToolCfg, timeout = 30000): Promise<void> {
await expect
.poll(
async () => {
@ -425,7 +428,7 @@ export async function settleConverged(trio: Trio, cfg: ToolCfg, timeout = 30000)
* 2. all three silent saves are byte-identical,
* 3. the room doc materializes, identically, on every tab.
*/
export async function oracleSweep(trio: Trio, cfg: ToolCfg): Promise<void> {
export async function oracleSweep(trio: TabSet, cfg: ToolCfg): Promise<void> {
for (const [label, page] of trio.tabs) {
const d = await drift(page, cfg);
expect(d?.added ?? [], `${label} drift added`).toEqual([]);

View file

@ -1518,15 +1518,23 @@ bool schCollabTestMoveSchItem( std::string aId, int aDx, int aDy )
if( !fr )
return false;
SCH_SHEET_PATH path;
SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
&path, /*allowNull*/ true );
if( !item )
SCH_SHEET_PATH probe;
if( !fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &probe,
/*allowNull*/ true ) )
return false;
SCH_SCREEN* screen = path.LastScreen();
fr->CallAfter( [fr, item, screen, aDx, aDy]() { collabTestMove( fr, item, screen, aDx, aDy ); } );
// Re-resolve when the deferred body runs: a remote remove can apply in
// between and the captured pointer would be dangling — the commit would
// resurrect a deleted item (drift-trio S4 move-vs-delete). Vanished =>
// the move loses, silently.
fr->CallAfter( [fr, aId, aDx, aDy]() {
SCH_SHEET_PATH path;
SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
&path, /*allowNull*/ true );
if( item )
collabTestMove( fr, item, path.LastScreen(), aDx, aDy );
} );
return true;
}
@ -1544,16 +1552,21 @@ bool schCollabTestMirrorSchItem( std::string aId, bool aHorizontal )
if( !item )
return false;
SCH_SCREEN* screen = path.LastScreen();
pcbjam_collab::runOnFiber( fr, [fr, aId, aHorizontal]() { // re-resolve on the fiber (S4)
SCH_SHEET_PATH path;
SCH_ITEM* live = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
&path, /*allowNull*/ true );
if( !live )
return;
pcbjam_collab::runOnFiber( fr, [fr, item, screen, aHorizontal]() {
SCH_COMMIT commit( fr );
commit.Modify( item, screen );
commit.Modify( live, path.LastScreen() );
if( aHorizontal )
item->MirrorHorizontally( item->GetPosition().x );
live->MirrorHorizontally( live->GetPosition().x );
else
item->MirrorVertically( item->GetPosition().y );
live->MirrorVertically( live->GetPosition().y );
commit.Push( wxT( "Collab test mirror" ) );
} );

View file

@ -2111,12 +2111,16 @@ std::string pcbCollabTestAddZone( int aX1, int aY1, int aX2, int aY2, std::strin
bool pcbCollabTestFlipBoardItem( std::string aId )
{
PCB_EDIT_FRAME* fr = pcbFrame();
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
if( !testResolve( fr, aId ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item]() {
pcbjam_collab::runOnFiber( fr, [fr, aId]() { // re-resolve on the fiber (S4)
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
return;
BOARD_COMMIT commit( fr );
commit.Modify( item );
item->Flip( item->GetPosition(), FLIP_DIRECTION::LEFT_RIGHT );
@ -2135,14 +2139,19 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s
if( !item || item->Type() != PCB_FOOTPRINT_T )
return false;
FOOTPRINT* fp = static_cast<FOOTPRINT*>( item );
wxString text = wxString::FromUTF8( aText.c_str() );
bool isRef = ( aField == "Reference" );
wxString text = wxString::FromUTF8( aText.c_str() );
bool isRef = ( aField == "Reference" );
if( !isRef && aField != "Value" )
return false;
pcbjam_collab::runOnFiber( fr, [fr, fp, text, isRef]() {
pcbjam_collab::runOnFiber( fr, [fr, aId, text, isRef]() { // re-resolve on the fiber (S4)
BOARD_ITEM* live = testResolve( fr, aId );
if( !live || live->Type() != PCB_FOOTPRINT_T )
return;
FOOTPRINT* fp = static_cast<FOOTPRINT*>( live );
BOARD_COMMIT commit( fr );
commit.Modify( fp );
@ -2160,12 +2169,16 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s
bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
{
PCB_EDIT_FRAME* fr = pcbFrame();
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
if( !testResolve( fr, aId ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item, aLocked]() {
pcbjam_collab::runOnFiber( fr, [fr, aId, aLocked]() { // re-resolve on the fiber (S4)
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
return;
BOARD_COMMIT commit( fr );
commit.Modify( item );
item->SetLocked( aLocked );
@ -2179,12 +2192,18 @@ bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
bool pcbCollabTestMoveBoardItem( std::string aId, int aDx, int aDy )
{
PCB_EDIT_FRAME* fr = pcbFrame();
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
if( !testResolve( fr, aId ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item, aDx, aDy]() { collabTestMove( fr, item, aDx, aDy ); } );
// Re-resolve ON the fiber: a remote remove can apply between scheduling
// and running, and doApplyItems FREES removed items — a captured pointer
// would be dangling and the commit would resurrect a deleted item
// (drift-trio S4 move-vs-delete). Vanished => the move loses, silently.
pcbjam_collab::runOnFiber( fr, [fr, aId, aDx, aDy]() {
if( BOARD_ITEM* item = testResolve( fr, aId ) )
collabTestMove( fr, item, aDx, aDy );
} );
return true;
}