From 2b228684171c7af3272f23925500af72fa6f6886 Mon Sep 17 00:00:00 2001 From: TheRefreshCNFT <113127907+TheRefreshCNFT@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:53:43 -0400 Subject: [PATCH 1/5] fix(server): use ws2_32 recv/send on Windows instead of NtReadFile/NtWriteFile Zig 0.15.2's std.net.Stream.read/write uses ReadFile (via NtReadFile) on Windows, which fails on sockets with GetLastError(87) 'The parameter is incorrect'. This is the same class of bug fixed in nullclaw PR #550. Adds net_compat.zig with Windows-safe socket I/O that uses ws2_32.recv and ws2_32.send directly, falling back to std stream operations on other platforms. All socket read/write paths in server.zig now go through the compat layer. --- src/net_compat.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.zig | 11 +++--- 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/net_compat.zig 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 { From 6e436b8ddabc2515d99b7d19f33ea744e93a14b9 Mon Sep 17 00:00:00 2001 From: TheRefreshCNFT <113127907+TheRefreshCNFT@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:03:30 -0400 Subject: [PATCH 2/5] fix(ui): skip nullhub.localhost DNS probe when already on 127.0.0.1 On Windows, resolving nullhub.localhost blocks until the 350ms timeout fires, causing a blank screen on load. The UI only renders after DevTools is opened (which adds enough latency to let the timeout complete). Fix: bail out early if already on the direct fallback host (127.0.0.1), skipping the DNS probe entirely. The redirect is only needed when arriving via a hostname alias. --- ui/src/lib/nullhubAccess.ts | 5 +++++ 1 file changed, 5 insertions(+) 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]; From 9502507056cdd0acefa4de6bcbb87c340883e3d4 Mon Sep 17 00:00:00 2001 From: TheRefreshCNFT <113127907+TheRefreshCNFT@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:10:31 -0400 Subject: [PATCH 3/5] fix(ui): move Google Fonts to non-blocking link tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS @import url() is render-blocking — the browser won't paint anything until the external stylesheet finishes loading. On slow connections or when Google Fonts stalls, this causes a black screen that only resolves when DevTools is opened (triggering a repaint). Move font loading to async tags in app.html using the media=print/onload=all pattern, with a noscript fallback. --- ui/src/app.css | 5 +++-- ui/src/app.html | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) 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% From 9e2434cc3e357aba28d520e90375d5da2cf598e9 Mon Sep 17 00:00:00 2001 From: TheRefreshCNFT <113127907+TheRefreshCNFT@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:51:38 -0400 Subject: [PATCH 4/5] fix(windows): binary .exe suffix, health socket compat, channel start expansion - paths.zig: append .exe suffix on Windows for binary path resolution - supervisor/health.zig: use ws2_32 compat layer (net_compat.zig) for health check socket I/O, same fix as server.zig (NtReadFile fails on sockets) - launch_args.zig: expand bare 'channel' launch_mode to 'channel start' so Hub can supervise nullclaw channel mode; adds test coverage --- src/core/launch_args.zig | 16 ++++++++++++++++ src/core/paths.zig | 7 +++++-- src/supervisor/health.zig | 5 +++-- 3 files changed, 24 insertions(+), 4 deletions(-) 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/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" }; }; From 2fca0bbccbf5d4e939f62dc20c9abe8c90b19e97 Mon Sep 17 00:00:00 2001 From: TheRefreshCNFT <113127907+TheRefreshCNFT@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:58:04 -0400 Subject: [PATCH 5/5] fix(supervisor): skip HTTP health for non-server launch modes Only 'serve' mode exposes an HTTP health endpoint. All other modes (agent, gateway, channel, or any future long-lived non-HTTP mode) should be supervised by process-alive checks only (port=0). Previously only 'agent' was exempted, meaning gateway/channel modes would incorrectly attempt HTTP health probes and fail. --- src/api/instances.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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();