feat(save): D group — client save lane + CAS PUT re-created from codex reference

The standalone's save path was a bare multipart POST: no per-path lane, no
revision tracking, 'Saved ✓' on any 2xx (findings D-1/D-2/D-3 client half —
the 8e3a886 server CAS + smoke oracle landed without it). Re-created fresh:

- save-flow.ts: full persistence lane — per-path active+pending snapshots,
  capacity admission before the byte copy, committed-ONLY promotion,
  conflict/unknown outcomes install a durable path block, status generations,
  SaveHookHandle.stop() aborts transports (D-9 teardown superseding the
  interim unregister). +17 unit tests (codex suite, green unmodified).
- project-source.ts: D-1 two-map revision tracking (baseRevisions = model
  ancestry and the only legal write precondition; observedRevisions =
  metadata), seeded from listing rows, download headers, and cache hits;
  uploadFileBytes is now the CAS PUT with x-pcbjam-file-revision and
  409 / pre-publish / ambiguous-outcome classification; refreshFileRevision.
- api.ts / idb-project-store / HomePage local sources / ToolPage /
  NewFileDialog / persistCreatedSheet: SaveOutcome contract threaded through.

Validated end-to-end by apps/tests editor-save-lane.spec.ts against the live
stack (revision 1 learned on load, PUT base 1 → 200 → server revision 2).
Standalone units 365/365, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZJ1pUePb4W47hGoLMYTw4
This commit is contained in:
Gergő Törcsvári 2026-08-19 11:51:45 +02:00
commit 4e089ae455
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
9 changed files with 1037 additions and 50 deletions

View file

