diff --git a/src/api/instances.zig b/src/api/instances.zig index dba6e2b..bd120fb 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -61,6 +61,95 @@ fn readPortFromConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths, comp } } +fn refreshLocalDevBinary( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + version: []const u8, +) void { + if (builtin.is_test) return; + if (!std.mem.eql(u8, version, "dev-local")) return; + + const src_bin = local_binary.find(allocator, component) orelse return; + defer allocator.free(src_bin); + + const dest_bin = paths.binary(allocator, component, version) catch return; + defer allocator.free(dest_bin); + + std_compat.fs.deleteFileAbsolute(dest_bin) catch |err| switch (err) { + error.FileNotFound => {}, + else => return, + }; + std_compat.fs.copyFileAbsolute(src_bin, dest_bin, .{}) catch return; + if (comptime std_compat.fs.has_executable_bit) { + if (std_compat.fs.openFileAbsolute(dest_bin, .{ .mode = .read_only })) |f| { + defer f.close(); + f.chmod(0o755) catch {}; + } else |_| {} + } +} + +const InstanceSnapshot = struct { + status: manager_mod.Status, + pid: ?std.process.Child.Id = null, + uptime_seconds: ?u64 = null, + restart_count: u32 = 0, + port: u16 = 0, +}; + +fn deriveStandaloneSnapshot( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) ?InstanceSnapshot { + if (!std.mem.eql(u8, component, "nullclaw")) return null; + + const inst_dir = paths.instanceDir(allocator, component, name) catch return null; + defer allocator.free(inst_dir); + const real_dir = std_compat.fs.realpathAlloc(allocator, inst_dir) catch return null; + defer allocator.free(real_dir); + + const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch return null; + defer allocator.free(home); + const standalone_root = std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }) catch return null; + defer allocator.free(standalone_root); + + if (!std.mem.eql(u8, real_dir, standalone_root)) return null; + if (!std.mem.eql(u8, entry.launch_mode, "gateway")) return null; + + const port = readPortFromConfig(allocator, paths, component, name, "gateway.port") orelse 0; + if (port == 0) return null; + + const health = @import("../supervisor/health.zig").check(allocator, "127.0.0.1", port, "/health"); + return .{ + .status = if (health.ok) .running else .stopped, + .port = port, + }; +} + +fn resolveInstanceSnapshot( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + manager: *manager_mod.Manager, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) InstanceSnapshot { + if (manager.getStatus(component, name)) |st| { + return .{ + .status = st.status, + .pid = st.pid, + .uptime_seconds = st.uptime_seconds, + .restart_count = st.restart_count, + .port = st.port, + }; + } + if (deriveStandaloneSnapshot(allocator, paths, component, name, entry)) |snapshot| return snapshot; + return .{ .status = .stopped }; +} + const FetchedJsonValue = struct { bytes: []u8, parsed: std.json.Parsed(std.json.Value), @@ -1772,8 +1861,8 @@ fn pidToU64(pid: std.process.Child.Id) u64 { }; } -fn appendInstanceJson(buf: *std.array_list.Managed(u8), entry: state_mod.InstanceEntry, runtime_status: ?manager_mod.InstanceStatus) !void { - const status_str = if (runtime_status) |status| @tagName(status.status) else "stopped"; +fn appendInstanceJson(buf: *std.array_list.Managed(u8), entry: state_mod.InstanceEntry, snapshot: InstanceSnapshot) !void { + const status_str = @tagName(snapshot.status); try buf.appendSlice("{\"version\":\""); try appendEscaped(buf, entry.version); try buf.appendSlice("\",\"auto_start\":"); @@ -1786,31 +1875,29 @@ fn appendInstanceJson(buf: *std.array_list.Managed(u8), entry: state_mod.Instanc try buf.appendSlice(status_str); try buf.append('"'); - if (runtime_status) |status| { - if (status.pid) |pid| { + if (snapshot.pid) |pid| { try buf.appendSlice(",\"pid\":"); var num_buf: [20]u8 = undefined; const text = try std.fmt.bufPrint(&num_buf, "{d}", .{pidToU64(pid)}); try buf.appendSlice(text); - } - if (status.uptime_seconds) |uptime| { + } + if (snapshot.uptime_seconds) |uptime| { try buf.appendSlice(",\"uptime_seconds\":"); var num_buf: [20]u8 = undefined; const text = try std.fmt.bufPrint(&num_buf, "{d}", .{uptime}); try buf.appendSlice(text); - } - if (status.restart_count > 0) { + } + if (snapshot.restart_count > 0) { try buf.appendSlice(",\"restart_count\":"); var num_buf: [20]u8 = undefined; - const text = try std.fmt.bufPrint(&num_buf, "{d}", .{status.restart_count}); + const text = try std.fmt.bufPrint(&num_buf, "{d}", .{snapshot.restart_count}); try buf.appendSlice(text); - } - if (status.port > 0) { + } + if (snapshot.port > 0) { try buf.appendSlice(",\"port\":"); var num_buf: [10]u8 = undefined; - const text = try std.fmt.bufPrint(&num_buf, "{d}", .{status.port}); + const text = try std.fmt.bufPrint(&num_buf, "{d}", .{snapshot.port}); try buf.appendSlice(text); - } } try buf.append('}'); @@ -1819,10 +1906,10 @@ fn appendInstanceJson(buf: *std.array_list.Managed(u8), entry: state_mod.Instanc // ─── Handlers ──────────────────────────────────────────────────────────────── /// GET /api/instances — list all instances grouped by component. -pub fn handleList(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager) ApiResponse { +pub fn handleList(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths) ApiResponse { var buf = std.array_list.Managed(u8).init(allocator); - buildListJson(&buf, s, manager) catch return .{ + buildListJson(&buf, s, manager, paths) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}", @@ -1831,7 +1918,7 @@ pub fn handleList(allocator: std.mem.Allocator, s: *state_mod.State, manager: *m return jsonOk(buf.items); } -fn buildListJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: *manager_mod.Manager) !void { +fn buildListJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths) !void { try buf.appendSlice("{\"instances\":{"); var comp_it = s.instances.iterator(); @@ -1850,12 +1937,12 @@ fn buildListJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: if (!first_inst) try buf.append(','); first_inst = false; - const runtime_status = manager.getStatus(comp_entry.key_ptr.*, inst_entry.key_ptr.*); + const snapshot = resolveInstanceSnapshot(buf.allocator, paths, manager, comp_entry.key_ptr.*, inst_entry.key_ptr.*, inst_entry.value_ptr.*); try buf.append('"'); try appendEscaped(buf, inst_entry.key_ptr.*); try buf.appendSlice("\":"); - try appendInstanceJson(buf, inst_entry.value_ptr.*, runtime_status); + try appendInstanceJson(buf, inst_entry.value_ptr.*, snapshot); } try buf.append('}'); @@ -1865,13 +1952,13 @@ fn buildListJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: } /// GET /api/instances/{component}/{name} — detail for one instance. -pub fn handleGet(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, component: []const u8, name: []const u8) ApiResponse { +pub fn handleGet(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, component: []const u8, name: []const u8) ApiResponse { const entry = s.getInstance(component, name) orelse return notFound(); - const runtime_status = manager.getStatus(component, name); + const snapshot = resolveInstanceSnapshot(allocator, paths, manager, component, name, entry); var buf = std.array_list.Managed(u8).init(allocator); - appendInstanceJson(&buf, entry, runtime_status) catch return .{ + appendInstanceJson(&buf, entry, snapshot) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}", @@ -1921,6 +2008,8 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * } } + refreshLocalDevBinary(allocator, paths, component, entry.version); + // Resolve binary path const bin_path = paths.binary(allocator, component, entry.version) catch return helpers.serverError(); defer allocator.free(bin_path); @@ -3651,7 +3740,7 @@ pub fn dispatch( ) ?ApiResponse { // Exact match for the collection endpoint. if (std.mem.eql(u8, stripQuery(target), "/api/instances")) { - if (std.mem.eql(u8, method, "GET")) return handleList(allocator, s, manager); + if (std.mem.eql(u8, method, "GET")) return handleList(allocator, s, manager, paths); return methodNotAllowed(); } @@ -3841,7 +3930,7 @@ pub fn dispatch( } // No action — CRUD on the instance itself. - if (std.mem.eql(u8, method, "GET")) return handleGet(allocator, s, manager, parsed.component, parsed.name); + if (std.mem.eql(u8, method, "GET")) return handleGet(allocator, s, manager, paths, parsed.component, parsed.name); if (std.mem.eql(u8, method, "DELETE")) return handleDelete(allocator, s, manager, paths, parsed.component, parsed.name); if (std.mem.eql(u8, method, "PATCH")) return handlePatch(s, parsed.component, parsed.name, body); diff --git a/src/api/status.zig b/src/api/status.zig index 444d25e..f476895 100644 --- a/src/api/status.zig +++ b/src/api/status.zig @@ -1,9 +1,11 @@ const std = @import("std"); const builtin = @import("builtin"); +const std_compat = @import("compat"); const state_mod = @import("../core/state.zig"); const platform = @import("../core/platform.zig"); const manager_mod = @import("../supervisor/manager.zig"); const paths_mod = @import("../core/paths.zig"); +const health_mod = @import("../supervisor/health.zig"); const helpers = @import("helpers.zig"); const access = @import("../access.zig"); const version = @import("../version.zig"); @@ -139,13 +141,103 @@ fn appendInstanceJson(buf: *std.array_list.Managed(u8), entry: state_mod.Instanc try buf.append('}'); } +fn readPortFromConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8, name: []const u8, dot_key: []const u8) ?u16 { + const config_path = paths.instanceConfig(allocator, component, name) catch return null; + defer allocator.free(config_path); + + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return null; + defer file.close(); + const contents = file.readToEndAlloc(allocator, 4 * 1024 * 1024) catch return null; + defer allocator.free(contents); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + }) catch return null; + defer parsed.deinit(); + + var current = parsed.value; + var it = std.mem.splitScalar(u8, dot_key, '.'); + while (it.next()) |segment| { + switch (current) { + .object => |obj| current = obj.get(segment) orelse return null, + else => return null, + } + } + + return switch (current) { + .integer => |v| if (v >= 0 and v <= 65535) @intCast(v) else null, + else => null, + }; +} + +const InstanceSnapshot = struct { + status: manager_mod.Status, + pid: ?std.process.Child.Id = null, + uptime_seconds: ?u64 = null, + restart_count: u32 = 0, + port: u16 = 0, +}; + +fn deriveStandaloneSnapshot( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) ?InstanceSnapshot { + if (!std.mem.eql(u8, component, "nullclaw")) return null; + + const inst_dir = paths.instanceDir(allocator, component, name) catch return null; + defer allocator.free(inst_dir); + const real_dir = std_compat.fs.realpathAlloc(allocator, inst_dir) catch return null; + defer allocator.free(real_dir); + + const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch return null; + defer allocator.free(home); + const standalone_root = std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }) catch return null; + defer allocator.free(standalone_root); + + if (!std.mem.eql(u8, real_dir, standalone_root)) return null; + if (!std.mem.eql(u8, entry.launch_mode, "gateway")) return null; + + const port = readPortFromConfig(allocator, paths, component, name, "gateway.port") orelse 0; + if (port == 0) return null; + + const health = health_mod.check(allocator, "127.0.0.1", port, "/health"); + return .{ + .status = if (health.ok) .running else .stopped, + .port = port, + }; +} + +fn resolveInstanceSnapshot( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + manager: *manager_mod.Manager, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) InstanceSnapshot { + if (manager.getStatus(component, name)) |st| { + return .{ + .status = st.status, + .pid = st.pid, + .uptime_seconds = st.uptime_seconds, + .restart_count = st.restart_count, + .port = st.port, + }; + } + if (deriveStandaloneSnapshot(allocator, paths, component, name, entry)) |snapshot| return snapshot; + return .{ .status = .stopped }; +} + // ─── Handlers ──────────────────────────────────────────────────────────────── /// GET /api/status — aggregated dashboard data. -pub fn handleStatus(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, uptime_seconds: u64, host: []const u8, port: u16, access_options: access.Options) ApiResponse { +pub fn handleStatus(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, uptime_seconds: u64, host: []const u8, port: u16, access_options: access.Options) ApiResponse { var buf = std.array_list.Managed(u8).init(allocator); - buildStatusJson(&buf, s, manager, uptime_seconds, host, port, access_options) catch return .{ + buildStatusJson(&buf, s, manager, paths, uptime_seconds, host, port, access_options) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}", @@ -154,7 +246,7 @@ pub fn handleStatus(allocator: std.mem.Allocator, s: *state_mod.State, manager: return .{ .status = "200 OK", .content_type = "application/json", .body = buf.items }; } -fn buildStatusJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: *manager_mod.Manager, uptime_seconds: u64, host: []const u8, port: u16, access_options: access.Options) !void { +fn buildStatusJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, uptime_seconds: u64, host: []const u8, port: u16, access_options: access.Options) !void { var urls = try access.buildAccessUrlsWithOptions(buf.allocator, host, port, access_options); defer urls.deinit(buf.allocator); var component_rollups = std.StringHashMap(ComponentRollup).init(buf.allocator); @@ -171,8 +263,8 @@ fn buildStatusJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manage var rollup = ComponentRollup{}; var inst_it = comp_entry.value_ptr.iterator(); while (inst_it.next()) |inst_entry| { - const mgr_status = manager.getStatus(comp_entry.key_ptr.*, inst_entry.key_ptr.*); - const runtime_status = if (mgr_status) |st| st.status else manager_mod.Status.stopped; + const snapshot = resolveInstanceSnapshot(buf.allocator, paths, manager, comp_entry.key_ptr.*, inst_entry.key_ptr.*, inst_entry.value_ptr.*); + const runtime_status = snapshot.status; rollup.total += 1; if (inst_entry.value_ptr.auto_start) rollup.auto_start += 1; observeStatus(&rollup, runtime_status); @@ -257,12 +349,12 @@ fn buildStatusJson(buf: *std.array_list.Managed(u8), s: *state_mod.State, manage const comp_name = comp_entry.key_ptr.*; const inst_name = inst_entry.key_ptr.*; - const mgr_status = manager.getStatus(comp_name, inst_name); - const status_str = if (mgr_status) |st| @tagName(st.status) else "stopped"; - const pid = if (mgr_status) |st| st.pid else null; - const instance_uptime = if (mgr_status) |st| st.uptime_seconds else null; - const restart_count: u32 = if (mgr_status) |st| st.restart_count else 0; - const instance_port: u16 = if (mgr_status) |st| st.port else 0; + const snapshot = resolveInstanceSnapshot(buf.allocator, paths, manager, comp_name, inst_name, inst_entry.value_ptr.*); + const status_str = @tagName(snapshot.status); + const pid = snapshot.pid; + const instance_uptime = snapshot.uptime_seconds; + const restart_count: u32 = snapshot.restart_count; + const instance_port: u16 = snapshot.port; try buf.append('"'); try appendEscaped(buf, inst_name); @@ -289,7 +381,7 @@ test "handleStatus returns valid JSON with hub version" { var mgr = manager_mod.Manager.init(allocator, p); defer mgr.deinit(); - const resp = handleStatus(allocator, &s, &mgr, 3600, access.default_bind_host, access.default_port, .{}); + const resp = handleStatus(allocator, &s, &mgr, p, 3600, access.default_bind_host, access.default_port, .{}); defer allocator.free(resp.body); try std.testing.expectEqualStrings("200 OK", resp.status); @@ -356,7 +448,7 @@ test "handleStatus includes instances" { try s.addInstance("nullclaw", "my-agent", .{ .version = "2026.3.1", .auto_start = true }); - const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port, .{}); + const resp = handleStatus(allocator, &s, &mgr, p, 0, access.default_bind_host, access.default_port, .{}); defer allocator.free(resp.body); try std.testing.expectEqualStrings("200 OK", resp.status); @@ -427,7 +519,7 @@ test "handleStatus overall_status becomes error when a component has failed inst .status = .failed, }); - const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port, .{}); + const resp = handleStatus(allocator, &s, &mgr, p, 0, access.default_bind_host, access.default_port, .{}); defer allocator.free(resp.body); try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"overall_status\":\"error\"") != null); @@ -447,7 +539,7 @@ test "handleStatus includes launch_mode" { try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0", .launch_mode = "agent" }); - const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port, .{}); + const resp = handleStatus(allocator, &s, &mgr, p, 0, access.default_bind_host, access.default_port, .{}); defer allocator.free(resp.body); try std.testing.expectEqualStrings("200 OK", resp.status); @@ -465,7 +557,7 @@ test "handleStatus includes verbose flag" { try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0", .verbose = true }); - const resp = handleStatus(allocator, &s, &mgr, 0, access.default_bind_host, access.default_port, .{}); + const resp = handleStatus(allocator, &s, &mgr, p, 0, access.default_bind_host, access.default_port, .{}); defer allocator.free(resp.body); try std.testing.expectEqualStrings("200 OK", resp.status); @@ -481,7 +573,7 @@ test "handleStatus with empty state returns empty instances" { var mgr = manager_mod.Manager.init(allocator, p); defer mgr.deinit(); - const resp = handleStatus(allocator, &s, &mgr, 42, access.default_bind_host, access.default_port, .{}); + const resp = handleStatus(allocator, &s, &mgr, p, 42, access.default_bind_host, access.default_port, .{}); defer allocator.free(resp.body); try std.testing.expectEqualStrings("200 OK", resp.status); diff --git a/src/core/local_binary.zig b/src/core/local_binary.zig index ca13f06..be30e3f 100644 --- a/src/core/local_binary.zig +++ b/src/core/local_binary.zig @@ -7,19 +7,28 @@ pub fn find(allocator: std.mem.Allocator, component: []const u8) ?[]const u8 { const cwd = std_compat.fs.cwd().realpathAlloc(allocator, ".") catch return null; defer allocator.free(cwd); - const candidates = [_][]const []const u8{ - &.{ cwd, "zig-out", "bin", component }, - &.{ cwd, component, "zig-out", "bin", component }, - &.{ cwd, "..", component, "zig-out", "bin", component }, + const native_name = std.fmt.allocPrint(allocator, "{s}-native", .{component}) catch return null; + defer allocator.free(native_name); + + const candidate_dirs = [_][]const []const u8{ + &.{ cwd, "zig-out", "bin" }, + &.{ cwd, component, "zig-out", "bin" }, + &.{ cwd, "..", component, "zig-out", "bin" }, }; + const candidate_names = [_][]const u8{ native_name, component }; + + for (candidate_dirs) |parts| { + const dir_path = std.fs.path.join(allocator, parts) catch continue; + defer allocator.free(dir_path); - for (candidates) |parts| { - const path = std.fs.path.join(allocator, parts) catch continue; - if (std_compat.fs.openFileAbsolute(path, .{})) |f| { - f.close(); - return path; - } else |_| { - allocator.free(path); + for (candidate_names) |name| { + const path = std.fs.path.join(allocator, &.{ dir_path, name }) catch continue; + if (std_compat.fs.openFileAbsolute(path, .{})) |f| { + f.close(); + return path; + } else |_| { + allocator.free(path); + } } } diff --git a/src/core/paths.zig b/src/core/paths.zig index 57dbf28..2d29abc 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -62,9 +62,16 @@ pub const Paths = struct { return std.fs.path.join(allocator, &.{ self.root, "manifests", filename }); } - /// `{root}/bin/{component}-{version}` (or `.exe` on Windows) + /// `{root}/bin/{component}-{version}` (or `.exe` on Windows). + /// For `dev-local`, use the canonical component basename instead so locally + /// staged binaries behave the same as the original executable. pub fn binary(self: Paths, allocator: std.mem.Allocator, component: []const u8, version: []const u8) ![]const u8 { - const filename = if (builtin.os.tag == .windows) + const filename = if (std.mem.eql(u8, version, "dev-local")) + if (builtin.os.tag == .windows) + try std.fmt.allocPrint(allocator, "{s}.exe", .{component}) + else + try allocator.dupe(u8, component) + else if (builtin.os.tag == .windows) try std.fmt.allocPrint(allocator, "{s}-{s}.exe", .{ component, version }) else try std.fmt.allocPrint(allocator, "{s}-{s}", .{ component, version }); diff --git a/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index ac1b190..5c0ce9b 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -552,11 +552,10 @@ fn stageLocalBinary(allocator: std.mem.Allocator, p: paths_mod.Paths, component: const bin_path = p.binary(allocator, component, version) catch return null; errdefer allocator.free(bin_path); - if (std_compat.fs.openFileAbsolute(bin_path, .{})) |f| { - f.close(); - return .{ .version = version, .bin_path = bin_path }; - } else |_| {} - + std_compat.fs.deleteFileAbsolute(bin_path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return null, + }; std_compat.fs.copyFileAbsolute(local_path, bin_path, .{}) catch return null; if (comptime std_compat.fs.has_executable_bit) { if (std_compat.fs.openFileAbsolute(bin_path, .{ .mode = .read_only })) |f| { diff --git a/src/server.zig b/src/server.zig index bc3f506..152714e 100644 --- a/src/server.zig +++ b/src/server.zig @@ -557,7 +557,7 @@ pub const Server = struct { if (std.mem.eql(u8, target, "/api/status")) { const now = std_compat.time.timestamp(); const uptime: u64 = @intCast(@max(0, now - self.start_time)); - const resp = status_api.handleStatus(allocator, self.state, self.manager, uptime, self.host, self.port, self.currentAccessOptions()); + const resp = status_api.handleStatus(allocator, self.state, self.manager, self.paths, uptime, self.host, self.port, self.currentAccessOptions()); return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; } if (meta_api.isRoutesPath(target)) { diff --git a/src/supervisor/process.zig b/src/supervisor/process.zig index 1567bbc..39f3f5b 100644 --- a/src/supervisor/process.zig +++ b/src/supervisor/process.zig @@ -3,6 +3,41 @@ const std_compat = @import("compat"); const builtin = @import("builtin"); const windows = std.os.windows; +const darwin = if (builtin.os.tag == .macos) struct { + const extern_structs = struct { + pub const ProcBsdInfo = extern struct { + proc_pid: u32, + proc_ppid: u32, + proc_pgid: u32, + proc_status: u32, + proc_comm: [17]u8, + proc_name: [33]u8, + proc_nice: i32, + proc_flag: u32, + proc_uid: u32, + proc_ruid: u32, + proc_svuid: u32, + proc_rgid: u32, + proc_svgid: u32, + rfu_1: u32, + proc_comm2: [17]u8, + proc_xstatus: u32, + proc_acflag: u32, + proc_pctcpu: u32, + proc_estcpu: u32, + proc_slptime: u32, + proc_realtimer: u64, + proc_start_tvsec: u64, + proc_start_tvusec: u64, + }; + }; + + const PROC_PIDTBSDINFO: i32 = 3; + const SZOMB: u32 = 5; + + extern "c" fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: ?*anyopaque, buffersize: i32) c_int; +} else struct {}; + const kernel32 = struct { extern "kernel32" fn GetProcessId( process: windows.HANDLE, @@ -207,10 +242,30 @@ pub fn isAlive(pid: std_compat.process.Child.Id) bool { else => false, }; } - return switch (std.posix.errno(std.posix.system.kill(pid, @as(std.posix.SIG, @enumFromInt(0))))) { + const alive = switch (std.posix.errno(std.posix.system.kill(pid, @as(std.posix.SIG, @enumFromInt(0))))) { .SUCCESS => true, else => false, }; + if (!alive) return false; + + if (comptime builtin.os.tag == .macos) { + return !isDarwinZombie(pid); + } + + return true; +} + +fn isDarwinZombie(pid: std_compat.process.Child.Id) bool { + var info: darwin.extern_structs.ProcBsdInfo = undefined; + const size = darwin.proc_pidinfo( + @intCast(pid), + darwin.PROC_PIDTBSDINFO, + 0, + @ptrCast(&info), + @sizeOf(darwin.extern_structs.ProcBsdInfo), + ); + if (size != @sizeOf(darwin.extern_structs.ProcBsdInfo)) return false; + return info.proc_status == darwin.SZOMB; } pub fn persistedPidValue(pid: std_compat.process.Child.Id) ?u64 { @@ -364,6 +419,20 @@ test "isAlive returns false for non-existent pid" { try std.testing.expect(!isAlive(99999999)); } +test "isAlive returns false for zombie process on macOS" { + if (comptime builtin.os.tag != .macos) return error.SkipZigTest; + + const result = try spawn(std.testing.allocator, .{ + .binary = "/bin/sh", + .argv = &.{ "-c", "exit 0" }, + }); + var child = result.child; + defer _ = child.wait() catch {}; + + std_compat.thread.sleep(50 * std.time.ns_per_ms); + try std.testing.expect(!isAlive(result.pid)); +} + test "terminate non-existent pid does not error" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest;