Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ae470b7
Pin the macOS memory-reduction native work and the audit briefs
Jul 30, 2026
4b218ae
Carry the memory-work handoff and the myclock bakeoff fixture
Jul 30, 2026
a296995
Force-include myclock/dist: the bakeoff harness consumes the built bu…
Jul 30, 2026
4e4eca8
Record macOS memory gate results
SunkenInTime Jul 30, 2026
921721c
Record the wall correction: workload-scoped, machine-dependent, gate …
Jul 30, 2026
b603f88
Split the GPU ledger investigation into its own brief
Jul 30, 2026
32f08a5
Name the GPU ledger wall: per-process Metal submission working set
Jul 30, 2026
599c841
Record the named wall and Dara's re-approval of the shared renderer
Jul 30, 2026
8ab27fb
Distill the session teachings from naming the GPU ledger wall
Jul 30, 2026
81d1fc4
Add Phase 1 kickoff calibration now that the wall has a name
Jul 30, 2026
8ea3ea6
Cut visualizer provider render cost
SunkenInTime Aug 1, 2026
03e618d
Keep visualizer rectangles on the GPU
SunkenInTime Aug 1, 2026
35a50e9
Skip Canvas frame tree rebuilds
SunkenInTime Aug 1, 2026
b769666
Disable continuous tracing for Widgets
SunkenInTime Aug 1, 2026
e6adc6b
Pin restacked Native visualizer work
SunkenInTime Aug 3, 2026
9a8bc77
Repin Native after Greptile fix
SunkenInTime Aug 3, 2026
8ce15dc
Repin Native after projection target fix
SunkenInTime Aug 3, 2026
0d18a96
Skip unoccupied Canvas projection slots
SunkenInTime Aug 3, 2026
4eeda31
Address CodeRabbit review findings
SunkenInTime Aug 3, 2026
3c3f364
Clarify macOS memory receipt gates
SunkenInTime Aug 3, 2026
e6983c2
Repin Native SDK after visualizer merge
SunkenInTime Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderName, Set<"useProvider" | "useProviderSignal">>();
const stateVariantAncestry = (node: ts.JsxOpeningElement | ts.JsxSelfClosingElement): "pressable" | "component-boundary" | "none" => {
let current: ts.Node | undefined = node.parent;
while (current) {
Expand Down Expand Up @@ -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];
Expand All @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions cli/test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>{cpu.percent + audio.rms}</text>;
const memorySnapshot = useProvider("memory");
const memory = useProviderSignal("memory");
return <row><text>{cpu.percent + audio.rms + memorySnapshot.percent}</text><text>{memory.map((value) => value.percent)}</text></row>;
});
`;
writeFileSync(sourcePath, source, "utf8");
Expand All @@ -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 });
}
Expand Down
148 changes: 148 additions & 0 deletions docs/error-propagation-brief.md
Original file line number Diff line number Diff line change
@@ -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: <name> (event <tag>)`, 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 <time>: <first error>" state in the dev stream (and ideally on the
widget).
- The watcher (`cli/src/index.ts:458`) watches only `widget.tsx` — asset and
font edits silently do nothing until an unrelated source change.
- Hot swap of a bundle that then throws mid-render: observed
`dev hot swap applied (preserved root hook state)` immediately followed by
process death (`CallbackFailed`) and an auto-restart that comes back blank.
The hot-swap path already knows how to reject a bad candidate
(`runtime/src/main.zig:362` evaluateCandidate) — extend that rejection to
candidates whose *first render* throws, and keep the old bundle running.

## Seam 6 — dynamic canvas denial remains screen-silent

- `weaver check` already reports statically knowable clipping and opacity
violations as `CanvasNeedsUnclippedAncestors` and
`CanvasNeedsOpaqueAncestors`; the relevant validators live in
`cli/src/index.ts:1527-1681`. The remaining gap is a runtime diagnostic when
the host denies a canvas surface dynamically, so the widget cannot blank
without a named reason.

## Seam 7 — empty-catch inventory

