Skip to content
Open
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
7 changes: 5 additions & 2 deletions src/api/instances.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions src/core/launch_args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -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]);
}
7 changes: 5 additions & 2 deletions src/core/paths.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
89 changes: 89 additions & 0 deletions src/net_compat.zig
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 6 additions & 5 deletions src/server.zig
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
}
}

Expand All @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/supervisor/health.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const std = @import("std");
const net_compat = @import("../net_compat.zig");

pub const HealthCheckResult = struct {
ok: bool,
Expand Down Expand Up @@ -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" };
};

Expand Down
5 changes: 3 additions & 2 deletions ui/src/app.css
Original file line number Diff line number Diff line change
@@ -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 <link> 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 {
Expand Down
8 changes: 8 additions & 0 deletions ui/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
<meta name="theme-color" content="#e00028" />
<meta name="msapplication-TileColor" content="#e00028" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
</noscript>
%sveltekit.head%
</head>
<body data-sveltekit-prerender="false" class="theme-matrix effects-disabled">
Expand Down
5 changes: 5 additions & 0 deletions ui/src/lib/nullhubAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export function buildNullHubAccessUrls(port: string | number, protocol = "http:"
export async function redirectToPreferredOrigin(location: Location): Promise<void> {
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];
Expand Down