diff --git a/cli/src/index.ts b/cli/src/index.ts index 89c71ab..c574fe6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1526,7 +1526,9 @@ function validateMediaTransportCapability(project: SourceProject, errors: string function validateSource(project: SourceProject): string[] { const errors: string[] = []; - const usedProviders = new Set<"time" | "cpu" | "memory" | "audio" | "media">(); + const providerNames = ["time", "cpu", "memory", "audio", "media"] as const; + type ProviderName = (typeof providerNames)[number]; + const usedProviders = new Map>(); const stateVariantAncestry = (node: ts.JsxOpeningElement | ts.JsxSelfClosingElement): "pressable" | "component-boundary" | "none" => { let current: ts.Node | undefined = node.parent; while (current) { @@ -1648,9 +1650,21 @@ function validateSource(project: SourceProject): string[] { } } } - if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useProvider") { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && + (node.expression.text === "useProvider" || node.expression.text === "useProviderSignal")) { const argument = node.arguments[0]; - if (argument && ts.isStringLiteral(argument) && ["time", "cpu", "memory", "audio", "media"].includes(argument.text)) usedProviders.add(argument.text as "time" | "cpu" | "memory" | "audio" | "media"); + if (argument && ts.isStringLiteral(argument) && (providerNames as readonly string[]).includes(argument.text)) { + const provider = argument.text as ProviderName; + const hooks = usedProviders.get(provider) ?? new Set<"useProvider" | "useProviderSignal">(); + hooks.add(node.expression.text); + usedProviders.set(provider, hooks); + } else if (argument && ts.isStringLiteral(argument)) { + errors.push(locationMessage( + node.getSourceFile(), + argument, + `${node.expression.text}("${argument.text}") names no known provider; available providers: ${providerNames.join(", ")}`, + )); + } } if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "wfetch") { const argument = node.arguments[0]; @@ -1663,8 +1677,10 @@ function validateSource(project: SourceProject): string[] { ts.forEachChild(node, visit); }; for (const sourceFile of project.sourceFiles) visit(sourceFile); - for (const provider of usedProviders) { - if (!project.config.subscribe?.includes(provider)) errors.push(`useProvider("${provider}") requires subscribe: ["${provider}"] in the widget config`); + for (const [provider, hooks] of usedProviders) { + if (!project.config.subscribe?.includes(provider)) { + for (const hook of hooks) errors.push(`${hook}("${provider}") requires subscribe: ["${provider}"] in the widget config`); + } } validateMediaTransportCapability(project, errors); validateLoweredTreeBudgets(project, errors); diff --git a/cli/test/cli.test.mjs b/cli/test/cli.test.mjs index 0090a67..d815389 100644 --- a/cli/test/cli.test.mjs +++ b/cli/test/cli.test.mjs @@ -162,11 +162,13 @@ test("bundle manifest is the subscription origin of truth", () => { try { assert.equal(spawnSync(process.execPath, [cli, "init", "system-card"], { cwd: root, encoding: "utf8" }).status, 0); const sourcePath = join(widget, "widget.tsx"); - const source = `import { useProvider, widget } from "@weaver/sdk"; + const source = `import { useProvider, useProviderSignal, widget } from "@weaver/sdk"; export default widget({ name: "System Card", size: [200, 100], subscribe: ["cpu", "memory", "audio", "media"] }, () => { const cpu = useProvider("cpu"); const audio = useProvider("audio"); - return {cpu.percent + audio.rms}; + const memorySnapshot = useProvider("memory"); + const memory = useProviderSignal("memory"); + return {cpu.percent + audio.rms + memorySnapshot.percent}{memory.map((value) => value.percent)}; }); `; writeFileSync(sourcePath, source, "utf8"); @@ -182,6 +184,17 @@ export default widget({ name: "System Card", size: [200, 100], subscribe: ["cpu" assert.equal(readFileSync(join(widget, "dist", "data", "widget.json"), "utf8"), "nested manifest asset"); assert.equal(readFileSync(join(widget, "dist", "assets", "dist", "pixel.bin"), "utf8"), "nested dist asset"); assert.equal(existsSync(join(widget, "dist", "widget.tsx")), false); + + writeFileSync(sourcePath, source.replace('subscribe: ["cpu", "memory", "audio", "media"]', 'subscribe: ["cpu", "audio", "media"]'), "utf8"); + const missingSignalSubscription = spawnSync(process.execPath, [cli, "check", widget], { encoding: "utf8" }); + assert.equal(missingSignalSubscription.status, 1); + assert.match(missingSignalSubscription.stderr, /useProvider\("memory"\) requires subscribe: \["memory"\]/); + assert.match(missingSignalSubscription.stderr, /useProviderSignal\("memory"\) requires subscribe: \["memory"\]/); + + writeFileSync(sourcePath, source.replace('useProviderSignal("memory")', 'useProviderSignal("memmory")'), "utf8"); + const unknownProvider = spawnSync(process.execPath, [cli, "check", widget], { encoding: "utf8" }); + assert.equal(unknownProvider.status, 1); + assert.match(unknownProvider.stderr, /useProviderSignal\("memmory"\) names no known provider; available providers: time, cpu, memory, audio, media/); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/docs/error-propagation-brief.md b/docs/error-propagation-brief.md new file mode 100644 index 0000000..e8e5bf1 --- /dev/null +++ b/docs/error-propagation-brief.md @@ -0,0 +1,148 @@ +# Error-propagation pass — the seams + +Brief for a detailed pass over every place an error can be born, swallowed, or +presented. The governing rule is the one this repo already committed to +(#39, "Name every silent failure where the developer looks"): a failure must +surface **where the developer is looking** — `weaver check` output, the dev CLI +stream, the per-widget log, or the widget window itself — and it must name the +budget/cause, not just exist as a bare error name. + +Everything below was reproduced live on 2026-07-29 against noro-shell unless +marked speculative. The single worst end-to-end demo: add ~6 retained nodes to +`examples/noro-shell/widget.tsx` and the widget window renders a flat field of +**uninitialized GPU memory** (a different random color every launch), with +`weaver check` passing and zero error lines in any log. + +## Seam 1 — the SDK render path has no error boundary (highest leverage) + +- `sdk/src/reconciler.ts:835` `scheduleRender()` runs `renderRoot()` inside + `void Promise.resolve().then(...)`. Any throw inside a re-render (including + every budget error the Zig bridge deliberately throws) becomes an unhandled + promise rejection. +- No `JS_SetHostPromiseRejectionTracker` is installed anywhere + (`rg PromiseRejection runtime/` is empty), so QuickJS drops the rejection on + the floor. Confirmed: over-budget fresh start logs *nothing*, hot swap logs + only a bare `error: CallbackFailed`. +- `renderRoot()` (`sdk/src/reconciler.ts:403`) has `try/finally` around the + batch but no catch: a throw mid-reconcile leaves a **half-built tree already + committed** via `native.endBatch()`. There is no rollback and no "don't + present a tree whose build threw." + +Wanted: a render error boundary that (a) catches, (b) logs the message + stack +through a bridge call so it lands in the per-widget log, (c) puts the widget +into a visible error state (even a solid color + name is fine), and (d) never +commits a partially-built generation. Same treatment for effect callbacks, +`useInterval` callbacks, and `onFrame` canvas callbacks. + +## Seam 2 — platform callback failures lose their name + +- `runtime/native-sdk/src/platform/macos/root.zig:763` intends to log + `platform callback failed: (event )`, but what actually reaches + the widget log is a bare `error: CallbackFailed` (observed three times + today). Find where that line is emitted (likely the runtime's top-level exit + path in `runtime/src/main.zig`) and make the *named* line the one that lands + in the per-widget log before the process dies. +- After the runtime process dies, the host keeps the widget window alive + showing whatever memory the surface had. That's both a UX bug and arguably an + info leak (stale GPU memory). The host should clear the surface and/or show a + tombstone when the runtime for a window is gone. + +## Seam 3 — budget errors: born loud, dying silent + +The bridge does the right thing at the throw site — `failFmt` +(`runtime/src/bridge.zig:142`) even documents that budget errors must name the +budget, the limit, and the ask. But: + +- `runtime/src/bridge.zig:166` `createNode` → "node capacity exhausted" names + neither `max_nodes` nor 128 nor the node count. Same for the generic + "appendChild failed" / "insertBefore failed" (`bridge.zig:185,195`) which is + how `max_children = 24` surfaces. Bring these up to the `failFmt` standard. +- All of them then die in Seam 1 anyway. Both halves need fixing. +- `runtime/src/tree.zig` budgets (`max_nodes 1024`, `max_children 64`, + `max_text_bytes 1024`, `max_canvases 8`) are statically checked for the + initial tree. `weaver check` validates the lowered representation—not just + authored JSX—so generated layout/text nodes and canvases count. Every + `max_nodes`, `max_children`, `max_text_bytes`, and `max_canvases` failure + reports the configured limit, requested amount, and remaining headroom. + +## Seam 4 — image failures are log-only, screen-silent + +- `runtime/src/main.zig:119` and `:1012` log `ImageTooLarge` etc. to the + per-widget log, then render proceeds with a black hole where the image was. + Nothing on screen, nothing in the dev CLI stream, `weaver check` passes. +- The pinned Native widget profile permits 1 MiB of decoded RGBA. A 256×256 + image is 256 × 256 × 4 = 262,144 bytes (256 KiB), so it passes. `weaver check` + reads local image dimensions and reports the decoded-byte calculation without + decoding pixels. Runtime failures report dimensions plus requested bytes. + Images over 1 MiB need downscaling or an on-widget placeholder that says why. + +## Seam 5 — dev loop failure modes + +- `cli/src/index.ts:431-460`: rebuild failures print once via `printFailure`, + but the runtime keeps hot-swapping/serving the **stale bundle** with no + banner that what's on screen no longer matches the file. Persist an "out of + date since