Each of these should either handle-and-log, narrow to the specific expected
error, or grow a comment proving silence is correct (some already have one —
those are fine and are the model):

- `sdk/src/reconciler.ts:873, 910` — hot-swap seed parse/capture: silence is
probably right, but a swap that falls back to fresh state should say so in
the log (today "preserved root hook state" prints even when seeding failed).
- `sdk/src/class-compiler.ts:610, 642`
- `cli/src/host-tools.ts:195, 200, 206, 224`, `cli/src/origin.ts:5, 15`,
`cli/src/index.ts:733, 812, 913, 1127`, `cli/src/weave.ts:344`
- Zig: 18 hits of `catch {}` in `runtime/src` (`rg 'catch \{\}' runtime/src`),
plus `catch return null` / `catch return` sites in `geometry.zig:38,65`
(corrupt geometry file → silently repositioned widget),
`dev_reload.zig:61` (accept failure → dev reload just stops working),
`js_engine.zig:100,110` (hot-swap capture failure → silent fresh state).
- `runtime/src/main.zig` msg-handler catches (lines 140-240) all log — good —
but several then continue with stale state where a degrade should be marked
once (e.g. repeated `provider dispatch failed` every 33 ms would flood; needs
latch-and-summarize).

## Seam 8 — status surfaces that lie by omission

- `weaver dev` stream prints provider/present milestones but not their
absence: a widget that never logs `presenter path=` never presented a frame —
after N seconds that should be an explicit error line (it was the only
signal, and today it's an *absence*).
- `~/Library/Application Support/Weaver/status.json.backend-*` orphan files
accumulate silently; `weaver status` doesn't reconcile them.
- Uninstall/registry restore paths (`cli/src/index.ts:414, 486, 616, 694`)
swallow the second-order failure by design (comments present) but nothing
ever reports the widget/registry divergence at the *next* `weaver status`.

## Suggested acceptance test for the whole pass

One integration test per seam that used to be silent, each asserting a named
error is visible at the correct surface. The canonical one: a widget whose
tree exceeds `max_nodes` must (a) fail `weaver check` naming the budget and
count, and if forced through anyway (b) log
`node capacity exhausted: max_nodes=1024, asked for 1025` and (c) render an
error surface — never uninitialized memory, never a silent flat fill. A failed
first render must commit only the error surface with no partially built nodes;
a failed hot-swap first render must keep the previously active tree and bundle
unchanged.
103 changes: 103 additions & 0 deletions docs/gpu-ledger-session-2026-07-30.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Session teachings — naming the GPU ledger wall (2026-07-30)

Scoped record of one working session on Dara's `Mac15,6` (M3 Pro, 18 GB,
macOS 26.5.2). The durable state lives in `docs/macos-memory-handoff.md`
and `docs/gpu-ledger-wall-brief.md`; this file is the distilled lessons of
how the 85 MB got its name, for anyone (human or agent) doing memory
forensics on this platform again. Branch state: weaver `78f8cdf` →
`0db488a`, native `6a8e6178`.

## The finding, in one paragraph

The ~85–96 MB of dirty "owned physical footprint (unmapped) (graphics)"
that every measured continuously presenting Weaver widget carried on this
machine is the Apple GPU driver's
**per-process command-submission working set**: a ~95 MB arena the driver
commits to any process that submits Metal command buffers in a sustained
cadence (≥ ~1 Hz), plus ~2.4 MB per additional presenting layer, reclaimed
after submission silence. The samples bound that release only indirectly: the
arena was held by 1-second submissions, did not establish across 5-second gaps,
and was gone by the 10-second idle checkpoint. It is not window setup, not
device creation, not presentation (offscreen renders trigger it), not
drawable size (240×110 and 960×440 pay the same), and not anything Weaver
allocates. Weaver pinned it forever because `renderFrame` presents
unconditionally from a 60 Hz timer — on the software backend too, since
both paths drive the same CAMetalLayer.

## The mental model that survived

**The driver charges every continuously active GPU client a flat
subscription fee, billed to the submitting process.** Everything follows:

