From c807a122a17efb8c1357b3cb7f4bec97aa5585b9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 00:03:43 -0700 Subject: [PATCH 1/5] Cut macOS widgets over to the shared renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS cutover from the shared-renderer plan (docs/macos-memory-handoff.md on feat/macos-memory-work): weaverd owns one render host — the only Metal-owning process — and every weaverd-spawned widget renders through it device-lessly. - weaver-widget gains --render-host : the process becomes the shared renderer (native_sdk_appkit_render_host_run) and never loads a widget bundle. - weaverd spawns the host at startup and supervises it like a widget: crash -> respawn after a 1 s backoff; widgets keep their retained frames and reconnect on their own (their existing 1/5/30 s retry cadence). Widget environments get NATIVE_SDK_GPU_SHARED_RENDERER=1 and the host's bootstrap name. The automation seam keeps the legacy in-process renderer (its verdicts sample the widget's own drawable). - native-sdk pinned to weaver-main b72da2d2 (shared-renderer slices 1-3: IOSurface presenter, render host + mach protocol, device-less client). Acceptance (Mac15,6, isolated-HOME weaverd, 8 registered clock widgets): every widget 32.2-35.8 MB phys_footprint with ZERO owned-unmapped-graphics regions (in-process baseline: 125.3 MB with the 85 MB arena each); 31-minute hold flat within ±0.3 MB per widget, host non-increasing (168.5 -> 158.9 MB, slope -165 KB/min); host killed -> weaverd restarted it in 1 s and the widgets resumed live rendering, verified by window-capture diff. Runtime test-platform-services and host test suites pass. --- host/src/macos_host.zig | 67 +++++++++++++++++++++++++++++++++++++++++ runtime/native-sdk | 2 +- runtime/src/main.zig | 14 ++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/host/src/macos_host.zig b/host/src/macos_host.zig index fa1817a..198675e 100644 --- a/host/src/macos_host.zig +++ b/host/src/macos_host.zig @@ -19,6 +19,13 @@ const c = @cImport({ const max_widgets = supervisor.max_widgets; const max_path_bytes = supervisor.max_path_bytes; const provider_environment = "WEAVER_HOST_ENDPOINT"; +const shared_renderer_environment = "NATIVE_SDK_GPU_SHARED_RENDERER"; +const render_host_name_environment = "WEAVER_RENDER_HOST_NAME"; +const render_host_default_name = "com.weaver.render-host"; +/// Restart backoff for a crashed render host. Widgets reconnect lazily on +/// their existing 1/5/30 s retry cadence, so one second of host absence +/// costs at most one retained-frame beat. +const render_host_restart_backoff_ms = 1000; const art_cache_environment = "WEAVER_ART_CACHE"; const backend_environment = "WEAVER_BACKEND_FILE"; @@ -431,6 +438,15 @@ const Host = struct { /// helper on a player-less CI runner. automation_seam: bool = false, status_write_failures: u64 = 0, + /// The shared render host: one Metal-owning process every widget's + /// frames route through (docs/macos-memory-handoff.md carries the + /// receipts — widgets drop from ~125 MB to ~30 MB because only this + /// process pays the GPU driver's per-process submission arena). + /// Supervised here like a widget: crash -> respawn with backoff; + /// widgets reconnect on their own. + render_host_name: []const u8 = render_host_default_name, + render_host_process: ?posix.pid_t = null, + render_host_restart_at_ms: u64 = 0, fn loadRegistry(self: *Host) !void { const owned_bytes = std.Io.Dir.cwd().readFileAlloc(self.io, self.registry_path, self.allocator, .limited(256 * 1024)) catch |err| switch (err) { @@ -559,6 +575,48 @@ const Host = struct { } } + /// Keep exactly one render host alive. The automation seam keeps the + /// legacy in-process renderer (its verdicts sample the widget's own + /// drawable), so it neither spawns a host nor points widgets at one. + fn superviseRenderHost(self: *Host, now_ms: u64) void { + if (self.automation_seam) return; + if (self.render_host_process) |pid| { + var status: c_int = 0; + const result = posix.system.waitpid(pid, &status, posix.W.NOHANG); + if (posix.errno(result) != .SUCCESS or result == 0) return; + std.log.warn("render host pid={d} exited; restarting in {d} ms (widgets keep retained frames and reconnect)", .{ pid, render_host_restart_backoff_ms }); + self.removeChildMarker(pid); + self.render_host_process = null; + self.render_host_restart_at_ms = now_ms + render_host_restart_backoff_ms; + } + if (self.render_host_process != null or now_ms < self.render_host_restart_at_ms) return; + const argv = [_][]const u8{ self.runtime_exe, "--render-host", self.render_host_name }; + var environment = self.environ_map.clone(self.allocator) catch return; + defer environment.deinit(); + const child = std.process.spawn(self.io, .{ + .argv = &argv, + .environ_map = &environment, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + .pgid = 0, + }) catch |err| { + std.log.err("render host spawn failed: {s}; retrying in {d} ms", .{ @errorName(err), render_host_restart_backoff_ms }); + self.render_host_restart_at_ms = now_ms + render_host_restart_backoff_ms; + return; + }; + self.render_host_process = child.id.?; + self.writeChildMarker(child.id.?) catch {}; + std.log.info("render host started pid={d} name={s}", .{ child.id.?, self.render_host_name }); + } + + fn stopRenderHost(self: *Host) void { + const pid = self.render_host_process orelse return; + self.removeChildMarker(pid); + posix.kill(pid, .TERM) catch {}; + self.render_host_process = null; + } + fn restartAfterMediaChannelFailure(self: *Host, slot: *Slot, now_ms: u64) void { std.log.warn("restarting widget {s} after fatal provider channel failure", .{slot.name()}); self.stopSlot(slot, false); @@ -621,6 +679,12 @@ const Host = struct { try environment.put(backend_environment, backend_path); if (endpoint_path) |path| try environment.put(provider_environment, path); if (slot.wants_media) try environment.put(art_cache_environment, self.art_cache_root); + if (!self.automation_seam) { + // The macOS cutover: weaverd-owned widgets render through the + // shared host and never create a Metal device in-process. + try environment.put(shared_renderer_environment, "1"); + try environment.put(render_host_name_environment, self.render_host_name); + } const dev_argv = [_][]const u8{ self.runtime_exe, "--dev", dist }; const run_argv = [_][]const u8{ self.runtime_exe, dist }; const argv: []const []const u8 = if (slot.dev) &dev_argv else &run_argv; @@ -1051,6 +1115,7 @@ fn run(init: std.process.Init) !void { .media_provider = &media_provider, .art_cache_root = art_cache_root, .automation_seam = c.weaver_macos_automation_seam() != 0, + .render_host_name = init.environ_map.get(render_host_name_environment) orelse render_host_default_name, }; defer host.audio_provider.deinit(); try host.loadRegistry(); @@ -1079,6 +1144,7 @@ fn run(init: std.process.Init) !void { }, }; const now = monotonicMilliseconds(); + host.superviseRenderHost(now); host.supervise(now); host.drainMediaCommands(now); host.sampleAudio(now); @@ -1104,6 +1170,7 @@ fn run(init: std.process.Init) !void { if (!stopping) std.Io.sleep(init.io, .fromMilliseconds(if (host.hasAudioSubscribers()) 30 else 50), .awake) catch {}; } for (&host.slots) |*slot| if (slot.platform.process != null) host.stopSlot(slot, true); + host.stopRenderHost(); host.audio_provider.setActive(false, monotonicMilliseconds()); host.media_provider.setActive(false); // The down acknowledgement is sent before slot teardown. Detached command diff --git a/runtime/native-sdk b/runtime/native-sdk index 8ab1b15..b72da2d 160000 --- a/runtime/native-sdk +++ b/runtime/native-sdk @@ -1 +1 @@ -Subproject commit 8ab1b159a972690c3eb2c7be68525174d3ac59e6 +Subproject commit b72da2d2d7a23e01ac2bc83ae27bf196679bcd02 diff --git a/runtime/src/main.zig b/runtime/src/main.zig index 6ee382a..eed0736 100644 --- a/runtime/src/main.zig +++ b/runtime/src/main.zig @@ -1189,9 +1189,21 @@ fn synchronizeImages(model: *Model, effects: *Effects) !void { pub fn main(init: std.process.Init) !void { const allocator = init.arena.allocator(); const args = try init.minimal.args.toSlice(allocator); + // Shared render host mode: this process becomes the one Metal-owning + // renderer every macOS widget talks to (weaverd spawns and supervises + // it; docs/macos-memory-handoff.md carries the receipts). It never + // loads a widget bundle. + if (builtin.os.tag == .macos and args.len == 3 and std.mem.eql(u8, args[1], "--render-host")) { + const seam = struct { + extern fn native_sdk_appkit_render_host_run(name: [*:0]const u8) c_int; + }; + const name_z = try allocator.dupeZ(u8, args[2]); + if (seam.native_sdk_appkit_render_host_run(name_z.ptr) != 0) return error.RenderHostStartFailed; + return; + } const dev = args.len == 3 and std.mem.eql(u8, args[1], "--dev"); if ((!dev and args.len != 2) or (dev and args.len != 3)) { - std.debug.print("usage: weaver-widget [--dev] \n", .{}); + std.debug.print("usage: weaver-widget [--dev | --render-host ] \n", .{}); return error.InvalidArguments; } const directory = args[if (dev) 2 else 1]; From 7ae4dee52be018dafab290e0136400cc5e86427a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 00:09:18 -0700 Subject: [PATCH 2/5] Ratchet the release audit's native pin to weaver-main b72da2d2 (shared-renderer slices) --- scripts/release-audit.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release-audit.mjs b/scripts/release-audit.mjs index dbe6cd5..deb074b 100755 --- a/scripts/release-audit.mjs +++ b/scripts/release-audit.mjs @@ -7,7 +7,7 @@ import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const expectedNativeCommit = "8ab1b159a972690c3eb2c7be68525174d3ac59e6"; +const expectedNativeCommit = "b72da2d2d7a23e01ac2bc83ae27bf196679bcd02"; function filesBelow(root) { const output = []; From bd83ac74bf35cb5c6e77556513c668927d359fcf Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 00:36:10 -0700 Subject: [PATCH 3/5] Bump the native pin to weaver-main with the contains-check fix --- runtime/native-sdk | 2 +- scripts/release-audit.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/runtime/native-sdk b/runtime/native-sdk index b72da2d..6678343 160000 --- a/runtime/native-sdk +++ b/runtime/native-sdk @@ -1 +1 @@ -Subproject commit b72da2d2d7a23e01ac2bc83ae27bf196679bcd02 +Subproject commit 66783435175187d25f5d762e1e3d3e691cbb9430 diff --git a/scripts/release-audit.mjs b/scripts/release-audit.mjs index deb074b..2b2490d 100755 --- a/scripts/release-audit.mjs +++ b/scripts/release-audit.mjs @@ -7,7 +7,7 @@ import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const expectedNativeCommit = "b72da2d2d7a23e01ac2bc83ae27bf196679bcd02"; +const expectedNativeCommit = "66783435175187d25f5d762e1e3d3e691cbb9430"; function filesBelow(root) { const output = []; From 28846c333fef8473d5fb076f1ab2b186c5524306 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 01:01:49 -0700 Subject: [PATCH 4/5] Reap the render host before dropping its recovery marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review is right that teardown removed the marker, sent a best-effort TERM, and forgot the pid — a TERM-ignoring host would outlive its marker and the next weaverd could not find the orphan. Teardown now escalates exactly like widget teardown (TERM, 1.5 s reap window, KILL, blocking reap) and removes the marker only after the process is dead. Also fixes the usage string to show the two argument shapes honestly. --- host/src/macos_host.zig | 24 +++++++++++++++++++++++- runtime/src/main.zig | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/host/src/macos_host.zig b/host/src/macos_host.zig index 198675e..231c1f8 100644 --- a/host/src/macos_host.zig +++ b/host/src/macos_host.zig @@ -612,8 +612,30 @@ const Host = struct { fn stopRenderHost(self: *Host) void { const pid = self.render_host_process orelse return; - self.removeChildMarker(pid); + // Same escalation as widget teardown: the marker is removed only + // after the process is actually dead and reaped — a TERM-ignoring + // host must never outlive its recovery marker, or the next weaverd + // cannot find the orphan. posix.kill(pid, .TERM) catch {}; + var status: c_int = 0; + const deadline = monotonicMilliseconds() + 1500; + var dead = false; + while (monotonicMilliseconds() < deadline) { + const result = posix.system.waitpid(pid, &status, posix.W.NOHANG); + if (posix.errno(result) == .CHILD or (posix.errno(result) == .SUCCESS and result != 0)) { + dead = true; + break; + } + std.Io.sleep(self.io, .fromMilliseconds(20), .awake) catch break; + } + if (!dead) { + posix.kill(pid, .KILL) catch {}; + while (true) { + const result = posix.system.waitpid(pid, &status, 0); + if (posix.errno(result) != .INTR) break; + } + } + self.removeChildMarker(pid); self.render_host_process = null; } diff --git a/runtime/src/main.zig b/runtime/src/main.zig index eed0736..d079d10 100644 --- a/runtime/src/main.zig +++ b/runtime/src/main.zig @@ -1203,7 +1203,7 @@ pub fn main(init: std.process.Init) !void { } const dev = args.len == 3 and std.mem.eql(u8, args[1], "--dev"); if ((!dev and args.len != 2) or (dev and args.len != 3)) { - std.debug.print("usage: weaver-widget [--dev | --render-host ] \n", .{}); + std.debug.print("usage: weaver-widget [--dev] | weaver-widget --render-host \n", .{}); return error.InvalidArguments; } const directory = args[if (dev) 2 else 1]; From 010f98ad637543ad6f9dd6e7778778a959f66528 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 31 Jul 2026 01:56:33 -0700 Subject: [PATCH 5/5] Advertise --render-host only where it is parsed (macOS) --- runtime/src/main.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/runtime/src/main.zig b/runtime/src/main.zig index d079d10..bff7950 100644 --- a/runtime/src/main.zig +++ b/runtime/src/main.zig @@ -1203,7 +1203,12 @@ pub fn main(init: std.process.Init) !void { } const dev = args.len == 3 and std.mem.eql(u8, args[1], "--dev"); if ((!dev and args.len != 2) or (dev and args.len != 3)) { - std.debug.print("usage: weaver-widget [--dev] | weaver-widget --render-host \n", .{}); + // The render-host form is parsed only on macOS; advertise it only + // where it is accepted. + std.debug.print(if (builtin.os.tag == .macos) + "usage: weaver-widget [--dev] | weaver-widget --render-host \n" + else + "usage: weaver-widget [--dev] \n", .{}); return error.InvalidArguments; } const directory = args[if (dev) 2 else 1];