From 5c7f8a2e85f31f5bc43b704dc3df52eff49cdc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sun, 2 Aug 2026 12:13:21 +0200 Subject: [PATCH] fix(editor): stagger the sibling restage out of the settle window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The differential-repro ladder (2026-08-02, five prod runs + local counter measurements) narrowed the crash trigger empirically: warm loads of sibling-heavy projects die at settle (V1/V4 fail 2/2 warm; V2/V3 without siblings never fail, warm or cold), while the flight-recorder counters show the settle-time collision windows themselves are universal (fcsTotal=72, rootHotTotal=3 on V1 AND V3, every load, cold and warm — so the windows are the shared fan-out, not sibling-made). The sibling restage's room connects + restage fetches are the only sibling-specific traffic contending with those windows, and warm IDB compresses it into exactly that moment. Nothing in the restage is needed for first paint — the boot snapshot staged every sibling seconds earlier — so it now starts on requestIdleCallback (5s timeout; setTimeout(3s) fallback), well clear of the settle storm. Unmount-safe via disposedRef (armed per mount, checked in the deferred starter and on handle resolution). Validation is empirical by design: the counters won't move (windows are not sibling-made); the test is warm V1/V4 prod loads no longer dying. If they still die, the sibling lever is exonerated too and the remaining suspects narrow to the ydoc-materialization path差 (second-load file source) — the next probe either way. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4 --- web/standalone/src/components/WasmTool.tsx | 67 +++++++++++++++++----- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index f675ff4..418507a 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -980,6 +980,9 @@ export function WasmTool({ // eeschema sheet rebinds — the bridge re-reads it on every startPresence. const crossAppRef = React.useRef(null); const siblingRestageRef = React.useRef(null); + // Set at the boot effect's cleanup; deferred starters bail on it (the + // sibling-restage idle stagger can fire after unmount). + const disposedRef = React.useRef(false); const sheetManagerRef = React.useRef(null); // The single-room collab doc (pcbnew/pl_editor), for the layout save-sync // (miss 08B); eeschema routes per sheet through the manager instead. @@ -1328,6 +1331,9 @@ export function WasmTool({ return; } + // Fresh mount (or StrictMode re-run): re-arm the deferred starters. + disposedRef.current = false; + const win = window as ToolWindow; // OOM recovery (feature 0002): watch for soft aborts + a stale hard-kill @@ -1833,21 +1839,51 @@ export function WasmTool({ // files a PCB session syncs from fresh in MEMFS, instead of the // one-shot boot snapshot. Same opt-out as the room collab; read-only // viewers skip it (they can't run the sync anyway). + // + // STAGGERED out of the settle window (2026-08-02, the ladder + // result): warm sibling-heavy projects crash at exactly this moment + // (V1/V4 fail 2/2 warm, V2/V3 without siblings never do), and the + // asyncify flight recorder places the fatal interleave inside the + // settle-time wake windows. The restage's room connects + restage + // fetches were the only sibling-specific traffic contending with + // those windows. Nothing here is needed for first paint — the boot + // snapshot staged every sibling seconds ago — so it starts when the + // main thread is idle (or after 5s, whichever first), well clear of + // the settle storm. Unmount-safety: the ref may be populated after + // unmount, so the cleanup check runs inside the callback too. if (tool === "pcbnew" && collabHandle && !readOnly) { - siblingRestageRef.current = await startSiblingRestage({ - win, - slug, - scopeId, - projectId, - files, - targetPath, - // Presence-scoped: connect a sheet's room only while a peer - // announces it open (zero sibling sockets when alone). Absent - // (provider "none" / connect failed) ⇒ eager fallback. - presence: crossAppRef.current ?? undefined, - provider: yjsProviderConfig(), - log: append, - }); + const startRestageIdle = () => { + if (disposedRef.current) return; + void startSiblingRestage({ + win, + slug, + scopeId, + projectId, + files, + targetPath, + // Presence-scoped: connect a sheet's room only while a peer + // announces it open (zero sibling sockets when alone). Absent + // (provider "none" / connect failed) ⇒ eager fallback. + presence: crossAppRef.current ?? undefined, + provider: yjsProviderConfig(), + log: append, + }).then((handle) => { + if (disposedRef.current) { + handle?.destroy(); + return; + } + siblingRestageRef.current = handle; + }); + }; + type IdleWindow = Window & { + requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number; + }; + const w = window as IdleWindow; + if (typeof w.requestIdleCallback === "function") { + w.requestIdleCallback(startRestageIdle, { timeout: 5000 }); + } else { + window.setTimeout(startRestageIdle, 3000); + } } if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) { driftRef.current = startDriftDetection({ @@ -1921,6 +1957,9 @@ export function WasmTool({ })(); return () => { + // Deferred starters (the sibling-restage idle stagger) check this + // before creating anything after unmount. + disposedRef.current = true; // A consent dialog pending at unmount resolves false — the boot IIFE // bails without ever starting the download. consentResolveRef.current?.(false);