- Who submits, pays. A process that submits no Metal and shows frames via
IOSurface contents on a plain CALayer pays ~0 — the compositing charge
lands in WindowServer, which is composing every window on the system
anyway. Measured: 0–16 KB graphics ledger at 60 Hz content updates.
- One bill per process, not per renderer/layer/window. 8 presenting
layers in one process: 95.6 → 112.7 MB, not 8 × 95 MB.
- The fee's size is driver policy per hardware class. Same binary, same
commit: ~95 MB on the M3 Pro (18 GB), near zero on the M2 Air (8 GB).
We measured THAT it differs, not why — separating GPU generation from
RAM size needs a third machine. Don't write a "why" without one.
- Lazy commit, idle release: it is not eagerly reserved at device
creation (one present ≈ 1 MB), and it releases within seconds of
silence. Held at 1 s submission intervals; never establishes at 5 s
intervals. The measured 1 Hz workload stays pinned; the measured 0.2 Hz
workload does not establish the arena. Cadences between them were not
measured.

## Method teachings (how the name was found)

1. **Turn it on and off, or it has no name.** The receipt is a ~100-line
probe with zero Weaver code that reproduces the exact vmmap signature
and releases it on idle. Every hypothesis got a probe variant: rate
(60/1/0.2 Hz), size, layer count, offscreen-vs-present,
IOSurface-vs-Metal. Probe sources + raw logs:
`.zig-cache/macos-memory/gpu-ledger-wall/` (throwaway; never in a PR).
2. **One-shot probes lie about steady-state costs.** Two prior threads
measured floors with a single present and concluded "no wall" — the
trigger was sustained cadence. When chasing a steady-state number,
the probe must run the steady-state loop.
3. **Match the suspect's exact configuration.** The probe copied weaver's
layer setup verbatim (framebufferOnly, allowsNextDrawableTimeout=NO,
BGRA8, contentsScale, drawable size) so a null result would have been
meaningful. It wasn't needed — but a config-mismatched probe proves
nothing either way.
4. **Ledger self-report + external attach, cross-checked.**
`task_info(TASK_VM_INFO).ledger_tag_graphics_footprint` from inside
the probe, `footprint`/`vmmap --summary` from outside (attach works on
this harness, unlike the Air's T3 harness). The graphics *category*,
not the total, is what identifies the wall — totals were ±10 MB noisy
across the whole investigation.
5. **PID-vs-build-time discipline is not optional.** This machine had
seven stale weaver-widget processes from prior sessions at
investigation start, two of them running the same fixture path.
Stale-PID mixups had already burned two threads; `ps -o lstart` vs
build finish time before every measurement.
6. **Read the incumbent platform before theorizing.** The Windows source
answered two architecture questions for free: presents are packet-
driven ("frames exist only on demand" — webview2_host.cpp), and "the
widget never creates or loads a D3D device" (d3d_presenter.h). The
macOS 60 Hz pump is the outlier, not the norm.

## What this settles (decisions recorded in the handoff doc)

- Shared renderer re-approved by Dara on this receipt: one render host
pays the arena once; device-less widget processes (IOSurface on plain
CALayer) pay ~0. Next work is Phase 1 of the handoff plan.
- Event-driven presenting (kill the unconditional 60 Hz pump) is correct
and Windows-proven, good for CPU/battery, and makes the shared host
cheap — but it is NOT the memory fix: 1 Hz still holds the arena.
- The Phase 1 "20s–30s MB" gate was calibrated on Air content costs; on
M3-class machines verify by vmmap category ("footprint minus the
~95 MB arena"), not by a single total.

## Corrections to prior session records

- The 2026-07-30 correction's fear that "whatever allocates the 85 MB
would move with the rendering" resolved favorably: it moves with the
*submitting process*, and is paid once there.
- The Phase 0 falsification stands as honest — its "Metal baseline"
theory was genuinely wrong. The gate asked "is the wall in device/window
setup?" and the true answer was no; the wall was in sustained
submission, a question the gate never posed.
Loading
Loading