diff --git a/src/api/instances.zig b/src/api/instances.zig index 272763a..0766298 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -1555,8 +1555,11 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * const launch_args = launch_args_mod.buildLaunchArgs(allocator, launch_cmd, launch_verbose) catch return helpers.serverError(); defer allocator.free(launch_args); const primary_cmd = if (launch_args.len > 0) launch_args[0] else launch_cmd; - // Agent mode has no HTTP health endpoint — skip health checks (port=0). - const effective_port: u16 = if (std.mem.eql(u8, primary_cmd, "agent")) 0 else port; + // Only HTTP server modes (e.g. "serve") expose a health endpoint. + // Non-HTTP modes — agent, gateway, channel, or any future long-lived non-server mode — + // should be supervised by process-alive checks only (port=0). + const is_http_server_mode = std.mem.eql(u8, primary_cmd, "serve"); + const effective_port: u16 = if (is_http_server_mode) port else 0; // Resolve instance working directory so the binary can find its config. const inst_dir = paths.instanceDir(allocator, component, name) catch return helpers.serverError(); diff --git a/src/core/launch_args.zig b/src/core/launch_args.zig index 98f2950..0f007d9 100644 --- a/src/core/launch_args.zig +++ b/src/core/launch_args.zig @@ -13,6 +13,12 @@ pub fn buildLaunchArgs( try list.append(allocator, token); } + // NullClaw convenience: bare `channel` isn't a runnable long-lived mode. + // Expand it to `channel start` so Hub can actually supervise it. + if (list.items.len == 1 and std.mem.eql(u8, list.items[0], "channel")) { + try list.append(allocator, "start"); + } + if (verbose) { try list.append(allocator, "--verbose"); } @@ -40,3 +46,13 @@ test "buildLaunchArgs preserves tokenized launch mode when verbose disabled" { try std.testing.expectEqualStrings("--foo", args[1]); try std.testing.expectEqualStrings("bar", args[2]); } + +test "buildLaunchArgs expands bare channel to channel start" { + const allocator = std.testing.allocator; + const args = try buildLaunchArgs(allocator, "channel", false); + defer allocator.free(args); + + try std.testing.expectEqual(@as(usize, 2), args.len); + try std.testing.expectEqualStrings("channel", args[0]); + try std.testing.expectEqualStrings("start", args[1]); +} diff --git a/src/core/paths.zig b/src/core/paths.zig index 50d23a0..2f8314f 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -61,9 +61,12 @@ pub const Paths = struct { return std.fs.path.join(allocator, &.{ self.root, "manifests", filename }); } - /// `{root}/bin/{component}-{version}` + /// `{root}/bin/{component}-{version}` (or `.exe` on Windows) pub fn binary(self: Paths, allocator: std.mem.Allocator, component: []const u8, version: []const u8) ![]const u8 { - const filename = try std.fmt.allocPrint(allocator, "{s}-{s}", .{ component, version }); + const filename = 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 }); defer allocator.free(filename); return std.fs.path.join(allocator, &.{ self.root, "bin", filename }); } diff --git a/src/net_compat.zig b/src/net_compat.zig new file mode 100644 index 0000000..077872c --- /dev/null +++ b/src/net_compat.zig @@ -0,0 +1,89 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Windows-safe socket read. Zig 0.15.2's std.net.Stream.read() uses +/// NtReadFile/ReadFile on Windows, which fails on sockets with +/// GetLastError(87) "The parameter is incorrect". +/// +/// This wrapper uses ws2_32.recv on Windows and falls back to the +/// standard stream.read() on other platforms. +pub fn streamRead(stream: std.net.Stream, buffer: []u8) !usize { + if (comptime builtin.os.tag == .windows) { + return windowsSocketRecv(stream.handle, buffer); + } + return stream.read(buffer); +} + +/// Windows-safe socket write. Same issue as read — uses ws2_32.send +/// instead of WriteFile/NtWriteFile on Windows. +pub fn streamWrite(stream: std.net.Stream, data: []const u8) !usize { + if (comptime builtin.os.tag == .windows) { + return windowsSocketSend(stream.handle, data); + } + return stream.write(data); +} + +/// Write all data to a Windows socket. +pub fn streamWriteAll(stream: std.net.Stream, data: []const u8) !void { + if (comptime builtin.os.tag == .windows) { + var offset: usize = 0; + while (offset < data.len) { + offset += try windowsSocketSend(stream.handle, data[offset..]); + } + return; + } + return stream.writeAll(data); +} + +/// A writer interface backed by Windows-safe socket writes. +pub const StreamWriter = struct { + stream: std.net.Stream, + + pub fn write(self: StreamWriter, data: []const u8) !usize { + return streamWrite(self.stream, data); + } + + pub fn writeAll(self: StreamWriter, data: []const u8) !void { + return streamWriteAll(self.stream, data); + } +}; + +pub fn safeWriter(stream: std.net.Stream) StreamWriter { + return .{ .stream = stream }; +} + +// ── Windows socket internals ──────────────────────────────────────── + +fn windowsSocketRecv(handle: std.os.windows.HANDLE, buffer: []u8) !usize { + const socket: std.os.windows.ws2_32.SOCKET = @ptrCast(handle); + const rc = std.os.windows.ws2_32.recv(socket, buffer.ptr, @intCast(buffer.len), 0); + if (rc == std.os.windows.ws2_32.SOCKET_ERROR) { + const err = std.os.windows.ws2_32.WSAGetLastError(); + return switch (err) { + .WSAECONNRESET, .WSAECONNABORTED => error.ConnectionResetByPeer, + .WSAETIMEDOUT => error.ConnectionTimedOut, + .WSAENETRESET => error.ConnectionResetByPeer, + .WSAENOTCONN => error.SocketNotConnected, + else => error.Unexpected, + }; + } + const bytes: usize = @intCast(rc); + if (bytes == 0) return 0; // clean close + return bytes; +} + +fn windowsSocketSend(handle: std.os.windows.HANDLE, data: []const u8) !usize { + const socket: std.os.windows.ws2_32.SOCKET = @ptrCast(handle); + const rc = std.os.windows.ws2_32.send(socket, data.ptr, @intCast(data.len), 0); + if (rc == std.os.windows.ws2_32.SOCKET_ERROR) { + const err = std.os.windows.ws2_32.WSAGetLastError(); + return switch (err) { + .WSAECONNRESET, .WSAECONNABORTED => error.ConnectionResetByPeer, + .WSAETIMEDOUT => error.ConnectionTimedOut, + .WSAENETRESET => error.ConnectionResetByPeer, + .WSAENOTCONN => error.SocketNotConnected, + else => error.Unexpected, + }; + } + return @intCast(rc); +} diff --git a/src/server.zig b/src/server.zig index 6d00e76..642f30b 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const net_compat = @import("net_compat.zig"); const auth = @import("auth.zig"); const instances_api = @import("api/instances.zig"); const platform = @import("core/platform.zig"); @@ -336,7 +337,7 @@ pub const Server = struct { fn handleConnection(self: *Server, conn: std.net.Server.Connection, alloc: std.mem.Allocator) !void { var req_buf: [max_request_size]u8 = undefined; - const n = conn.stream.read(&req_buf) catch return; + const n = net_compat.streamRead(conn.stream, &req_buf) catch return; if (n == 0) return; const raw = req_buf[0..n]; @@ -1040,7 +1041,7 @@ fn readBody(raw: []const u8, n: usize, stream: std.net.Stream, alloc: std.mem.Al @memcpy(full_buf[0..n], raw); var total_read = n; while (total_read < total_size) { - const extra = stream.read(full_buf[total_read..total_size]) catch break; + const extra = net_compat.streamRead(stream, full_buf[total_read..total_size]) catch break; if (extra == 0) break; total_read += extra; } @@ -1061,9 +1062,9 @@ fn sendResponse(stream: std.net.Stream, response: Response, raw_request: []const try appendCorsHeaders(writer, raw_request, bind_host, port); try writer.writeAll("Connection: close\r\n\r\n"); - _ = try stream.write(header_stream.getWritten()); + try net_compat.streamWriteAll(stream, header_stream.getWritten()); if (response.body.len > 0) { - _ = try stream.write(response.body); + try net_compat.streamWriteAll(stream, response.body); } } @@ -1078,7 +1079,7 @@ fn sendRedirect(stream: std.net.Stream, location: []const u8, raw_request: []con try appendCorsHeaders(writer, raw_request, bind_host, port); try writer.writeAll("Connection: close\r\n\r\n"); - _ = try stream.write(header_stream.getWritten()); + try net_compat.streamWriteAll(stream, header_stream.getWritten()); } pub fn extractBody(raw: []const u8) []const u8 { diff --git a/src/supervisor/health.zig b/src/supervisor/health.zig index d8363e8..ab9dcdc 100644 --- a/src/supervisor/health.zig +++ b/src/supervisor/health.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const net_compat = @import("../net_compat.zig"); pub const HealthCheckResult = struct { ok: bool, @@ -29,13 +30,13 @@ pub fn check(allocator: std.mem.Allocator, host: []const u8, port: u16, endpoint }; defer allocator.free(request); - _ = stream.writeAll(request) catch { + net_compat.streamWriteAll(stream, request) catch { return .{ .ok = false, .error_message = "failed to send request" }; }; // Read response (just need the status line) var buf: [1024]u8 = undefined; - const n = stream.read(&buf) catch { + const n = net_compat.streamRead(stream, &buf) catch { return .{ .ok = false, .error_message = "failed to read response" }; }; diff --git a/ui/src/app.css b/ui/src/app.css index 24beee8..230fae2 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1,5 +1,6 @@ -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=VT323&display=swap'); +/* Fonts loaded non-blocking via in app.html to prevent render-blocking. + CSS @import blocks rendering until the external stylesheet loads, which causes + a black screen on Windows when Google Fonts is slow or stalls. */ :root, body.theme-matrix { diff --git a/ui/src/app.html b/ui/src/app.html index 4be2f40..6490cbe 100644 --- a/ui/src/app.html +++ b/ui/src/app.html @@ -14,6 +14,14 @@ + + + + + %sveltekit.head% diff --git a/ui/src/lib/nullhubAccess.ts b/ui/src/lib/nullhubAccess.ts index 095d1c4..3c275c0 100644 --- a/ui/src/lib/nullhubAccess.ts +++ b/ui/src/lib/nullhubAccess.ts @@ -24,6 +24,11 @@ export function buildNullHubAccessUrls(port: string | number, protocol = "http:" export async function redirectToPreferredOrigin(location: Location): Promise { if (!LOOPBACK_HOSTS.has(location.hostname)) return; + // If already on the direct fallback (127.0.0.1), no redirect needed. + // Skips the nullhub.localhost DNS probe which hangs on Windows before + // timing out, causing a blank screen until DevTools is opened. + if (location.hostname === FALLBACK_LOCAL_HOST) return; + const urls = buildNullHubAccessUrls(resolvePort(location), location.protocol); const currentOrigin = location.origin; const candidates = [urls.browserOpenUrl, urls.fallbackUrl];