From faf4c3472ec1ffc7a8a273ce107277c99a7d4960 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:29:25 +0000 Subject: [PATCH 1/5] remote: a client that finds, starts and connects to the installed fizzy Client.zig locates fizzy per OS (FIZZY_EXE, the usual install places, Launch Services, App Paths), asks it for its socket (--control-path), starts it with --quiet when nothing listens (macOS: open -g -j) and dials with backoff to a deadline, then upgrades to the remote-view stream and exchanges hellos. Distinct errors: not installed, fizzy too old, add-on too old, timed out, start failed, sandboxed (never a private second fizzy). reconnect runs the same steps after a drop. capi.zig and fizzy_remote.h are the C ABI. Tested against fake_fizzy.zig, an executable on a test socket: found, not found, quiet start, slow start, crash during start, version skew, drop and reconnect. Part of fizzyedit/work#41 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KLaaExP2YTZiSnff98PmgV --- build/app.zig | 31 +- sdk/remote/Client.zig | 563 ++++++++++++++++++++++++++++++++++++ sdk/remote/README.md | 26 ++ sdk/remote/capi.zig | 282 ++++++++++++++++++ sdk/remote/client_tests.zig | 194 +++++++++++++ sdk/remote/fake_fizzy.zig | 119 ++++++++ sdk/remote/fizzy_remote.h | 109 +++++++ sdk/remote/root.zig | 5 + 8 files changed, 1326 insertions(+), 3 deletions(-) create mode 100644 sdk/remote/Client.zig create mode 100644 sdk/remote/capi.zig create mode 100644 sdk/remote/client_tests.zig create mode 100644 sdk/remote/fake_fizzy.zig create mode 100644 sdk/remote/fizzy_remote.h diff --git a/build/app.zig b/build/app.zig index 84f259601..cb3d78d71 100644 --- a/build/app.zig +++ b/build/app.zig @@ -891,9 +891,6 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // dvui half (`sdk/replay/`) compiles on its own below, and is covered by // `tests/integration.zig`. .{ "fizzy-tape-tests", "sdk/tape/root.zig" }, - // The remote-view protocol (`sdk/remote/`, plans/EMBEDDED_EDITOR_PLAN.md step 2): messages, - // framing, the connection rules and a loopback pair. std-only, so a host's adapter can link it. - .{ "fizzy-remote-tests", "sdk/remote/root.zig" }, // Where Report a Bug goes when the build isn't told. std-only (see issues_url.zig); the // build is its only user. .{ "fizzy-issues-url-tests", "build/issues_url.zig" }, @@ -1037,6 +1034,34 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. })); } + { + // The remote-view protocol (`sdk/remote/`, plans/EMBEDDED_EDITOR_PLAN.md steps 2 and 5): + // messages, framing, the connection rules, and the client that finds, starts and connects + // to fizzy, against a fake fizzy executable built here. std-only, so a host's adapter can + // link it. + const fake_fizzy = b.addExecutable(.{ + .name = "fake-fizzy", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("sdk/remote/fake_fizzy.zig"), + }), + }); + const fake_options = b.addOptions(); + fake_options.addOptionPath("fake_fizzy", fake_fizzy.getEmittedBin()); + const remote_tests = b.addTest(.{ + .name = "fizzy-remote-tests", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("sdk/remote/root.zig"), + .imports = &.{.{ .name = "fake_fizzy_options", .module = fake_options.createModule() }}, + }), + .filters = test_filters, + }); + try unit_test_artifacts.append(b.allocator, remote_tests); + } + for (unit_test_artifacts.items) |unit_test| { test_step.dependOn(&b.addRunArtifact(unit_test).step); check_step.dependOn(&unit_test.step); diff --git a/sdk/remote/Client.zig b/sdk/remote/Client.zig new file mode 100644 index 000000000..05ae7b6c2 --- /dev/null +++ b/sdk/remote/Client.zig @@ -0,0 +1,563 @@ +//! `Client` — a host application's way to a running fizzy: find the installed one, quietly start it +//! when nothing listens, connect to its control socket, upgrade to the remote-view stream and +//! exchange hellos (plans/EMBEDDED_EDITOR_PLAN.md, step 5). `capi.zig` is the C ABI over it. +//! +//! The steps, and the error each one ends in: +//! +//! 1. *Locate* the executable (`locate`): `FIZZY_EXE`, then the OS's usual install places +//! (`candidates`), then the system's own index (Launch Services, the registry's App Paths). +//! Nothing found is `NotInstalled`, or `Sandboxed` from a sandbox, which cannot see them. +//! 2. Ask it for its socket (`fizzy --control-path`, with the `--profile` in play), unless the +//! caller names the socket. +//! 3. *Dial.* If nothing listens, start fizzy with `--quiet` (macOS: `open -g -j`, which never +//! takes focus) and dial again with a growing pause until the deadline. A start that dies +//! is `StartFailed`, one that is only slow `TimedOut`. Fizzy is never started with a +//! private profile of its own to get around a sandbox: that would be a second fizzy the +//! person did not ask for. +//! 4. *Upgrade* `GET /remote` and exchange hellos. `FizzyTooOld` (no `/remote`, or a protocol +//! older than ours) and `ClientTooOld` (newer than ours) say who updates. +//! +//! `reconnect` runs the same steps again after a drop. std-only. +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const message = @import("message.zig"); +const codec = @import("codec.zig"); +const Endpoint = @import("Endpoint.zig"); + +const Client = @This(); + +pub const upgrade_token = "fizzy-remote-view/1"; + +/// The environment variable that names the executable, and the profile folder, for a host that +/// must not search (and for tests). +pub const env_exe = "FIZZY_EXE"; +pub const env_profile = "FIZZY_PROFILE"; + +pub const Error = error{ + /// No fizzy where one would be installed. + NotInstalled, + /// Nothing listens and the caller asked not to start one. + NotRunning, + /// This process cannot see fizzy's install or socket, or may not start it. + Sandboxed, + /// Fizzy has no remote view, or speaks only older versions: update fizzy. + FizzyTooOld, + /// Fizzy speaks only newer versions: update the add-on. + ClientTooOld, + /// Started (or found) but not answering by the deadline. + TimedOut, + /// The fizzy we started exited with a failure before it listened. + StartFailed, + /// The peer is not speaking the protocol. + Protocol, + /// The stream ended. + Closed, + OutOfMemory, +}; + +/// Which application to find: an app built on fizzy takes its own. +pub const Identity = struct { + /// The executable's name and the Velopack pack id. + name: []const u8 = "fizzy", + /// The macOS bundle's name (`Fizzy.app`). + display_name: []const u8 = "Fizzy", + bundle_id: []const u8 = "com.foxnne.fizzy", +}; + +/// What the process's environment says, as values, so a host that has no libc environment (or a +/// test) can say it too. +pub const Env = struct { + os: std.Target.Os.Tag = builtin.os.tag, + exe: ?[]const u8 = null, + profile: ?[]const u8 = null, + home: ?[]const u8 = null, + local_app_data: ?[]const u8 = null, + path: ?[]const u8 = null, + /// An OS sandbox (App Sandbox, Flatpak, Snap): the install and socket may be out of sight. + sandboxed: bool = false, + + /// The process's own, through libc; empty without it. + pub fn fromProcess() Env { + return .{ + .exe = getenv(env_exe), + .profile = getenv(env_profile), + .home = getenv(if (builtin.os.tag == .windows) "USERPROFILE" else "HOME"), + .local_app_data = getenv("LOCALAPPDATA"), + .path = getenv("PATH"), + .sandboxed = getenv("APP_SANDBOX_CONTAINER_ID") != null or + getenv("FLATPAK_ID") != null or getenv("SNAP") != null, + }; + } + + fn getenv(name: [:0]const u8) ?[]const u8 { + if (!builtin.link_libc) return null; + const value = std.c.getenv(name) orelse return null; + const slice = std.mem.span(value); + return if (slice.len == 0) null else slice; + } +}; + +pub const Options = struct { + identity: Identity = .{}, + env: Env = .{}, + /// What the host calls itself in its hello, and its version. + host_name: []const u8 = "host", + host_version: []const u8 = "", + tiers: message.Tiers = .{ .draw_list = true }, + /// The socket, when the host knows it (and cannot run fizzy to ask). + socket_path: ?[]const u8 = null, + /// Start a quiet fizzy when none listens. + start: bool = true, + /// How long `connect` may take from the first dial, a start included. + timeout_ms: u32 = 15_000, +}; + +/// An installed fizzy. On macOS `bundle` is the `.app`, started through `open`. +pub const Install = struct { + exe: []const u8, + bundle: ?[]const u8 = null, +}; + +gpa: std.mem.Allocator, +io: Io, +opts: Options, +socket_path: ?[]u8 = null, +stream: ?Io.net.Stream = null, +endpoint: ?Endpoint = null, + +pub fn init(gpa: std.mem.Allocator, io: Io, opts: Options) Client { + return .{ .gpa = gpa, .io = io, .opts = opts }; +} + +pub fn deinit(self: *Client) void { + self.close(); + if (self.socket_path) |p| self.gpa.free(p); +} + +/// Where fizzy would be installed on `env.os`, likeliest first. Touches nothing. +pub fn candidates(arena: std.mem.Allocator, identity: Identity, env: Env) error{OutOfMemory}![]const Install { + var list: std.ArrayList(Install) = .empty; + const sep: []const u8 = if (env.os == .windows) "\\" else "/"; + switch (env.os) { + .macos => { + const roots = [_]?[]const u8{ "/Applications", if (env.home) |h| try join(arena, "/", &.{ h, "Applications" }) else null }; + for (roots) |root_opt| { + const root = root_opt orelse continue; + const app = try std.fmt.allocPrint(arena, "{s}/{s}.app", .{ root, identity.display_name }); + try list.append(arena, .{ .exe = try join(arena, "/", &.{ app, "Contents/MacOS", identity.name }), .bundle = app }); + } + }, + .windows => if (env.local_app_data) |base| { + // Velopack: %LOCALAPPDATA%\\current\.exe. + const exe = try std.fmt.allocPrint(arena, "{s}.exe", .{identity.name}); + try list.append(arena, .{ .exe = try join(arena, sep, &.{ base, identity.name, "current", exe }) }); + }, + else => if (env.home) |h| { + const packed_exe = try join(arena, sep, &.{ h, ".local/share", identity.name, "current", identity.name }); + try list.append(arena, .{ .exe = packed_exe }); + try list.append(arena, .{ .exe = try join(arena, sep, &.{ h, ".local/bin", identity.name }) }); + const image = try std.fmt.allocPrint(arena, "{s}.AppImage", .{identity.name}); + try list.append(arena, .{ .exe = try join(arena, sep, &.{ h, "Applications", image }) }); + }, + } + if (env.path) |path| { + const exe = if (env.os == .windows) try std.fmt.allocPrint(arena, "{s}.exe", .{identity.name}) else identity.name; + var dirs = std.mem.tokenizeScalar(u8, path, if (env.os == .windows) ';' else ':'); + while (dirs.next()) |dir| try list.append(arena, .{ .exe = try join(arena, sep, &.{ dir, exe }) }); + } + return list.items; +} + +fn join(arena: std.mem.Allocator, sep: []const u8, parts: []const []const u8) error{OutOfMemory}![]u8 { + return std.mem.join(arena, sep, parts); +} + +/// The installed fizzy. `FIZZY_EXE` is the only place looked when set: a named executable that +/// is not there is not installed. +pub fn locate(io: Io, arena: std.mem.Allocator, identity: Identity, env: Env) Error!Install { + if (env.exe) |exe| return if (exists(io, exe)) .{ .exe = exe } else notFound(env); + for (try candidates(arena, identity, env)) |install| { + if (exists(io, install.exe)) return install; + } + return lookupSystem(io, arena, identity, env) orelse notFound(env); +} + +fn notFound(env: Env) Error { + return if (env.sandboxed) error.Sandboxed else error.NotInstalled; +} + +fn exists(io: Io, path: []const u8) bool { + Io.Dir.cwd().access(io, path, .{}) catch return false; + return true; +} + +/// Launch Services (macOS) and the registry's App Paths (Windows) know installs that no fixed +/// place does. +fn lookupSystem(io: Io, arena: std.mem.Allocator, identity: Identity, env: Env) ?Install { + switch (env.os) { + .macos => { + if (std.mem.findAny(u8, identity.bundle_id, "\"\\") != null) return null; + const query = std.fmt.allocPrint(arena, "kMDItemCFBundleIdentifier == \"{s}\"", .{identity.bundle_id}) catch return null; + const out = runCapture(io, arena, &.{ "/usr/bin/mdfind", query }) orelse return null; + const bundle = std.mem.trim(u8, firstLine(out), " \t\r"); + if (bundle.len == 0) return null; + const exe = join(arena, "/", &.{ bundle, "Contents/MacOS", identity.name }) catch return null; + return if (exists(io, exe)) .{ .exe = exe, .bundle = bundle } else null; + }, + .windows => for ([_][]const u8{ "HKCU", "HKLM" }) |hive| { + const key = std.fmt.allocPrint(arena, "{s}\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{s}.exe", .{ hive, identity.name }) catch return null; + const out = runCapture(io, arena, &.{ "reg", "query", key, "/ve" }) orelse continue; + const exe = parseRegValue(out) orelse continue; + if (exists(io, exe)) return .{ .exe = exe }; + }, + else => {}, + } + return null; +} + +/// The value in `reg query … /ve` output: ` (Default) REG_SZ C:\path\fizzy.exe`. +fn parseRegValue(out: []const u8) ?[]const u8 { + const at = std.mem.find(u8, out, "REG_SZ") orelse return null; + const line = firstLine(out[at + "REG_SZ".len ..]); + const value = std.mem.trim(u8, line, " \t\r\""); + return if (value.len == 0) null else value; +} + +fn firstLine(text: []const u8) []const u8 { + return text[0 .. std.mem.findScalar(u8, text, '\n') orelse text.len]; +} + +/// Stdout of a run that exited 0, or null. +fn runCapture(io: Io, arena: std.mem.Allocator, argv: []const []const u8) ?[]const u8 { + const result = std.process.run(arena, io, .{ .argv = argv, .timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } } }) catch return null; + return switch (result.term) { + .exited => |code| if (code == 0) result.stdout else null, + else => null, + }; +} + +/// The socket `install` listens on: `fizzy --control-path`, with the same profile as a launch. +pub fn controlPath(io: Io, arena: std.mem.Allocator, install: Install, profile: ?[]const u8) Error![]const u8 { + const argv: []const []const u8 = if (profile) |p| + &.{ install.exe, "--control-path", "--profile", p } + else + &.{ install.exe, "--control-path" }; + const result = std.process.run(arena, io, .{ .argv = argv, .timeout = .{ .duration = .{ .raw = .fromSeconds(10), .clock = .awake } } }) catch |err| return spawnError(err); + // A fizzy that predates `--control-path` starts as an editor instead: no path to read. + const ok = switch (result.term) { + .exited => |code| code == 0, + else => false, + }; + const path = std.mem.trim(u8, firstLine(result.stdout), " \t\r"); + if (!ok or path.len == 0) return error.FizzyTooOld; + return path; +} + +fn spawnError(err: anyerror) Error { + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.AccessDenied, error.PermissionDenied => error.Sandboxed, + error.Timeout => error.TimedOut, + else => error.NotInstalled, + }; +} + +/// Start fizzy without taking the person's focus. The child is left to run on its own. +fn startQuiet(io: Io, arena: std.mem.Allocator, install: Install, profile: ?[]const u8, opts: Options) Error!Started { + var argv: std.ArrayList([]const u8) = .empty; + const via_open = opts.env.os == .macos and install.bundle != null; + if (via_open) { + try argv.appendSlice(arena, &.{ "/usr/bin/open", "-g", "-j", install.bundle.?, "--args" }); + } else try argv.append(arena, install.exe); + try argv.append(arena, "--quiet"); + if (profile) |p| try argv.appendSlice(arena, &.{ "--profile", p }); + const child = std.process.spawn(io, .{ + .argv = argv.items, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }) catch |err| return spawnError(err); + return .{ .child = child, .via_open = via_open }; +} + +const Started = struct { + child: std.process.Child, + /// `open` returns at once and the app runs on: its exit says nothing of the app. + via_open: bool, + + const Status = enum { running, exited_clean, failed }; + + fn status(self: *Started) Status { + if (comptime builtin.os.tag == .windows or builtin.os.tag == .wasi) return .running; + const pid = self.child.id orelse return .running; + var wait_status: i32 = 0; + const rc = std.posix.system.waitpid(pid, &wait_status, std.posix.W.NOHANG); + switch (std.posix.errno(rc)) { + .SUCCESS => {}, + else => return .running, + } + if (rc == 0) return .running; + self.child.id = null; + const raw: u32 = @bitCast(wait_status); + const clean = std.posix.W.IFEXITED(raw) and std.posix.W.EXITSTATUS(raw) == 0; + return if (clean) .exited_clean else .failed; + } +}; + +const Deadline = struct { + io: Io, + at_ns: i96, + + fn init(io: Io, ms: u32) Deadline { + return .{ .io = io, .at_ns = Io.Clock.awake.now(io).nanoseconds + @as(i96, ms) * std.time.ns_per_ms }; + } + + fn remainingMs(self: Deadline) u32 { + const left = self.at_ns - Io.Clock.awake.now(self.io).nanoseconds; + return if (left <= 0) 0 else @intCast(@divTrunc(left + std.time.ns_per_ms - 1, std.time.ns_per_ms)); + } +}; + +fn timeoutMs(ms: u32) Io.Timeout { + return .{ .duration = .{ .raw = .fromMilliseconds(ms), .clock = .awake } }; +} + +/// One read of at most `buf.len` bytes: the count (0 is the end of the stream), or null when +/// `timeout` passed first. +pub fn readSome(io: Io, stream: Io.net.Stream, buf: []u8, timeout: Io.Timeout) error{Closed}!?usize { + var data: [1][]u8 = .{buf}; + const result = io.operateTimeout(.{ .net_read = .{ .socket_handle = stream.socket.handle, .data = &data } }, timeout) catch |err| switch (err) { + error.Timeout => return null, + else => return error.Closed, + }; + const read = result.net_read catch return error.Closed; + return read.data_len; +} + +pub fn writeAll(io: Io, stream: Io.net.Stream, bytes: []const u8) error{Closed}!void { + var buf: [1024]u8 = undefined; + var writer = stream.writer(io, &buf); + writer.interface.writeAll(bytes) catch return error.Closed; + writer.interface.flush() catch return error.Closed; +} + +/// Find, start if need be, connect and say hello. On error nothing stays open. +pub fn connect(self: *Client) Error!void { + self.close(); + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const deadline: Deadline = .init(self.io, self.opts.timeout_ms); + const opts = self.opts; + + var install: ?Install = null; + if (opts.socket_path) |p| { + if (self.socket_path == null) self.socket_path = try self.gpa.dupe(u8, p); + } else { + const found = try locate(self.io, arena, opts.identity, opts.env); + install = found; + const path = try controlPath(self.io, arena, found, opts.env.profile); + if (self.socket_path) |old| self.gpa.free(old); + self.socket_path = try self.gpa.dupe(u8, path); + } + const socket = self.socket_path.?; + const addr = Io.net.UnixAddress.init(socket) catch return error.Protocol; + + var started: ?Started = null; + var pause_ms: u32 = 10; + while (true) { + if (addr.connect(self.io)) |stream| { + if (self.upgrade(stream, deadline)) |_| return else |err| switch (err) { + // fizzy is quitting or still coming up: a later dial may be served. + error.Closed => {}, + else => return err, + } + } else |err| switch (err) { + error.AccessDenied, error.PermissionDenied => return error.Sandboxed, + else => {}, + } + if (!opts.start) return error.NotRunning; + if (started == null) { + const found = install orelse try locate(self.io, arena, opts.identity, opts.env); + started = try startQuiet(self.io, arena, found, opts.env.profile, opts); + } else if (!started.?.via_open and started.?.status() == .failed) { + return error.StartFailed; + } + const left = deadline.remainingMs(); + if (left == 0) return error.TimedOut; + self.io.sleep(.fromMilliseconds(@min(pause_ms, left)), .awake) catch return error.Closed; + pause_ms = @min(pause_ms * 2, 250); + } +} + +/// `GET /remote` on `stream`, then the hellos. Takes ownership of `stream`. +fn upgrade(self: *Client, stream: Io.net.Stream, deadline: Deadline) Error!void { + errdefer stream.close(self.io); + try writeAll(self.io, stream, "GET /remote HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: " ++ upgrade_token ++ "\r\n\r\n"); + + var head: [2048]u8 = undefined; + var len: usize = 0; + const body_at = while (true) { + if (std.mem.find(u8, head[0..len], "\r\n\r\n")) |at| break at + 4; + if (len == head.len) return error.Protocol; + const n = try readSome(self.io, stream, head[len..], timeoutMs(deadline.remainingMs())) orelse return error.TimedOut; + if (n == 0) return error.Closed; + len += n; + }; + const status_line = firstLine(head[0..len]); + if (std.mem.startsWith(u8, status_line, "HTTP/1.1 503")) return error.Closed; + if (!std.mem.startsWith(u8, status_line, "HTTP/1.1 101")) return error.FizzyTooOld; + + var ep: Endpoint = .init(self.gpa, .host, .{ + .app_id = self.opts.host_name, + .app_version = self.opts.host_version, + .tiers_offered = self.opts.tiers, + .tiers_wanted = self.opts.tiers, + }); + errdefer ep.deinit(); + ep.start() catch |err| return epError(err); + try ep.feed(head[body_at..len]); + self.stream = stream; + errdefer self.stream = null; + try self.flush(&ep); + + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + var buf: [4096]u8 = undefined; + while (true) { + const got = ep.poll(arena_state.allocator()) catch |err| { + self.flush(&ep) catch {}; + return skewError(&ep, err); + }; + if (got) |msg| switch (msg) { + .hello => break, + .bye => |bye| return switch (bye.reason) { + .your_side_too_old => error.ClientTooOld, + .your_side_too_new => error.FizzyTooOld, + else => error.Closed, + }, + else => return error.Protocol, + }; + const n = try readSome(self.io, stream, &buf, timeoutMs(deadline.remainingMs())) orelse return error.TimedOut; + if (n == 0) return error.Closed; + try ep.feed(buf[0..n]); + } + self.endpoint = ep; +} + +fn epError(err: Endpoint.Error) Error { + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.Closed => error.Closed, + else => error.Protocol, + }; +} + +fn skewError(ep: *const Endpoint, err: Endpoint.Error) Error { + return switch (err) { + error.VersionSkew => if (ep.conn.skew == .host) error.ClientTooOld else error.FizzyTooOld, + error.OutOfMemory => error.OutOfMemory, + else => error.Protocol, + }; +} + +fn flush(self: *Client, ep: *Endpoint) Error!void { + const stream = self.stream orelse return error.Closed; + if (ep.pending().len == 0) return; + try writeAll(self.io, stream, ep.pending()); + ep.consume(ep.pending().len); +} + +/// Whether a stream is open (a drop shows on the next `poll` or `send`). +pub fn connected(self: *const Client) bool { + return self.endpoint != null; +} + +/// Check `msg` against the conversation's rules and send it. +pub fn send(self: *Client, msg: message.Message) Error!void { + const ep = &(self.endpoint orelse return error.Closed); + ep.send(msg) catch |err| return epError(err); + self.flush(ep) catch |err| { + self.close(); + return err; + }; +} + +/// The next message from fizzy, waiting up to `wait_ms` (null: forever); null when that passes. +/// Lists are allocated from `arena`; strings borrow the client until the next `poll`. Whatever +/// ends the stream closes the client, and `reconnect` opens it again. +pub fn poll(self: *Client, arena: std.mem.Allocator, wait_ms: ?u32) Error!?message.Message { + const ep = &(self.endpoint orelse return error.Closed); + const stream = self.stream.?; + const deadline: ?Deadline = if (wait_ms) |ms| .init(self.io, ms) else null; + var buf: [16 * 1024]u8 = undefined; + while (true) { + const got = ep.poll(arena) catch |err| { + self.flush(ep) catch {}; + self.close(); + return skewError(ep, err); + }; + if (got) |msg| { + if (msg == .bye) self.closeAfterBye(); + return msg; + } + const timeout: Io.Timeout = if (deadline) |d| timeoutMs(d.remainingMs()) else .none; + const n = readSome(self.io, stream, &buf, timeout) catch |err| { + self.close(); + return err; + } orelse return null; + if (n == 0) { + self.close(); + return error.Closed; + } + try ep.feed(buf[0..n]); + } +} + +fn closeAfterBye(self: *Client) void { + // The peer ended the conversation; its bye is the caller's to read, the stream is done. + if (self.stream) |s| s.close(self.io); + self.stream = null; +} + +/// Open again after a drop, finding and starting fizzy as `connect` does. Views are gone with +/// the old stream: the host creates them again. +pub fn reconnect(self: *Client) Error!void { + return self.connect(); +} + +pub fn close(self: *Client) void { + if (self.endpoint) |*ep| ep.deinit(); + self.endpoint = null; + if (self.stream) |s| s.close(self.io); + self.stream = null; +} + +test candidates { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const id: Identity = .{}; + + const mac = try candidates(arena, id, .{ .os = .macos, .home = "/Users/a", .path = "/usr/bin:/opt/bin" }); + try std.testing.expectEqualStrings("/Applications/Fizzy.app/Contents/MacOS/fizzy", mac[0].exe); + try std.testing.expectEqualStrings("/Applications/Fizzy.app", mac[0].bundle.?); + try std.testing.expectEqualStrings("/Users/a/Applications/Fizzy.app", mac[1].bundle.?); + try std.testing.expectEqualStrings("/opt/bin/fizzy", mac[mac.len - 1].exe); + + const win = try candidates(arena, id, .{ .os = .windows, .local_app_data = "C:\\Users\\a\\AppData\\Local", .path = "C:\\bin;D:\\tools" }); + try std.testing.expectEqualStrings("C:\\Users\\a\\AppData\\Local\\fizzy\\current\\fizzy.exe", win[0].exe); + try std.testing.expectEqualStrings("D:\\tools\\fizzy.exe", win[win.len - 1].exe); + + const linux = try candidates(arena, .{ .name = "paint" }, .{ .os = .linux, .home = "/home/a", .path = "/usr/bin" }); + try std.testing.expectEqualStrings("/home/a/.local/share/paint/current/paint", linux[0].exe); + try std.testing.expectEqualStrings("/home/a/Applications/paint.AppImage", linux[2].exe); + try std.testing.expectEqualStrings("/usr/bin/paint", linux[3].exe); +} + +test parseRegValue { + const out = "\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\fizzy.exe\r\n (Default) REG_SZ C:\\Users\\a\\AppData\\Local\\fizzy\\current\\fizzy.exe\r\n"; + try std.testing.expectEqualStrings("C:\\Users\\a\\AppData\\Local\\fizzy\\current\\fizzy.exe", parseRegValue(out).?); + try std.testing.expect(parseRegValue("ERROR: The system was unable to find the specified registry key") == null); +} diff --git a/sdk/remote/README.md b/sdk/remote/README.md index c2ed16ab1..915f301ae 100644 --- a/sdk/remote/README.md +++ b/sdk/remote/README.md @@ -69,3 +69,29 @@ while (try ep.poll(arena)) |msg| { ... } // arena for lists; strings borrow un ``` `Endpoint.pump(a, b, chunk)` moves one end's bytes to the other for a loopback in tests. + +## Finding fizzy (`Client`, `fizzy_remote.h`) + +`Client.zig` is what an adapter calls first: find the installed fizzy, start it without taking +focus when nothing listens, connect to its control socket and upgrade to this stream. `capi.zig` +exports it as the C API in `fizzy_remote.h` (open, reconnect, send/recv whole frames, close). + +- **Where fizzy is:** `FIZZY_EXE`, else per OS: macOS `/Applications/Fizzy.app`, + `~/Applications`, then Launch Services by bundle id (`mdfind`); Windows the Velopack folder + under `%LOCALAPPDATA%\\current`, then the registry's App Paths; Linux Velopack under + `~/.local/share`, `~/.local/bin`, an AppImage in `~/Applications`; then `PATH`. An app built on + fizzy passes its own name, display name and bundle id (`Identity`). +- **Its socket:** `fizzy --control-path` (with `--profile`, or `FIZZY_PROFILE`), unless the host + names it. +- **Starting it:** `fizzy --quiet` (macOS: `open -g -j --args --quiet`), then dialing + with a pause growing from 10 to 250 ms until `timeout_ms`. +- **Errors say what the person does:** `NotInstalled`, `FizzyTooOld` (update fizzy: no `/remote`, + or older versions), `ClientTooOld` (update the add-on), `TimedOut`, `StartFailed` (the start + exited with a failure), and `Sandboxed`: an App Sandbox, Flatpak or Snap host that sees neither + fizzy nor its socket, or may not start it. It never starts a private second fizzy to get around + that; the host asks the person for the socket (or an entitlement) instead. +- **After a drop** `poll`/`send` return `Closed` and `reconnect` runs the same steps again. + +Tested against `fake_fizzy.zig`, an executable that listens on a test socket (Linux CI). Discovery on a real +macOS or Windows install (`candidates` is tested as strings; `mdfind`, `reg query` and `open -g -j` +are not) has not been tried by hand. diff --git a/sdk/remote/capi.zig b/sdk/remote/capi.zig new file mode 100644 index 000000000..9a9016dd0 --- /dev/null +++ b/sdk/remote/capi.zig @@ -0,0 +1,282 @@ +//! The C ABI over `Client` (`fizzy_remote.h`): an adapter in a host application (Blender, +//! Unreal, ...) finds, starts and connects to the installed fizzy and exchanges whole frames with +//! it. A frame is the wire's (`README.md`): the adapter's encoder/decoder is the generated half +//! this file leaves out, so what crosses here is bytes. +//! +//! Calls on one handle are not thread safe; two handles are independent. +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const message = @import("message.zig"); +const codec = @import("codec.zig"); +const FrameReader = @import("FrameReader.zig"); +const Client = @import("Client.zig"); + +/// `fizzy_remote_status` in the header: the numbers are the ABI. +const Status = enum(c_int) { + ok = 0, + not_installed = 1, + not_running = 2, + sandboxed = 3, + fizzy_too_old = 4, + client_too_old = 5, + timed_out = 6, + start_failed = 7, + protocol = 8, + closed = 9, + out_of_memory = 10, + buffer_too_small = 11, + invalid_argument = 12, +}; + +fn status(err: Client.Error) c_int { + return @intFromEnum(switch (err) { + error.NotInstalled => Status.not_installed, + error.NotRunning => .not_running, + error.Sandboxed => .sandboxed, + error.FizzyTooOld => .fizzy_too_old, + error.ClientTooOld => .client_too_old, + error.TimedOut => .timed_out, + error.StartFailed => .start_failed, + error.Protocol => .protocol, + error.Closed => .closed, + error.OutOfMemory => .out_of_memory, + }); +} + +/// Every pointer is a NUL-terminated string or null (the default or the environment's). +pub const Options = extern struct { + /// `sizeof` this struct, so a later field can be added without breaking a built adapter. + size: u32, + timeout_ms: u32, + /// `FIZZY_REMOTE_NO_START` (1): connect to a running fizzy, never start one. + flags: u32, + host_name: ?[*:0]const u8, + host_version: ?[*:0]const u8, + /// The application to find, for an app built on fizzy: executable/pack id, macOS bundle name + /// and bundle id. Fizzy's own when null. + app_name: ?[*:0]const u8, + app_display_name: ?[*:0]const u8, + app_bundle_id: ?[*:0]const u8, + /// Override `FIZZY_EXE`, `FIZZY_PROFILE`; and the socket itself, for a host that cannot run + /// fizzy to ask. + exe: ?[*:0]const u8, + profile: ?[*:0]const u8, + socket_path: ?[*:0]const u8, +}; + +const no_start_flag: u32 = 1; + +const Handle = struct { + arena: std.heap.ArenaAllocator, + threaded: Io.Threaded, + client: Client, + /// An encoded message `recv` could not fit in the caller's buffer yet. + stash: std.ArrayList(u8) = .empty, + socket_z: ?[:0]u8 = null, + + fn refreshSocket(self: *Handle) void { + const path = self.client.socket_path orelse return; + self.socket_z = self.arena.allocator().dupeSentinel(u8, path, 0) catch return; + } +}; + +const gpa = std.heap.smp_allocator; + +fn dup(a: std.mem.Allocator, s: ?[*:0]const u8) error{OutOfMemory}!?[]const u8 { + return if (s) |p| try a.dupe(u8, std.mem.span(p)) else null; +} + +/// Find, start and connect: `*out` is a handle to close, on `ok` only. +export fn fizzy_remote_open(options: ?*const Options, out: ?*?*Handle) callconv(.c) c_int { + const o = options orelse return @intFromEnum(Status.invalid_argument); + const result = out orelse return @intFromEnum(Status.invalid_argument); + result.* = null; + if (o.size < @offsetOf(Options, "socket_path") + @sizeOf(?[*:0]const u8)) return @intFromEnum(Status.invalid_argument); + + const handle = gpa.create(Handle) catch return @intFromEnum(Status.out_of_memory); + handle.* = .{ .arena = .init(gpa), .threaded = .init(gpa, .{ .environ = processEnviron() }), .client = undefined }; + var started = false; + defer if (!started) { + handle.arena.deinit(); + handle.threaded.deinit(); + gpa.destroy(handle); + }; + const a = handle.arena.allocator(); + + const opts = clientOptions(a, o) catch return @intFromEnum(Status.out_of_memory); + + handle.client = .init(gpa, handle.threaded.io(), opts); + handle.client.connect() catch |err| { + handle.client.deinit(); + return status(err); + }; + handle.refreshSocket(); + started = true; + result.* = handle; + return 0; +} + +fn clientOptions(a: std.mem.Allocator, o: *const Options) error{OutOfMemory}!Client.Options { + var opts: Client.Options = .{ .env = .fromProcess() }; + if (try dup(a, o.exe)) |v| opts.env.exe = v; + if (try dup(a, o.profile)) |v| opts.env.profile = v; + if (try dup(a, o.app_name)) |v| opts.identity.name = v; + if (try dup(a, o.app_display_name)) |v| opts.identity.display_name = v; + if (try dup(a, o.app_bundle_id)) |v| opts.identity.bundle_id = v; + if (try dup(a, o.host_name)) |v| opts.host_name = v; + if (try dup(a, o.host_version)) |v| opts.host_version = v; + opts.socket_path = try dup(a, o.socket_path); + opts.start = o.flags & no_start_flag == 0; + if (o.timeout_ms != 0) opts.timeout_ms = o.timeout_ms; + return opts; +} + +fn processEnviron() std.process.Environ { + if (comptime !builtin.link_libc or builtin.os.tag == .windows) return .empty; + var n: usize = 0; + while (std.c.environ[n] != null) n += 1; + return .{ .block = .{ .slice = std.c.environ[0..n :null] } }; +} + +/// Connect again after a drop (`recv`/`send` returned `closed`), starting fizzy if it quit. +export fn fizzy_remote_reconnect(handle: ?*Handle) callconv(.c) c_int { + const h = handle orelse return @intFromEnum(Status.invalid_argument); + h.client.connect() catch |err| return status(err); + h.refreshSocket(); + return 0; +} + +/// Send one whole frame. +export fn fizzy_remote_send(handle: ?*Handle, frame: ?[*]const u8, len: usize) callconv(.c) c_int { + const h = handle orelse return @intFromEnum(Status.invalid_argument); + const bytes = (frame orelse return @intFromEnum(Status.invalid_argument))[0..len]; + var reader: FrameReader = .init(gpa); + defer reader.deinit(); + reader.push(bytes) catch return @intFromEnum(Status.out_of_memory); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const frame_in = (reader.next() catch return @intFromEnum(Status.protocol)) orelse return @intFromEnum(Status.protocol); + const msg = (codec.decode(arena.allocator(), frame_in) catch return @intFromEnum(Status.protocol)) orelse return @intFromEnum(Status.protocol); + h.client.send(msg) catch |err| return status(err); + return 0; +} + +/// The next frame from fizzy into `buf`, waiting up to `timeout_ms` (negative: forever). +/// `*len` is its length; 0 with `ok` means the wait passed. A frame larger than `cap` is kept: +/// `buffer_too_small` with `*len` the size needed, then call again with room. +export fn fizzy_remote_recv(handle: ?*Handle, buf: ?[*]u8, cap: usize, len: ?*usize, timeout_ms: i32) callconv(.c) c_int { + const h = handle orelse return @intFromEnum(Status.invalid_argument); + const out_len = len orelse return @intFromEnum(Status.invalid_argument); + out_len.* = 0; + if (h.stash.items.len == 0) { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const wait: ?u32 = if (timeout_ms < 0) null else @intCast(timeout_ms); + const msg = (h.client.poll(arena.allocator(), wait) catch |err| return status(err)) orelse return 0; + codec.encode(gpa, &h.stash, msg, codec.default_max_frame) catch return @intFromEnum(Status.protocol); + } + out_len.* = h.stash.items.len; + if (h.stash.items.len > cap or buf == null) return @intFromEnum(Status.buffer_too_small); + @memcpy(buf.?[0..h.stash.items.len], h.stash.items); + h.stash.clearRetainingCapacity(); + return 0; +} + +/// The control socket in use, valid until the next call on the handle; null before a connect. +export fn fizzy_remote_socket_path(handle: ?*const Handle) callconv(.c) ?[*:0]const u8 { + const h = handle orelse return null; + return if (h.socket_z) |p| p.ptr else null; +} + +/// A short English reason for a status, for a host's log; never null. +export fn fizzy_remote_status_string(code: c_int) callconv(.c) [*:0]const u8 { + return switch (std.enums.fromInt(Status, code) orelse return "unknown status") { + .ok => "ok", + .not_installed => "fizzy is not installed", + .not_running => "fizzy is not running", + .sandboxed => "this app's sandbox hides or forbids fizzy", + .fizzy_too_old => "fizzy is too old: update fizzy", + .client_too_old => "this add-on is too old: update it", + .timed_out => "fizzy did not answer in time", + .start_failed => "fizzy exited while starting", + .protocol => "fizzy did not speak the protocol", + .closed => "the connection closed", + .out_of_memory => "out of memory", + .buffer_too_small => "the buffer is too small", + .invalid_argument => "invalid argument", + }; +} + +/// The newest protocol version this library speaks. +export fn fizzy_remote_protocol_version() callconv(.c) u32 { + return message.max_version; +} + +/// End the connection and free the handle. Fizzy keeps running. +export fn fizzy_remote_close(handle: ?*Handle) callconv(.c) void { + const h = handle orelse return; + h.client.deinit(); + h.stash.deinit(gpa); + h.threaded.deinit(); + h.arena.deinit(); + gpa.destroy(h); +} + +const t = std.testing; + +test "the C ABI opens, round trips a frame and closes" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = t.tmpDir(.{}); + defer tmp.cleanup(); + const profile = try tmp.dir.realPathFileAlloc(t.io, ".", t.allocator); + defer t.allocator.free(profile); + try tmp.dir.writeFile(t.io, .{ .sub_path = "mode", .data = "serve" }); + + var opts = std.mem.zeroes(Options); + opts.size = @sizeOf(Options); + opts.timeout_ms = 5000; + opts.host_name = "c-test"; + const fake = try t.allocator.dupeSentinel(u8, @import("client_tests.zig").fake_exe, 0); + defer t.allocator.free(fake); + opts.exe = fake; + opts.profile = profile; + var handle: ?*Handle = null; + try t.expectEqual(@as(c_int, 0), fizzy_remote_open(&opts, &handle)); + defer fizzy_remote_close(handle); + try t.expect(std.mem.endsWith(u8, std.mem.span(fizzy_remote_socket_path(handle).?), "control.sock")); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(t.allocator); + try codec.encode(t.allocator, &out, .{ .create_view = .{ .view = 3, .width = 64, .height = 48 } }, codec.default_max_frame); + try t.expectEqual(@as(c_int, 0), fizzy_remote_send(handle, out.items.ptr, out.items.len)); + + var small: [4]u8 = undefined; + var len: usize = 0; + try t.expectEqual(@intFromEnum(Status.buffer_too_small), fizzy_remote_recv(handle, &small, small.len, &len, 5000)); + try t.expect(len > small.len); + var frame: [256]u8 = undefined; + try t.expectEqual(@as(c_int, 0), fizzy_remote_recv(handle, &frame, frame.len, &len, 5000)); + var reader: FrameReader = .init(t.allocator); + defer reader.deinit(); + try reader.push(frame[0..len]); + var arena: std.heap.ArenaAllocator = .init(t.allocator); + defer arena.deinit(); + const created = (try codec.decode(arena.allocator(), (try reader.next()).?)).?.view_created; + try t.expectEqual(@as(u32, 3), created.view); + + try t.expectEqual(@as(c_int, 0), fizzy_remote_recv(handle, &frame, frame.len, &len, 20)); + try t.expectEqual(@as(usize, 0), len); +} + +test "the C ABI says why it could not open" { + var opts = std.mem.zeroes(Options); + opts.size = @sizeOf(Options); + opts.exe = "/nonexistent/fizzy"; + var handle: ?*Handle = null; + const code = fizzy_remote_open(&opts, &handle); + try t.expectEqual(@intFromEnum(Status.not_installed), code); + try t.expect(handle == null); + try t.expectEqualStrings("fizzy is not installed", std.mem.span(fizzy_remote_status_string(code))); + try t.expectEqual(@intFromEnum(Status.invalid_argument), fizzy_remote_open(null, &handle)); +} diff --git a/sdk/remote/client_tests.zig b/sdk/remote/client_tests.zig new file mode 100644 index 000000000..288b1ab78 --- /dev/null +++ b/sdk/remote/client_tests.zig @@ -0,0 +1,194 @@ +//! `Client` against `fake_fizzy.zig`, a stand-in executable that listens on a test socket. The +//! build compiles it and names its path in the `fake_fizzy_options` import, so these run under +//! `zig build test`, not a bare `zig test`. POSIX only: the fake's socket and the child's exit +//! status are skipped on Windows. +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const remote = @import("root.zig"); +const Client = remote.Client; +const message = remote.message; + +const t = std.testing; + +/// The compiled `fake_fizzy.zig`. +pub const fake_exe: []const u8 = @import("fake_fizzy_options").fake_fizzy; + +const Fixture = struct { + tmp: t.TmpDir, + profile: [:0]u8, + fake: []const u8, + + fn init(mode: []const u8) !Fixture { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = t.tmpDir(.{}); + errdefer tmp.cleanup(); + const profile = try tmp.dir.realPathFileAlloc(t.io, ".", t.allocator); + errdefer t.allocator.free(profile); + try tmp.dir.writeFile(t.io, .{ .sub_path = "mode", .data = mode }); + return .{ .tmp = tmp, .profile = profile, .fake = fake_exe }; + } + + fn deinit(self: *Fixture) void { + t.allocator.free(self.profile); + self.tmp.cleanup(); + } + + fn options(self: *const Fixture) Client.Options { + return .{ .env = .{ .exe = self.fake, .profile = self.profile }, .host_name = "test-host", .timeout_ms = 5000 }; + } + + /// How many times the fake was started with `--quiet`. + fn starts(self: *const Fixture) usize { + const text = self.tmp.dir.readFileAlloc(t.io, "launches", t.allocator, .limited(4096)) catch return 0; + defer t.allocator.free(text); + return std.mem.count(u8, text, "start\n"); + } +}; + +/// A view round trip: proof the stream carries the conversation past the hellos. +fn roundTrip(client: *Client, view: message.ViewId) !void { + var arena_state: std.heap.ArenaAllocator = .init(t.allocator); + defer arena_state.deinit(); + try client.send(.{ .create_view = .{ .view = view, .width = 320, .height = 200 } }); + const created = (try client.poll(arena_state.allocator(), 5000)).?.view_created; + try t.expectEqual(view, created.view); + try t.expectEqual(@as(u32, 320), created.width); +} + +test "a fizzy that is already listening is found, not started again" { + var fx = try Fixture.init("serve"); + defer fx.deinit(); + var child = try std.process.spawn(t.io, .{ + .argv = &.{ fx.fake, "--quiet", "--profile", fx.profile }, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }); + defer _ = child.wait(t.io) catch {}; + var listening = false; + for (0..500) |_| { + if (fx.tmp.dir.access(t.io, "control.sock", .{})) |_| { + listening = true; + break; + } else |_| try t.io.sleep(.fromMilliseconds(10), .awake); + } + try t.expect(listening); + + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + try roundTrip(&client, 1); + try t.expectEqual(@as(usize, 1), fx.starts()); +} + +test "nothing listening: fizzy is started quietly and connected to" { + var fx = try Fixture.init("serve"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + try roundTrip(&client, 7); + try t.expectEqual(@as(usize, 1), fx.starts()); +} + +test "not installed, not running, and sandboxed are three errors" { + var fx = try Fixture.init("serve"); + defer fx.deinit(); + const nowhere = try std.fmt.allocPrint(t.allocator, "{s}/nope", .{fx.profile}); + defer t.allocator.free(nowhere); + + var opts = fx.options(); + opts.env.exe = nowhere; + var client: Client = .init(t.allocator, t.io, opts); + defer client.deinit(); + try t.expectError(error.NotInstalled, client.connect()); + + // A search that finds nothing in a home with nothing in it. + opts.env = .{ .os = .linux, .home = fx.profile, .path = fx.profile }; + client.opts = opts; + try t.expectError(error.NotInstalled, client.connect()); + client.opts.env.sandboxed = true; + try t.expectError(error.Sandboxed, client.connect()); + try t.expectEqual(@as(usize, 0), fx.starts()); + + // A named socket nobody listens on, and no start asked for. + opts.env = .{}; + opts.socket_path = nowhere; + opts.start = false; + client.opts = opts; + try t.expectError(error.NotRunning, client.connect()); +} + +test "a slow start is waited for, a too slow one times out and is found later" { + var fx = try Fixture.init("slow 300"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + try roundTrip(&client, 1); + client.close(); + + var slow = try Fixture.init("slow 700"); + defer slow.deinit(); + var opts = slow.options(); + opts.timeout_ms = 200; + var late: Client = .init(t.allocator, t.io, opts); + defer late.deinit(); + try t.expectError(error.TimedOut, late.connect()); + try t.expectEqual(@as(usize, 1), slow.starts()); + late.opts.start = false; + late.opts.timeout_ms = 5000; + // Not started a second time: the first one finishes starting and is found. + var found = false; + for (0..100) |_| { + late.connect() catch { + try t.io.sleep(.fromMilliseconds(50), .awake); + continue; + }; + found = true; + break; + } + try t.expect(found); + try t.expectEqual(@as(usize, 1), slow.starts()); +} + +test "a fizzy that dies while starting is StartFailed" { + var fx = try Fixture.init("crash"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try t.expectError(error.StartFailed, client.connect()); + try t.expectEqual(@as(usize, 1), fx.starts()); +} + +test "versions: a fizzy from the future or the past says who updates" { + var newer = try Fixture.init("old"); + defer newer.deinit(); + var client: Client = .init(t.allocator, t.io, newer.options()); + defer client.deinit(); + try t.expectError(error.ClientTooOld, client.connect()); + try t.expect(!client.connected()); + + var no_remote = try Fixture.init("noremote"); + defer no_remote.deinit(); + client.opts = no_remote.options(); + try t.expectError(error.FizzyTooOld, client.connect()); +} + +test "a dropped stream closes the client and reconnect opens it again" { + var fx = try Fixture.init("drop"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + var arena_state: std.heap.ArenaAllocator = .init(t.allocator); + defer arena_state.deinit(); + try t.expectError(error.Closed, client.poll(arena_state.allocator(), 5000)); + try t.expect(!client.connected()); + try t.expectError(error.Closed, client.send(.{ .create_view = .{ .view = 1, .width = 1, .height = 1 } })); + + try client.reconnect(); + try roundTrip(&client, 1); + try t.expectEqual(@as(usize, 1), fx.starts()); +} diff --git a/sdk/remote/fake_fizzy.zig b/sdk/remote/fake_fizzy.zig new file mode 100644 index 000000000..02f9c8c75 --- /dev/null +++ b/sdk/remote/fake_fizzy.zig @@ -0,0 +1,119 @@ +//! A stand-in for the fizzy executable, for `client_tests.zig`: answers `--control-path`, and +//! otherwise listens on `/control.sock` and speaks the editor's side of the remote-view +//! stream. How it behaves is the word in `/mode`, so a test sets it before the client +//! starts it: +//! +//! * `serve` — listen at once. +//! * `slow ` — listen after a pause. +//! * `crash` — exit 3 before listening. +//! * `old` — hello with versions the client lacks. +//! * `noremote` — answer `GET /remote` with 404, as a fizzy without a remote view does. +//! * `drop` — close the first connection right after the hellos; the second is served. +//! +//! Each `--quiet` start appends a line to `/launches`. It answers `create_view` with +//! `view_created`, serves one connection (two for `drop`) and exits. +const std = @import("std"); +const Io = std.Io; +const remote = @import("root.zig"); +const Client = remote.Client; +const Endpoint = remote.Endpoint; +const message = remote.message; + +const Mode = enum { serve, slow, crash, old, noremote, drop }; + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + var profile: ?[]const u8 = null; + var control_path = false; + var quiet = false; + var args = try std.process.Args.Iterator.initAllocator(init.minimal.args, gpa); + defer args.deinit(); + _ = args.next(); + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--control-path")) control_path = true; + if (std.mem.eql(u8, arg, "--quiet")) quiet = true; + if (std.mem.eql(u8, arg, "--profile")) profile = args.next(); + } + const dir = profile orelse std.process.exit(1); + const sock = try std.fmt.allocPrint(gpa, "{s}/control.sock", .{dir}); + + if (control_path) { + var buf: [512]u8 = undefined; + var out = Io.File.stdout().writer(io, &buf); + try out.interface.print("{s}\n", .{sock}); + try out.interface.flush(); + return; + } + if (quiet) try appendLaunch(gpa, io, dir); + + const text = try Io.Dir.cwd().readFileAlloc(io, try std.fmt.allocPrint(gpa, "{s}/mode", .{dir}), gpa, .limited(256)); + var words = std.mem.tokenizeAny(u8, text, " \r\n"); + const mode = std.meta.stringToEnum(Mode, words.next() orelse "serve") orelse .serve; + switch (mode) { + .crash => std.process.exit(3), + .slow => try io.sleep(.fromMilliseconds(try std.fmt.parseInt(i64, words.next() orelse "300", 10)), .awake), + else => {}, + } + // A test that fails halfway leaves no fake behind. + _ = io.concurrent(selfDestruct, .{io}) catch {}; + + Io.Dir.deleteFileAbsolute(io, sock) catch {}; + var server = try (try Io.net.UnixAddress.init(sock)).listen(io, .{}); + defer server.deinit(io); + const connections: usize = if (mode == .drop) 2 else 1; + for (0..connections) |n| { + const stream = try server.accept(io); + defer stream.close(io); + talk(gpa, io, stream, mode, n) catch {}; + } +} + +fn selfDestruct(io: Io) void { + io.sleep(.fromSeconds(30), .awake) catch {}; + std.process.exit(0); +} + +fn appendLaunch(gpa: std.mem.Allocator, io: Io, dir: []const u8) !void { + const path = try std.fmt.allocPrint(gpa, "{s}/launches", .{dir}); + const before = Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(4096)) catch ""; + try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = try std.fmt.allocPrint(gpa, "{s}start\n", .{before}) }); +} + +fn talk(gpa: std.mem.Allocator, io: Io, stream: Io.net.Stream, mode: Mode, n: usize) !void { + var head: [2048]u8 = undefined; + var len: usize = 0; + const body_at = while (true) { + if (std.mem.find(u8, head[0..len], "\r\n\r\n")) |at| break at + 4; + const got = try Client.readSome(io, stream, head[len..], .none) orelse return; + if (got == 0) return; + len += got; + }; + if (mode == .noremote) return Client.writeAll(io, stream, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + try Client.writeAll(io, stream, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: " ++ Client.upgrade_token ++ "\r\n\r\n"); + + var ep: Endpoint = .init(gpa, .editor, .{ .app_id = "fizzy-fake", .mode = .shared, .tiers_offered = .{ .draw_list = true }, .version_min = if (mode == .old) 9 else message.min_version, .version_max = if (mode == .old) 9 else message.max_version }); + defer ep.deinit(); + try ep.start(); + try ep.feed(head[body_at..len]); + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + var buf: [4096]u8 = undefined; + while (true) { + _ = arena_state.reset(.retain_capacity); + while (ep.poll(arena_state.allocator()) catch null) |msg| switch (msg) { + .hello => if (mode == .drop and n == 0) { + try Client.writeAll(io, stream, ep.pending()); + return; + }, + .create_view => |cv| try ep.send(.{ .view_created = .{ .view = cv.view, .tier = .draw_list, .width = cv.width, .height = cv.height } }), + .bye => return, + else => {}, + }; + try Client.writeAll(io, stream, ep.pending()); + ep.consume(ep.pending().len); + const got = try Client.readSome(io, stream, &buf, .none) orelse return; + if (got == 0) return; + try ep.feed(buf[0..got]); + } +} diff --git a/sdk/remote/fizzy_remote.h b/sdk/remote/fizzy_remote.h new file mode 100644 index 000000000..e749430f9 --- /dev/null +++ b/sdk/remote/fizzy_remote.h @@ -0,0 +1,109 @@ +/* fizzy_remote.h — find, quietly start and connect to the installed fizzy. + * + * The C ABI of sdk/remote/capi.zig. A host application's adapter (Blender, Unreal, ...) opens a + * handle, then sends and receives whole frames of the remote-view protocol (sdk/remote/README.md). + * Calls on one handle are not thread safe; two handles are independent. Every function returns a + * fizzy_remote_status, 0 on success. + * + * fizzy_remote_options o = {0}; + * o.size = sizeof o; + * o.host_name = "blender"; + * fizzy_remote *r; + * int st = fizzy_remote_open(&o, &r); + * if (st != FIZZY_REMOTE_OK) show(fizzy_remote_status_string(st)); + */ +#ifndef FIZZY_REMOTE_H +#define FIZZY_REMOTE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct fizzy_remote fizzy_remote; + +typedef enum fizzy_remote_status { + FIZZY_REMOTE_OK = 0, + /* No fizzy where one would be installed. */ + FIZZY_REMOTE_NOT_INSTALLED = 1, + /* Nothing listens and FIZZY_REMOTE_NO_START was set. */ + FIZZY_REMOTE_NOT_RUNNING = 2, + /* This process's sandbox hides or forbids fizzy. A private second fizzy is never started. */ + FIZZY_REMOTE_SANDBOXED = 3, + /* Fizzy has no remote view or speaks only older protocols: the person updates fizzy. */ + FIZZY_REMOTE_FIZZY_TOO_OLD = 4, + /* Fizzy speaks only newer protocols: the person updates this add-on. */ + FIZZY_REMOTE_CLIENT_TOO_OLD = 5, + /* Fizzy did not answer by timeout_ms. */ + FIZZY_REMOTE_TIMED_OUT = 6, + /* The fizzy that was started exited with a failure before it listened. */ + FIZZY_REMOTE_START_FAILED = 7, + FIZZY_REMOTE_PROTOCOL = 8, + /* The connection ended (fizzy quit, or a bye): call fizzy_remote_reconnect. */ + FIZZY_REMOTE_CLOSED = 9, + FIZZY_REMOTE_OUT_OF_MEMORY = 10, + /* fizzy_remote_recv: the frame is kept, *len is the size it needs. */ + FIZZY_REMOTE_BUFFER_TOO_SMALL = 11, + FIZZY_REMOTE_INVALID_ARGUMENT = 12 +} fizzy_remote_status; + +/* fizzy_remote_options.flags */ +enum { + /* Connect to a running fizzy; never start one. */ + FIZZY_REMOTE_NO_START = 1 +}; + +/* Every string is NUL-terminated or NULL (the default, or the environment's). */ +typedef struct fizzy_remote_options { + uint32_t size; /* sizeof this struct */ + uint32_t timeout_ms; /* for open and reconnect, a start included; 0 is 15000 */ + uint32_t flags; + const char *host_name; /* what the host calls itself in its hello */ + const char *host_version; + /* The application to find, for an app built on fizzy: executable and pack id, macOS bundle + * name, bundle id. Fizzy's own when NULL. */ + const char *app_name; + const char *app_display_name; + const char *app_bundle_id; + /* Override FIZZY_EXE and FIZZY_PROFILE, and name the socket directly (for a host that cannot + * run fizzy to ask for it). */ + const char *exe; + const char *profile; + const char *socket_path; +} fizzy_remote_options; + +/* Find the installed fizzy, start it quietly (never taking focus) if nothing listens, connect + * and exchange hellos. On success *out is a handle for fizzy_remote_close. */ +int fizzy_remote_open(const fizzy_remote_options *options, fizzy_remote **out); + +/* Connect again after FIZZY_REMOTE_CLOSED, starting fizzy if it quit. Views are gone with the old + * stream: create them again. */ +int fizzy_remote_reconnect(fizzy_remote *r); + +/* Send one whole frame, as encoded for the wire. */ +int fizzy_remote_send(fizzy_remote *r, const uint8_t *frame, size_t len); + +/* The next frame from fizzy into buf, waiting up to timeout_ms (negative: forever). *len is its + * length, 0 with FIZZY_REMOTE_OK if the wait passed. A frame larger than cap is kept: + * FIZZY_REMOTE_BUFFER_TOO_SMALL with *len the size needed; call again with room. */ +int fizzy_remote_recv(fizzy_remote *r, uint8_t *buf, size_t cap, size_t *len, int32_t timeout_ms); + +/* The control socket in use, valid until the next call on r; NULL before a connect. */ +const char *fizzy_remote_socket_path(const fizzy_remote *r); + +/* A short English reason for a status, for a log. Never NULL. */ +const char *fizzy_remote_status_string(int status); + +/* The newest protocol version this library speaks. */ +uint32_t fizzy_remote_protocol_version(void); + +/* End the connection and free the handle. Fizzy keeps running. */ +void fizzy_remote_close(fizzy_remote *r); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/sdk/remote/root.zig b/sdk/remote/root.zig index 1205479c5..0214b791a 100644 --- a/sdk/remote/root.zig +++ b/sdk/remote/root.zig @@ -10,11 +10,13 @@ //! * `FrameReader` — cuts a byte stream into frames, in whatever chunks it arrives. //! * `Connection` — the rules of the conversation, with no bytes in it. //! * `Endpoint` — a `Connection` with the bytes around it, and a loopback `pump` for tests. +//! * `Client` — finds, quietly starts and connects to the installed fizzy; `capi.zig` is its C ABI. pub const message = @import("message.zig"); pub const codec = @import("codec.zig"); pub const FrameReader = @import("FrameReader.zig"); pub const Connection = @import("Connection.zig"); pub const Endpoint = @import("Endpoint.zig"); +pub const Client = @import("Client.zig"); pub const Message = message.Message; @@ -24,5 +26,8 @@ test { _ = FrameReader; _ = Connection; _ = Endpoint; + _ = Client; _ = @import("tests.zig"); + _ = @import("client_tests.zig"); + _ = @import("capi.zig"); } From b9d2dcd378df238989432732f5410ff2b92d44ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:31:10 +0000 Subject: [PATCH 2/5] remote: zig fmt the C ABI Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KLaaExP2YTZiSnff98PmgV --- sdk/remote/capi.zig | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/sdk/remote/capi.zig b/sdk/remote/capi.zig index 9a9016dd0..66a25cb20 100644 --- a/sdk/remote/capi.zig +++ b/sdk/remote/capi.zig @@ -30,7 +30,7 @@ const Status = enum(c_int) { }; fn status(err: Client.Error) c_int { - return @intFromEnum(switch (err) { + return @backingInt(switch (err) { error.NotInstalled => Status.not_installed, error.NotRunning => .not_running, error.Sandboxed => .sandboxed, @@ -89,12 +89,12 @@ fn dup(a: std.mem.Allocator, s: ?[*:0]const u8) error{OutOfMemory}!?[]const u8 { /// Find, start and connect: `*out` is a handle to close, on `ok` only. export fn fizzy_remote_open(options: ?*const Options, out: ?*?*Handle) callconv(.c) c_int { - const o = options orelse return @intFromEnum(Status.invalid_argument); - const result = out orelse return @intFromEnum(Status.invalid_argument); + const o = options orelse return @backingInt(Status.invalid_argument); + const result = out orelse return @backingInt(Status.invalid_argument); result.* = null; - if (o.size < @offsetOf(Options, "socket_path") + @sizeOf(?[*:0]const u8)) return @intFromEnum(Status.invalid_argument); + if (o.size < @offsetOf(Options, "socket_path") + @sizeOf(?[*:0]const u8)) return @backingInt(Status.invalid_argument); - const handle = gpa.create(Handle) catch return @intFromEnum(Status.out_of_memory); + const handle = gpa.create(Handle) catch return @backingInt(Status.out_of_memory); handle.* = .{ .arena = .init(gpa), .threaded = .init(gpa, .{ .environ = processEnviron() }), .client = undefined }; var started = false; defer if (!started) { @@ -104,7 +104,7 @@ export fn fizzy_remote_open(options: ?*const Options, out: ?*?*Handle) callconv( }; const a = handle.arena.allocator(); - const opts = clientOptions(a, o) catch return @intFromEnum(Status.out_of_memory); + const opts = clientOptions(a, o) catch return @backingInt(Status.out_of_memory); handle.client = .init(gpa, handle.threaded.io(), opts); handle.client.connect() catch |err| { @@ -141,7 +141,7 @@ fn processEnviron() std.process.Environ { /// Connect again after a drop (`recv`/`send` returned `closed`), starting fizzy if it quit. export fn fizzy_remote_reconnect(handle: ?*Handle) callconv(.c) c_int { - const h = handle orelse return @intFromEnum(Status.invalid_argument); + const h = handle orelse return @backingInt(Status.invalid_argument); h.client.connect() catch |err| return status(err); h.refreshSocket(); return 0; @@ -149,15 +149,15 @@ export fn fizzy_remote_reconnect(handle: ?*Handle) callconv(.c) c_int { /// Send one whole frame. export fn fizzy_remote_send(handle: ?*Handle, frame: ?[*]const u8, len: usize) callconv(.c) c_int { - const h = handle orelse return @intFromEnum(Status.invalid_argument); - const bytes = (frame orelse return @intFromEnum(Status.invalid_argument))[0..len]; + const h = handle orelse return @backingInt(Status.invalid_argument); + const bytes = (frame orelse return @backingInt(Status.invalid_argument))[0..len]; var reader: FrameReader = .init(gpa); defer reader.deinit(); - reader.push(bytes) catch return @intFromEnum(Status.out_of_memory); + reader.push(bytes) catch return @backingInt(Status.out_of_memory); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); - const frame_in = (reader.next() catch return @intFromEnum(Status.protocol)) orelse return @intFromEnum(Status.protocol); - const msg = (codec.decode(arena.allocator(), frame_in) catch return @intFromEnum(Status.protocol)) orelse return @intFromEnum(Status.protocol); + const frame_in = (reader.next() catch return @backingInt(Status.protocol)) orelse return @backingInt(Status.protocol); + const msg = (codec.decode(arena.allocator(), frame_in) catch return @backingInt(Status.protocol)) orelse return @backingInt(Status.protocol); h.client.send(msg) catch |err| return status(err); return 0; } @@ -166,18 +166,18 @@ export fn fizzy_remote_send(handle: ?*Handle, frame: ?[*]const u8, len: usize) c /// `*len` is its length; 0 with `ok` means the wait passed. A frame larger than `cap` is kept: /// `buffer_too_small` with `*len` the size needed, then call again with room. export fn fizzy_remote_recv(handle: ?*Handle, buf: ?[*]u8, cap: usize, len: ?*usize, timeout_ms: i32) callconv(.c) c_int { - const h = handle orelse return @intFromEnum(Status.invalid_argument); - const out_len = len orelse return @intFromEnum(Status.invalid_argument); + const h = handle orelse return @backingInt(Status.invalid_argument); + const out_len = len orelse return @backingInt(Status.invalid_argument); out_len.* = 0; if (h.stash.items.len == 0) { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const wait: ?u32 = if (timeout_ms < 0) null else @intCast(timeout_ms); const msg = (h.client.poll(arena.allocator(), wait) catch |err| return status(err)) orelse return 0; - codec.encode(gpa, &h.stash, msg, codec.default_max_frame) catch return @intFromEnum(Status.protocol); + codec.encode(gpa, &h.stash, msg, codec.default_max_frame) catch return @backingInt(Status.protocol); } out_len.* = h.stash.items.len; - if (h.stash.items.len > cap or buf == null) return @intFromEnum(Status.buffer_too_small); + if (h.stash.items.len > cap or buf == null) return @backingInt(Status.buffer_too_small); @memcpy(buf.?[0..h.stash.items.len], h.stash.items); h.stash.clearRetainingCapacity(); return 0; @@ -253,7 +253,7 @@ test "the C ABI opens, round trips a frame and closes" { var small: [4]u8 = undefined; var len: usize = 0; - try t.expectEqual(@intFromEnum(Status.buffer_too_small), fizzy_remote_recv(handle, &small, small.len, &len, 5000)); + try t.expectEqual(@backingInt(Status.buffer_too_small), fizzy_remote_recv(handle, &small, small.len, &len, 5000)); try t.expect(len > small.len); var frame: [256]u8 = undefined; try t.expectEqual(@as(c_int, 0), fizzy_remote_recv(handle, &frame, frame.len, &len, 5000)); @@ -275,8 +275,8 @@ test "the C ABI says why it could not open" { opts.exe = "/nonexistent/fizzy"; var handle: ?*Handle = null; const code = fizzy_remote_open(&opts, &handle); - try t.expectEqual(@intFromEnum(Status.not_installed), code); + try t.expectEqual(@backingInt(Status.not_installed), code); try t.expect(handle == null); try t.expectEqualStrings("fizzy is not installed", std.mem.span(fizzy_remote_status_string(code))); - try t.expectEqual(@intFromEnum(Status.invalid_argument), fizzy_remote_open(null, &handle)); + try t.expectEqual(@backingInt(Status.invalid_argument), fizzy_remote_open(null, &handle)); } From e66efde91f4d15d8a24cd922c7d4741a9b9ea9e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:37:50 +0000 Subject: [PATCH 3/5] remote: give the slow-start test room on a loaded runner Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KLaaExP2YTZiSnff98PmgV --- sdk/remote/client_tests.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/remote/client_tests.zig b/sdk/remote/client_tests.zig index 288b1ab78..0990b2890 100644 --- a/sdk/remote/client_tests.zig +++ b/sdk/remote/client_tests.zig @@ -129,13 +129,18 @@ test "a slow start is waited for, a too slow one times out and is found later" { try roundTrip(&client, 1); client.close(); - var slow = try Fixture.init("slow 700"); + var slow = try Fixture.init("slow 2500"); defer slow.deinit(); var opts = slow.options(); - opts.timeout_ms = 200; + opts.timeout_ms = 1000; var late: Client = .init(t.allocator, t.io, opts); defer late.deinit(); try t.expectError(error.TimedOut, late.connect()); + // The process may not have run far enough to note its start when the deadline passed. + for (0..100) |_| { + if (slow.starts() != 0) break; + try t.io.sleep(.fromMilliseconds(50), .awake); + } try t.expectEqual(@as(usize, 1), slow.starts()); late.opts.start = false; late.opts.timeout_ms = 5000; From 9a094aa1974c0bed34cabd4883dd8b481e93878a Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 12:49:02 -0500 Subject: [PATCH 4/5] remote: closeAfterBye leaves no stale endpoint, PATH searched last A bye closed the stream but left the endpoint, so connected() stayed true and the next poll unwrapped a null stream. connected() now needs the stream, and poll and send finish the close (Closed) when the stream is gone; the endpoint is kept until then because the bye's strings borrow it. New fake mode `bye` and a test polling and sending after a bye. locate now tries the $PATH search (onPath) last, after the fixed places and the system lookup, instead of inside candidates, so a stray fizzy on the path no longer beats the install. A fizzy that startQuiet launched directly (not through open) is now waited for on a detached thread once connected, so its exit leaves no zombie. poll reads the version skew before closing the endpoint it lives in. The bye's borrow is documented on poll and closeAfterBye. connect now reaps a fizzy it started on every exit, a timeout or failed upgrade included (not killed: a slow one may still be found later). Co-Authored-By: Claude Sonnet 5.5 --- sdk/remote/Client.zig | 82 +++++++++++++++++++++++++++++-------- sdk/remote/client_tests.zig | 16 ++++++++ sdk/remote/fake_fizzy.zig | 5 ++- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/sdk/remote/Client.zig b/sdk/remote/Client.zig index 05ae7b6c2..14fb473eb 100644 --- a/sdk/remote/Client.zig +++ b/sdk/remote/Client.zig @@ -135,7 +135,7 @@ pub fn deinit(self: *Client) void { if (self.socket_path) |p| self.gpa.free(p); } -/// Where fizzy would be installed on `env.os`, likeliest first. Touches nothing. +/// Where fizzy would be installed on `env.os`, likeliest first; not `$PATH` (`onPath`). Touches nothing. pub fn candidates(arena: std.mem.Allocator, identity: Identity, env: Env) error{OutOfMemory}![]const Install { var list: std.ArrayList(Install) = .empty; const sep: []const u8 = if (env.os == .windows) "\\" else "/"; @@ -161,11 +161,18 @@ pub fn candidates(arena: std.mem.Allocator, identity: Identity, env: Env) error{ try list.append(arena, .{ .exe = try join(arena, sep, &.{ h, "Applications", image }) }); }, } - if (env.path) |path| { - const exe = if (env.os == .windows) try std.fmt.allocPrint(arena, "{s}.exe", .{identity.name}) else identity.name; - var dirs = std.mem.tokenizeScalar(u8, path, if (env.os == .windows) ';' else ':'); - while (dirs.next()) |dir| try list.append(arena, .{ .exe = try join(arena, sep, &.{ dir, exe }) }); - } + return list.items; +} + +/// `identity.name` in each `$PATH` directory. Tried after everything else: a bare `fizzy` on the +/// path may be a build or a stray copy, not the install. +fn onPath(arena: std.mem.Allocator, identity: Identity, env: Env) error{OutOfMemory}![]const Install { + var list: std.ArrayList(Install) = .empty; + const path = env.path orelse return list.items; + const sep: []const u8 = if (env.os == .windows) "\\" else "/"; + const exe = if (env.os == .windows) try std.fmt.allocPrint(arena, "{s}.exe", .{identity.name}) else identity.name; + var dirs = std.mem.tokenizeScalar(u8, path, if (env.os == .windows) ';' else ':'); + while (dirs.next()) |dir| try list.append(arena, .{ .exe = try join(arena, sep, &.{ dir, exe }) }); return list.items; } @@ -180,7 +187,11 @@ pub fn locate(io: Io, arena: std.mem.Allocator, identity: Identity, env: Env) Er for (try candidates(arena, identity, env)) |install| { if (exists(io, install.exe)) return install; } - return lookupSystem(io, arena, identity, env) orelse notFound(env); + if (lookupSystem(io, arena, identity, env)) |install| return install; + for (try onPath(arena, identity, env)) |install| { + if (exists(io, install.exe)) return install; + } + return notFound(env); } fn notFound(env: Env) Error { @@ -288,6 +299,26 @@ const Started = struct { const Status = enum { running, exited_clean, failed }; + /// A started fizzy that is not `open`'s runs on after we connect, and its exit would leave a + /// zombie until the host quits: wait for it on a thread of its own. If the thread cannot be + /// made the child is left, as before. + fn reapInBackground(self: *Started) void { + if (comptime builtin.os.tag == .windows or builtin.os.tag == .wasi) return; + if (self.via_open) return; + const pid = self.child.id orelse return; + const thread = std.Thread.spawn(.{}, reap, .{pid}) catch return; + thread.detach(); + self.child.id = null; + } + + fn reap(pid: std.posix.pid_t) void { + var wait_status: i32 = 0; + while (true) { + const rc = std.posix.system.waitpid(pid, &wait_status, 0); + if (std.posix.errno(rc) != .INTR) return; + } + } + fn status(self: *Started) Status { if (comptime builtin.os.tag == .windows or builtin.os.tag == .wasi) return .running; const pid = self.child.id orelse return .running; @@ -365,6 +396,9 @@ pub fn connect(self: *Client) Error!void { const addr = Io.net.UnixAddress.init(socket) catch return error.Protocol; var started: ?Started = null; + // Whatever ends `connect`, a fizzy we started is waited for when it exits. It is not killed: + // a slow one may still come up and be found by a later `connect`. + defer if (started) |*st| st.reapInBackground(); var pause_ms: u32 = 10; while (true) { if (addr.connect(self.io)) |stream| { @@ -471,12 +505,13 @@ fn flush(self: *Client, ep: *Endpoint) Error!void { /// Whether a stream is open (a drop shows on the next `poll` or `send`). pub fn connected(self: *const Client) bool { - return self.endpoint != null; + return self.endpoint != null and self.stream != null; } /// Check `msg` against the conversation's rules and send it. pub fn send(self: *Client, msg: message.Message) Error!void { const ep = &(self.endpoint orelse return error.Closed); + if (self.stream == null) return self.closeStale(); ep.send(msg) catch |err| return epError(err); self.flush(ep) catch |err| { self.close(); @@ -485,18 +520,21 @@ pub fn send(self: *Client, msg: message.Message) Error!void { } /// The next message from fizzy, waiting up to `wait_ms` (null: forever); null when that passes. -/// Lists are allocated from `arena`; strings borrow the client until the next `poll`. Whatever -/// ends the stream closes the client, and `reconnect` opens it again. +/// Lists are allocated from `arena`; strings borrow the client until the next `poll`, `send`, +/// `reconnect` or `close`, after which they dangle (a `bye`'s too: it borrows the endpoint, which +/// outlives the stream until then). Whatever ends the stream closes the client, and `reconnect` +/// opens it again. pub fn poll(self: *Client, arena: std.mem.Allocator, wait_ms: ?u32) Error!?message.Message { const ep = &(self.endpoint orelse return error.Closed); - const stream = self.stream.?; + const stream = self.stream orelse return self.closeStale(); const deadline: ?Deadline = if (wait_ms) |ms| .init(self.io, ms) else null; var buf: [16 * 1024]u8 = undefined; while (true) { const got = ep.poll(arena) catch |err| { self.flush(ep) catch {}; + const failure = skewError(ep, err); self.close(); - return skewError(ep, err); + return failure; }; if (got) |msg| { if (msg == .bye) self.closeAfterBye(); @@ -516,11 +554,19 @@ pub fn poll(self: *Client, arena: std.mem.Allocator, wait_ms: ?u32) Error!?messa } fn closeAfterBye(self: *Client) void { - // The peer ended the conversation; its bye is the caller's to read, the stream is done. + // The peer ended the conversation; its bye is the caller's to read, the stream is done. The + // endpoint stays until the next `poll`, `send`, `reconnect` or `close`, because the bye's + // strings borrow it and dangle once it is gone. if (self.stream) |s| s.close(self.io); self.stream = null; } +/// The stream ended with a bye already delivered: finish closing. +fn closeStale(self: *Client) error{Closed} { + self.close(); + return error.Closed; +} + /// Open again after a drop, finding and starting fizzy as `connect` does. Views are gone with /// the old stream: the host creates them again. pub fn reconnect(self: *Client) Error!void { @@ -544,16 +590,20 @@ test candidates { try std.testing.expectEqualStrings("/Applications/Fizzy.app/Contents/MacOS/fizzy", mac[0].exe); try std.testing.expectEqualStrings("/Applications/Fizzy.app", mac[0].bundle.?); try std.testing.expectEqualStrings("/Users/a/Applications/Fizzy.app", mac[1].bundle.?); - try std.testing.expectEqualStrings("/opt/bin/fizzy", mac[mac.len - 1].exe); + try std.testing.expectEqual(@as(usize, 2), mac.len); + const mac_path = try onPath(arena, id, .{ .os = .macos, .path = "/usr/bin:/opt/bin" }); + try std.testing.expectEqualStrings("/opt/bin/fizzy", mac_path[1].exe); const win = try candidates(arena, id, .{ .os = .windows, .local_app_data = "C:\\Users\\a\\AppData\\Local", .path = "C:\\bin;D:\\tools" }); try std.testing.expectEqualStrings("C:\\Users\\a\\AppData\\Local\\fizzy\\current\\fizzy.exe", win[0].exe); - try std.testing.expectEqualStrings("D:\\tools\\fizzy.exe", win[win.len - 1].exe); + const win_path = try onPath(arena, id, .{ .os = .windows, .path = "C:\\bin;D:\\tools" }); + try std.testing.expectEqualStrings("D:\\tools\\fizzy.exe", win_path[1].exe); const linux = try candidates(arena, .{ .name = "paint" }, .{ .os = .linux, .home = "/home/a", .path = "/usr/bin" }); try std.testing.expectEqualStrings("/home/a/.local/share/paint/current/paint", linux[0].exe); try std.testing.expectEqualStrings("/home/a/Applications/paint.AppImage", linux[2].exe); - try std.testing.expectEqualStrings("/usr/bin/paint", linux[3].exe); + try std.testing.expectEqual(@as(usize, 3), linux.len); + try std.testing.expectEqualStrings("/usr/bin/paint", (try onPath(arena, .{ .name = "paint" }, .{ .os = .linux, .path = "/usr/bin" }))[0].exe); } test parseRegValue { diff --git a/sdk/remote/client_tests.zig b/sdk/remote/client_tests.zig index 0990b2890..c60a34699 100644 --- a/sdk/remote/client_tests.zig +++ b/sdk/remote/client_tests.zig @@ -197,3 +197,19 @@ test "a dropped stream closes the client and reconnect opens it again" { try roundTrip(&client, 1); try t.expectEqual(@as(usize, 1), fx.starts()); } + +test "polling after a bye closes the client instead of crashing" { + var fx = try Fixture.init("bye"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + var arena_state: std.heap.ArenaAllocator = .init(t.allocator); + defer arena_state.deinit(); + try client.send(.{ .create_view = .{ .view = 1, .width = 1, .height = 1 } }); + const bye = (try client.poll(arena_state.allocator(), 5000)).?.bye; + try t.expectEqualStrings("going away", bye.text); + try t.expect(!client.connected()); + try t.expectError(error.Closed, client.poll(arena_state.allocator(), 100)); + try t.expectError(error.Closed, client.send(.{ .create_view = .{ .view = 2, .width = 1, .height = 1 } })); +} diff --git a/sdk/remote/fake_fizzy.zig b/sdk/remote/fake_fizzy.zig index 02f9c8c75..6324717b4 100644 --- a/sdk/remote/fake_fizzy.zig +++ b/sdk/remote/fake_fizzy.zig @@ -8,6 +8,7 @@ //! * `crash` — exit 3 before listening. //! * `old` — hello with versions the client lacks. //! * `noremote` — answer `GET /remote` with 404, as a fizzy without a remote view does. +//! * `bye` — answer `create_view` with a bye. //! * `drop` — close the first connection right after the hellos; the second is served. //! //! Each `--quiet` start appends a line to `/launches`. It answers `create_view` with @@ -19,7 +20,7 @@ const Client = remote.Client; const Endpoint = remote.Endpoint; const message = remote.message; -const Mode = enum { serve, slow, crash, old, noremote, drop }; +const Mode = enum { serve, slow, crash, old, noremote, drop, bye }; pub fn main(init: std.process.Init) !void { const gpa = init.gpa; @@ -106,7 +107,7 @@ fn talk(gpa: std.mem.Allocator, io: Io, stream: Io.net.Stream, mode: Mode, n: us try Client.writeAll(io, stream, ep.pending()); return; }, - .create_view => |cv| try ep.send(.{ .view_created = .{ .view = cv.view, .tier = .draw_list, .width = cv.width, .height = cv.height } }), + .create_view => |cv| if (mode == .bye) try ep.send(.{ .bye = .{ .reason = .shutdown, .text = "going away" } }) else try ep.send(.{ .view_created = .{ .view = cv.view, .tier = .draw_list, .width = cv.width, .height = cv.height } }), .bye => return, else => {}, }; From ab248aada7d1bdc0ff45037a68bd428a4539f0f3 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 13:59:26 -0500 Subject: [PATCH 5/5] remote: poll closes on a feed error; send and closeAfterBye docs say what closes --- sdk/remote/Client.zig | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sdk/remote/Client.zig b/sdk/remote/Client.zig index 14fb473eb..1d93f469e 100644 --- a/sdk/remote/Client.zig +++ b/sdk/remote/Client.zig @@ -508,7 +508,8 @@ pub fn connected(self: *const Client) bool { return self.endpoint != null and self.stream != null; } -/// Check `msg` against the conversation's rules and send it. +/// Check `msg` against the conversation's rules and send it. A message the rules refuse is the +/// caller's mistake: it is not sent and the stream stays open. A write that fails closes the client. pub fn send(self: *Client, msg: message.Message) Error!void { const ep = &(self.endpoint orelse return error.Closed); if (self.stream == null) return self.closeStale(); @@ -549,14 +550,17 @@ pub fn poll(self: *Client, arena: std.mem.Allocator, wait_ms: ?u32) Error!?messa self.close(); return error.Closed; } - try ep.feed(buf[0..n]); + ep.feed(buf[0..n]) catch |err| { + self.close(); + return err; + }; } } +/// The peer ended the conversation; its bye is the caller's to read, the stream is done. The +/// endpoint stays until the next `poll`, `send`, `reconnect` or `close`, because the bye's +/// strings borrow it and dangle once it is gone. fn closeAfterBye(self: *Client) void { - // The peer ended the conversation; its bye is the caller's to read, the stream is done. The - // endpoint stays until the next `poll`, `send`, `reconnect` or `close`, because the bye's - // strings borrow it and dangle once it is gone. if (self.stream) |s| s.close(self.io); self.stream = null; }