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:
parent
1d54e7afc8
commit
0efeb5d3d9
6 changed files with 246 additions and 16 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 43b28a47e7df8be7e659f9512eb8afa860719df5
|
||||
Subproject commit f1240eaa02154311b9b6ca9d9cc30e054894d387
|
||||
|
|
@ -5,8 +5,8 @@ import {
|
|||
EXTENSION_TOOL,
|
||||
FILELESS_TOOLS,
|
||||
fileToDoc,
|
||||
kicadItemsMap,
|
||||
toolSchema,
|
||||
ydocHasState,
|
||||
yToDoc,
|
||||
type KicadDoc,
|
||||
type Tool,
|
||||
|
|
@ -29,7 +29,11 @@ import {
|
|||
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||
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 { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
||||
|
|
@ -236,7 +240,11 @@ async function maybeConnectDocSession(
|
|||
const room = collabRoomId(opts.projectId, opts.targetPath);
|
||||
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)`);
|
||||
return { session };
|
||||
}
|
||||
|
|
@ -272,7 +280,7 @@ async function maybeStartCollab(
|
|||
log: (m: string) => void;
|
||||
onStatus: (t: string) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<KicadCollabHandle | undefined> {
|
||||
const collabParam = new URLSearchParams(win.location.search).get("collab");
|
||||
const mod = win.Module;
|
||||
clog("maybeStartCollab gate:", {
|
||||
|
|
@ -289,11 +297,11 @@ async function maybeStartCollab(
|
|||
// so detaching would silently drop every edit.
|
||||
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
|
||||
clog("disabled (?collab=0) — skipping");
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
if (!COLLAB_TOOLS.has(opts.tool)) {
|
||||
clog(`tool ${opts.tool} has no collab bridge — skipping`);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
|
||||
cwarn(
|
||||
|
|
@ -301,7 +309,7 @@ async function maybeStartCollab(
|
|||
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.`,
|
||||
);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
// when the room was empty (API fallback), seed() file-seeds it as usual.
|
||||
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,
|
||||
editorMatchesDoc: opts.editorMatchesDoc,
|
||||
});
|
||||
opts.log(`[collab] attached to Y.Doc session`);
|
||||
opts.onStatus("Collab: connected");
|
||||
clog("connected ✓");
|
||||
return;
|
||||
return handle;
|
||||
}
|
||||
|
||||
const provider = yjsProviderConfig();
|
||||
|
|
@ -328,7 +336,7 @@ async function maybeStartCollab(
|
|||
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
|
||||
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
|
||||
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,
|
||||
room,
|
||||
seedDoc,
|
||||
|
|
@ -336,6 +344,7 @@ async function maybeStartCollab(
|
|||
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
||||
opts.onStatus("Collab: connected");
|
||||
clog("connected ✓");
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -405,6 +414,7 @@ export function WasmTool({
|
|||
}) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const startedRef = React.useRef(false);
|
||||
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||
const [status, setStatus] = React.useState("Loading tool…");
|
||||
const [logs, setLogs] = React.useState<string[]>([]);
|
||||
const [showLog, setShowLog] = React.useState(false);
|
||||
|
|
@ -540,7 +550,7 @@ export function WasmTool({
|
|||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
await maybeStartCollab(win, {
|
||||
const collabHandle = await maybeStartCollab(win, {
|
||||
tool,
|
||||
slug,
|
||||
projectId,
|
||||
|
|
@ -550,6 +560,21 @@ export function WasmTool({
|
|||
log: append,
|
||||
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
|
||||
// before dropping the overlay, so we don't reveal a still-blank editor.
|
||||
await waitForWxUi(win);
|
||||
|
|
@ -563,6 +588,8 @@ export function WasmTool({
|
|||
|
||||
return () => {
|
||||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||
driftRef.current?.stop();
|
||||
driftRef.current = null;
|
||||
oom.stop();
|
||||
};
|
||||
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
contract,
|
||||
type DriftReportBody,
|
||||
type Lib,
|
||||
type Project,
|
||||
type ProjectWithFiles,
|
||||
|
|
@ -79,6 +80,36 @@ export async function fetchFileBytes(
|
|||
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
|
||||
* (POST /api/projects/:project/files — upserts by (project, path); the form
|
||||
|
|
|
|||
166
web/standalone/src/wasm/collab/drift-detect.ts
Normal file
166
web/standalone/src/wasm/collab/drift-detect.ts
Normal 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
|
||||
* File→Save 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
kicadItemsMap,
|
||||
parseItemsWireDelta,
|
||||
renderItem,
|
||||
ydocHasState,
|
||||
yToItem,
|
||||
type ItemsWireDelta,
|
||||
type KicadDoc,
|
||||
|
|
@ -125,7 +126,10 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
|
||||
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
|
||||
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
|
||||
// adopt apply needed. snapshotItems() still runs to BASELINE the wasm
|
||||
// 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;
|
||||
}
|
||||
if (items.size === 0 && seedDoc) {
|
||||
if (!ydocHasState(doc) && seedDoc) {
|
||||
// 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 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 hasState = ydocHasState(doc);
|
||||
clog(
|
||||
`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.
|
||||
applyDeltaToY(doc, local, ORIGIN);
|
||||
return;
|
||||
|
|
|
|||
1
web/standalone/src/wasm/global.d.ts
vendored
1
web/standalone/src/wasm/global.d.ts
vendored
|
|
@ -6,6 +6,7 @@ declare global {
|
|||
writeFile(path: string, data: Uint8Array | string): void;
|
||||
readFile(path: string, opts?: { encoding?: "binary" | "utf8" }): unknown;
|
||||
analyzePath(path: string): { exists: boolean };
|
||||
unlink(path: string): void;
|
||||
}
|
||||
|
||||
// Loose shape of the wxWidgets-WASM element registry exposed by wx.js.
|
||||
|
|
|
|||
Loading…
Reference in a new issue