feat(ysync): drift-detection loop + fix second-tab ydoc adopt

- drift-detect.ts: every-N-edits + beforeunload check via kicadSave* (no save-hook), reports drift
- WasmTool: start drift detection after collab; return collab handle; stop on cleanup
- fix uuid-less docs (pl_editor): use ydocHasState in maybeConnectDocSession + seed() so a 2nd tab materializes the room instead of refetching the stale file
- api: reportDrift + reportDriftBeacon (sendBeacon/keepalive)
- global.d.ts: FS.unlink
- bump web/pcbjam-shared pointer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-16 16:26:23 +02:00
commit 0efeb5d3d9
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
6 changed files with 246 additions and 16 deletions

@ -1 +1 @@
Subproject commit 43b28a47e7df8be7e659f9512eb8afa860719df5 Subproject commit f1240eaa02154311b9b6ca9d9cc30e054894d387

View file

@ -5,8 +5,8 @@ import {
EXTENSION_TOOL, EXTENSION_TOOL,
FILELESS_TOOLS, FILELESS_TOOLS,
fileToDoc, fileToDoc,
kicadItemsMap,
toolSchema, toolSchema,
ydocHasState,
yToDoc, yToDoc,
type KicadDoc, type KicadDoc,
type Tool, type Tool,
@ -29,7 +29,11 @@ import {
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants"; import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner"; import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow"; import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
import type { KicadDocSession, KicadItemsWindow } from "@/wasm/collab"; import type {
KicadCollabHandle,
KicadDocSession,
KicadItemsWindow,
} from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug"; import { clog, cwarn } from "@/wasm/collab/debug";
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch"; import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog"; import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
@ -236,7 +240,11 @@ async function maybeConnectDocSession(
const room = collabRoomId(opts.projectId, opts.targetPath); const room = collabRoomId(opts.projectId, opts.targetPath);
const session = await connectKicadDoc({ provider: yjsProviderConfig(), room }); const session = await connectKicadDoc({ provider: yjsProviderConfig(), room });
if (kicadItemsMap(session.doc).size === 0) { // Use the full doc state (meta + layout + items), NOT just item count: a
// populated drawing sheet (pl_editor `.kicad_wks`) has zero uuid items, so an
// items-only check makes a joining tab refetch the stale file instead of
// materializing the shared doc's current state.
if (!ydocHasState(session.doc)) {
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`); opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
return { session }; return { session };
} }
@ -272,7 +280,7 @@ async function maybeStartCollab(
log: (m: string) => void; log: (m: string) => void;
onStatus: (t: string) => void; onStatus: (t: string) => void;
}, },
): Promise<void> { ): Promise<KicadCollabHandle | undefined> {
const collabParam = new URLSearchParams(win.location.search).get("collab"); const collabParam = new URLSearchParams(win.location.search).get("collab");
const mod = win.Module; const mod = win.Module;
clog("maybeStartCollab gate:", { clog("maybeStartCollab gate:", {
@ -289,11 +297,11 @@ async function maybeStartCollab(
// so detaching would silently drop every edit. // so detaching would silently drop every edit.
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) { if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
clog("disabled (?collab=0) — skipping"); clog("disabled (?collab=0) — skipping");
return; return undefined;
} }
if (!COLLAB_TOOLS.has(opts.tool)) { if (!COLLAB_TOOLS.has(opts.tool)) {
clog(`tool ${opts.tool} has no collab bridge — skipping`); clog(`tool ${opts.tool} has no collab bridge — skipping`);
return; return undefined;
} }
if (typeof mod?.kicadCollabSnapshotItems !== "function") { if (typeof mod?.kicadCollabSnapshotItems !== "function") {
cwarn( cwarn(
@ -301,7 +309,7 @@ async function maybeStartCollab(
typeof mod?.kicadCollabSnapshotItems, typeof mod?.kicadCollabSnapshotItems,
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`, `— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
); );
return; return undefined;
} }
const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab"); const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab");
@ -312,14 +320,14 @@ async function maybeStartCollab(
// opened the file materialized from this very doc, attach + baseline only; // opened the file materialized from this very doc, attach + baseline only;
// when the room was empty (API fallback), seed() file-seeds it as usual. // when the room was empty (API fallback), seed() file-seeds it as usual.
clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc); clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc);
attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, { const handle = attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
seedDoc, seedDoc,
editorMatchesDoc: opts.editorMatchesDoc, editorMatchesDoc: opts.editorMatchesDoc,
}); });
opts.log(`[collab] attached to Y.Doc session`); opts.log(`[collab] attached to Y.Doc session`);
opts.onStatus("Collab: connected"); opts.onStatus("Collab: connected");
clog("connected ✓"); clog("connected ✓");
return; return handle;
} }
const provider = yjsProviderConfig(); const provider = yjsProviderConfig();
@ -328,7 +336,7 @@ async function maybeStartCollab(
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId). // verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool); const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc); clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
await startKicadCollab(mod, win as unknown as KicadItemsWindow, { const handle = await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider, provider,
room, room,
seedDoc, seedDoc,
@ -336,6 +344,7 @@ async function maybeStartCollab(
opts.log(`[collab] ${provider.kind} connected on ${room}`); opts.log(`[collab] ${provider.kind} connected on ${room}`);
opts.onStatus("Collab: connected"); opts.onStatus("Collab: connected");
clog("connected ✓"); clog("connected ✓");
return handle;
} }
/** /**
@ -405,6 +414,7 @@ export function WasmTool({
}) { }) {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const startedRef = React.useRef(false); const startedRef = React.useRef(false);
const driftRef = React.useRef<{ stop(): void } | null>(null);
const [status, setStatus] = React.useState("Loading tool…"); const [status, setStatus] = React.useState("Loading tool…");
const [logs, setLogs] = React.useState<string[]>([]); const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = React.useState(false); const [showLog, setShowLog] = React.useState(false);
@ -540,7 +550,7 @@ export function WasmTool({
log: append, log: append,
onStatus: setStatus, onStatus: setStatus,
}); });
await maybeStartCollab(win, { const collabHandle = await maybeStartCollab(win, {
tool, tool,
slug, slug,
projectId, projectId,
@ -550,6 +560,21 @@ export function WasmTool({
log: append, log: append,
onStatus: setStatus, onStatus: setStatus,
}); });
// Drift detection: while this doc is collaboratively edited, periodically
// (every N edits + at session end) compare the WASM serialization to the
// Y.Doc and report any divergence. Gated on a real collab session.
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
driftRef.current = startDriftDetection({
doc: collabHandle.doc,
mod: win.Module,
win,
tool,
slug,
targetPath,
log: append,
});
}
// Tool booted + project opened. Wait for the wx UI to actually build // Tool booted + project opened. Wait for the wx UI to actually build
// before dropping the overlay, so we don't reveal a still-blank editor. // before dropping the overlay, so we don't reveal a still-blank editor.
await waitForWxUi(win); await waitForWxUi(win);
@ -563,6 +588,8 @@ export function WasmTool({
return () => { return () => {
win.removeEventListener("keydown", swallowBrowserSave, true); win.removeEventListener("keydown", swallowBrowserSave, true);
driftRef.current?.stop();
driftRef.current = null;
oom.stop(); oom.stop();
}; };
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so // Boot is one-shot per mount; deps intentionally exclude files/targetPath so

View file

@ -1,5 +1,6 @@
import { import {
contract, contract,
type DriftReportBody,
type Lib, type Lib,
type Project, type Project,
type ProjectWithFiles, type ProjectWithFiles,
@ -79,6 +80,36 @@ export async function fetchFileBytes(
return new Uint8Array(await res.arrayBuffer()); return new Uint8Array(await res.arrayBuffer());
} }
// --- collaboration drift reporting (ysync) ---
/**
* Report a detected ydoc/wasm drift (the editor's periodic, every-N-edits check).
* Best-effort: a failed report must never disrupt editing, so callers ignore
* rejections.
*/
export async function reportDrift(
slug: string,
body: DriftReportBody,
): Promise<void> {
await client.reportDrift({ params: { project: slug }, body });
}
/**
* Fire-and-forget drift report that survives the page closing used by the
* session-end (`beforeunload`) check. `sendBeacon` queues the POST past unload;
* a keepalive `fetch` is the fallback when the beacon is rejected (too large).
*/
export function reportDriftBeacon(slug: string, body: DriftReportBody): void {
const url = `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/drift`;
const blob = new Blob([JSON.stringify(body)], { type: "application/json" });
try {
if (navigator.sendBeacon(url, blob)) return;
} catch {
/* fall through to keepalive fetch */
}
void fetch(url, { method: "POST", body: blob, keepalive: true }).catch(() => {});
}
/** /**
* Persist one saved file back to the backend via the multipart upload route * Persist one saved file back to the backend via the multipart upload route
* (POST /api/projects/:project/files upserts by (project, path); the form * (POST /api/projects/:project/files upserts by (project, path); the form

View file

@ -0,0 +1,166 @@
/**
* Drift detection (ysync): periodically compare what the WASM editor would
* actually serialize on save against what the Y.Doc represents, and report any
* divergence so it can be tracked down later.
*
* Cadence (deliberately infrequent): a check runs every N Y.Doc updates and once
* more at session end (`beforeunload`). There is NO periodic timer an editor
* left open for hours must never flood the backend; missing the occasional drift
* is acceptable (another session reports it).
*
* The "true WASM representation" comes from the per-tool `kicadSave*` embind fns,
* which serialize the current model to a scratch MEMFS path using the same writer
* FileSave uses but WITHOUT firing `kicadCollab.onSave` so a drift check never
* looks like a user save (no upload, no peer-tab dirty flag).
*/
import {
docDelta,
type DriftReportBody,
fileToDoc,
isEmptyKicadDelta,
type Tool,
yToDoc,
} from "@pcbjam/shared";
import * as Y from "yjs";
import { reportDrift, reportDriftBeacon } from "@/lib/api";
import { memfsFilePath } from "../constants";
/** The embind serialize fn per collab-capable tool (see module header). */
const SAVE_FN = {
pcbnew: "kicadSaveBoard",
eeschema: "kicadSaveSchematic",
pl_editor: "kicadSaveDrawingSheet",
} as const satisfies Partial<Record<Tool, string>>;
type CollabTool = keyof typeof SAVE_FN;
interface DriftModule {
kicadSaveBoard?(path: string): void;
kicadSaveSchematic?(path: string): void;
kicadSaveDrawingSheet?(path: string): void;
}
export interface DriftDetectOptions {
doc: Y.Doc;
mod: DriftModule;
win: { FS?: EmscriptenFS };
tool: Tool;
slug: string;
targetPath: string;
/** Run a drift check every N Y.Doc updates (default 50). No periodic timer. */
everyN?: number;
log?: (m: string) => void;
}
const DEFAULT_EVERY_N = 50;
function isCollabTool(tool: Tool): tool is CollabTool {
return tool in SAVE_FN;
}
export interface DriftDetector {
stop(): void;
}
/**
* Start drift detection for one collaboratively-edited document. Returns a handle
* whose `stop()` detaches the Y.Doc observer and the unload listener. A no-op
* detector is returned for tools without a serialize fn (so callers need no
* special-casing).
*/
export function startDriftDetection(opts: DriftDetectOptions): DriftDetector {
const log = opts.log ?? (() => {});
if (!isCollabTool(opts.tool)) return { stop() {} };
const saveName = SAVE_FN[opts.tool];
const rawSave = opts.mod[saveName];
if (typeof rawSave !== "function") {
log(`[drift] ${saveName} unavailable on this build — drift detection off`);
return { stop() {} };
}
// Bind to a non-optional type so the nested computeDrift closure can invoke it.
const save = rawSave as (path: string) => void;
const everyN = opts.everyN && opts.everyN > 0 ? opts.everyN : DEFAULT_EVERY_N;
const scratchPath = `${memfsFilePath(opts.slug, opts.targetPath)}.drift`;
let changes = 0;
let inFlight = false;
let stopped = false;
// Synchronous on purpose: serialize the live model, diff it against the Y.Doc,
// and return the report body (or null when there's no drift). Being fully
// synchronous is what lets the session-end check finish during page unload.
function computeDrift(): DriftReportBody | null {
save(scratchPath);
let text: unknown;
try {
text = opts.win.FS?.readFile(scratchPath, { encoding: "utf8" });
} finally {
try {
opts.win.FS?.unlink(scratchPath);
} catch {
/* scratch cleanup is best-effort */
}
}
if (typeof text !== "string") return null;
const wasmDoc = fileToDoc(text);
const ydocDoc = yToDoc(opts.doc);
const diff = docDelta(ydocDoc, wasmDoc);
// docDelta covers items only; flag layout/preamble divergence separately.
const layoutChanged =
JSON.stringify(ydocDoc.layout) !== JSON.stringify(wasmDoc.layout);
const metaChanged = ydocDoc.root !== wasmDoc.root;
if (isEmptyKicadDelta(diff) && !layoutChanged && !metaChanged) return null;
return { docPath: opts.targetPath, wasmDoc, ydocDoc, diff, layoutChanged, metaChanged };
}
async function checkOnce(): Promise<void> {
if (stopped || inFlight) return;
inFlight = true;
try {
const body = computeDrift();
if (body) {
log(
`[drift] ${opts.targetPath}: +${body.diff.added.length} ~${body.diff.updated.length} -${body.diff.removed.length}` +
`${body.layoutChanged ? " layout" : ""}${body.metaChanged ? " meta" : ""}`,
);
await reportDrift(opts.slug, body);
}
} catch (e) {
log(`[drift] check failed: ${String(e)}`);
} finally {
inFlight = false;
}
}
const onUpdate = (): void => {
if (stopped) return;
if (++changes >= everyN) {
changes = 0;
void checkOnce();
}
};
opts.doc.on("update", onUpdate);
const onBeforeUnload = (): void => {
try {
const body = computeDrift();
if (body) reportDriftBeacon(opts.slug, body);
} catch {
/* best-effort at page close */
}
};
window.addEventListener("beforeunload", onBeforeUnload);
log(`[drift] on for ${opts.targetPath} (every ${everyN} edits + at session end)`);
return {
stop(): void {
stopped = true;
opts.doc.off("update", onUpdate);
window.removeEventListener("beforeunload", onBeforeUnload);
},
};
}

View file

@ -10,6 +10,7 @@ import {
kicadItemsMap, kicadItemsMap,
parseItemsWireDelta, parseItemsWireDelta,
renderItem, renderItem,
ydocHasState,
yToItem, yToItem,
type ItemsWireDelta, type ItemsWireDelta,
type KicadDoc, type KicadDoc,
@ -125,7 +126,10 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void { function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
seeded = true; // open the UP gate; everything below runs synchronously seeded = true; // open the UP gate; everything below runs synchronously
if (opts?.editorMatchesDoc && items.size > 0) { // `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
// drawing sheet (pl_editor .kicad_wks) has zero uuid items, so an items-only
// check would mis-classify a seeded room as empty and re-seed/clobber it.
if (opts?.editorMatchesDoc && ydocHasState(doc)) {
// The editor opened exactly this doc's content (Y.Doc-load path): no // The editor opened exactly this doc's content (Y.Doc-load path): no
// adopt apply needed. snapshotItems() still runs to BASELINE the wasm // adopt apply needed. snapshotItems() still runs to BASELINE the wasm
// differ — otherwise the first local edit would re-emit the full model. // differ — otherwise the first local edit would re-emit the full model.
@ -137,7 +141,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
} }
return; return;
} }
if (items.size === 0 && seedDoc) { if (!ydocHasState(doc) && seedDoc) {
// First tab, file-seeded: write the FULL doc (meta + layout + items) so // First tab, file-seeded: write the FULL doc (meta + layout + items) so
// the Y.Doc — not the editor snapshot — is the lossless source of truth // the Y.Doc — not the editor snapshot — is the lossless source of truth
// (the file is recoverable via docToFile). The editor already opened the // (the file is recoverable via docToFile). The editor already opened the
@ -158,12 +162,13 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
} }
const local = itemsWireToDelta(wire, {}); const local = itemsWireToDelta(wire, {});
const hasState = ydocHasState(doc);
clog( clog(
`seed: doc has ${items.size} item(s), editor has ${local.added.length}`, `seed: doc has ${items.size} item(s), editor has ${local.added.length}`,
items.size === 0 ? "SEEDING doc (first tab)" : "ADOPTING doc (joining)", hasState ? "ADOPTING doc (joining)" : "SEEDING doc (first tab)",
); );
if (items.size === 0) { if (!hasState) {
// First tab, no file source: seed the shared doc from the editor model. // First tab, no file source: seed the shared doc from the editor model.
applyDeltaToY(doc, local, ORIGIN); applyDeltaToY(doc, local, ORIGIN);
return; return;

View file

@ -6,6 +6,7 @@ declare global {
writeFile(path: string, data: Uint8Array | string): void; writeFile(path: string, data: Uint8Array | string): void;
readFile(path: string, opts?: { encoding?: "binary" | "utf8" }): unknown; readFile(path: string, opts?: { encoding?: "binary" | "utf8" }): unknown;
analyzePath(path: string): { exists: boolean }; analyzePath(path: string): { exists: boolean };
unlink(path: string): void;
} }
// Loose shape of the wxWidgets-WASM element registry exposed by wx.js. // Loose shape of the wxWidgets-WASM element registry exposed by wx.js.