Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 14 additions & 1 deletion host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ and acknowledged reload/shutdown. Constructing the Widget renderer and
QuickJS in the one always-running process would spend memory without buying a
host capability.

Build and test on macOS 14.2 or later:
The macOS host reads the shared renderer's wire contract from the pinned
Native SDK submodule. Initialize it with `git submodule update --init
runtime/native-sdk` from the repository root before building.

Build and test on macOS 14.2 or later, from `host`:

```sh
zig build -Doptimize=ReleaseFast
Expand All @@ -21,6 +25,15 @@ Recording usage description. The CLI launches only its nested executable so
authorization, daemon work, and status share one stable identity. This is a
developer build, not a Developer ID/notarized distribution claim.

Widget launches wait for a valid hello reply from the shared renderer's
message loop. Registering its service name alone does not establish readiness.
Pending widgets report the wait in status without consuming restart attempts,
and host reload/shutdown remain available while startup is pending. A renderer
exit clears readiness before another process starts. Existing widgets retain
their state and use the Native SDK's reconnect behavior; this launch gate does
not promote a widget that already selected software during a renderer outage
or a later packet/presentation refusal.

On Windows, build with the repository's Zig toolchain and an installed Windows
10 SDK:

Expand Down
4 changes: 4 additions & 0 deletions host/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ pub fn build(b: *std.Build) void {
}),
});
addMacosAudio(tests.root_module, b, automation_seam);
tests.root_module.addCSourceFile(.{
.file = b.path("src/macos_renderer_test.c"),
.flags = &.{ "-std=c11", "-mmacosx-version-min=14.2", "-isysroot", b.sysroot.?, b.fmt("-I{s}/usr/include", .{b.sysroot.?}) },
});
tests.root_module.linkSystemLibrary("c", .{});
const test_step = b.step("test", "Run macOS host and portable supervisor tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
Expand Down
111 changes: 107 additions & 4 deletions host/src/macos_host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@ const Host = struct {
render_host_name: []const u8 = render_host_default_name,
render_host_process: ?posix.pid_t = null,
render_host_restart_at_ms: u64 = 0,
render_host_ready: bool = false,
render_host_probe_port: u32 = 0,
render_host_probe_error: c_int = 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) {
Expand Down Expand Up @@ -496,8 +499,14 @@ const Host = struct {
.none => {},
.stop_missing => self.stopSlot(slot, true),
.handle_exit => self.handleExit(slot, now_ms),
.launch => self.launch(slot, now_ms) catch |err| {
supervisor.recordLaunchFailure(slot, now_ms, err);
.launch => {
if (!self.automation_seam and !self.render_host_ready) {
slot.setReason("waiting for shared renderer readiness; check host log if startup does not complete", .{});
continue;
}
self.launch(slot, now_ms) catch |err| {
supervisor.recordLaunchFailure(slot, now_ms, err);
};
},
}
}
Expand Down Expand Up @@ -583,16 +592,24 @@ const Host = struct {
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;
if (result == 0 or posix.errno(result) == .INTR) {
self.pollRenderHostReadiness();
return;
}
if (posix.errno(result) != .SUCCESS and posix.errno(result) != .CHILD) 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.resetRenderHostReadiness();
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();
// This process owns Metal. An inherited client flag would make its
// headless surfaces wait for a window layer that they never create.
environment.put(shared_renderer_environment, "0") catch return;
const child = std.process.spawn(self.io, .{
.argv = &argv,
.environ_map = &environment,
Expand All @@ -607,10 +624,34 @@ const Host = struct {
};
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 });
std.log.info("render host started pid={d} name={s}; widgets wait for its readiness reply", .{ child.id.?, self.render_host_name });
}

fn pollRenderHostReadiness(self: *Host) void {
if (self.render_host_ready) return;
const result = c.weaver_renderer_readiness_poll(self.render_host_name.ptr, self.render_host_name.len, &self.render_host_probe_port);
switch (result) {
c.WEAVER_RENDERER_READY => {
self.render_host_ready = true;
self.render_host_probe_error = 0;
std.log.info("render host ready pid={d} name={s}; widget launches enabled", .{ self.render_host_process.?, self.render_host_name });
},
c.WEAVER_RENDERER_WAITING => {},
else => if (self.render_host_probe_error != result) {
self.render_host_probe_error = result;
std.log.err("render host readiness hello failed code=0x{x} name={s}; widget launches remain pending; check that host and runtime use the same renderer protocol", .{ result, self.render_host_name });
},
}
}

fn resetRenderHostReadiness(self: *Host) void {
c.weaver_renderer_readiness_reset(&self.render_host_probe_port);
self.render_host_ready = false;
self.render_host_probe_error = 0;
}

fn stopRenderHost(self: *Host) void {
self.resetRenderHostReadiness();
const pid = self.render_host_process orelse return;
// Same escalation as widget teardown: the marker is removed only
// after the process is actually dead and reaped — a TERM-ignoring
Expand Down Expand Up @@ -1376,6 +1417,68 @@ test "process CPU samples report nanoseconds" {
try std.testing.expect(cpu_time_ns <= after_ns + 2 * std.time.ns_per_us);
}

test "widget supervision waits for renderer readiness without consuming restart attempts" {
var directory = std.testing.tmpDir(.{});
defer directory.cleanup();
const source = try directory.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator);
defer std.testing.allocator.free(source);
const host = try std.testing.allocator.create(Host);
defer std.testing.allocator.destroy(host);
host.* = .{
.io = std.testing.io,
.allocator = std.testing.allocator,
.environ_map = undefined,
.registry_path = "",
.status_path = "",
.status_temp_path = "",
.runtime_exe = "",
.cli_script = "",
.runtime_root = source,
.audio_authorization_marker = "",
.media_provider = undefined,
.art_cache_root = "",
};
// This empty directory has no widget.tsx. Any attempted launch fails
// before spawning or accessing the deliberately unused host services.
const slot = &host.slots[0];
try slot.setRegistration(.{ .name = "waiting", .sourcePath = source, .enabled = true });
slot.state = .starting;
host.supervise(100);
try std.testing.expectEqual(supervisor.RunState.starting, slot.state);
try std.testing.expectEqual(@as(usize, 0), slot.crash_count);
try std.testing.expect(std.mem.indexOf(u8, slot.reason(), "renderer") != null);

host.render_host_ready = true;
host.supervise(150);
try std.testing.expectEqual(supervisor.RunState.backoff, slot.state);
try std.testing.expectEqual(@as(usize, 1), slot.crash_count);

// A missing/reaped renderer invalidates the old handshake before a
// replacement worker can launch. Our PID is deliberately not a child.
host.render_host_process = posix.system.getpid();
host.superviseRenderHost(200);
try std.testing.expect(host.render_host_process == null);
try std.testing.expect(!host.render_host_ready);
try std.testing.expectEqual(200 + render_host_restart_backoff_ms, host.render_host_restart_at_ms);
slot.next_restart_ms = 200;
host.supervise(200);
try std.testing.expectEqual(supervisor.RunState.backoff, slot.state);
try std.testing.expectEqual(@as(usize, 1), slot.crash_count);
try std.testing.expect(std.mem.indexOf(u8, slot.reason(), "renderer") != null);

// Automation uses its own in-process renderer and must not be gated.
host.automation_seam = true;
host.supervise(250);
try std.testing.expectEqual(@as(usize, 2), slot.crash_count);
}

test "renderer readiness requires a valid hello and releases probe rights" {
const probe = struct {
extern fn weaver_test_renderer_readiness() c_int;
};
try std.testing.expectEqual(@as(c_int, 0), probe.weaver_test_renderer_readiness());
}

test "provider socket peer pid rejects a same-user hijacker pid" {
var path_buffer: [96]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buffer, "/tmp/weaver-peer-test-{d}.sock", .{posix.system.getpid()});
Expand Down
98 changes: 98 additions & 0 deletions host/src/macos_renderer_test.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#include "macos_system.h"
#include "../../runtime/native-sdk/src/platform/macos/renderer_protocol_mach.h"
#include <servers/bootstrap.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define REQUIRE(expression) do { \
if (!(expression)) { \
fprintf(stderr, "renderer readiness regression failed at line %d: %s\n", __LINE__, #expression); \
return 1; \
} \
} while (0)

static size_t port_name_count(void) {
mach_port_name_array_t names = NULL;
mach_port_type_array_t types = NULL;
mach_msg_type_number_t name_count = 0, type_count = 0;
if (mach_port_names(mach_task_self(), &names, &name_count, &types, &type_count) != KERN_SUCCESS) return SIZE_MAX;
vm_deallocate(mach_task_self(), (vm_address_t)names, name_count * sizeof(*names));
vm_deallocate(mach_task_self(), (vm_address_t)types, type_count * sizeof(*types));
return name_count;
}

int weaver_test_renderer_readiness(void) {
char name[BOOTSTRAP_MAX_NAME_LEN];
snprintf(name, sizeof(name), "com.weaver.readiness-test.%d", getpid());
mach_port_t service = MACH_PORT_NULL;
uint32_t probe = MACH_PORT_NULL;
REQUIRE(weaver_renderer_readiness_poll(name, strlen(name), &probe) == WEAVER_RENDERER_WAITING);
REQUIRE(probe == MACH_PORT_NULL);
REQUIRE(bootstrap_check_in(bootstrap_port, name, &service) == KERN_SUCCESS);

for (int malformed = 0; malformed < 2; malformed++) {
// Service registration happens before Metal startup. Only a reply
// from the running message loop makes this service ready.
REQUIRE(weaver_renderer_readiness_poll(name, strlen(name), &probe) == WEAVER_RENDERER_WAITING);
REQUIRE(probe != MACH_PORT_NULL);
REQUIRE(weaver_renderer_readiness_poll(name, strlen(name), &probe) == WEAVER_RENDERER_WAITING);
struct { WeaverRendererMachHello hello; mach_msg_trailer_t trailer; } request = {0};
REQUIRE(mach_msg(&request.hello.header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, 0, sizeof(request), service, 0, MACH_PORT_NULL) == KERN_SUCCESS);
REQUIRE(weaverRendererMachHelloValid(&request.hello));
mach_port_t session = MACH_PORT_NULL;
REQUIRE(mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &session) == KERN_SUCCESS);
WeaverRendererMachHelloReply reply = {0};
reply.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_MOVE_SEND_ONCE, 0) | MACH_MSGH_BITS_COMPLEX;
reply.header.msgh_remote_port = request.hello.header.msgh_remote_port;
reply.header.msgh_size = sizeof(reply);
reply.body.msgh_descriptor_count = 1;
reply.session_port.name = session;
reply.session_port.disposition = MACH_MSG_TYPE_MAKE_SEND;
reply.session_port.type = MACH_MSG_PORT_DESCRIPTOR;
reply.magic = kWeaverRendererMachMagic;
reply.version = kWeaverRendererMachVersion + malformed;
reply.status = kWeaverRendererMachStatusOk;
REQUIRE(mach_msg(&reply.header, MACH_SEND_MSG | MACH_SEND_TIMEOUT, sizeof(reply), 0, MACH_PORT_NULL, 0, MACH_PORT_NULL) == KERN_SUCCESS);
REQUIRE(weaver_renderer_readiness_poll(name, strlen(name), &probe) == (malformed ? KERN_INVALID_ARGUMENT : WEAVER_RENDERER_READY));
REQUIRE(probe == MACH_PORT_NULL);
// Both accepted and rejected replies must release the session right.
mach_port_status_t status;
mach_msg_type_number_t count = MACH_PORT_RECEIVE_STATUS_COUNT;
REQUIRE(mach_port_get_attributes(mach_task_self(), session, MACH_PORT_RECEIVE_STATUS, (mach_port_info_t)&status, &count) == KERN_SUCCESS);
REQUIRE(status.mps_srights == 0);
REQUIRE(mach_port_destroy(mach_task_self(), session) == KERN_SUCCESS);
}
// A busy service must neither block supervision nor leak the send-once
// reply right returned by Mach's failed-send pseudo-receive.
REQUIRE(mach_port_insert_right(mach_task_self(), service, service, MACH_MSG_TYPE_MAKE_SEND) == KERN_SUCCESS);
mach_port_status_t service_status;
mach_msg_type_number_t service_count = MACH_PORT_RECEIVE_STATUS_COUNT;
REQUIRE(mach_port_get_attributes(mach_task_self(), service, MACH_PORT_RECEIVE_STATUS, (mach_port_info_t)&service_status, &service_count) == KERN_SUCCESS);
for (mach_port_msgcount_t i = 0; i < service_status.mps_qlimit; i++) {
mach_msg_header_t filler = {0};
filler.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0);
filler.msgh_remote_port = service;
filler.msgh_size = sizeof(filler);
REQUIRE(mach_msg(&filler, MACH_SEND_MSG | MACH_SEND_TIMEOUT, sizeof(filler), 0, MACH_PORT_NULL, 0, MACH_PORT_NULL) == KERN_SUCCESS);
}
const size_t before = port_name_count();
REQUIRE(before != SIZE_MAX);
REQUIRE(weaver_renderer_readiness_poll(name, strlen(name), &probe) == WEAVER_RENDERER_WAITING);
REQUIRE(probe == MACH_PORT_NULL);
REQUIRE(port_name_count() == before);
for (mach_port_msgcount_t i = 0; i < service_status.mps_qlimit; i++) {
struct { mach_msg_header_t header; mach_msg_trailer_t trailer; } filler = {0};
REQUIRE(mach_msg(&filler.header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, 0, sizeof(filler), service, 0, MACH_PORT_NULL) == KERN_SUCCESS);
mach_msg_destroy(&filler.header);
}
REQUIRE(mach_port_deallocate(mach_task_self(), service) == KERN_SUCCESS);
REQUIRE(weaver_renderer_readiness_poll(name, strlen(name), &probe) == WEAVER_RENDERER_WAITING);
const mach_port_t pending = probe;
weaver_renderer_readiness_reset(&probe);
REQUIRE(probe == MACH_PORT_NULL);
mach_port_type_t type = 0;
REQUIRE(mach_port_type(mach_task_self(), pending, &type) == KERN_INVALID_NAME);
REQUIRE(mach_port_destroy(mach_task_self(), service) == KERN_SUCCESS);
return 0;
}
Loading
Loading