@ -111,7 +111,14 @@ export function NewFileDialog({
slug = created.slug;
} else {
slug = project ? project.slug : target;
for (const f of files) await uploadFileBytes(slug, f.path, f.bytes);
for (const f of files) {
const outcome = await uploadFileBytes(slug, f.path, f.bytes);
if (outcome.kind !== "committed") {
throw new Error(
outcome.message ?? `upload did not commit: ${f.path}`,
);
}
}
}
// Full navigation so Emscripten boots into a clean page opening the file.
// A home-created project is browser-local (@local scope); an in-project new

View file

@ -879,7 +879,16 @@ function persistCreatedSheet(
const bytes = win.FS?.readFile(memfsFilePath(slug, relPath));
if (!(bytes instanceof Uint8Array)) return;
void saveBytes(relPath, bytes)
.then(() => log(`[sheet] registered created subsheet ${relPath} (${bytes.length} bytes)`))
.then((outcome) => {
if (outcome.kind === "committed") {
log(`[sheet] registered created subsheet ${relPath} (${bytes.length} bytes)`);
} else {
cwarn(
`[sheet] upload of created subsheet ${relPath} did not commit`,
outcome,
);
}
})
.catch((err) => cwarn(`[sheet] upload of created subsheet ${relPath} failed`, err));
} catch (err) {
cwarn(`[sheet] read of created subsheet ${relPath} failed`, err);
@ -1636,6 +1645,9 @@ export function WasmTool({
// cleanup below closes over it — and never rejected, so aborting mid-sync
// leaks nothing and throws nothing.
const presyncAbort = new AbortController();
// The save sink is a global slot (D-9): keep its teardown edge so a
// remount can't leave the dead mount uploading + publishing status.
let unregisterSaveHook: (() => void) | null = null;
// The libs source THIS boot created (vs. one injected via props, which the
// caller owns) — cleanup disposes it so its SyncStack sockets don't outlive
// the editor.
@ -1850,7 +1862,7 @@ export function WasmTool({
// Read-only sessions register neither upload nor the save-driven room
// writers (onSaved onboarding, onSavedText layout sync) — saves, were
// any reachable past the wasm lock, stay MEMFS-only.
registerSaveHook(win, {
const saveHookHandle = registerSaveHook(win, {
slug,
saveBytes: readOnly ? undefined : saveBytes,
log: append,
@ -1881,6 +1893,7 @@ export function WasmTool({
},
}),
});
unregisterSaveHook = () => saveHookHandle.stop();
// The room connect started in the fan-out above — settle it before
// staging so the target file materializes from the doc when it has one.
const docResult = await docSessionReady;
@ -2233,6 +2246,9 @@ export function WasmTool({
presyncAbort.abort();
win.removeEventListener("keydown", swallowBrowserSave, true);
win.removeEventListener("keydown", chromeHotkey, true);
// The global save sink must not outlive this mount (D-9).
unregisterSaveHook?.();
unregisterSaveHook = null;
// Every collab surface + any not-yet-adopted doc session (C-1/C-7).
teardownCollab();
// Close the lib SyncStacks this boot opened (mirror mux + any dedicated

View file

@ -23,6 +23,7 @@ import {
projectSource,
} from "./project-source";
import type { SourceDescriptor } from "./project-source-shared";
import { SAVE_COMMITTED, type SaveOutcome } from "../wasm/save-flow";
/**
* Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts):
@ -132,24 +133,33 @@ export async function uploadFileBytes(
slug: string,
relPath: string,
bytes: Uint8Array,
): Promise<void> {
signal?: AbortSignal,
): Promise<SaveOutcome> {
const source = projectSource();
if (!source.uploadFileBytes) {
downloadBytes(relPath, bytes);
return;
return SAVE_COMMITTED;
}
try {
await source.uploadFileBytes(slug, relPath, bytes);
return await source.uploadFileBytes(slug, relPath, bytes, signal);
} catch (e) {
// Composite write to a read-only (gallery) project → fall back to download.
if (e instanceof ReadOnlyProjectError) {
downloadBytes(relPath, bytes);
return;
return SAVE_COMMITTED;
}
throw e;
}
}
/** Observe the server revision after an ambiguous save; never rebases this model. */
export async function refreshFileRevision(
slug: string,
relPath: string,
): Promise<void> {
await projectSource().refreshFileRevision?.(slug, relPath);
}
/**
* Create a project file that a tool switch found missing (WasmTool's nav
* hook): write `bytes` at `relPath` unless the file already exists on the
@ -168,7 +178,10 @@ export async function createProjectFileIfMissing(
if (!source.uploadFileBytes) throw new ReadOnlyProjectError(slug);
const { files } = await source.getProject(slug);
if (files.some((file) => file.path === relPath)) return;
await source.uploadFileBytes(slug, relPath, bytes);
const outcome = await source.uploadFileBytes(slug, relPath, bytes);
if (outcome.kind !== "committed") {
throw new Error(outcome.message ?? `file creation did not commit: ${relPath}`);
}
}
// --- collaboration drift reporting (ysync; backend-only) ---

View file

@ -5,6 +5,7 @@ import {
type ProjectWithFiles,
} from "@pcbjam/shared";
import type { ProjectSource } from "./project-source";
import { SAVE_COMMITTED } from "../wasm/save-flow";
import {
SOURCE_DESCRIPTORS,
deterministicUuid,
@ -198,13 +199,16 @@ export function idbProjectStore(): LocalProjectStore {
return v.bytes;
},
async uploadFileBytes(slug, relPath, bytes): Promise<void> {
async uploadFileBytes(slug, relPath, bytes) {
const d = await db();
const tx = d.transaction(FILES, "readwrite");
const rec: FileRecord = { slug, path: relPath, size: bytes.length, bytes };
tx.objectStore(FILES).put(rec, fileKey(slug, relPath));
await txDone(tx);
await touch(d, slug);
// Local IDB is single-writer per browser profile — an awaited put IS the
// commit (SaveOutcome contract, findings D-2).
return SAVE_COMMITTED;
},
async hasProject(slug: string): Promise<boolean> {

View file

@ -5,9 +5,11 @@ import {
type Project,
type ProjectFile,
type ProjectWithFiles,
PROJECT_FILE_REVISION_HEADER,
YDOC_CONTENT_TYPE,
ydocUpdateToKicadDoc,
} from "@pcbjam/shared";
import { SAVE_COMMITTED, type SaveOutcome } from "../wasm/save-flow";
import {
API_BASE_URL,
LOCAL_PROJECTS_ENABLED,
@ -60,12 +62,24 @@ export interface ProjectSource {
relPath: string,
meta?: ProjectFile,
): Promise<Uint8Array>;
/** Present only on writable sources; absent ⇒ read-only (download on save). */
/**
* Present only on writable sources; absent read-only (download on save).
* The outcome is the save lane's contract (findings D-1/D-2): only
* `committed` promotes a queued successor; `conflict`/`unknown` block the
* path until an authoritative reload.
*/
uploadFileBytes?(
slug: string,
relPath: string,
bytes: Uint8Array,
): Promise<void>;
signal?: AbortSignal,
): Promise<SaveOutcome>;
/**
* Observe one path's latest server revision without downloading its body.
* This is diagnostic metadata only: it never rebases the in-memory model or
* becomes a legal write precondition. Optional for non-CAS sources.
*/
refreshFileRevision?(slug: string, relPath: string): Promise<void>;
}
// --- remote (REST backend over the shared contract) ---------------------------
@ -84,6 +98,42 @@ function remoteProjectSource(): ProjectSource {
`${API_BASE_URL}/api/scopes/${encodeURIComponent(currentScope())}/projects`;
const fileUrl = (slug: string, relPath: string) =>
`${projectsBase()}/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
// CAS revision tracking (findings D-1): TWO maps because "revision I saw on
// the server" and "revision my in-memory model was built from" are different
// facts — conflating them let a fresh Ctrl+S after a conflict silently
// overwrite the winner. Only `baseRevisions` is a legal write precondition.
const baseRevisions = new Map<string, number>();
const observedRevisions = new Map<string, number>();
const revisionKey = (slug: string, relPath: string) =>
`${currentScope()}\u0000${slug}\u0000${relPath}`;
const rememberObservedRevision = (
slug: string,
relPath: string,
revision: number | undefined,
): void => {
if (revision !== undefined && Number.isSafeInteger(revision) && revision >= 0) {
observedRevisions.set(revisionKey(slug, relPath), revision);
}
};
const rememberFiles = (slug: string, files: ProjectFile[]): void => {
for (const file of files) {
rememberObservedRevision(slug, file.path, file.revision);
}
};
const rememberResponseRevision = (
slug: string,
relPath: string,
response: Response,
): number | undefined => {
const text = response.headers.get(PROJECT_FILE_REVISION_HEADER);
if (text === null || !/^\d+$/.test(text)) return undefined;
const value = Number(text);
if (Number.isSafeInteger(value)) {
observedRevisions.set(revisionKey(slug, relPath), value);
return value;
}
return undefined;
};
return {
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
readOnly: false,
@ -98,6 +148,7 @@ function remoteProjectSource(): ProjectSource {
});
if (res.status === 404) throw new Error("project not found");
if (res.status !== 200) throw new Error("failed to load project");
rememberFiles(slug, res.body.files);
// Fresh listing = fresh cache truth: drop cached bodies this listing no
// longer vouches for (deleted paths, superseded revisions). Best-effort.
const valid = new Map<string, string>();
@ -114,7 +165,16 @@ function remoteProjectSource(): ProjectSource {
const validator = meta ? fileCacheValidator(meta) : null;
if (validator && meta) {
const hit = await readCachedFileBytes(meta.projectId, relPath, validator);
if (hit) return hit;
if (hit) {
// The cached body IS the listed row's body — its revision is the
// ancestry the model is about to be built from.
const listed = meta.revision;
rememberObservedRevision(slug, relPath, listed);
if (listed !== undefined && Number.isSafeInteger(listed) && listed >= 0) {
baseRevisions.set(revisionKey(slug, relPath), listed);
}
return hit;
}
}
// credentials: session-cookie auth (see contract-client.ts). The static
// gallery fetches below stay credential-less — a CDN's wildcard CORS
@ -132,6 +192,14 @@ function remoteProjectSource(): ProjectSource {
headers: { accept: `${YDOC_CONTENT_TYPE}, */*` },
});
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
// These bytes become the in-memory model — their revision is the CAS
// ancestry every later Ctrl+S publishes against (D-1: base, not merely
// observed). Holds for the ydoc-materialized form too: the header still
// names the row the doc supersedes, which is the row CAS guards.
const responseRevision = rememberResponseRevision(slug, relPath, res);
if (responseRevision !== undefined) {
baseRevisions.set(revisionKey(slug, relPath), responseRevision);
}
const bytes = new Uint8Array(await res.arrayBuffer());
// A converted ydoc body may be cached ONLY under the ydoc-form validator
// (blob fingerprint) — never under `revision:updatedAt`, whose row does
@ -165,6 +233,10 @@ function remoteProjectSource(): ProjectSource {
if (!plain.ok) {
throw new Error(`download failed (${plain.status}): ${relPath} (${String(err)})`);
}
const plainRevision = rememberResponseRevision(slug, relPath, plain);
if (plainRevision !== undefined) {
baseRevisions.set(revisionKey(slug, relPath), plainRevision);
}
const materialized = new Uint8Array(await plain.arrayBuffer());
// Caching the fallback under the blob tag ends the double-fetch for
// stale unconvertible ydocs — one per tag instead of two per load.
@ -174,18 +246,118 @@ function remoteProjectSource(): ProjectSource {
return materialized;
}
},
async uploadFileBytes(slug, relPath, bytes) {
const name = relPath.split("/").pop() ?? relPath;
const form = new FormData();
// The form FIELD NAME carries the project-relative path (upsert by
// (project, path)) — same convention as the management app's folder upload.
form.append(relPath, new File([bytes as BlobPart], name));
const res = await fetch(`${projectsBase()}/${encodeURIComponent(slug)}/files`, {
method: "POST",
body: form,
credentials: "include",
async refreshFileRevision(slug, relPath) {
const res = await client.listFiles({
params: { scope: currentScope(), project: slug },
});
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
if (res.status !== 200) throw new Error("failed to refresh file revision");
const file = res.body.find((candidate) => candidate.path === relPath);
rememberObservedRevision(slug, relPath, file?.revision);
},
// Editor saves publish through the CAS PUT (findings D-3 client half): the
// expected revision is the model's ANCESTRY (baseRevisions), never the
// latest observed metadata — so a save issued after a conflict cannot
// silently overwrite the winner. The multipart POST remains the bulk
// import path only (deliberately unconditional; see files.ts route note).
async uploadFileBytes(slug, relPath, bytes, signal) {
const key = revisionKey(slug, relPath);
const expectedRevision = baseRevisions.get(key) ?? 0;
let res: Response;
try {
res = await fetch(fileUrl(slug, relPath), {
method: "PUT",
body: bytes as BodyInit,
credentials: "include",
signal,
headers: {
"content-type": "application/octet-stream",
[PROJECT_FILE_REVISION_HEADER]: String(expectedRevision),
},
});
} catch {
// Even an AbortError can arrive after the server published the CAS but
// before this client received its acknowledgement. Hook retirement
// ignores the result; a live caller must treat it as ambiguous.
return {
kind: "unknown",
message: `Save state unknown: ${relPath} — reload or save a copy`,
};
}
const responseRevision = rememberResponseRevision(slug, relPath, res);
if (res.status === 409) {
const conflict = (await res.json().catch(() => null)) as {
current?: ProjectFile | null;
} | null;
rememberObservedRevision(slug, relPath, conflict?.current?.revision);
const headerText = res.headers.get(PROJECT_FILE_REVISION_HEADER);
const headerRevision =
headerText !== null && /^\d+$/.test(headerText)
? Number(headerText)
: NaN;
const currentRevision =
conflict?.current?.revision ??
(Number.isSafeInteger(headerRevision) && headerRevision >= 0
? headerRevision
: 0);
return {
kind: "conflict",
message: `Save conflict: ${relPath} (local base ${expectedRevision}, server ${currentRevision}) — reload or merge, or save a copy`,
};
}
if (!res.ok) {
// These answers are produced before body publication. A generic 5xx
// can happen after publication but before the response is encoded, so
// its commit state is unknown and the hook must block the path.
const prePublish = new Set([400, 401, 403, 404, 413, 415, 428]);
return prePublish.has(res.status)
? {
kind: "not-committed",
message: `Save failed (${res.status}): ${relPath}`,
}
: {
kind: "unknown",
message: `Save state unknown (${res.status}): ${relPath} — reload or save a copy`,
};
}
let savedRevision: number | undefined;
try {
const saved = (await res.json()) as Partial<ProjectFile>;
if (
Number.isSafeInteger(saved.revision) &&
(saved.revision as number) >= 0
) {
savedRevision = saved.revision;
rememberObservedRevision(slug, relPath, savedRevision);
}
} catch {
// A conforming response also carries the revision header, so malformed
// JSON need not make an otherwise acknowledged commit ambiguous.
}
if (
savedRevision !== undefined &&
responseRevision !== undefined &&
savedRevision !== responseRevision
) {
return {
kind: "unknown",
message: `Save returned inconsistent revisions for ${relPath} — reload or save a copy`,
};
}
const committedRevision = savedRevision ?? responseRevision;
if (committedRevision === undefined) {
return {
kind: "unknown",
message: `Save committed without a revision: ${relPath} — reload or save a copy`,
};
}
if (committedRevision <= expectedRevision) {
return {
kind: "unknown",
message: `Save returned a non-advancing revision for ${relPath} — reload or save a copy`,
};
}
baseRevisions.set(key, committedRevision);
return SAVE_COMMITTED;
},
};
}
@ -308,12 +480,16 @@ function compositeProjectSource(
getProject: (slug) => route(slug).then((s) => s.getProject(slug)),
fetchFileBytes: (slug, p, meta) =>
route(slug).then((s) => s.fetchFileBytes(slug, p, meta)),
uploadFileBytes: async (slug, p, bytes) => {
uploadFileBytes: async (slug, p, bytes, signal) => {
const s = await route(slug);
if (s.uploadFileBytes) return s.uploadFileBytes(slug, p, bytes);
if (s.uploadFileBytes) return s.uploadFileBytes(slug, p, bytes, signal);
// Read-only gallery project being edited → download (api.ts also guards).
throw new ReadOnlyProjectError(slug);
},
refreshFileRevision: async (slug, p) => {
const s = await route(slug);
await s.refreshFileRevision?.(slug, p);
},
};
}

View file

@ -22,7 +22,7 @@ import { ToolGrid } from "@/components/ToolGrid";
import { ProjectsSection } from "@/components/ProjectsSection";
import { WaitlistForm } from "@/components/WaitlistForm";
import { NewFileDialog } from "@/components/NewFileDialog";
import type { SaveBytes } from "@/wasm/save-flow";
import { SAVE_COMMITTED, type SaveBytes } from "@/wasm/save-flow";
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
import { StorageUsageCard } from "@/components/StorageUsageCard";
import { WasmTool } from "@/components/WasmTool";
@ -82,6 +82,8 @@ async function buildFsaProject(root: FileSystemDirectoryHandle): Promise<LocalPr
const writable = await handle.createWritable();
await writable.write(bytes as unknown as FileSystemWriteChunkType);
await writable.close();
// An awaited close IS the local-disk commit (SaveOutcome contract).
return SAVE_COMMITTED;
},
};
}
@ -111,7 +113,10 @@ function buildLocalProject(fileList: FileList): LocalProject {
return new Uint8Array(await f.arrayBuffer());
},
// A webkitdirectory FileList is read-only — saves become downloads.
saveBytes: async (relPath, bytes) => downloadBytes(relPath, bytes),
saveBytes: async (relPath, bytes) => {
downloadBytes(relPath, bytes);
return SAVE_COMMITTED;
},
};
}

View file

@ -103,7 +103,8 @@ export function ToolPage() {
saveBytes={
readOnly
? undefined
: (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
: (relPath, bytes, signal) =>
uploadFileBytes(slug, relPath, bytes, signal)
}
createFile={
readOnly

View file

@ -1,13 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MEMFS_PROJECTS_DIR } from "./constants";
import { registerSaveHook, type SaveHookWindow } from "./save-flow";
import {
registerSaveHook,
SAVE_COMMITTED,
type SaveBlock,
type SaveHookWindow,
type SaveOutcome,
} from "./save-flow";
const SLUG = "myproj";
const HOME = MEMFS_PROJECTS_DIR; // …/projects (editor's default "projects home")
const PROJ = `${HOME}/${SLUG}`; // …/projects/myproj (this project's own folder)
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
afterEach(() => vi.useRealTimers());
function setup() {
const saveBytes = vi.fn(async () => {});
const saveBytes = vi.fn(async () => SAVE_COMMITTED);
const onSaved = vi.fn();
const win: SaveHookWindow = {
FS: { readFile: () => new Uint8Array([1, 2, 3]) } as unknown as SaveHookWindow["FS"],
@ -22,14 +44,22 @@ describe("registerSaveHook path routing", () => {
const { fire, saveBytes, onSaved } = setup();
fire(`${PROJ}/sub/sheet.kicad_sch`);
expect(onSaved).toHaveBeenCalledWith("sub/sheet.kicad_sch");
expect(saveBytes).toHaveBeenCalledWith("sub/sheet.kicad_sch", expect.any(Uint8Array));
expect(saveBytes).toHaveBeenCalledWith(
"sub/sheet.kicad_sch",
expect.any(Uint8Array),
expect.any(AbortSignal),
);
});
it("routes a bare file saved in the editor's default projects home to the project root", () => {
const { fire, saveBytes, onSaved } = setup();
fire(`${HOME}/main.kicad_sch`);
expect(onSaved).toHaveBeenCalledWith("main.kicad_sch");
expect(saveBytes).toHaveBeenCalledWith("main.kicad_sch", expect.any(Uint8Array));
expect(saveBytes).toHaveBeenCalledWith(
"main.kicad_sch",
expect.any(Uint8Array),
expect.any(AbortSignal),
);
});
it("ignores a save outside the projects tree", () => {
@ -81,3 +111,444 @@ describe("registerSaveHook path routing", () => {
expect(log).toHaveBeenCalledWith(expect.stringContaining("onSavedText read failed"));
});
});
describe("registerSaveHook persistence ordering", () => {
it("coalesces 1,000 same-path saves to one active and the immutable latest", async () => {
const source = new Uint8Array([0, 0]);
const writes: Array<{
path: string;
bytes: Uint8Array;
completion: ReturnType<typeof deferred<SaveOutcome>>;
}> = [];
const saveBytes = vi.fn((path: string, bytes: Uint8Array) => {
const completion = deferred<SaveOutcome>();
writes.push({ path, bytes, completion });
return completion.promise;
});
const win: SaveHookWindow = {
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
saveBytes,
log: () => {},
onStatus: () => {},
});
for (let revision = 0; revision < 1_000; revision++) {
source[0] = revision & 0xff;
source[1] = revision >>> 8;
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
}
expect(saveBytes).toHaveBeenCalledTimes(1);
expect([...writes[0]!.bytes]).toEqual([0, 0]);
writes[0]!.completion.resolve(SAVE_COMMITTED);
await flushMicrotasks();
expect(saveBytes).toHaveBeenCalledTimes(2);
expect(writes.map((write) => write.path)).toEqual([
"board.kicad_pcb",
"board.kicad_pcb",
]);
expect([...writes[1]!.bytes]).toEqual([999 & 0xff, 999 >>> 8]);
writes[1]!.completion.resolve(SAVE_COMMITTED);
await flushMicrotasks();
expect(saveBytes).toHaveBeenCalledTimes(2);
});
it("drops a not-committed latest, releases capacity, and permits an explicit retry", async () => {
const source = new Uint8Array([1]);
const writes: Array<{
bytes: Uint8Array;
completion: ReturnType<typeof deferred<SaveOutcome>>;
}> = [];
const saveBytes = vi.fn((_path: string, bytes: Uint8Array) => {
const completion = deferred<SaveOutcome>();
writes.push({ bytes, completion });
return completion.promise;
});
const statuses: string[] = [];
const win: SaveHookWindow = {
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
saveBytes,
log: () => {},
onStatus: (status) => statuses.push(status),
maxRetainedBytes: 2,
});
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
source[0] = 2;
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
writes[0]!.completion.resolve({
kind: "not-committed",
message: "Save rejected before publication",
});
await flushMicrotasks();
// The newest status generation belongs to the pending snapshot, but it
// cannot be promoted without proof that its predecessor committed.
expect(saveBytes).toHaveBeenCalledTimes(1);
expect(statuses.at(-1)).toBe("Save rejected before publication");
// A not-committed result is safe to retry explicitly, and both retained
// bytes were released before this new snapshot is admitted.
source[0] = 3;
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
expect(saveBytes).toHaveBeenCalledTimes(2);
expect([...writes[1]!.bytes]).toEqual([3]);
writes[1]!.completion.resolve(SAVE_COMMITTED);
await flushMicrotasks();
expect(statuses.at(-1)).toBe("Saved board.kicad_pcb ✓");
});
it("absorbs a conflict, keeps callbacks alive, and leaves other paths usable", async () => {
const source = new Uint8Array([1]);
const writes: Array<ReturnType<typeof deferred<SaveOutcome>>> = [];
const saveBytes = vi.fn((_path: string, _bytes: Uint8Array) => {
const completion = deferred<SaveOutcome>();
writes.push(completion);
return completion.promise;
});
const statuses: string[] = [];
const blocks: SaveBlock[] = [];
const onSaved = vi.fn();
const onSavedText = vi.fn();
const readFile = vi.fn(() => source);
const win: SaveHookWindow = {
FS: { readFile } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
saveBytes,
onSaved,
onSavedText,
log: () => {},
onStatus: (status) => statuses.push(status),
onBlocked: (block) => blocks.push(block),
// Active + pending fill the cap. Starting a later explicit save proves
// conflict retirement released both snapshots exactly once.
maxRetainedBytes: 2,
});
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`); // active A
source[0] = 2;
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`); // captured B
writes[0]!.resolve({
kind: "conflict",
message:
"Save conflict: board.kicad_pcb (local base 4, server 5) — reload or merge, or save a copy",
});
await flushMicrotasks();
// B was captured under revision 4. It must never be promoted or retried
// against a merely observed remote revision.
expect(saveBytes).toHaveBeenCalledTimes(1);
expect(blocks).toEqual([
{
relPath: "board.kicad_pcb",
kind: "conflict",
message:
"Save conflict: board.kicad_pcb (local base 4, server 5) — reload or merge, or save a copy",
},
]);
expect(statuses.at(-1)).toContain("reload or merge");
// A third native Save notification still feeds collaboration callbacks,
// but the poisoned path does not read a persistence snapshot or call PUT.
source[0] = 3;
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
expect(onSaved).toHaveBeenCalledTimes(3);
expect(onSavedText).toHaveBeenCalledTimes(3);
expect(saveBytes).toHaveBeenCalledTimes(1);
// A and B each read once for text and once for persistence. C reads only
// for the still-live text callback; it creates no byte snapshot.
expect(readFile).toHaveBeenCalledTimes(5);
// Conflict retirement released active + pending exactly once. Another
// path can use the full one-byte capacity and remains independent.
win.kicadCollab!.onSave!(`${PROJ}/other.kicad_sch`);
expect(saveBytes).toHaveBeenCalledTimes(2);
expect(saveBytes.mock.calls[1]![0]).toBe("other.kicad_sch");
writes[1]!.resolve(SAVE_COMMITTED);
});
it("turns an unexpected throw into an absorbing unknown block", async () => {
const completion = deferred<SaveOutcome>();
const saveBytes = vi.fn(() => completion.promise);
const onBlocked = vi.fn();
const onSaved = vi.fn();
const win: SaveHookWindow = {
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
saveBytes,
onSaved,
onBlocked,
log: () => {},
onStatus: () => {},
});
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
completion.reject(new Error("connection disappeared"));
await flushMicrotasks();
expect(onBlocked).toHaveBeenCalledWith({
relPath: "board.kicad_pcb",
kind: "unknown",
message: "Save state unknown: board.kicad_pcb — reload or save a copy",
});
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
expect(onSaved).toHaveBeenCalledTimes(2);
expect(saveBytes).toHaveBeenCalledOnce();
});
it("keeps different paths concurrent", () => {
const completions: Array<ReturnType<typeof deferred<SaveOutcome>>> = [];
const saveBytes = vi.fn((_path: string, _bytes: Uint8Array) => {
const completion = deferred<SaveOutcome>();
completions.push(completion);
return completion.promise;
});
const win: SaveHookWindow = {
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
saveBytes,
log: () => {},
onStatus: () => {},
});
win.kicadCollab!.onSave!(`${PROJ}/a.kicad_sch`);
win.kicadCollab!.onSave!(`${PROJ}/b.kicad_sch`);
expect(saveBytes).toHaveBeenCalledTimes(2);
expect(saveBytes.mock.calls.map(([path]) => path)).toEqual([
"a.kicad_sch",
"b.kicad_sch",
]);
for (const completion of completions) completion.resolve(SAVE_COMMITTED);
});
it("does not let an older completion or clear timer overwrite newer status", async () => {
vi.useFakeTimers();
const completions = new Map<string, ReturnType<typeof deferred<SaveOutcome>>>();
const saveBytes = vi.fn((path: string) => {
const completion = deferred<SaveOutcome>();
completions.set(path, completion);
return completion.promise;
});
const statuses: string[] = [];
const win: SaveHookWindow = {
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
saveBytes,
log: () => {},
onStatus: (status) => statuses.push(status),
});
win.kicadCollab!.onSave!(`${PROJ}/old.kicad_sch`);
win.kicadCollab!.onSave!(`${PROJ}/new.kicad_sch`);
completions.get("new.kicad_sch")!.resolve(SAVE_COMMITTED);
await flushMicrotasks();
expect(statuses.at(-1)).toBe("Saved new.kicad_sch ✓");
completions.get("old.kicad_sch")!.resolve(SAVE_COMMITTED);
await flushMicrotasks();
expect(statuses.at(-1)).toBe("Saved new.kicad_sch ✓");
await vi.advanceTimersByTimeAsync(2500);
expect(statuses.at(-1)).toBe("");
});
it("refuses excess retained bytes and excess distinct paths before copying", async () => {
const source = new Uint8Array([1, 2]);
const completions: Array<ReturnType<typeof deferred<SaveOutcome>>> = [];
const saveBytes = vi.fn(() => {
const completion = deferred<SaveOutcome>();
completions.push(completion);
return completion.promise;
});
const logs: string[] = [];
const statuses: string[] = [];
const win: SaveHookWindow = {
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
saveBytes,
log: (line) => logs.push(line),
onStatus: (status) => statuses.push(status),
maxRetainedBytes: 2,
maxPaths: 1,
});
win.kicadCollab!.onSave!(`${PROJ}/a.kicad_sch`);
win.kicadCollab!.onSave!(`${PROJ}/b.kicad_sch`);
expect(saveBytes).toHaveBeenCalledTimes(1);
expect(logs.at(-1)).toContain("1 active paths");
expect(statuses.at(-1)).toContain("queue is full");
// A newer same-path revision replaces the pending slot, but the active
// two-byte snapshot already owns the complete byte budget.
source[0] = 3;
win.kicadCollab!.onSave!(`${PROJ}/a.kicad_sch`);
expect(saveBytes).toHaveBeenCalledTimes(1);
expect(logs.at(-1)).toContain("retained bytes");
completions[0]!.resolve(SAVE_COMMITTED);
await flushMicrotasks();
win.kicadCollab!.onSave!(`${PROJ}/b.kicad_sch`);
expect(saveBytes).toHaveBeenCalledTimes(2);
completions[1]!.resolve(SAVE_COMMITTED);
});
});
describe("registerSaveHook lifetime", () => {
it("aborts the active transport, drops pending and makes completion inert", async () => {
const source = new Uint8Array([1]);
const active = deferred<SaveOutcome>();
let activeSignal: AbortSignal | undefined;
const saveBytes = vi.fn(
(_path: string, _bytes: Uint8Array, signal?: AbortSignal) => {
activeSignal = signal;
return active.promise;
},
);
const statuses: string[] = [];
const logs: string[] = [];
const win: SaveHookWindow = {
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
const handle = registerSaveHook(win, {
slug: SLUG,
saveBytes,
log: (line) => logs.push(line),
onStatus: (status) => statuses.push(status),
});
const installed = win.kicadCollab!.onSave!;
installed(`${PROJ}/board.kicad_pcb`);
source[0] = 2;
installed(`${PROJ}/board.kicad_pcb`);
expect(saveBytes).toHaveBeenCalledTimes(1);
handle.stop();
expect(activeSignal?.aborted).toBe(true);
expect(win.kicadCollab!.onSave).toBeUndefined();
installed(`${PROJ}/ignored.kicad_pcb`);
active.resolve(SAVE_COMMITTED);
await flushMicrotasks();
// The pending revision never starts, and the already-running revision does
// not report through the retired component lifetime when it settles.
expect(saveBytes).toHaveBeenCalledTimes(1);
expect(statuses).toEqual([
"Saving board.kicad_pcb…",
"Saving board.kicad_pcb…",
]);
expect(logs).toEqual([]);
});
it("does not start a queued latest write when stop aborts the active one", async () => {
const source = new Uint8Array([1]);
const active = deferred<SaveOutcome>();
const saveBytes = vi.fn(() => active.promise);
const win: SaveHookWindow = {
FS: { readFile: () => source } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
const handle = registerSaveHook(win, {
slug: SLUG,
saveBytes,
log: () => {},
onStatus: () => {},
});
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
source[0] = 2;
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
handle.stop();
active.reject(new DOMException("Aborted", "AbortError"));
await flushMicrotasks();
expect(saveBytes).toHaveBeenCalledOnce();
});
it("does not detach a replacement hook and gives the remount a fresh cap", () => {
const oldWrite = deferred<SaveOutcome>();
const oldSave = vi.fn(() => oldWrite.promise);
const newSave = vi.fn(async () => SAVE_COMMITTED);
const win: SaveHookWindow = {
FS: { readFile: () => new Uint8Array([1, 2]) } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
const oldHandle = registerSaveHook(win, {
slug: SLUG,
saveBytes: oldSave,
log: () => {},
onStatus: () => {},
maxRetainedBytes: 2,
maxPaths: 1,
});
win.kicadCollab!.onSave!(`${PROJ}/old.kicad_sch`);
const newHandle = registerSaveHook(win, {
slug: SLUG,
saveBytes: newSave,
log: () => {},
onStatus: () => {},
maxRetainedBytes: 2,
maxPaths: 1,
});
const replacement = win.kicadCollab!.onSave;
oldHandle.stop();
expect(win.kicadCollab!.onSave).toBe(replacement);
win.kicadCollab!.onSave!(`${PROJ}/new.kicad_sch`);
expect(newSave).toHaveBeenCalledOnce();
newHandle.stop();
expect(win.kicadCollab!.onSave).toBeUndefined();
oldWrite.resolve(SAVE_COMMITTED);
});
it("cancels a pending success-clear timer on stop", async () => {
vi.useFakeTimers();
const statuses: string[] = [];
const win: SaveHookWindow = {
FS: { readFile: () => new Uint8Array([1]) } as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
const handle = registerSaveHook(win, {
slug: SLUG,
saveBytes: async () => SAVE_COMMITTED,
log: () => {},
onStatus: (status) => statuses.push(status),
});
win.kicadCollab!.onSave!(`${PROJ}/board.kicad_pcb`);
await flushMicrotasks();
expect(statuses.at(-1)).toBe("Saved board.kicad_pcb ✓");
expect(vi.getTimerCount()).toBe(1);
handle.stop();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(2500);
expect(statuses.at(-1)).toBe("Saved board.kicad_pcb ✓");
});
});

View file

@ -1,5 +1,21 @@
import { MEMFS_PROJECTS_DIR, memfsProjectDir } from "./constants";
/** Transport-independent result of one persistence attempt. */
export type SaveOutcome =
| { kind: "committed" }
| { kind: "not-committed"; message?: string }
| { kind: "conflict"; message?: string }
| { kind: "unknown"; message?: string };
export const SAVE_COMMITTED = Object.freeze({ kind: "committed" } as const);
/** A path whose in-memory ancestry is no longer safe to publish. */
export interface SaveBlock {
relPath: string;
kind: "conflict" | "unknown";
message: string;
}
/**
* Persist one saved file's bytes outside MEMFS. The counterpart of
* `fetchBytes` on the load side: each page decides the destination
@ -7,13 +23,63 @@ import { MEMFS_PROJECTS_DIR, memfsProjectDir } from "./constants";
* folders). Absent saves stay MEMFS-only (e.g. Y.Doc-backed sessions,
* where the provider already persists the document).
*/
export type SaveBytes = (relPath: string, bytes: Uint8Array) => Promise<void>;
export type SaveBytes = (
relPath: string,
bytes: Uint8Array,
signal?: AbortSignal,
) => Promise<SaveOutcome>;
/** Active plus latest save snapshots retained by one registered hook. */
export const MAX_RETAINED_SAVE_BYTES = 64 * 1024 * 1024;
/** One active lane per distinct path; each lane retains at most two snapshots. */
export const MAX_SAVE_PATHS = 256;
interface SaveSnapshot {
bytes: Uint8Array;
generation: number;
}
interface SaveLane {
active?: SaveSnapshot;
pending?: SaveSnapshot;
activeController?: AbortController;
}
function normalizeSaveOutcome(outcome: unknown, relPath: string): SaveOutcome {
if (typeof outcome === "object" && outcome !== null && "kind" in outcome) {
const candidate = outcome as { kind?: unknown; message?: unknown };
if (
candidate.kind === "committed" ||
candidate.kind === "not-committed" ||
candidate.kind === "conflict" ||
candidate.kind === "unknown"
) {
return typeof candidate.message === "string"
? { kind: candidate.kind, message: candidate.message }
: { kind: candidate.kind };
}
}
return {
kind: "unknown",
message: `Save state unknown: ${relPath} — reload or save a copy`,
};
}
export interface SaveHookWindow {
FS?: EmscriptenFS;
kicadCollab?: { onSave?: (absPath: string) => void };
}
export interface SaveHookHandle {
/**
* Retire this exact hook lifetime.
*
* Aborts every active transport, releases every queued latest snapshot and
* removes this hook only when it still owns the global callback slot.
*/
stop(): void;
}
/**
* Register the C++ JS save notification sink (`window.kicadCollab.onSave`).
* The kicad fork fires it from each tool's save chokepoint (SaveDrawingSheetFile /
@ -41,8 +107,22 @@ export function registerSaveHook(
* document state (title block, paper, setup) into the room doc.
*/
onSavedText?: (relPath: string, text: string) => void;
/** Test/host override. Admission fails before copying above this total. */
maxRetainedBytes?: number;
/** Test/host override for concurrently active distinct paths. */
maxPaths?: number;
/** Durable per-path safety block; only hook retirement clears it. */
onBlocked?: (block: SaveBlock) => void;
},
): void {
): SaveHookHandle {
const maxRetainedBytes = opts.maxRetainedBytes ?? MAX_RETAINED_SAVE_BYTES;
const maxPaths = opts.maxPaths ?? MAX_SAVE_PATHS;
if (!Number.isSafeInteger(maxRetainedBytes) || maxRetainedBytes < 0) {
throw new RangeError(`maxRetainedBytes must be a non-negative safe integer`);
}
if (!Number.isSafeInteger(maxPaths) || maxPaths < 1) {
throw new RangeError(`maxPaths must be a positive safe integer`);
}
const projectPrefix = `${memfsProjectDir(opts.slug)}/`;
// The editor's default "projects" home (KiCad's GetDefaultUserProjectsPath) — one
// level above this project's own folder. A blank editor's Save-As lands HERE, not in
@ -50,6 +130,16 @@ export function registerSaveHook(
// holds exactly one project in MEMFS, so such a file belongs to it. (Files under the
// project's own folder still take the first branch, with their full relative path.)
const projectsHome = `${MEMFS_PROJECTS_DIR}/`;
// Each file has one active write and one replaceable latest snapshot. This
// keeps call order without retaining every intermediate Ctrl+S payload.
const persistenceLanes = new Map<string, SaveLane>();
const blockedPaths = new Map<string, SaveBlock>();
let retainedBytes = 0;
// The status surface is global. Only the newest save notification may
// publish an asynchronous result or clear a newer result.
let statusGeneration = 0;
let statusClearTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
/** Saved MEMFS path → project-relative path, or null if it's outside the project. */
const toRelPath = (absPath: string): string | null => {
@ -61,7 +151,144 @@ export function registerSaveHook(
return null;
};
const releaseSnapshot = (snapshot: SaveSnapshot | undefined): void => {
if (!snapshot) return;
retainedBytes -= snapshot.bytes.byteLength;
};
const reportCapacityFailure = (
relPath: string,
generation: number,
reason: "paths" | "bytes",
bytes: number,
): void => {
const detail =
reason === "paths"
? `${maxPaths} active paths`
: `${retainedBytes + bytes} > ${maxRetainedBytes} retained bytes`;
opts.log(`[save] FAILED to queue ${relPath}: save queue capacity (${detail})`);
if (generation === statusGeneration) {
opts.onStatus(`Save failed: ${relPath} — save queue is full`);
}
};
const retireUncommittedLane = (
relPath: string,
lane: SaveLane,
snapshot: SaveSnapshot,
controller: AbortController,
): number => {
const terminalGeneration = lane.pending?.generation ?? snapshot.generation;
releaseSnapshot(lane.pending);
lane.pending = undefined;
releaseSnapshot(snapshot);
lane.active = undefined;
if (lane.activeController === controller) lane.activeController = undefined;
persistenceLanes.delete(relPath);
return terminalGeneration;
};
const startPersistence = (relPath: string, lane: SaveLane): void => {
const snapshot = lane.active;
if (!snapshot) return;
const controller = new AbortController();
lane.activeController = controller;
let persistence: Promise<SaveOutcome>;
try {
persistence = Promise.resolve(
opts.saveBytes!(relPath, snapshot.bytes, controller.signal),
);
} catch (error) {
persistence = Promise.reject(error);
}
void persistence
.then(
(rawOutcome) => {
if (stopped) return;
const outcome = normalizeSaveOutcome(rawOutcome, relPath);
if (outcome.kind !== "committed") {
const terminalGeneration = retireUncommittedLane(
relPath,
lane,
snapshot,
controller,
);
if (outcome.kind === "conflict" || outcome.kind === "unknown") {
const block: SaveBlock = {
relPath,
kind: outcome.kind,
message:
outcome.message ??
(outcome.kind === "conflict"
? `Save conflict: ${relPath} — reload or merge, or save a copy`
: `Save state unknown: ${relPath} — reload or save a copy`),
};
blockedPaths.set(relPath, block);
opts.log(`[save] BLOCKED ${relPath}: ${block.message}`);
opts.onBlocked?.(block);
if (terminalGeneration === statusGeneration) {
opts.onStatus(block.message);
}
} else if (terminalGeneration === statusGeneration) {
opts.onStatus(
outcome.message ?? `Save failed: ${relPath} — see console`,
);
}
return;
}
opts.log(
`[save] ${relPath} persisted (${snapshot.bytes.length} bytes)`,
);
if (snapshot.generation !== statusGeneration) return;
opts.onStatus(`Saved ${relPath}`);
statusClearTimer = setTimeout(() => {
statusClearTimer = undefined;
if (snapshot.generation === statusGeneration) opts.onStatus("");
}, 2500);
},
(error) => {
if (stopped) return;
opts.log(`[save] FAILED to persist ${relPath}: ${String(error)}`);
// An unexpected throw has unknown commit state. It is equivalent to
// an explicit unknown outcome and permanently blocks this path.
const terminalGeneration = retireUncommittedLane(
relPath,
lane,
snapshot,
controller,
);
const block: SaveBlock = {
relPath,
kind: "unknown",
message: `Save state unknown: ${relPath} — reload or save a copy`,
};
blockedPaths.set(relPath, block);
opts.onBlocked?.(block);
if (terminalGeneration === statusGeneration) {
opts.onStatus(block.message);
}
},
)
.finally(() => {
if (stopped) return;
if (lane.active !== snapshot) return;
if (lane.activeController === controller) lane.activeController = undefined;
releaseSnapshot(snapshot);
// Rejections retire the lane in the handler above and return at the
// guard. Only a positively acknowledged save may promote its latest
// captured successor.
lane.active = lane.pending;
lane.pending = undefined;
if (lane.active) startPersistence(relPath, lane);
else persistenceLanes.delete(relPath);
});
};
const onSave = (absPath: string) => {
if (stopped) return;
const relPath = toRelPath(absPath);
if (relPath === null) {
opts.log(`[save] ignoring save outside project dir: ${absPath}`);
@ -81,37 +308,104 @@ export function registerSaveHook(
}
}
// The callbacks above still observe native saves for collab/layout state,
// but a poisoned file ancestry cannot publish bytes in this hook lifetime.
if (blockedPaths.has(relPath)) return;
if (!opts.saveBytes) {
opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`);
return;
}
let bytes: Uint8Array;
const generation = ++statusGeneration;
if (statusClearTimer) {
clearTimeout(statusClearTimer);
statusClearTimer = undefined;
}
let data: Uint8Array;
try {
const data = win.FS?.readFile(absPath);
data = win.FS?.readFile(absPath) as Uint8Array;
if (!(data instanceof Uint8Array)) throw new Error("FS.readFile returned no bytes");
bytes = data;
} catch (err) {
opts.log(`[save] FAILED to read ${absPath} back from MEMFS: ${String(err)}`);
opts.onStatus(`Save failed: ${relPath}`);
if (generation === statusGeneration) opts.onStatus(`Save failed: ${relPath}`);
return;
}
opts.onStatus(`Saving ${relPath}`);
void opts
.saveBytes(relPath, bytes)
.then(() => {
opts.log(`[save] ${relPath} persisted (${bytes.length} bytes)`);
opts.onStatus(`Saved ${relPath}`);
setTimeout(() => opts.onStatus(""), 2500);
})
.catch((err) => {
opts.log(`[save] FAILED to persist ${relPath}: ${String(err)}`);
opts.onStatus(`Save failed: ${relPath} — see console`);
});
let lane = persistenceLanes.get(relPath);
if (!lane && persistenceLanes.size >= maxPaths) {
reportCapacityFailure(relPath, generation, "paths", data.byteLength);
return;
}
// A newer notification makes the old pending snapshot obsolete even when
// the new payload cannot be admitted. Never persist an intermediate state
// after reporting that the latest save failed admission.
if (lane?.pending) {
releaseSnapshot(lane.pending);
lane.pending = undefined;
}
if (data.byteLength > maxRetainedBytes - retainedBytes) {
reportCapacityFailure(relPath, generation, "bytes", data.byteLength);
return;
}
// Copy only after capacity admission. FS adapters may return a mutable view
// which a later native save reuses.
let snapshot: SaveSnapshot;
try {
snapshot = { bytes: data.slice(), generation };
} catch (error) {
opts.log(`[save] FAILED to snapshot ${relPath}: ${String(error)}`);
if (generation === statusGeneration) {
opts.onStatus(`Save failed: ${relPath} — not enough memory`);
}
return;
}
retainedBytes += snapshot.bytes.byteLength;
if (!lane) {
lane = { active: snapshot };
persistenceLanes.set(relPath, lane);
startPersistence(relPath, lane);
} else if (!lane.active) {
lane.active = snapshot;
startPersistence(relPath, lane);
} else {
lane.pending = snapshot;
}
};
// Spread-merge like moduleItemsBridge does, so sibling hooks (onItems/onDelta)
// registered before or after survive.
win.kicadCollab = { ...win.kicadCollab, onSave };
return {
stop() {
if (stopped) return;
stopped = true;
// Invalidate every asynchronous completion before releasing state. An
// already-started SaveBytes Promise still owns its immutable argument,
// but it cannot publish status or advance to the queued snapshot.
statusGeneration++;
if (statusClearTimer) clearTimeout(statusClearTimer);
statusClearTimer = undefined;
for (const lane of persistenceLanes.values()) {
lane.activeController?.abort();
lane.activeController = undefined;
lane.active = undefined;
lane.pending = undefined;
}
persistenceLanes.clear();
retainedBytes = 0;
// A newer registration may have replaced the global slot. Never remove
// another lifetime's hook when this older handle retires late.
if (win.kicadCollab?.onSave === onSave) {
delete win.kicadCollab.onSave;
}
},
};
}