findings Q-2: a non-target sibling fetch failure no longer aborts the open

syncProjectToMemfs rethrew the first rejected fetchBytes whatever file it
was, so one missing/unreadable sibling (a .kicad_sch body gone, a Q-1
phantom row, a transient 5xx) took the whole board open down behind
"download failed (404)". Now only the TARGET's failure rejects; siblings
are logged, counted and reported through onStatus, and KiCad reports a
missing sheet itself. kicad-runner.test.ts pinned the old contract with a
non-target file — corrected to a target; new kicad-runner.findings-q.test
gates both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9KFksoViNPYYs1ygkcAoQ
This commit is contained in:
Gergő Törcsvári 2026-08-28 15:15:08 +02:00
commit d069b7be80
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 127 additions and 11 deletions

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { driveProjectIntoTool, type DriveOptions } from "./kicad-runner";
import { memfsFilePath } from "./constants";
/**
* Findings Q-2 (docs/features/findings/groups/Q-): a 404 on a NON-TARGET
* sibling must degrade, not abort the open. Today `syncProjectToMemfs`
* rethrows the first rejected fetch, so one missing `.kicad_sch` beside the
* board (or a Q-1 phantom row the listing invented) takes the whole editor
* boot down behind a "download failed (404)" that reads as corruption.
* The TARGET file failing must still reject there is nothing to open.
*/
interface FakeFS {
files: Map<string, Uint8Array>;
}
function fakeWin(): { win: Parameters<typeof driveProjectIntoTool>[0]; fs: FakeFS } {
const fs: FakeFS = { files: new Map() };
const win = {
FS: {
writeFile: (path: string, data: Uint8Array) => {
fs.files.set(path, data);
},
mkdirTree: () => {},
readFile: () => new Uint8Array(),
analyzePath: () => ({ exists: false }),
},
} as unknown as Parameters<typeof driveProjectIntoTool>[0];
return { win, fs };
}
function opts(
files: string[],
fetchBytes: DriveOptions["fetchBytes"],
extra: Partial<DriveOptions> = {},
): DriveOptions {
return {
tool: "pcbnew",
slug: "proj",
files: files.map((path) => ({ path })),
fetchBytes,
log: () => {},
onStatus: () => {},
...extra,
};
}
describe("Q-2 · sibling fetch failures during MEMFS staging", () => {
it("a 404 on a non-target sibling is logged and skipped; every other file still stages", async () => {
const { win, fs } = fakeWin();
const files = ["board.kicad_pcb", "board.kicad_sch", "sub sheet.kicad_sch"];
const logs: string[] = [];
// No targetPath → the run stops after staging (no open flow), like the
// sibling tests in kicad-runner.test.ts. The failing file is NOT the
// target here either way.
await driveProjectIntoTool(
win,
opts(
files,
async (p) => {
if (p === "sub sheet.kicad_sch") {
throw new Error(`download failed (404): ${p}`);
}
return new TextEncoder().encode(`body:${p}`);
},
{ log: (m) => logs.push(m) },
),
);
expect(fs.files.has(memfsFilePath("proj", "board.kicad_pcb"))).toBe(true);
expect(fs.files.has(memfsFilePath("proj", "board.kicad_sch"))).toBe(true);
expect(fs.files.has(memfsFilePath("proj", "sub sheet.kicad_sch"))).toBe(false);
expect(logs.some((l) => l.includes("sub sheet.kicad_sch") && l.includes("404"))).toBe(
true,
);
});
it("the target file failing still rejects (nothing to open)", async () => {
const { win } = fakeWin();
await expect(
driveProjectIntoTool(
win,
opts(
["board.kicad_pcb", "board.kicad_sch"],
async (p) => {
if (p === "board.kicad_pcb") throw new Error(`download failed (404): ${p}`);
return new TextEncoder().encode(`body:${p}`);
},
{ targetPath: "board.kicad_pcb" },
),
),
).rejects.toThrow("board.kicad_pcb");
});
});

View file

@ -112,18 +112,18 @@ describe("MEMFS project staging", () => {
expect(seen.at(-1)?.[1]).toBe(files.length);
});
it("rejects when a fetch fails", async () => {
it("rejects when the TARGET's fetch fails (siblings: see kicad-runner.findings-q.test)", async () => {
const { win } = fakeWin();
const files = ["ok1.kicad_sym", "bad.kicad_sym", "ok2.kicad_sym"];
const files = ["ok1.kicad_sym", "bad.kicad_pcb", "ok2.kicad_sym"];
await expect(
driveProjectIntoTool(
win,
opts(files, async (p) => {
if (p === "bad.kicad_sym") throw new Error("fetch exploded");
driveProjectIntoTool(win, {
...opts(files, async (p) => {
if (p === "bad.kicad_pcb") throw new Error("fetch exploded");
return new Uint8Array([1]);
}),
),
targetPath: "bad.kicad_pcb",
}),
).rejects.toThrow("fetch exploded");
});
});

View file

@ -191,20 +191,41 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
: Promise.resolve(opts.files));
const queue = [...perFile];
const skipped: string[] = [];
const worker = async (): Promise<void> => {
for (let file = queue.shift(); file; file = queue.shift()) {
const bytes = await opts.fetchBytes(file.path);
let bytes: Uint8Array;
try {
bytes = await opts.fetchBytes(file.path);
} catch (err) {
// Findings Q-2: only the TARGET is load-bearing — without its bytes
// there is nothing to open, so that failure still rejects below. A
// sibling that cannot be fetched (a schematic whose body is gone, a
// stale listing row, a transient 5xx) must NOT abort the open of an
// unrelated board: log it, count it, and let KiCad report the missing
// sheet through its own dialog if it ever needs it.
if (file.path === opts.targetPath) throw err;
skipped.push(file.path);
opts.log(`[stage] skipped ${file.path}: ${String(err)}`);
opts.onFileProgress?.(++staged, opts.files.length);
continue;
}
stageOne(file.path, bytes);
}
};
// One rejection fails the stage (same as the serial loop did), but let the
// in-flight siblings settle first so a failure can't leave a fetch writing
// into MEMFS after the caller has moved on.
// A target rejection fails the stage, but let the in-flight siblings settle
// first so a failure can't leave a fetch writing into MEMFS after the caller
// has moved on.
const results = await Promise.allSettled(
Array.from({ length: Math.min(STAGE_CONCURRENCY, queue.length) }, worker),
);
const failed = results.find((r) => r.status === "rejected");
if (failed) throw (failed as PromiseRejectedResult).reason;
if (skipped.length) {
opts.onStatus(
`${skipped.length} project file(s) could not be loaded: ${skipped.join(", ")}`,
);
}
}
/**