From e40ef3bb8cb3a24259d24622685a40aa79320a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:41:26 +0000 Subject: [PATCH 1/5] app: serve remote views on GET /remote A client upgrades a connection on the control socket (Upgrade: fizzy-remote-view/1) and speaks the sdk/remote protocol: it opens views, sends pointer, key and text input, and asks for frames. Each view is a RemoteViewHost, a ViewHost the OS never shows; its frame is a stub, one quad filling the view. The server hands an upgraded connection to the app as two byte queues (control/Link), so the UI thread reads and writes once a frame and no thread is needed per view. A view the client closes, or that goes with a dropped connection, closes its window. fizzy.remote.views lists them. Part of fizzyedit/work#40 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016mgAaDN3ndpHLtmoA72ZLQ --- app/control/Link.zig | 127 +++++++++ app/control/Server.zig | 184 ++++++++++++- app/control/routes.zig | 37 +++ app/remote/RemoteViewHost.zig | 172 ++++++++++++ app/remote/Service.zig | 125 +++++++++ app/remote/Session.zig | 174 ++++++++++++ app/remote/encoder.zig | 34 +++ app/remote/keys.zig | 115 ++++++++ app/remote/tests.zig | 502 ++++++++++++++++++++++++++++++++++ app/root.zig | 8 + build/sdk.zig | 3 + docs/CONTROL.md | 21 ++ 12 files changed, 1498 insertions(+), 4 deletions(-) create mode 100644 app/control/Link.zig create mode 100644 app/remote/RemoteViewHost.zig create mode 100644 app/remote/Service.zig create mode 100644 app/remote/Session.zig create mode 100644 app/remote/encoder.zig create mode 100644 app/remote/keys.zig create mode 100644 app/remote/tests.zig diff --git a/app/control/Link.zig b/app/control/Link.zig new file mode 100644 index 000000000..5ba5ef2a1 --- /dev/null +++ b/app/control/Link.zig @@ -0,0 +1,127 @@ +//! The two byte queues between a connection the server upgraded (`GET /remote`) and the app: what +//! the peer sent, and what the app wants sent. The connection's tasks fill the first and empty the +//! second; the UI thread does the opposite, once a frame, so a connection costs the frame no +//! blocking read or write (`Server.serveLink`). +//! +//! std-only, like the server that carries it. +const std = @import("std"); +const Io = std.Io; + +const Link = @This(); + +/// The most that may wait to be sent: a peer that reads slower than the app writes is cut off +/// rather than buffered without end. +pub const max_backlog = 8 * 1024 * 1024; + +gpa: std.mem.Allocator, +io: Io, +/// Wakes the app so its next frame reads what arrived. Safe from any thread. +wake: *const fn () void, +lock: Io.Mutex = .init, +/// From the peer, under `lock`. +inbox: std.ArrayListUnmanaged(u8) = .empty, +/// To the peer, under `lock`. +outbox: std.ArrayListUnmanaged(u8) = .empty, +out_ready: Io.Event = .unset, +/// The app is done with the link: its tasks end, and the socket with them. Set by `close`. +closing: std.atomic.Value(bool) = .init(false), +/// The connection's tasks are done with the link: the peer left, or `close` was answered. Only +/// then may the app `deinit` it; the tasks touch nothing after setting this. +gone: std.atomic.Value(bool) = .init(false), + +pub fn init(gpa: std.mem.Allocator, io: Io, wake: *const fn () void) Link { + return .{ .gpa = gpa, .io = io, .wake = wake }; +} + +/// Free the queues. Only once `isGone`, or if the server never served it. +pub fn deinit(self: *Link) void { + self.inbox.deinit(self.gpa); + self.outbox.deinit(self.gpa); +} + +/// Move what the peer has sent so far onto the end of `out`. UI thread. +pub fn take(self: *Link, out: *std.ArrayListUnmanaged(u8)) error{OutOfMemory}!void { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + try out.appendSlice(self.gpa, self.inbox.items); + self.inbox.clearRetainingCapacity(); +} + +/// Queue `bytes` for the peer. A peer too far behind is closed instead. UI thread. +pub fn send(self: *Link, bytes: []const u8) error{OutOfMemory}!void { + if (self.closing.load(.acquire) or self.gone.load(.acquire)) return; + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + if (self.outbox.items.len + bytes.len <= max_backlog) { + try self.outbox.appendSlice(self.gpa, bytes); + self.out_ready.set(self.io); + return; + } + } + self.close(); +} + +/// End the connection once what is queued is written. UI thread. +pub fn close(self: *Link) void { + self.closing.store(true, .release); + self.out_ready.set(self.io); +} + +pub fn isGone(self: *const Link) bool { + return self.gone.load(.acquire); +} + +/// From the connection's reader task: bytes arrived. +pub fn push(self: *Link, bytes: []const u8) error{OutOfMemory}!void { + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + try self.inbox.appendSlice(self.gpa, bytes); + } + self.wake(); +} + +/// From the connection's writer task: everything queued so far, which the caller frees; null when +/// there is nothing and the link is not closing. +pub fn drain(self: *Link) error{OutOfMemory}!?[]u8 { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + self.out_ready.reset(); + if (self.outbox.items.len == 0) return null; + return try self.outbox.toOwnedSlice(self.gpa); +} + +/// From the connection's task, last: it will not touch the link again. +pub fn finish(self: *Link) void { + self.gone.store(true, .release); + self.wake(); +} + +fn noWake() void {} + +test "bytes cross in both directions, and a link too far behind is closed" { + const gpa = std.testing.allocator; + var link: Link = .init(gpa, std.testing.io, noWake); + defer link.deinit(); + try link.push("ab"); + try link.push("cd"); + var got: std.ArrayListUnmanaged(u8) = .empty; + defer got.deinit(gpa); + try link.take(&got); + try std.testing.expectEqualStrings("abcd", got.items); + + try std.testing.expect((try link.drain()) == null); + try link.send("xy"); + const out = (try link.drain()).?; + defer gpa.free(out); + try std.testing.expectEqualStrings("xy", out); + + var big: std.ArrayListUnmanaged(u8) = .empty; + defer big.deinit(gpa); + try big.appendNTimes(gpa, 0, max_backlog); + try link.send(big.items); + try link.send("z"); + try std.testing.expect(link.closing.load(.acquire)); + link.outbox.clearRetainingCapacity(); +} diff --git a/app/control/Server.zig b/app/control/Server.zig index 38d10c6ad..c2a194db4 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -21,6 +21,8 @@ const std = @import("std"); const builtin = @import("builtin"); const Io = std.Io; +pub const Link = @import("Link.zig"); + /// The most a request body may hold. A command's arguments can be a whole drawing, but not this. pub const max_body = 64 * 1024 * 1024; /// The most a stream may fall behind: a client that reads no faster than events come is closed @@ -157,6 +159,8 @@ pub const Request = struct { content_type: ?[]const u8, /// The `Mcp-Session-Id` header, as sent (`routes`, `mcp.Sessions`). mcp_session: ?[]const u8 = null, + /// The `Upgrade` header, as sent: what the client asks the connection to become (`/remote`). + upgrade: ?[]const u8 = null, /// The `Mcp-Session-Id` to answer with, formatted by the routes (`mcp.formatSession`). mcp_session_reply: ?[16]u8 = null, body: []const u8, @@ -196,6 +200,15 @@ pub const Request = struct { self.done.set(server.io); } + /// Answer by handing the connection to `link` from now on (`101 Switching Protocols`, then the + /// peer's bytes into `link` and what the app queues on it out), from the UI thread. The link + /// is the app's to free once `isGone`. + pub fn answerUpgrade(self: *Request, server: *Server, link: *Link) void { + self.response = .{ .status = .switching_protocols, .upgrade = link }; + server.settle(self); + self.done.set(server.io); + } + /// Answer, from the UI thread. `body` is copied with the server's `gpa`. pub fn answer(self: *Request, server: *Server, status: std.http.Status, content_type: []const u8, body: []const u8) void { self.response = .{ @@ -210,6 +223,8 @@ pub const Request = struct { }; pub const Response = struct { + /// Answered by turning the connection into `link`'s bytes (`answerUpgrade`). + upgrade: ?*Link = null, /// Answered by turning the connection into an event stream (`GET /events`): the status and /// body are not sent, the connection stays open and `publish` writes to it. stream: bool = false, @@ -483,7 +498,8 @@ fn serve(self: *Server, conn: *Conn) void { .method = head.head.method, .target = arena.dupe(u8, head.head.target) catch return, .content_type = if (head.head.content_type) |t| arena.dupe(u8, t) catch return else null, - .mcp_session = mcpSession(&head, arena) catch return, + .mcp_session = headerValue(&head, arena, "mcp-session-id") catch return, + .upgrade = headerValue(&head, arena, "upgrade") catch return, .body = "", .conn = conn, .arena = arena, @@ -507,6 +523,12 @@ fn serve(self: *Server, conn: *Conn) void { // into a frame that is gone. req.done.waitUncancelable(self.io); if (req.response.stream) return self.serveStream(&head, &req.stream); + if (req.response.upgrade) |link| { + self.lock.lockUncancelable(self.io); + conn.waiting = false; + self.lock.unlock(self.io); + return self.serveLink(link, conn.stream, &reader.interface, &writer.interface, req.upgrade orelse ""); + } const body = req.response.body orelse "out of memory\n"; defer if (req.response.body) |b| self.gpa.free(b); const headers = [_]std.http.Header{ @@ -527,13 +549,47 @@ fn serve(self: *Server, conn: *Conn) void { } } -/// The request's `Mcp-Session-Id`, copied into `arena`, or null. -fn mcpSession(head: *std.http.Server.Request, arena: std.mem.Allocator) error{OutOfMemory}!?[]const u8 { +/// The request's header `name` (lowercase), copied into `arena`, or null. +fn headerValue(head: *std.http.Server.Request, arena: std.mem.Allocator, name: []const u8) error{OutOfMemory}!?[]const u8 { var it = head.iterateHeaders(); - while (it.next()) |h| if (std.ascii.eqlIgnoreCase(h.name, "mcp-session-id")) return try arena.dupe(u8, h.value); + while (it.next()) |h| if (std.ascii.eqlIgnoreCase(h.name, name)) return try arena.dupe(u8, h.value); return null; } +/// The connection is `link`'s from here: the `101`, then what the peer sends into the link while a +/// task of its own writes what the app queues on it. Ends when the peer goes, the app closes the +/// link, or the server stops; the link is finished last, whichever, and not touched after. +fn serveLink(self: *Server, link: *Link, stream: Io.net.Stream, reader: *Io.Reader, writer: *Io.Writer, token: []const u8) void { + defer link.finish(); + writer.print("HTTP/1.1 101 Switching Protocols\r\nconnection: Upgrade\r\nupgrade: {s}\r\n\r\n", .{token}) catch return; + writer.flush() catch return; + var sender = self.io.concurrent(writeLink, .{ self, link, stream, writer }) catch return; + defer sender.cancel(self.io); + while (!self.stopping.load(.acquire)) { + const bytes = reader.peekGreedy(1) catch return; + link.push(bytes) catch return; + reader.toss(bytes.len); + } +} + +/// Write what the app queues on `link` until it closes it or a write fails, then shut the socket +/// down, which ends the read that `serveLink` is blocked in. +fn writeLink(self: *Server, link: *Link, stream: Io.net.Stream, writer: *Io.Writer) void { + while (true) { + // Read first: what was queued before `close` is still written. + const closing = link.closing.load(.acquire); + if (link.drain() catch break) |bytes| { + defer link.gpa.free(bytes); + writer.writeAll(bytes) catch break; + writer.flush() catch break; + continue; + } + if (closing) break; + link.out_ready.wait(self.io) catch break; + } + stream.shutdown(self.io, .both) catch {}; +} + /// Write events to the connection until the client goes, falls too far behind, or the server /// stops. `stream` was registered by `answerStream`; it is removed here, whatever happens. fn serveStream(self: *Server, head: *std.http.Server.Request, stream: *Stream) void { @@ -1091,3 +1147,123 @@ test "a stopped server ends a keep-alive connection waiting for its next request io.sleep(.fromMilliseconds(50), .awake) catch {}; server.stop(); } + +/// A client that asks for `GET /remote` to become a byte stream: reads the `101`, writes `ping` +/// right behind its request, reads four bytes, then reads to the end unless `leave`, which closes +/// the socket as soon as it has the head. +const UpgradePeer = struct { + io: Io, + path: []const u8, + leave: bool = false, + head: [160]u8 = undefined, + head_len: usize = 0, + reply: [4]u8 = undefined, + ended: bool = false, + + fn run(self: *UpgradePeer) void { + const addr = Io.net.UnixAddress.init(self.path) catch return; + const stream = addr.connect(self.io) catch return; + defer stream.close(self.io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(self.io, &wbuf); + w.interface.writeAll("GET /remote HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: fizzy-remote-view/1\r\n\r\nping") catch return; + w.interface.flush() catch return; + var rbuf: [256]u8 = undefined; + var r = stream.reader(self.io, &rbuf); + while (self.head_len < self.head.len) { + const n = r.interface.readSliceShort(self.head[self.head_len..][0..1]) catch return; + if (n == 0) return; + self.head_len += 1; + if (std.mem.endsWith(u8, self.head[0..self.head_len], "\r\n\r\n")) break; + } + if (self.leave) return; + r.interface.readSliceAll(&self.reply) catch return; + var more: [1]u8 = undefined; + self.ended = if (r.interface.readSliceShort(&more)) |n| n == 0 else |_| true; + } +}; + +/// Frames, as the UI thread's, until `taken` holds a request. +fn takeOne(server: *Server, taken: *std.ArrayListUnmanaged(*Request)) !*Request { + var waited: usize = 0; + while (taken.items.len == 0 and waited < 2000) : (waited += 1) { + _ = server.take(taken); + if (taken.items.len == 0) server.io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), taken.items.len); + return taken.items[0]; +} + +test "an upgraded connection carries bytes both ways until the app closes it" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + defer server.stop(); + + var peer: UpgradePeer = .{ .io = io, .path = path }; + const thread = try std.Thread.spawn(.{}, UpgradePeer.run, .{&peer}); + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + const req = try takeOne(&server, &taken); + try std.testing.expectEqualStrings("fizzy-remote-view/1", req.upgrade.?); + var link: Link = .init(gpa, io, noWake); + defer link.deinit(); + req.answerUpgrade(&server, &link); + + // What the client wrote behind its request is the link's. + var got: std.ArrayListUnmanaged(u8) = .empty; + defer got.deinit(gpa); + var waited: usize = 0; + while (got.items.len < 4 and waited < 2000) : (waited += 1) { + try link.take(&got); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqualStrings("ping", got.items); + try link.send("pong"); + // Closing ends the client's read, after what was queued is written. + link.close(); + thread.join(); + try std.testing.expect(std.mem.startsWith(u8, peer.head[0..peer.head_len], "HTTP/1.1 101 Switching Protocols\r\n")); + try std.testing.expect(std.mem.find(u8, peer.head[0..peer.head_len], "upgrade: fizzy-remote-view/1\r\n") != null); + try std.testing.expectEqualStrings("pong", &peer.reply); + try std.testing.expect(peer.ended); + waited = 0; + while (!link.isGone() and waited < 2000) : (waited += 1) io.sleep(.fromMilliseconds(1), .awake) catch {}; + try std.testing.expect(link.isGone()); +} + +test "a client that leaves an upgraded connection finishes its link" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + defer server.stop(); + + var peer: UpgradePeer = .{ .io = io, .path = path, .leave = true }; + const thread = try std.Thread.spawn(.{}, UpgradePeer.run, .{&peer}); + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + const req = try takeOne(&server, &taken); + var link: Link = .init(gpa, io, noWake); + defer link.deinit(); + req.answerUpgrade(&server, &link); + thread.join(); + var waited: usize = 0; + while (!link.isGone() and waited < 2000) : (waited += 1) io.sleep(.fromMilliseconds(1), .awake) catch {}; + try std.testing.expect(link.isGone()); +} + +test { + _ = Link; +} diff --git a/app/control/routes.zig b/app/control/routes.zig index ef43f981e..3d46b5153 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -10,6 +10,8 @@ //! POST /mcp the Model Context Protocol, its tools the commands (`app/mcp.zig`) //! GET /events the app's events as Server-Sent Events, `?topics=a,b` to choose //! (a name, or a prefix ending in `*`); every event when absent +//! GET /remote upgrades to the remote-view protocol (`Upgrade: fizzy-remote-view/1`): +//! a client opens views of the app and gets frames (`app/remote/`) //! //! Bodies are JSON, converted at this edge (`json.zig`). A request sent as `application/zon` is //! passed through as ZON and answered in ZON. Errors are HTTP's: 404 for an unknown command, 400 @@ -22,6 +24,7 @@ const json = @import("json.zig"); const openapi = @import("openapi.zig"); const mcp = @import("../mcp.zig"); const Server = @import("Server.zig"); +const Service = @import("../remote/Service.zig"); pub const Answer = struct { /// Stream the app's events to this connection instead (`GET /events`). @@ -45,6 +48,10 @@ pub fn answerPending(server: *Server, state: *State, host: *sdk.Host) void { defer taken.deinit(server.gpa); var streaming = server.take(&taken) > 0; for (taken.items) |req| { + if (std.mem.eql(u8, std.mem.trimEnd(u8, req.path(), "/"), "/remote")) { + upgradeRemote(server, state, host, req); + continue; + } var arena: std.heap.ArenaAllocator = .init(server.gpa); defer arena.deinit(); // Ready for a command that answers later; let go of when it answers now. @@ -90,6 +97,7 @@ pub fn answerPending(server: *Server, state: *State, host: *sdk.Host) void { req.answer(server, a.status, a.content_type, a.body); } } + if (state.remote) |*remote| remote.pump(); if (state.sessions.list.items.len > 0) state.sessions.expire(server.gpa, host, std.Io.Clock.awake.now(server.io).nanoseconds); state.follow(host, server, streaming); } @@ -107,10 +115,37 @@ pub fn stop(server: *Server, state: *State, host: *sdk.Host) void { state.sessions.endAll(server.gpa, host); state.sessions.deinit(server.gpa); state.sessions = .{}; + if (state.remote) |*remote| { + remote.closeAll(); + remote.deinit(host); + state.remote = null; + } state.follow(host, server, false); server.stop(); } +/// `GET /remote`: the connection becomes the remote-view protocol's stream (`Service`). +fn upgradeRemote(server: *Server, state: *State, host: *sdk.Host, req: *Server.Request) void { + if (req.method != .GET) return req.answer(server, .method_not_allowed, "application/json", "{\"error\":\"use GET\"}"); + const wants = req.upgrade orelse ""; + if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, wants, " "), Service.upgrade_token)) + return req.answer(server, .upgrade_required, "application/json", "{\"error\":\"send Upgrade: " ++ Service.upgrade_token ++ "\"}"); + const oom = "{\"error\":\"out of memory\"}"; + const link = server.gpa.create(Server.Link) catch return req.answer(server, .internal_server_error, "application/json", oom); + link.* = .init(server.gpa, server.io, server.wake); + if (state.remote == null) state.remote = Service.init(server.gpa, .{ + .app_id = AppInfo.current.name, + .app_version = AppInfo.current.version, + .tiers_offered = .{ .draw_list = true }, + .mode = .standalone, + }); + state.remote.?.accept(host, link) catch { + server.gpa.destroy(link); + return req.answer(server, .internal_server_error, "application/json", oom); + }; + req.answerUpgrade(server, link); +} + /// A request whose command answers in a later frame: answered by the host's call to `done` /// (`Host.finishCall`), or by `server.stop` if that comes first (`stop`). const Waiter = struct { @@ -164,6 +199,8 @@ pub const State = struct { waiters: std.ArrayListUnmanaged(*Waiter) = .empty, /// The MCP clients that initialized (`mcp.Sessions`). UI thread. sessions: mcp.Sessions = .{}, + /// The remote-view clients (`GET /remote`), once one has connected. UI thread. + remote: ?Service = null, const relay_vtable: sdk.Plugin.VTable = .{}; diff --git a/app/remote/RemoteViewHost.zig b/app/remote/RemoteViewHost.zig new file mode 100644 index 000000000..945cfc459 --- /dev/null +++ b/app/remote/RemoteViewHost.zig @@ -0,0 +1,172 @@ +//! A `ViewHost` whose surface is on the other side of the control socket: a view a remote client +//! (an adapter in Blender, Unreal, …) asked for. Like a pop-out it is a window the app lays out +//! and draws into the one frame, but the OS never shows it; the client does, from the frames it +//! asks for. Input the client sends waits here as dvui events until the frame injects it. +//! +//! The app owns the part of the frame this host shows (`surface`); `end` answers the client's +//! newest frame request from it. Frames come from the UI thread, on request: nothing here runs on +//! a thread of its own. +const std = @import("std"); +const dvui = @import("dvui"); +const remote = @import("remote"); +const ViewHost = @import("../view/ViewHost.zig"); +const keys = @import("keys.zig"); +const Session = @import("Session.zig"); + +const message = remote.message; +const RemoteViewHost = @This(); + +/// What the client sent that a frame has yet to take, in order. +pub const Input = union(enum) { + motion: struct { x: f32, y: f32, mods: message.Mods }, + button: message.PointerButton, + wheel: message.Wheel, + key: message.Key, + /// `gpa`-owned. + text: []u8, +}; + +/// A client sending faster than frames run drops the rest rather than grow without end. +pub const max_queued = 1024; + +gpa: std.mem.Allocator, +session: *Session, +id: message.ViewId, +kind: message.ViewKind, +/// What the view shows (a region's name, a document's path); `gpa`-owned. +target: []u8, +/// Physical pixels. +width: u32, +height: u32, +scale: f32, +/// Where its part of the frame lies, physical; null until the app has placed it. Input is +/// delivered only once it has: a view nobody draws has nothing under the pointer. +origin: ?dvui.Point.Physical = null, +/// Placed or sized since `begin` last said so. +moved: bool = true, +/// The client has this view's focus: keys and text reach it only then. +focused: bool = false, +/// The client closed it, or the connection did. +close_asked: bool = false, +/// The newest frame the client asked for, and the newest answered; 0 before any. +wanted: u64 = 0, +answered: u64 = 0, +input: std.ArrayListUnmanaged(Input) = .empty, + +pub fn create(gpa: std.mem.Allocator, session: *Session, cv: message.CreateView) error{OutOfMemory}!*RemoteViewHost { + const self = try gpa.create(RemoteViewHost); + errdefer gpa.destroy(self); + self.* = .{ + .gpa = gpa, + .session = session, + .id = cv.view, + .kind = cv.kind, + .target = try gpa.dupe(u8, cv.target), + .width = cv.width, + .height = cv.height, + .scale = cv.scale, + }; + return self; +} + +pub fn destroy(self: *RemoteViewHost) void { + self.clearInput(); + self.input.deinit(self.gpa); + self.gpa.free(self.target); + self.gpa.destroy(self); +} + +pub fn viewHost(self: *RemoteViewHost) ViewHost { + return .{ .ptr = self, .vtable = ViewHost.vtableOf(RemoteViewHost) }; +} + +/// The app placed this view's part of the frame at `origin`. +pub fn place(self: *RemoteViewHost, origin: dvui.Point.Physical) void { + const at = self.origin orelse origin; + if (self.origin == null or at.x != origin.x or at.y != origin.y) self.moved = true; + self.origin = origin; +} + +pub fn resize(self: *RemoteViewHost, width: u32, height: u32, scale: f32) void { + if (width != self.width or height != self.height or scale != self.scale) self.moved = true; + self.width = width; + self.height = height; + self.scale = scale; +} + +pub fn begin(self: *RemoteViewHost) ?dvui.Rect.Physical { + if (!self.moved) return null; + self.moved = false; + return self.surface(); +} + +/// Answers the newest frame the client asked for, if one is waiting. +pub fn end(self: *RemoteViewHost) void { + if (self.wanted <= self.answered or self.close_asked) return; + self.session.sendFrame(self); +} + +pub fn surface(self: *RemoteViewHost) dvui.Rect.Physical { + const at = self.origin orelse dvui.Point.Physical{}; + return .{ .x = at.x, .y = at.y, .w = @floatFromInt(self.width), .h = @floatFromInt(self.height) }; +} + +pub fn space(_: *RemoteViewHost) ViewHost.Space { + return .{}; +} + +/// The client's surface is its own: the app draws the base. +pub fn materialBase(_: *RemoteViewHost, _: dvui.Color, _: f32) ?dvui.Color { + return null; +} + +pub fn closeRequested(self: *RemoteViewHost) bool { + return self.close_asked; +} + +/// Hold `item` until `inject`. Keys and text are the focused view's alone. +pub fn queue(self: *RemoteViewHost, item: Input) error{OutOfMemory}!void { + const wanted = switch (item) { + .key, .text => self.focused, + else => true, + }; + if (!wanted or self.input.items.len >= max_queued) { + if (item == .text) self.gpa.free(item.text); + return; + } + try self.input.append(self.gpa, item); +} + +/// Hand what is waiting to `win` as dvui events, at this view's place in the frame, before the +/// frame draws. With no place yet it is dropped. +pub fn inject(self: *RemoteViewHost, win: *dvui.Window) error{OutOfMemory}!void { + defer self.clearInput(); + const at = self.origin orelse return; + for (self.input.items) |item| switch (item) { + .motion => |m| { + win.modifiers = keys.toMod(m.mods); + _ = try win.addEventMouseMotion(.{ .pt = .{ .x = at.x + m.x, .y = at.y + m.y } }); + }, + .button => |b| { + win.modifiers = keys.toMod(b.mods); + _ = try win.addEventMouseMotion(.{ .pt = .{ .x = at.x + b.x, .y = at.y + b.y } }); + _ = try win.addEventMouseButton(keys.button(b.button), if (b.pressed) .press else .release); + }, + .wheel => |w| { + win.modifiers = keys.toMod(w.mods); + if (w.dy != 0) _ = try win.addEventMouseWheel(w.dy, .vertical, null); + if (w.dx != 0) _ = try win.addEventMouseWheel(w.dx, .horizontal, null); + }, + .key => |k| _ = try win.addEventKey(.{ + .code = keys.fromHid(k.code), + .action = if (!k.down) .up else if (k.repeat) .repeat else .down, + .mod = keys.toMod(k.mods), + }), + .text => |t| _ = try win.addEventText(.{ .text = t }), + }; +} + +fn clearInput(self: *RemoteViewHost) void { + for (self.input.items) |item| if (item == .text) self.gpa.free(item.text); + self.input.clearRetainingCapacity(); +} diff --git a/app/remote/Service.zig b/app/remote/Service.zig new file mode 100644 index 000000000..286066a14 --- /dev/null +++ b/app/remote/Service.zig @@ -0,0 +1,125 @@ +//! The remote views of a running fizzy: the clients connected to `GET /remote`, each a `Session`, +//! and the views they opened, each a `RemoteViewHost` (plans/EMBEDDED_EDITOR_PLAN.md, step 3). +//! The control server hands over a connection it upgraded (`accept`); the UI thread calls `pump` +//! once a frame, which is the whole of the work: no thread per view, no blocking. +//! +//! **The seam to the frame.** What an app that draws remote views does each frame: +//! `views`, then for each host `place` it, `inject` its input into the window before the frame +//! draws, and `end` it after (setting `editor_draws`, so `pump` leaves the answering to it). Until +//! an app does, `pump` answers frame requests itself with the stub's fill and no input reaches a +//! window: a view the app has not placed has nothing under the pointer. +//! `TODO(seam)`: the editor's frame loop wires this (EMBEDDED_EDITOR_PLAN step 4). +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const remote = @import("remote"); +const Link = @import("../control/Link.zig"); +const RemoteViewHost = @import("RemoteViewHost.zig"); +const Session = @import("Session.zig"); + +const Service = @This(); + +/// The `Upgrade` token of `GET /remote`, with the protocol's major version. +pub const upgrade_token = "fizzy-remote-view/1"; + +gpa: std.mem.Allocator, +/// What fizzy says first to each client. +hello: remote.message.Hello, +sessions: std.ArrayListUnmanaged(*Session) = .empty, +next_session: u32 = 1, +/// The app draws the hosts itself (`end`); `pump` does not answer frames. +editor_draws: bool = false, +/// Owner of `fizzy.remote.views`, a pseudo-plugin as the other routes' are: never in +/// `host.plugins`. +plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, .id = "fizzy.remote", .display_name = "Remote views" }, +registered: bool = false, + +const vtable: sdk.Plugin.VTable = .{}; + +pub fn init(gpa: std.mem.Allocator, hello: remote.message.Hello) Service { + return .{ .gpa = gpa, .hello = hello }; +} + +/// Drop every connection (their links must be gone: `closeAll` first) and the command. +pub fn deinit(self: *Service, host: *sdk.Host) void { + if (self.registered) host.withdrawPlugin(&self.plugin); + self.registered = false; + self.sessions.deinit(self.gpa); +} + +/// Take a connection the control server upgraded. The link is the service's from here: it frees it +/// once the connection's tasks are done with it. +pub fn accept(self: *Service, host: *sdk.Host, link: *Link) error{OutOfMemory}!void { + try self.ensureCommand(host); + const session = try Session.create(self.gpa, link, self.hello); + errdefer session.destroy(); + session.id = self.next_session; + try self.sessions.append(self.gpa, session); + self.next_session += 1; +} + +/// Once a frame, on the UI thread: act on what every client sent, send what it earned, and let go +/// of a connection that is over. +pub fn pump(self: *Service) void { + var i: usize = 0; + while (i < self.sessions.items.len) { + const s = self.sessions.items[i]; + if (s.pump(!self.editor_draws)) { + s.destroy(); + _ = self.sessions.swapRemove(i); + } else i += 1; + } +} + +/// End every connection, waiting for their tasks: at quit, or when the control server stops. +pub fn closeAll(self: *Service) void { + for (self.sessions.items) |s| s.link.close(); + for (self.sessions.items) |s| { + while (!s.link.isGone()) s.link.io.sleep(.fromMilliseconds(1), .awake) catch break; + s.destroy(); + } + self.sessions.clearRetainingCapacity(); +} + +/// Every open view, in the order they were opened, into `out`. +pub fn views(self: *const Service, out: *std.ArrayListUnmanaged(*RemoteViewHost)) error{OutOfMemory}!void { + for (self.sessions.items) |s| for (s.views.values()) |v| try out.append(self.gpa, v); +} + +fn ensureCommand(self: *Service, host: *sdk.Host) error{OutOfMemory}!void { + if (self.registered) return; + self.plugin.state = self; + host.registerCommand(.{ + .id = "fizzy.remote.views", + .owner = &self.plugin, + .title = "Remote Views", + .visibility = .internal, + .runWith = listViews, + }) catch return error.OutOfMemory; + self.registered = true; +} + +const ViewInfo = struct { + connection: u32, + view: u32, + kind: []const u8, + target: []const u8, + width: u32, + height: u32, + focused: bool, +}; + +/// `fizzy.remote.views`: the open views, as a list. +fn listViews(state: *anyopaque, call: *sdk.Command.Call) anyerror!void { // lint-allow(anyerror): `Command.runWith`'s signature + const self: *Service = @ptrCast(@alignCast(state)); + var list: std.ArrayListUnmanaged(ViewInfo) = .empty; + for (self.sessions.items) |s| for (s.views.values()) |v| try list.append(call.arena, .{ + .connection = s.id, + .view = v.id, + .kind = @tagName(v.kind), + .target = v.target, + .width = v.width, + .height = v.height, + .focused = v.focused, + }); + try call.returns(list.items); +} diff --git a/app/remote/Session.zig b/app/remote/Session.zig new file mode 100644 index 000000000..943b29805 --- /dev/null +++ b/app/remote/Session.zig @@ -0,0 +1,174 @@ +//! One remote client's conversation: the `Link` its bytes cross, the protocol `Endpoint` that cuts +//! them into messages, and the views it opened. `pump` runs once a frame on the UI thread: it +//! takes what arrived, acts on it, and queues what is to go back. It never blocks and has no +//! thread; the connection's own tasks (`control/Server.serveLink`) do the waiting. +const std = @import("std"); +const remote = @import("remote"); +const Link = @import("../control/Link.zig"); +const RemoteViewHost = @import("RemoteViewHost.zig"); +const encoder = @import("encoder.zig"); + +const message = remote.message; +const Session = @This(); + +gpa: std.mem.Allocator, +/// Which connection, for `fizzy.remote.views`; the service numbers them from 1. +id: u32 = 0, +link: *Link, +endpoint: remote.Endpoint, +views: std.AutoArrayHashMapUnmanaged(message.ViewId, *RemoteViewHost) = .empty, +inbound: std.ArrayListUnmanaged(u8) = .empty, +/// The stream is over: it broke the protocol, the client left or said `bye`. The link closes +/// once what is queued is written. +dead: bool = false, + +pub fn create(gpa: std.mem.Allocator, link: *Link, hello: message.Hello) error{OutOfMemory}!*Session { + const self = try gpa.create(Session); + errdefer gpa.destroy(self); + self.* = .{ .gpa = gpa, .link = link, .endpoint = .init(gpa, .editor, hello) }; + errdefer self.endpoint.deinit(); + self.endpoint.start() catch return error.OutOfMemory; + return self; +} + +/// Free everything. Only once the link `isGone` (`Service`). +pub fn destroy(self: *Session) void { + for (self.views.values()) |v| v.destroy(); + self.views.deinit(self.gpa); + self.inbound.deinit(self.gpa); + self.endpoint.deinit(); + self.link.deinit(); + self.gpa.destroy(self.link); + self.gpa.destroy(self); +} + +/// Take what arrived, act on it, send what it earned. Returns whether the session is over and its +/// link gone, for the service to drop it. +pub fn pump(self: *Session, answer_frames: bool) bool { + if (!self.dead) self.receive(); + if (self.link.isGone()) self.dead = true; + if (!self.dead and answer_frames) for (self.views.values()) |v| v.end(); + self.reap(); + self.flush(); + return self.dead and self.link.isGone(); +} + +/// The views the client closed are dropped, each told to it; a stream that is over takes all of +/// its views with it. +fn reap(self: *Session) void { + var i: usize = 0; + while (i < self.views.count()) { + const v = self.views.values()[i]; + if (!v.close_asked and !self.dead) { + i += 1; + continue; + } + if (!self.dead) self.say(.{ .view_closed = .{ .view = v.id, .reason = .closed } }); + v.destroy(); + self.views.swapRemoveAt(i); + } +} + +fn flush(self: *Session) void { + const out = self.endpoint.pending(); + if (out.len > 0 and !self.link.isGone()) { + self.link.send(out) catch { + self.dead = true; + return; + }; + } + self.endpoint.consume(out.len); + if (self.dead) self.link.close(); +} + +fn receive(self: *Session) void { + self.inbound.clearRetainingCapacity(); + self.link.take(&self.inbound) catch { + self.dead = true; + return; + }; + self.endpoint.feed(self.inbound.items) catch { + self.dead = true; + return; + }; + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + while (true) { + _ = arena_state.reset(.retain_capacity); + const next = self.endpoint.poll(arena_state.allocator()) catch { + self.dead = true; + break; + }; + const msg = next orelse break; + self.handle(msg) catch { + self.dead = true; + break; + }; + if (self.dead) break; + } + // A bye (the client's, or the one a violation queued) ends it. + if (self.endpoint.conn.state == .closed) self.dead = true; +} + +fn say(self: *Session, msg: message.Message) void { + self.endpoint.send(msg) catch { + self.dead = true; + }; +} + +fn handle(self: *Session, msg: message.Message) error{OutOfMemory}!void { + switch (msg) { + .create_view => |cv| try self.createView(cv), + .close_view => |m| if (self.views.get(m.view)) |v| { + v.close_asked = true; + }, + .resize => |m| if (self.views.get(m.view)) |v| v.resize(m.width, m.height, m.scale), + .focus => |m| if (self.views.get(m.view)) |v| { + v.focused = m.focused; + }, + .frame_request => |m| if (self.views.get(m.view)) |v| { + v.wanted = m.seq; + }, + .pointer_motion => |m| if (self.views.get(m.view)) |v| try v.queue(.{ .motion = .{ .x = m.x, .y = m.y, .mods = m.mods } }), + .pointer_button => |m| if (self.views.get(m.view)) |v| try v.queue(.{ .button = m }), + .wheel => |m| if (self.views.get(m.view)) |v| try v.queue(.{ .wheel = m }), + .key => |m| if (self.views.get(m.view)) |v| try v.queue(.{ .key = m }), + .text_input => |m| if (self.views.get(m.view)) |v| { + const text = try self.gpa.dupe(u8, m.text); + errdefer self.gpa.free(text); + try v.queue(.{ .text = text }); + }, + // The tier-3 stub has no use for these yet: touch, IME, clipboard. + else => {}, + } +} + +fn createView(self: *Session, cv: message.CreateView) error{OutOfMemory}!void { + if (cv.tier != .draw_list) { + self.refuse(cv.view, .unsupported_tier, "only the draw-list tier is served"); + return; + } + if (cv.width == 0 or cv.height == 0) { + self.refuse(cv.view, .create_failed, "a view has a size"); + return; + } + const v = try RemoteViewHost.create(self.gpa, self, cv); + errdefer v.destroy(); + try self.views.put(self.gpa, cv.view, v); + self.say(.{ .view_created = .{ .view = cv.view, .tier = .draw_list, .width = cv.width, .height = cv.height } }); +} + +/// Answer a `create_view` with a close: the connection took the view in, so the client hears it +/// closed (`view_closed` is the only reply that drops the connection's record of it). +fn refuse(self: *Session, id: message.ViewId, reason: message.ViewClosedReason, text: []const u8) void { + self.say(.{ .view_closed = .{ .view = id, .reason = reason, .text = text } }); +} + +/// Answer `v`'s newest request with the stub's fill. +pub fn sendFrame(self: *Session, v: *RemoteViewHost) void { + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + const draw = encoder.solidFill(arena_state.allocator(), v.width, v.height, encoder.fill_rgba) catch return; + self.say(.{ .frame = .{ .view = v.id, .seq = v.wanted, .tier = .draw_list, .width = v.width, .height = v.height, .draw = draw } }); + v.answered = v.wanted; +} diff --git a/app/remote/encoder.zig b/app/remote/encoder.zig new file mode 100644 index 000000000..d65d098ea --- /dev/null +++ b/app/remote/encoder.zig @@ -0,0 +1,34 @@ +//! What a remote view is sent. For now a solid fill the size of the view, as one quad, so a host +//! can prove the whole path (a view, input, a frame back) before the real encoder lands: +//! `TODO(seam)`: the draw-list encoder replaces `solidFill` behind `RemoteViewHost.end`, taking +//! the frame's triangles and texture changes where this takes a colour. +const std = @import("std"); +const message = @import("remote").message; + +/// The colour of the stub's fill: fizzy's accent. +pub const fill_rgba: u32 = 0xff8a4bff; + +/// One untextured quad over `width` x `height`. Allocated from `arena`. +pub fn solidFill(arena: std.mem.Allocator, width: u32, height: u32, rgba: u32) error{OutOfMemory}!message.DrawList { + const w: f32 = @floatFromInt(width); + const h: f32 = @floatFromInt(height); + const vertices = try arena.dupe(message.Vertex, &.{ + .{ .x = 0, .y = 0, .u = 0, .v = 0, .rgba = rgba }, + .{ .x = w, .y = 0, .u = 0, .v = 0, .rgba = rgba }, + .{ .x = w, .y = h, .u = 0, .v = 0, .rgba = rgba }, + .{ .x = 0, .y = h, .u = 0, .v = 0, .rgba = rgba }, + }); + const indices = try arena.dupe(u32, &.{ 0, 1, 2, 0, 2, 3 }); + const cmds = try arena.dupe(message.DrawCmd, &.{.{ .texture = 0, .index_offset = 0, .index_count = 6 }}); + return .{ .vertices = vertices, .indices = indices, .cmds = cmds }; +} + +test "the fill is one quad covering the view" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const list = try solidFill(arena_state.allocator(), 640, 480, fill_rgba); + try std.testing.expectEqual(@as(usize, 4), list.vertices.len); + try std.testing.expectEqual(@as(f32, 640), list.vertices[2].x); + try std.testing.expectEqual(@as(f32, 480), list.vertices[2].y); + try std.testing.expectEqual(@as(u32, 6), list.cmds[0].index_count); +} diff --git a/app/remote/keys.zig b/app/remote/keys.zig new file mode 100644 index 000000000..d35d5ce0f --- /dev/null +++ b/app/remote/keys.zig @@ -0,0 +1,115 @@ +//! A remote host's key and modifier codes as dvui's. `Key.code` is the physical key as a USB HID +//! usage (page 0x07), the numbering SDL's scancodes and most hosts' own tables share; a host +//! whose own codes differ maps them before it sends. `host_code` is the host's and not read here. +const std = @import("std"); +const dvui = @import("dvui"); +const message = @import("remote").message; + +/// The dvui key a HID usage names; `.unknown` for one fizzy has no key for. +pub fn fromHid(code: u32) dvui.enums.Key { + const K = dvui.enums.Key; + return switch (code) { + 4...29 => @enumFromInt(@intFromEnum(K.a) + (code - 4)), + 30...38 => @enumFromInt(@intFromEnum(K.one) + (code - 30)), + 39 => .zero, + 40 => .enter, + 41 => .escape, + 42 => .backspace, + 43 => .tab, + 44 => .space, + 45 => .minus, + 46 => .equal, + 47 => .left_bracket, + 48 => .right_bracket, + 49 => .backslash, + 51 => .semicolon, + 52 => .apostrophe, + 53 => .grave, + 54 => .comma, + 55 => .period, + 56 => .slash, + 57 => .caps_lock, + 58...69 => @enumFromInt(@intFromEnum(K.f1) + (code - 58)), + 70 => .print, + 71 => .scroll_lock, + 72 => .pause, + 73 => .insert, + 74 => .home, + 75 => .page_up, + 76 => .delete, + 77 => .end, + 78 => .page_down, + 79 => .right, + 80 => .left, + 81 => .down, + 82 => .up, + 83 => .num_lock, + 84 => .kp_divide, + 85 => .kp_multiply, + 86 => .kp_subtract, + 87 => .kp_add, + 88 => .kp_enter, + 89...97 => @enumFromInt(@intFromEnum(K.kp_1) + (code - 89)), + 98 => .kp_0, + 99 => .kp_decimal, + 101 => .menu, + 103 => .kp_equal, + 104...115 => @enumFromInt(@intFromEnum(K.f13) + (code - 104)), + 224 => .left_control, + 225 => .left_shift, + 226 => .left_alt, + 227 => .left_command, + 228 => .right_control, + 229 => .right_shift, + 230 => .right_alt, + 231 => .right_command, + else => .unknown, + }; +} + +/// The modifiers a host reports, as dvui's left-hand bits (a host does not say which side). +pub fn toMod(mods: message.Mods) dvui.enums.Mod { + var bits: u16 = 0; + if (mods.shift) bits |= @intFromEnum(dvui.enums.Mod.lshift); + if (mods.ctrl) bits |= @intFromEnum(dvui.enums.Mod.lcontrol); + if (mods.alt) bits |= @intFromEnum(dvui.enums.Mod.lalt); + if (mods.super) bits |= @intFromEnum(dvui.enums.Mod.lcommand); + return @enumFromInt(bits); +} + +/// 0 left, 1 middle, 2 right, then the extras. +pub fn button(n: u8) dvui.enums.Button { + return switch (n) { + 0 => .left, + 1 => .middle, + 2 => .right, + 3 => .four, + 4 => .five, + 5 => .six, + 6 => .seven, + else => .eight, + }; +} + +test "HID usages land on the keys they name" { + try std.testing.expectEqual(dvui.enums.Key.a, fromHid(4)); + try std.testing.expectEqual(dvui.enums.Key.z, fromHid(29)); + try std.testing.expectEqual(dvui.enums.Key.one, fromHid(30)); + try std.testing.expectEqual(dvui.enums.Key.nine, fromHid(38)); + try std.testing.expectEqual(dvui.enums.Key.zero, fromHid(39)); + try std.testing.expectEqual(dvui.enums.Key.f1, fromHid(58)); + try std.testing.expectEqual(dvui.enums.Key.f12, fromHid(69)); + try std.testing.expectEqual(dvui.enums.Key.f24, fromHid(115)); + try std.testing.expectEqual(dvui.enums.Key.kp_9, fromHid(97)); + try std.testing.expectEqual(dvui.enums.Key.up, fromHid(82)); + try std.testing.expectEqual(dvui.enums.Key.right_command, fromHid(231)); + try std.testing.expectEqual(dvui.enums.Key.unknown, fromHid(0)); +} + +test "modifiers become dvui's left-hand bits" { + const m = toMod(.{ .shift = true, .super = true }); + try std.testing.expect(m.has(.lshift)); + try std.testing.expect(m.has(.lcommand)); + try std.testing.expect(!m.has(.lcontrol)); + try std.testing.expectEqual(dvui.enums.Mod.none, toMod(.{})); +} diff --git a/app/remote/tests.zig b/app/remote/tests.zig new file mode 100644 index 000000000..ee7a4e89a --- /dev/null +++ b/app/remote/tests.zig @@ -0,0 +1,502 @@ +//! Remote views end to end: a `Session` over an in-memory link, the input that reaches a dvui +//! window, one body of calls run against a local host and a remote one, and a client on the real +//! control socket. +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("fizzy_sdk"); +const remote = @import("remote"); +const Link = @import("../control/Link.zig"); +const Server = @import("../control/Server.zig"); +const routes = @import("../control/routes.zig"); +const RemoteViewHost = @import("RemoteViewHost.zig"); +const Service = @import("Service.zig"); +const Session = @import("Session.zig"); +const ViewHost = @import("../view/ViewHost.zig"); + +const message = remote.message; +const Message = message.Message; + +fn noWake() void {} + +const hello_client: message.Hello = .{ .app_id = "blender", .tiers_offered = .{ .draw_list = true } }; +const hello_fizzy: message.Hello = .{ .app_id = "fizzy", .tiers_offered = .{ .draw_list = true }, .mode = .standalone }; + +/// A client with no socket: its bytes cross a `Link` and the session is pumped by hand. +const Harness = struct { + link: *Link, + session: *Session, + client: remote.Endpoint, + arena: std.heap.ArenaAllocator, + + fn init(self: *Harness) !void { + const gpa = std.testing.allocator; + const link = try gpa.create(Link); + link.* = .init(gpa, std.testing.io, noWake); + errdefer gpa.destroy(link); + self.* = .{ + .link = link, + .session = try Session.create(gpa, link, hello_fizzy), + .client = .init(gpa, .host, hello_client), + .arena = .init(gpa), + }; + try self.client.start(); + try self.step(); + const hello = (try self.next()).hello; + try std.testing.expectEqualStrings("fizzy", hello.app_id); + } + + fn deinit(self: *Harness) void { + self.client.deinit(); + self.arena.deinit(); + // The connection's tasks would set this; there are none. + self.link.finish(); + self.session.destroy(); + } + + /// Send what the client queued, pump the session, bring back what it answered. + fn step(self: *Harness) !void { + try self.link.push(self.client.pending()); + self.client.consume(self.client.pending().len); + _ = self.session.pump(true); + if (try self.link.drain()) |bytes| { + defer std.testing.allocator.free(bytes); + try self.client.feed(bytes); + } + } + + fn next(self: *Harness) !Message { + _ = self.arena.reset(.retain_capacity); + return (try self.client.poll(self.arena.allocator())) orelse error.NothingSent; + } + + fn silent(self: *Harness) !void { + _ = self.arena.reset(.retain_capacity); + try std.testing.expect(try self.client.poll(self.arena.allocator()) == null); + } + + fn openView(self: *Harness, id: message.ViewId, w: u32, h: u32) !*RemoteViewHost { + try self.client.send(.{ .create_view = .{ .view = id, .width = w, .height = h, .target = "Main" } }); + try self.step(); + try std.testing.expectEqual(id, (try self.next()).view_created.view); + return self.session.views.get(id).?; + } +}; + +test "a client opens a view and gets the frame it asks for" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + const v = try h.openView(1, 320, 200); + try std.testing.expectEqualStrings("Main", v.target); + + try h.client.send(.{ .frame_request = .{ .view = 1, .seq = 1 } }); + try h.step(); + const frame = (try h.next()).frame; + try std.testing.expectEqual(@as(u64, 1), frame.seq); + try std.testing.expectEqual(@as(u32, 320), frame.width); + try std.testing.expectEqual(message.Tier.draw_list, frame.tier); + try std.testing.expectEqual(@as(usize, 4), frame.draw.vertices.len); + try std.testing.expectEqual(@as(f32, 320), frame.draw.vertices[2].x); + + // Requests that pile up are answered once, by the newest. + try h.client.send(.{ .frame_request = .{ .view = 1, .seq = 2 } }); + try h.client.send(.{ .frame_request = .{ .view = 1, .seq = 3 } }); + try h.step(); + try std.testing.expectEqual(@as(u64, 3), (try h.next()).frame.seq); + try h.silent(); + + // Resized, the next frame has the new size. + try h.client.send(.{ .resize = .{ .view = 1, .width = 64, .height = 32 } }); + try h.client.send(.{ .frame_request = .{ .view = 1, .seq = 4 } }); + try h.step(); + try std.testing.expectEqual(@as(u32, 64), (try h.next()).frame.width); +} + +test "input is held for the frame, keys and text only while the view has focus" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + const v = try h.openView(1, 100, 100); + + try h.client.send(.{ .key = .{ .view = 1, .down = true, .host_code = 0, .code = 4 } }); + try h.client.send(.{ .text_input = .{ .view = 1, .text = "ignored" } }); + try h.client.send(.{ .pointer_motion = .{ .view = 1, .x = 1, .y = 2 } }); + try h.step(); + try std.testing.expectEqual(@as(usize, 1), v.input.items.len); + + try h.client.send(.{ .focus = .{ .view = 1, .focused = true } }); + try h.client.send(.{ .key = .{ .view = 1, .down = true, .host_code = 0, .code = 4 } }); + try h.client.send(.{ .text_input = .{ .view = 1, .text = "hi" } }); + try h.step(); + try std.testing.expectEqual(@as(usize, 3), v.input.items.len); + try std.testing.expectEqualStrings("hi", v.input.items[2].text); +} + +test "input reaches a window where the view lies, and not before it is placed" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + const v = try h.openView(1, 100, 100); + try h.client.send(.{ .focus = .{ .view = 1, .focused = true } }); + try h.client.send(.{ .pointer_button = .{ .view = 1, .button = 0, .pressed = true, .x = 5, .y = 6 } }); + try h.client.send(.{ .key = .{ .view = 1, .down = true, .host_code = 0, .code = 4 } }); + try h.client.send(.{ .text_input = .{ .view = 1, .text = "hi" } }); + try h.step(); + + var t = try dvui.testing.init(.{}); + defer t.deinit(); + try v.inject(t.window); + for (t.window.events.items) |e| try std.testing.expect(e.evt == .mouse and e.evt.mouse.action == .position); + try std.testing.expectEqual(@as(usize, 0), v.input.items.len); + + try h.client.send(.{ .pointer_button = .{ .view = 1, .button = 0, .pressed = true, .x = 5, .y = 6 } }); + try h.client.send(.{ .key = .{ .view = 1, .down = true, .host_code = 0, .code = 4 } }); + try h.client.send(.{ .text_input = .{ .view = 1, .text = "hi" } }); + try h.step(); + v.place(.{ .x = 30, .y = 40 }); + try v.inject(t.window); + var pressed_at: ?dvui.Point.Physical = null; + var key: ?dvui.enums.Key = null; + var text: []const u8 = ""; + for (t.window.events.items) |e| switch (e.evt) { + .mouse => |m| if (m.action == .press) { + pressed_at = m.p; + }, + .key => |k| key = k.code, + .text => |tx| text = tx.action.value.txt, + else => {}, + }; + try std.testing.expectEqual(@as(f32, 35), pressed_at.?.x); + try std.testing.expectEqual(@as(f32, 46), pressed_at.?.y); + try std.testing.expectEqual(dvui.enums.Key.a, key.?); + try std.testing.expectEqualStrings("hi", text); +} + +test "closing a view tells the client, and the host is gone from the session" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + _ = try h.openView(1, 100, 100); + try h.client.send(.{ .close_view = .{ .view = 1 } }); + try h.step(); + const closed = (try h.next()).view_closed; + try std.testing.expectEqual(@as(u32, 1), closed.view); + try std.testing.expectEqual(message.ViewClosedReason.closed, closed.reason); + try std.testing.expectEqual(@as(usize, 0), h.session.views.count()); +} + +test "a view the app cannot serve is refused, not left half open" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + try h.client.send(.{ .create_view = .{ .view = 1, .width = 10, .height = 10, .tier = .shared_texture } }); + try h.client.send(.{ .create_view = .{ .view = 2, .width = 0, .height = 10 } }); + try h.step(); + try std.testing.expectEqual(message.ViewClosedReason.unsupported_tier, (try h.next()).view_closed.reason); + try std.testing.expectEqual(message.ViewClosedReason.create_failed, (try h.next()).view_closed.reason); + try std.testing.expectEqual(@as(usize, 0), h.session.views.count()); + // Ids are free to use again. + _ = try h.openView(1, 10, 10); +} + +test "a protocol violation ends the connection with a bye" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + _ = try h.openView(1, 10, 10); + try h.link.push(&.{ 1, 0, 0, 0 }); + _ = h.session.pump(true); + try std.testing.expect(h.session.dead); + try std.testing.expect(h.link.closing.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), h.session.views.count()); + const out = (try h.link.drain()).?; + defer std.testing.allocator.free(out); + try h.client.feed(out); + try std.testing.expectEqual(message.CloseReason.protocol_error, (try h.next()).bye.reason); +} + +/// What an app does with a view's host each frame, through the seam alone. +fn editorFacing(host: ViewHost, w: f32, h: f32) !void { + const first = host.begin().?; + try std.testing.expectEqual(w, first.w); + try std.testing.expectEqual(h, first.h); + try std.testing.expect(host.begin() == null); + try std.testing.expectEqual(w, host.surface().w); + try std.testing.expect(!host.closeRequested()); + try std.testing.expect(!host.space().covers); + try std.testing.expect(host.materialBase(.{ .r = 1, .g = 2, .b = 3, .a = 255 }, 1) == null); + host.end(); +} + +test "one editor-facing body runs against a local host and a remote one" { + const Local = struct { + moved: bool = true, + pub fn begin(l: *@This()) ?dvui.Rect.Physical { + defer l.moved = false; + return if (l.moved) surface(l) else null; + } + pub fn end(_: *@This()) void {} + pub fn surface(_: *@This()) dvui.Rect.Physical { + return .{ .w = 100, .h = 50 }; + } + pub fn space(_: *@This()) ViewHost.Space { + return .{}; + } + pub fn materialBase(_: *@This(), _: dvui.Color, _: f32) ?dvui.Color { + return null; + } + pub fn closeRequested(_: *@This()) bool { + return false; + } + }; + var local: Local = .{}; + try editorFacing(.{ .ptr = &local, .vtable = ViewHost.vtableOf(Local) }, 100, 50); + + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + const v = try h.openView(1, 100, 50); + try editorFacing(v.viewHost(), 100, 50); + // The seam sees the client's close. + try h.client.send(.{ .close_view = .{ .view = 1 } }); + try h.step(); + try std.testing.expectEqual(@as(usize, 0), h.session.views.count()); +} + +/// A client on the real socket: blocking, on a thread of its own, as an adapter is. +const Peer = struct { + ep: remote.Endpoint, + stream: std.Io.net.Stream, + reader: std.Io.net.Stream.Reader, + writer: std.Io.net.Stream.Writer, + rbuf: [4096]u8 = undefined, + wbuf: [4096]u8 = undefined, + arena: std.heap.ArenaAllocator, + + const request = "GET /remote HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: " ++ Service.upgrade_token ++ "\r\n\r\n"; + + fn open(self: *Peer, path: []const u8) !void { + const io = std.testing.io; + const gpa = std.testing.allocator; + self.stream = try (try std.Io.net.UnixAddress.init(path)).connect(io); + errdefer self.stream.close(io); + self.reader = self.stream.reader(io, &self.rbuf); + self.writer = self.stream.writer(io, &self.wbuf); + self.ep = .init(gpa, .host, hello_client); + errdefer self.ep.deinit(); + self.arena = .init(gpa); + try self.writer.interface.writeAll(request); + try self.writer.interface.flush(); + var head: [512]u8 = undefined; + var n: usize = 0; + while (!std.mem.endsWith(u8, head[0..n], "\r\n\r\n")) { + head[n] = try self.reader.interface.takeByte(); + n += 1; + } + if (!std.mem.startsWith(u8, head[0..n], "HTTP/1.1 101 ")) return error.NotUpgraded; + try self.ep.start(); + try self.flush(); + try std.testing.expectEqualStrings("fizzy", (try self.next()).hello.app_id); + } + + fn close(self: *Peer) void { + self.ep.deinit(); + self.arena.deinit(); + self.stream.close(std.testing.io); + } + + fn flush(self: *Peer) !void { + try self.writer.interface.writeAll(self.ep.pending()); + try self.writer.interface.flush(); + self.ep.consume(self.ep.pending().len); + } + + fn send(self: *Peer, msg: Message) !void { + try self.ep.send(msg); + try self.flush(); + } + + /// Blocks until the editor has said something. + fn next(self: *Peer) !Message { + _ = self.arena.reset(.retain_capacity); + while (true) { + if (try self.ep.poll(self.arena.allocator())) |m| return m; + const got = try self.reader.interface.peekGreedy(1); + try self.ep.feed(got); + self.reader.interface.toss(got.len); + } + } +}; + +const Client = struct { + path: []const u8, + /// How far the script has got, for the test to wait on. + stage: std.atomic.Value(u32) = .init(0), + failed: std.atomic.Value(bool) = .init(false), + frame_seq: u64 = 0, + frame_width: u32 = 0, + + fn run(self: *Client, drop: bool) void { + self.play(drop) catch |err| { + std.log.warn("remote test client: {s}", .{@errorName(err)}); + self.failed.store(true, .release); + }; + self.stage.store(99, .release); + } + + fn play(self: *Client, drop: bool) !void { + var peer: Peer = undefined; + try peer.open(self.path); + defer peer.close(); + try peer.send(.{ .create_view = .{ .view = 1, .width = 320, .height = 200, .target = "Main" } }); + const created = (try peer.next()).view_created; + if (created.view != 1 or created.tier != .draw_list) return error.WrongView; + try peer.send(.{ .focus = .{ .view = 1, .focused = true } }); + try peer.send(.{ .pointer_motion = .{ .view = 1, .x = 3, .y = 4 } }); + try peer.send(.{ .pointer_button = .{ .view = 1, .button = 0, .pressed = true, .x = 3, .y = 4 } }); + try peer.send(.{ .key = .{ .view = 1, .down = true, .host_code = 0, .code = 4 } }); + try peer.send(.{ .text_input = .{ .view = 1, .text = "hi" } }); + try peer.send(.{ .frame_request = .{ .view = 1, .seq = 1 } }); + const frame = (try peer.next()).frame; + self.frame_seq = frame.seq; + self.frame_width = frame.width; + self.stage.store(1, .release); + if (drop) { + // Gone with a frame asked for and unread. + try peer.send(.{ .frame_request = .{ .view = 1, .seq = 2 } }); + return; + } + try peer.send(.{ .close_view = .{ .view = 1 } }); + _ = (try peer.next()).view_closed; + } +}; + +test "a remote client opens a view, sends input, gets frames, and leaves without hurting the app" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + var state: routes.State = .{}; + var stopped = false; + defer if (!stopped) routes.stop(&server, &state, &host); + + var first: Client = .{ .path = path }; + const t1 = try std.Thread.spawn(.{}, Client.run, .{ &first, true }); + var frames: usize = 0; + while (first.stage.load(.acquire) < 1 and frames < 5000) : (frames += 1) { + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expect(!first.failed.load(.acquire)); + try std.testing.expectEqual(@as(u64, 1), first.frame_seq); + try std.testing.expectEqual(@as(u32, 320), first.frame_width); + + // The app lists the view, and its input waits for the frame. + const remote_service = &state.remote.?; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const listed = host.callCommand("fizzy.remote.views", "", arena_state.allocator()); + try std.testing.expect(std.mem.find(u8, listed.ok.?, ".target = \"Main\"") != null); + try std.testing.expect(std.mem.find(u8, listed.ok.?, ".width = 320") != null); + var all: std.ArrayListUnmanaged(*RemoteViewHost) = .empty; + defer all.deinit(gpa); + try remote_service.views(&all); + try std.testing.expectEqual(@as(usize, 1), all.items.len); + try std.testing.expectEqual(@as(usize, 4), all.items[0].input.items.len); + + // It drops the connection with a frame asked for: the session and its view go, the app carries on. + frames = 0; + while (remote_service.sessions.items.len > 0 and frames < 5000) : (frames += 1) { + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + t1.join(); + try std.testing.expectEqual(@as(usize, 0), remote_service.sessions.items.len); + try std.testing.expect(std.mem.find(u8, host.callCommand("fizzy.remote.views", "", arena_state.allocator()).ok.?, "view") == null); + + // A second client is served as if the first had never been. + var second: Client = .{ .path = path }; + const t2 = try std.Thread.spawn(.{}, Client.run, .{ &second, false }); + frames = 0; + while (second.stage.load(.acquire) < 99 and frames < 5000) : (frames += 1) { + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + t2.join(); + try std.testing.expect(!second.failed.load(.acquire)); + try std.testing.expectEqual(@as(u64, 1), second.frame_seq); + + // Stopping closes what is still open. + routes.stop(&server, &state, &host); + stopped = true; +} + +/// One plain request on the socket; the status line is what the test reads. +fn statusOf(path: []const u8, request: []const u8, out: []u8) ![]const u8 { + const io = std.testing.io; + const stream = try (try std.Io.net.UnixAddress.init(path)).connect(io); + defer stream.close(io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(io, &wbuf); + try w.interface.writeAll(request); + try w.interface.flush(); + var rbuf: [256]u8 = undefined; + var r = stream.reader(io, &rbuf); + var n: usize = 0; + while (n < out.len and !std.mem.endsWith(u8, out[0..n], "\r\n\r\n")) : (n += 1) out[n] = try r.interface.takeByte(); + return out[0 .. std.mem.findScalar(u8, out[0..n], '\r') orelse n]; +} + +test "GET /remote needs the upgrade, and only GET" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + var state: routes.State = .{}; + defer routes.stop(&server, &state, &host); + + const Ask = struct { + path: []const u8, + request: []const u8, + status: [128]u8 = undefined, + len: usize = 0, + fn run(self: *@This()) void { + var buf: [512]u8 = undefined; + const line = statusOf(self.path, self.request, &buf) catch return; + @memcpy(self.status[0..line.len], line); + self.len = line.len; + } + }; + var plain: Ask = .{ .path = path, .request = "GET /remote HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" }; + var post: Ask = .{ .path = path, .request = "POST /remote HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" }; + const ta = try std.Thread.spawn(.{}, Ask.run, .{&plain}); + const tb = try std.Thread.spawn(.{}, Ask.run, .{&post}); + var frames: usize = 0; + while ((plain.len == 0 or post.len == 0) and frames < 5000) : (frames += 1) { + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + ta.join(); + tb.join(); + try std.testing.expectEqualStrings("HTTP/1.1 426 Upgrade Required", plain.status[0..plain.len]); + try std.testing.expectEqualStrings("HTTP/1.1 405 Method Not Allowed", post.status[0..post.len]); + try std.testing.expect(state.remote == null); +} diff --git a/app/root.zig b/app/root.zig index 4fb7895d4..c969bcc37 100644 --- a/app/root.zig +++ b/app/root.zig @@ -127,6 +127,13 @@ comptime { _ = &mcp.handle; } +/// Views of the app shown by another process, over the control socket (`GET /remote`): the +/// service the control routes feed and the `ViewHost` a remote view is. Native only. +pub const remote = struct { + pub const Service = @import("remote/Service.zig"); + pub const RemoteViewHost = @import("remote/RemoteViewHost.zig"); +}; + /// The control server: HTTP on a Unix socket in the config folder, the app's commands as its API, /// so `curl --unix-socket` drives the app with no agent (plans/CONTROL_SERVER_PLAN.md). Native /// only. The app starts a `Server`, calls `routes.answerPending` each frame and stops it at quit. @@ -222,6 +229,7 @@ test { _ = HistoryTransport; _ = dome; _ = control.routes; + _ = @import("remote/tests.zig"); _ = HistoryFrames; _ = Recents; _ = @import("automation/Calm.zig"); diff --git a/build/sdk.zig b/build/sdk.zig index 0eed56a13..509391408 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -115,6 +115,9 @@ pub fn wireAppModule( app_module.addImport("tape", core_module.import_table.get("tape").?); app_module.addImport("replay", core_module.import_table.get("replay").?); app_module.addImport("crash", crashModule(b, target, optimize)); + // The remote-view protocol (`sdk/remote/`): std-only, so a host application's adapter links the + // same files. + app_module.addImport("remote", b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("sdk/remote/root.zig") })); if (consumer) |c| { c.addImport("app", app_module); // The bundled demos' scripts (`src/editor/demos/`) need only `tape`, so they build on the diff --git a/docs/CONTROL.md b/docs/CONTROL.md index 977e6ba8b..879ca6432 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -59,6 +59,7 @@ fizzy with its own Windows target must set it too, or its server logs that it ca | `GET /openapi.json` | all of this as OpenAPI 3.1, generated from what is registered now | | `POST /mcp` | the Model Context Protocol, its tools the commands | | `DELETE /mcp` | ends the MCP session its `Mcp-Session-Id` names | +| `GET /remote` | upgrades to the remote-view protocol: a client opens views of the app and gets frames | **Bodies are JSON**, converted to the ZON commands take at the server's edge: an object of arguments keyed by the command's parameter names, a string where the parameter is an enumeration @@ -176,6 +177,26 @@ session that is not open gets `404`, and the client initializes again. A session `DELETE /mcp`, after an hour with no request, or when the server stops, and `fizzy.mcp.session_ended` (`.{ .session, .label, .plugin }`) says so. +## Remote views + +`GET /remote` with `Connection: Upgrade` and `Upgrade: fizzy-remote-view/1` answers `101 +Switching Protocols`, and the connection then carries the remote-view protocol (`sdk/remote/`, +its README): the client says hello, creates views, sends pointer, key and text input, and asks for +frames; fizzy answers each request from the UI thread. Without the `Upgrade` header the answer is +`426`, with another method `405`. The socket is the control socket, so the same rules apply: mode +`0600`, the same user. + +Each view is a `RemoteViewHost` (`app/remote/`), a window like a pop-out that the OS never shows. +Keys and text reach a view only while the client says it has focus. A view the client closes, or +that goes with a dropped connection, closes its window; fizzy carries on. The only tier served +so far is the draw list, and its frame is a stub, one quad filling the view. + +`fizzy.remote.views` (internal) lists the open views: `.connection`, `.view`, `.kind`, `.target`, +`.width`, `.height`, `.focused`. It exists once a client has connected. + +The frame loop does not draw remote views yet: until it does, input waits in the host and a frame +request is answered with the stub's fill (`app/remote/Service.zig` lists the calls an app makes). + ## The description `GET /openapi.json` describes the server as it is now, so it changes as plugins load and reload. From 214b959d795cf0e40350a7369b96f230c2b505c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:50:22 +0000 Subject: [PATCH 2/5] app: harden the remote-view link after review Fixes from reviewing the diff: a double free of the link when accepting it ran out of memory; a close that could be missed by the writer task; the link read after the app could free it; a slow peer that could not be cut off (the link now shuts its socket down on overflow, and the stop path shuts connections down before waiting on them); an unbounded inbox. Input past the queue's cap now drops motion, never a press, a release or a key-up, and a zero-sized resize is ignored. Part of fizzyedit/work#40 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016mgAaDN3ndpHLtmoA72ZLQ --- app/control/Link.zig | 23 ++++++++++++++++++++--- app/control/Server.zig | 10 ++++++++++ app/control/routes.zig | 5 +++-- app/remote/RemoteViewHost.zig | 26 ++++++++++++++++++++++---- app/remote/Service.zig | 5 +++-- app/remote/keys.zig | 20 ++++++++++---------- app/remote/tests.zig | 19 +++++++++++++++++++ 7 files changed, 87 insertions(+), 21 deletions(-) diff --git a/app/control/Link.zig b/app/control/Link.zig index 5ba5ef2a1..f57b9d165 100644 --- a/app/control/Link.zig +++ b/app/control/Link.zig @@ -28,6 +28,8 @@ closing: std.atomic.Value(bool) = .init(false), /// The connection's tasks are done with the link: the peer left, or `close` was answered. Only /// then may the app `deinit` it; the tasks touch nothing after setting this. gone: std.atomic.Value(bool) = .init(false), +/// The connection's socket, once its task has one, under `lock`: `abort` shuts it down. +stream: ?Io.net.Stream = null, pub fn init(gpa: std.mem.Allocator, io: Io, wake: *const fn () void) Link { return .{ .gpa = gpa, .io = io, .wake = wake }; @@ -59,7 +61,7 @@ pub fn send(self: *Link, bytes: []const u8) error{OutOfMemory}!void { return; } } - self.close(); + self.abort(); } /// End the connection once what is queued is written. UI thread. @@ -68,15 +70,26 @@ pub fn close(self: *Link) void { self.out_ready.set(self.io); } +/// End the connection now, dropping what is queued: a peer that does not read would otherwise +/// leave the writer blocked and `close` unheard. UI thread. +pub fn abort(self: *Link) void { + self.close(); + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + if (self.stream) |s| s.shutdown(self.io, .both) catch {}; +} + pub fn isGone(self: *const Link) bool { return self.gone.load(.acquire); } -/// From the connection's reader task: bytes arrived. +/// From the connection's reader task: bytes arrived. A peer that sends more than the app takes +/// is refused the rest (`error.OutOfMemory`, which ends the connection). pub fn push(self: *Link, bytes: []const u8) error{OutOfMemory}!void { { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); + if (self.inbox.items.len + bytes.len > max_backlog) return error.OutOfMemory; try self.inbox.appendSlice(self.gpa, bytes); } self.wake(); @@ -88,14 +101,18 @@ pub fn drain(self: *Link) error{OutOfMemory}!?[]u8 { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); self.out_ready.reset(); + // A `close` that came after the caller read `closing` set the event before this reset. + if (self.closing.load(.acquire)) self.out_ready.set(self.io); if (self.outbox.items.len == 0) return null; return try self.outbox.toOwnedSlice(self.gpa); } /// From the connection's task, last: it will not touch the link again. pub fn finish(self: *Link) void { + // Read first: the app frees the link once it sees `gone`. + const wake = self.wake; self.gone.store(true, .release); - self.wake(); + wake(); } fn noWake() void {} diff --git a/app/control/Server.zig b/app/control/Server.zig index c2a194db4..a385fb326 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -561,6 +561,16 @@ fn headerValue(head: *std.http.Server.Request, arena: std.mem.Allocator, name: [ /// link, or the server stops; the link is finished last, whichever, and not touched after. fn serveLink(self: *Server, link: *Link, stream: Io.net.Stream, reader: *Io.Reader, writer: *Io.Writer, token: []const u8) void { defer link.finish(); + link.lock.lockUncancelable(self.io); + link.stream = stream; + link.lock.unlock(self.io); + // Cleared before `finish`, under the lock `abort` shuts the socket down in: the connection + // closes it as soon as this returns. + defer { + link.lock.lockUncancelable(self.io); + link.stream = null; + link.lock.unlock(self.io); + } writer.print("HTTP/1.1 101 Switching Protocols\r\nconnection: Upgrade\r\nupgrade: {s}\r\n\r\n", .{token}) catch return; writer.flush() catch return; var sender = self.io.concurrent(writeLink, .{ self, link, stream, writer }) catch return; diff --git a/app/control/routes.zig b/app/control/routes.zig index 3d46b5153..e2f7b7c4a 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -115,13 +115,14 @@ pub fn stop(server: *Server, state: *State, host: *sdk.Host) void { state.sessions.endAll(server.gpa, host); state.sessions.deinit(server.gpa); state.sessions = .{}; + state.follow(host, server, false); + server.stop(); + // After the server: stopping it shuts every connection down, so no link is left blocked. if (state.remote) |*remote| { remote.closeAll(); remote.deinit(host); state.remote = null; } - state.follow(host, server, false); - server.stop(); } /// `GET /remote`: the connection becomes the remote-view protocol's stream (`Service`). diff --git a/app/remote/RemoteViewHost.zig b/app/remote/RemoteViewHost.zig index 945cfc459..1db633ed9 100644 --- a/app/remote/RemoteViewHost.zig +++ b/app/remote/RemoteViewHost.zig @@ -26,7 +26,8 @@ pub const Input = union(enum) { text: []u8, }; -/// A client sending faster than frames run drops the rest rather than grow without end. +/// A client sending faster than frames run loses motion past this, and everything past twice it, +/// rather than grow without end. pub const max_queued = 1024; gpa: std.mem.Allocator, @@ -88,6 +89,8 @@ pub fn place(self: *RemoteViewHost, origin: dvui.Point.Physical) void { } pub fn resize(self: *RemoteViewHost, width: u32, height: u32, scale: f32) void { + // A view has a size (`create_view` refuses one without). + if (width == 0 or height == 0) return; if (width != self.width or height != self.height or scale != self.scale) self.moved = true; self.width = width; self.height = height; @@ -124,13 +127,28 @@ pub fn closeRequested(self: *RemoteViewHost) bool { return self.close_asked; } -/// Hold `item` until `inject`. Keys and text are the focused view's alone. +/// Hold `item` until `inject`. Keys and text are the focused view's alone, but a key's release is +/// always kept: dropped, the key would stay held. Past `max_queued`, motion is what gives way (a +/// run of it keeps only the last); a press, release or key never does. pub fn queue(self: *RemoteViewHost, item: Input) error{OutOfMemory}!void { const wanted = switch (item) { - .key, .text => self.focused, + .key => |k| self.focused or !k.down, + .text => self.focused, else => true, }; - if (!wanted or self.input.items.len >= max_queued) { + if (!wanted) { + if (item == .text) self.gpa.free(item.text); + return; + } + if (item == .motion) { + const last = self.input.items.len; + if (last > 0 and self.input.items[last - 1] == .motion) { + self.input.items[last - 1] = item; + return; + } + if (last >= max_queued) return; + } + if (self.input.items.len >= 2 * max_queued) { if (item == .text) self.gpa.free(item.text); return; } diff --git a/app/remote/Service.zig b/app/remote/Service.zig index 286066a14..c326387a0 100644 --- a/app/remote/Service.zig +++ b/app/remote/Service.zig @@ -50,10 +50,11 @@ pub fn deinit(self: *Service, host: *sdk.Host) void { /// once the connection's tasks are done with it. pub fn accept(self: *Service, host: *sdk.Host, link: *Link) error{OutOfMemory}!void { try self.ensureCommand(host); + // The link is the caller's until this succeeds: nothing after the session fails. + try self.sessions.ensureUnusedCapacity(self.gpa, 1); const session = try Session.create(self.gpa, link, self.hello); - errdefer session.destroy(); session.id = self.next_session; - try self.sessions.append(self.gpa, session); + self.sessions.appendAssumeCapacity(session); self.next_session += 1; } diff --git a/app/remote/keys.zig b/app/remote/keys.zig index d35d5ce0f..ddd0e1141 100644 --- a/app/remote/keys.zig +++ b/app/remote/keys.zig @@ -9,8 +9,8 @@ const message = @import("remote").message; pub fn fromHid(code: u32) dvui.enums.Key { const K = dvui.enums.Key; return switch (code) { - 4...29 => @enumFromInt(@intFromEnum(K.a) + (code - 4)), - 30...38 => @enumFromInt(@intFromEnum(K.one) + (code - 30)), + 4...29 => @fromBackingInt(@intCast(@backingInt(K.a) + (code - 4))), + 30...38 => @fromBackingInt(@intCast(@backingInt(K.one) + (code - 30))), 39 => .zero, 40 => .enter, 41 => .escape, @@ -29,7 +29,7 @@ pub fn fromHid(code: u32) dvui.enums.Key { 55 => .period, 56 => .slash, 57 => .caps_lock, - 58...69 => @enumFromInt(@intFromEnum(K.f1) + (code - 58)), + 58...69 => @fromBackingInt(@intCast(@backingInt(K.f1) + (code - 58))), 70 => .print, 71 => .scroll_lock, 72 => .pause, @@ -49,12 +49,12 @@ pub fn fromHid(code: u32) dvui.enums.Key { 86 => .kp_subtract, 87 => .kp_add, 88 => .kp_enter, - 89...97 => @enumFromInt(@intFromEnum(K.kp_1) + (code - 89)), + 89...97 => @fromBackingInt(@intCast(@backingInt(K.kp_1) + (code - 89))), 98 => .kp_0, 99 => .kp_decimal, 101 => .menu, 103 => .kp_equal, - 104...115 => @enumFromInt(@intFromEnum(K.f13) + (code - 104)), + 104...115 => @fromBackingInt(@intCast(@backingInt(K.f13) + (code - 104))), 224 => .left_control, 225 => .left_shift, 226 => .left_alt, @@ -70,11 +70,11 @@ pub fn fromHid(code: u32) dvui.enums.Key { /// The modifiers a host reports, as dvui's left-hand bits (a host does not say which side). pub fn toMod(mods: message.Mods) dvui.enums.Mod { var bits: u16 = 0; - if (mods.shift) bits |= @intFromEnum(dvui.enums.Mod.lshift); - if (mods.ctrl) bits |= @intFromEnum(dvui.enums.Mod.lcontrol); - if (mods.alt) bits |= @intFromEnum(dvui.enums.Mod.lalt); - if (mods.super) bits |= @intFromEnum(dvui.enums.Mod.lcommand); - return @enumFromInt(bits); + if (mods.shift) bits |= @backingInt(dvui.enums.Mod.lshift); + if (mods.ctrl) bits |= @backingInt(dvui.enums.Mod.lcontrol); + if (mods.alt) bits |= @backingInt(dvui.enums.Mod.lalt); + if (mods.super) bits |= @backingInt(dvui.enums.Mod.lcommand); + return @fromBackingInt(@intCast(bits)); } /// 0 left, 1 middle, 2 right, then the extras. diff --git a/app/remote/tests.zig b/app/remote/tests.zig index ee7a4e89a..0741c9b0d 100644 --- a/app/remote/tests.zig +++ b/app/remote/tests.zig @@ -500,3 +500,22 @@ test "GET /remote needs the upgrade, and only GET" { try std.testing.expectEqualStrings("HTTP/1.1 405 Method Not Allowed", post.status[0..post.len]); try std.testing.expect(state.remote == null); } + +test "a flood of motion keeps the last, and a release survives a full queue" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + const v = try h.openView(1, 100, 100); + try h.client.send(.{ .focus = .{ .view = 1, .focused = true } }); + try h.client.send(.{ .key = .{ .view = 1, .down = true, .host_code = 0, .code = 4 } }); + try h.client.send(.{ .focus = .{ .view = 1, .focused = false } }); + for (0..4) |i| try h.client.send(.{ .pointer_motion = .{ .view = 1, .x = @floatFromInt(i), .y = 0 } }); + try h.client.send(.{ .key = .{ .view = 1, .down = false, .host_code = 0, .code = 4 } }); + try h.client.send(.{ .resize = .{ .view = 1, .width = 0, .height = 0 } }); + try h.step(); + // The key down, one motion (the last), the key up although focus had left. + try std.testing.expectEqual(@as(usize, 3), v.input.items.len); + try std.testing.expectEqual(@as(f32, 3), v.input.items[1].motion.x); + try std.testing.expect(!v.input.items[2].key.down); + try std.testing.expectEqual(@as(u32, 100), v.width); +} From bad012f48f81abdb0993511fc8d33722f5039f4c Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 12:49:02 -0500 Subject: [PATCH 3/5] app: bound remote frames, queued bytes and sessions The control link's backlog (8 MiB) sat below the protocol's 32 MiB frame cap, so a legal large frame dropped the connection; it is now 64 MiB, asserted at comptime against the cap. A view's queued text is capped at 1 MiB, a connection holds at most 64 views, and the service at 8 connections (a ninth gets 503). A duplicate view id, which the protocol layer already ends the stream for, is guarded in the session without a view_closed that would make the client forget the open view. Co-Authored-By: Claude Sonnet 5.5 --- app/control/Link.zig | 28 +++++++++++++++++++++++++--- app/control/routes.zig | 3 ++- app/remote/RemoteViewHost.zig | 15 ++++++++++++--- app/remote/Service.zig | 6 +++++- app/remote/Session.zig | 19 +++++++++++++++++++ app/remote/tests.zig | 17 +++++++++++++++++ 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/app/control/Link.zig b/app/control/Link.zig index f57b9d165..b905d35a6 100644 --- a/app/control/Link.zig +++ b/app/control/Link.zig @@ -9,9 +9,11 @@ const Io = std.Io; const Link = @This(); -/// The most that may wait to be sent: a peer that reads slower than the app writes is cut off -/// rather than buffered without end. -pub const max_backlog = 8 * 1024 * 1024; +/// The most that may wait on either side: a peer that reads slower than the app writes, or sends +/// faster than the app takes, is cut off rather than buffered without end. It must hold a whole +/// frame of the remote-view protocol (32 MiB) with room for the traffic behind it; this file is +/// std-only, so `remote/Session.zig` asserts that at comptime. +pub const max_backlog = 64 * 1024 * 1024; gpa: std.mem.Allocator, io: Io, @@ -142,3 +144,23 @@ test "bytes cross in both directions, and a link too far behind is closed" { try std.testing.expect(link.closing.load(.acquire)); link.outbox.clearRetainingCapacity(); } + +test "a frame between 8 and 32 MiB crosses in both directions" { + const gpa = std.testing.allocator; + var link: Link = .init(gpa, std.testing.io, noWake); + defer link.deinit(); + const frame = try gpa.alloc(u8, 20 * 1024 * 1024); + defer gpa.free(frame); + @memset(frame, 7); + try link.push(frame); + var got: std.ArrayListUnmanaged(u8) = .empty; + defer got.deinit(gpa); + try link.take(&got); + try std.testing.expectEqual(frame.len, got.items.len); + + try link.send(frame); + try std.testing.expect(!link.closing.load(.acquire)); + const out = (try link.drain()).?; + defer gpa.free(out); + try std.testing.expectEqual(frame.len, out.len); +} diff --git a/app/control/routes.zig b/app/control/routes.zig index e2f7b7c4a..14f5af0d1 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -140,8 +140,9 @@ fn upgradeRemote(server: *Server, state: *State, host: *sdk.Host, req: *Server.R .tiers_offered = .{ .draw_list = true }, .mode = .standalone, }); - state.remote.?.accept(host, link) catch { + state.remote.?.accept(host, link) catch |err| { server.gpa.destroy(link); + if (err == error.TooManySessions) return req.answer(server, .service_unavailable, "application/json", "{\"error\":\"too many remote clients\"}"); return req.answer(server, .internal_server_error, "application/json", oom); }; req.answerUpgrade(server, link); diff --git a/app/remote/RemoteViewHost.zig b/app/remote/RemoteViewHost.zig index 1db633ed9..2d4c72dbb 100644 --- a/app/remote/RemoteViewHost.zig +++ b/app/remote/RemoteViewHost.zig @@ -26,10 +26,13 @@ pub const Input = union(enum) { text: []u8, }; -/// A client sending faster than frames run loses motion past this, and everything past twice it, -/// rather than grow without end. +/// A client sending faster than frames run loses motion past this, and everything past twice it +/// (and text past `max_text_bytes`), rather than grow without end. pub const max_queued = 1024; +/// The most text that may wait for a frame, in bytes; past it, text is dropped. +pub const max_text_bytes = 1024 * 1024; + gpa: std.mem.Allocator, session: *Session, id: message.ViewId, @@ -53,6 +56,8 @@ close_asked: bool = false, wanted: u64 = 0, answered: u64 = 0, input: std.ArrayListUnmanaged(Input) = .empty, +/// The bytes of the `text` items in `input`. +text_bytes: usize = 0, pub fn create(gpa: std.mem.Allocator, session: *Session, cv: message.CreateView) error{OutOfMemory}!*RemoteViewHost { const self = try gpa.create(RemoteViewHost); @@ -148,11 +153,14 @@ pub fn queue(self: *RemoteViewHost, item: Input) error{OutOfMemory}!void { } if (last >= max_queued) return; } - if (self.input.items.len >= 2 * max_queued) { + if (self.input.items.len >= 2 * max_queued or + (item == .text and self.text_bytes + item.text.len > max_text_bytes)) + { if (item == .text) self.gpa.free(item.text); return; } try self.input.append(self.gpa, item); + if (item == .text) self.text_bytes += item.text.len; } /// Hand what is waiting to `win` as dvui events, at this view's place in the frame, before the @@ -187,4 +195,5 @@ pub fn inject(self: *RemoteViewHost, win: *dvui.Window) error{OutOfMemory}!void fn clearInput(self: *RemoteViewHost) void { for (self.input.items) |item| if (item == .text) self.gpa.free(item.text); self.input.clearRetainingCapacity(); + self.text_bytes = 0; } diff --git a/app/remote/Service.zig b/app/remote/Service.zig index c326387a0..9c9136585 100644 --- a/app/remote/Service.zig +++ b/app/remote/Service.zig @@ -21,6 +21,9 @@ const Service = @This(); /// The `Upgrade` token of `GET /remote`, with the protocol's major version. pub const upgrade_token = "fizzy-remote-view/1"; +/// The most connections at once: each may hold `Link.max_backlog` in either direction. +pub const max_sessions = 8; + gpa: std.mem.Allocator, /// What fizzy says first to each client. hello: remote.message.Hello, @@ -48,7 +51,8 @@ pub fn deinit(self: *Service, host: *sdk.Host) void { /// Take a connection the control server upgraded. The link is the service's from here: it frees it /// once the connection's tasks are done with it. -pub fn accept(self: *Service, host: *sdk.Host, link: *Link) error{OutOfMemory}!void { +pub fn accept(self: *Service, host: *sdk.Host, link: *Link) error{ OutOfMemory, TooManySessions }!void { + if (self.sessions.items.len >= max_sessions) return error.TooManySessions; try self.ensureCommand(host); // The link is the caller's until this succeeds: nothing after the session fails. try self.sessions.ensureUnusedCapacity(self.gpa, 1); diff --git a/app/remote/Session.zig b/app/remote/Session.zig index 943b29805..cb056b44e 100644 --- a/app/remote/Session.zig +++ b/app/remote/Session.zig @@ -5,6 +5,14 @@ const std = @import("std"); const remote = @import("remote"); const Link = @import("../control/Link.zig"); + +/// The most views one connection may hold open. +pub const max_views = 64; + +comptime { + // A link must hold a whole protocol frame (`Link.max_backlog`). + std.debug.assert(Link.max_backlog >= remote.codec.default_max_frame); +} const RemoteViewHost = @import("RemoteViewHost.zig"); const encoder = @import("encoder.zig"); @@ -152,6 +160,17 @@ fn createView(self: *Session, cv: message.CreateView) error{OutOfMemory}!void { self.refuse(cv.view, .create_failed, "a view has a size"); return; } + if (self.views.contains(cv.view)) { + // The protocol layer already ends a stream that does this; this only guards the map. + // Not `refuse`: its `view_closed` would make the client forget the view that is open. + self.say(.{ .bye = .{ .reason = .protocol_error, .text = "that view id is already open" } }); + self.dead = true; + return; + } + if (self.views.count() >= max_views) { + self.refuse(cv.view, .create_failed, "too many views"); + return; + } const v = try RemoteViewHost.create(self.gpa, self, cv); errdefer v.destroy(); try self.views.put(self.gpa, cv.view, v); diff --git a/app/remote/tests.zig b/app/remote/tests.zig index 0741c9b0d..309bc8341 100644 --- a/app/remote/tests.zig +++ b/app/remote/tests.zig @@ -519,3 +519,20 @@ test "a flood of motion keeps the last, and a release survives a full queue" { try std.testing.expect(!v.input.items[2].key.down); try std.testing.expectEqual(@as(u32, 100), v.width); } + +test "queued text is bounded in bytes, and a connection holds a bounded number of views" { + var h: Harness = undefined; + try h.init(); + defer h.deinit(); + const v = try h.openView(1, 100, 100); + v.focused = true; + const chunk = RemoteViewHost.max_text_bytes / 2 + 1; + for (0..3) |_| try v.queue(.{ .text = try std.testing.allocator.alloc(u8, chunk) }); + // The second would pass the cap, so only the first waits. + try std.testing.expectEqual(@as(usize, 1), v.input.items.len); + try std.testing.expectEqual(chunk, v.text_bytes); + + for (2..Session.max_views + 3) |i| try h.client.send(.{ .create_view = .{ .view = @intCast(i), .width = 10, .height = 10 } }); + try h.step(); + try std.testing.expectEqual(@as(usize, Session.max_views), h.session.views.count()); +} From 4fbe091d1f26f543db8e59476efa922eb5c50eae Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 14:24:55 -0500 Subject: [PATCH 4/5] app: cut off remote peers that stop reading A session that dies is closed, and if its peer never reads the bye the writer stays blocked: Link.reapStuck turns that close into an abort after 2 s, from the session's pump and from closeAll (which no longer spins forever). Tests: 503 for the ninth client on the socket, and a stuck writer whose slot frees. Co-Authored-By: Claude Sonnet 5.5 --- app/control/Link.zig | 18 ++++++ app/remote/Service.zig | 12 +++- app/remote/Session.zig | 6 +- app/remote/tests.zig | 129 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/app/control/Link.zig b/app/control/Link.zig index b905d35a6..e75da105d 100644 --- a/app/control/Link.zig +++ b/app/control/Link.zig @@ -15,6 +15,9 @@ const Link = @This(); /// std-only, so `remote/Session.zig` asserts that at comptime. pub const max_backlog = 64 * 1024 * 1024; +/// How long a closing link may take to write what is queued before `reapStuck` cuts it off. +pub const close_grace_ms = 2000; + gpa: std.mem.Allocator, io: Io, /// Wakes the app so its next frame reads what arrived. Safe from any thread. @@ -25,6 +28,8 @@ inbox: std.ArrayListUnmanaged(u8) = .empty, /// To the peer, under `lock`. outbox: std.ArrayListUnmanaged(u8) = .empty, out_ready: Io.Event = .unset, +/// When `close` was first called (awake-clock ns + 1), 0 before: `reapStuck` measures from it. +closed_at: std.atomic.Value(u64) = .init(0), /// The app is done with the link: its tasks end, and the socket with them. Set by `close`. closing: std.atomic.Value(bool) = .init(false), /// The connection's tasks are done with the link: the peer left, or `close` was answered. Only @@ -68,6 +73,8 @@ pub fn send(self: *Link, bytes: []const u8) error{OutOfMemory}!void { /// End the connection once what is queued is written. UI thread. pub fn close(self: *Link) void { + const now: u64 = @intCast(Io.Clock.awake.now(self.io).nanoseconds + 1); + _ = self.closed_at.cmpxchgStrong(0, now, .acq_rel, .acquire); self.closing.store(true, .release); self.out_ready.set(self.io); } @@ -81,6 +88,17 @@ pub fn abort(self: *Link) void { if (self.stream) |s| s.shutdown(self.io, .both) catch {}; } +/// A `close` that nobody heard (the writer is blocked on a peer that does not read) is made an +/// `abort` once it has waited `close_grace_ms`. True when it did. UI thread, once a frame. +pub fn reapStuck(self: *Link) bool { + const at = self.closed_at.load(.acquire); + if (at == 0 or self.gone.load(.acquire)) return false; + const now: u64 = @intCast(Io.Clock.awake.now(self.io).nanoseconds + 1); + if (now -| at < close_grace_ms * std.time.ns_per_ms) return false; + self.abort(); + return true; +} + pub fn isGone(self: *const Link) bool { return self.gone.load(.acquire); } diff --git a/app/remote/Service.zig b/app/remote/Service.zig index 9c9136585..a795da79a 100644 --- a/app/remote/Service.zig +++ b/app/remote/Service.zig @@ -21,7 +21,11 @@ const Service = @This(); /// The `Upgrade` token of `GET /remote`, with the protocol's major version. pub const upgrade_token = "fizzy-remote-view/1"; -/// The most connections at once: each may hold `Link.max_backlog` in either direction. +/// The most connections at once. Worst case per connection is about three `Link.max_backlog`s: +/// the inbox, the outbox, and what the writer task has taken from the outbox to write (up to the +/// same again), plus the capacity those lists retain. A connection that never says hello is not +/// timed out: the socket is the user's own (0600), and a handshake deadline would hook in +/// `Session.pump`, where a session with no peer hello after a time could be marked `dead`. pub const max_sessions = 8; gpa: std.mem.Allocator, @@ -79,7 +83,11 @@ pub fn pump(self: *Service) void { pub fn closeAll(self: *Service) void { for (self.sessions.items) |s| s.link.close(); for (self.sessions.items) |s| { - while (!s.link.isGone()) s.link.io.sleep(.fromMilliseconds(1), .awake) catch break; + // Bounded: a peer that does not read is cut off after `Link.close_grace_ms`. + while (!s.link.isGone()) { + _ = s.link.reapStuck(); + s.link.io.sleep(.fromMilliseconds(1), .awake) catch break; + } s.destroy(); } self.sessions.clearRetainingCapacity(); diff --git a/app/remote/Session.zig b/app/remote/Session.zig index cb056b44e..a507d1509 100644 --- a/app/remote/Session.zig +++ b/app/remote/Session.zig @@ -86,7 +86,11 @@ fn flush(self: *Session) void { }; } self.endpoint.consume(out.len); - if (self.dead) self.link.close(); + // `close` lets the bye out; `reapStuck` cuts a peer that will not read it. + if (self.dead) { + self.link.close(); + _ = self.link.reapStuck(); + } } fn receive(self: *Session) void { diff --git a/app/remote/tests.zig b/app/remote/tests.zig index 309bc8341..4b8d8bf47 100644 --- a/app/remote/tests.zig +++ b/app/remote/tests.zig @@ -536,3 +536,132 @@ test "queued text is bounded in bytes, and a connection holds a bounded number o try h.step(); try std.testing.expectEqual(@as(usize, Session.max_views), h.session.views.count()); } + +test "a service takes max_sessions connections, refuses the next, and a connection whose tasks are done frees a slot" { + const gpa = std.testing.allocator; + var svc: Service = .init(gpa, hello_fizzy); + // The command is registered once and not needed here: no host is touched. + svc.registered = true; + const host: *sdk.Host = undefined; + defer { + // The connections' tasks would set this; there are none, and `closeAll` waits for it. + for (svc.sessions.items) |s| s.link.finish(); + svc.closeAll(); + svc.sessions.deinit(gpa); + } + for (0..Service.max_sessions) |_| { + const link = try gpa.create(Link); + link.* = .init(gpa, std.testing.io, noWake); + try svc.accept(host, link); + } + const extra = try gpa.create(Link); + extra.* = .init(gpa, std.testing.io, noWake); + try std.testing.expectError(error.TooManySessions, svc.accept(host, extra)); + // The caller keeps a refused link. + extra.deinit(); + gpa.destroy(extra); + + // A connection whose tasks are done is dropped by the next pump. + const first = svc.sessions.items[0].link; + first.close(); + first.finish(); + svc.pump(); + try std.testing.expectEqual(@as(usize, Service.max_sessions - 1), svc.sessions.items.len); + const again = try gpa.create(Link); + again.* = .init(gpa, std.testing.io, noWake); + try svc.accept(host, again); + try std.testing.expectEqual(@as(usize, Service.max_sessions), svc.sessions.items.len); +} + +/// A raw upgraded connection that never reads past the `101`: all a stuck peer is. +fn upgradeRaw(path: []const u8) !std.Io.net.Stream { + const io = std.testing.io; + const stream = try (try std.Io.net.UnixAddress.init(path)).connect(io); + errdefer stream.close(io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(io, &wbuf); + try w.interface.writeAll(Peer.request); + try w.interface.flush(); + return stream; +} + +fn refusedStatus(path: []const u8, out: *[128]u8, result: *[]const u8) void { + result.* = statusOf(path, Peer.request, out) catch "error"; +} + +test "on the socket, the ninth client is refused with 503, and a peer that stops reading is cut off to free its slot" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + var state: routes.State = .{}; + var stopped = false; + defer if (!stopped) routes.stop(&server, &state, &host); + + var streams: [Service.max_sessions]std.Io.net.Stream = undefined; + var opened: usize = 0; + defer for (streams[0..opened]) |s| s.close(io); + for (&streams) |*s| { + s.* = try upgradeRaw(path); + opened += 1; + } + var frames: usize = 0; + const svc_ptr = blk: { + while (state.remote == null and frames < 5000) : (frames += 1) { + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + break :blk &state.remote.?; + }; + while (svc_ptr.sessions.items.len < Service.max_sessions and frames < 5000) : (frames += 1) { + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, Service.max_sessions), svc_ptr.sessions.items.len); + + // The next is refused with a plain 503, not a 101. + var head: [128]u8 = undefined; + var status: []const u8 = ""; + const t = try std.Thread.spawn(.{}, refusedStatus, .{ path, &head, &status }); + frames = 0; + var done = false; + while (!done and frames < 5000) : (frames += 1) { + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + done = status.len > 0; + } + t.join(); + try std.testing.expectEqualStrings("HTTP/1.1 503 Service Unavailable", status); + try std.testing.expectEqual(@as(usize, Service.max_sessions), svc_ptr.sessions.items.len); + + // Fill the first peer's way until its writer blocks, then close: the grace period ends in an + // abort, and the next pump frees the slot. + const link = svc_ptr.sessions.items[0].link; + const big = try gpa.alloc(u8, 8 * 1024 * 1024); + defer gpa.free(big); + @memset(big, 0); + try link.send(big); + try link.send(big); + io.sleep(.fromMilliseconds(200), .awake) catch {}; + try std.testing.expect(!link.isGone()); + link.close(); + frames = 0; + while (svc_ptr.sessions.items.len == Service.max_sessions and frames < 10000) : (frames += 1) { + _ = link.reapStuck(); + routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, Service.max_sessions - 1), svc_ptr.sessions.items.len); + + routes.stop(&server, &state, &host); + stopped = true; +} From b4aeef365ef2c7b180690d706ec499a8d54a8ed3 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 17:31:01 -0500 Subject: [PATCH 5/5] app: wake an idle app to cut off a stuck remote peer routes pumps ask for a frame while any remote link is closing, so the 2 s grace ends in an abort even when the app is idle. closeAll keeps waiting when its sleep is cancelled instead of freeing a session whose tasks may still run. The 503 test hands its status over with an atomic flag. Co-Authored-By: Claude Sonnet 5.5 --- app/control/routes.zig | 7 ++++++- app/remote/Service.zig | 10 +++++++++- app/remote/tests.zig | 10 +++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/control/routes.zig b/app/control/routes.zig index 14f5af0d1..66c3b91de 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -97,7 +97,12 @@ pub fn answerPending(server: *Server, state: *State, host: *sdk.Host) void { req.answer(server, a.status, a.content_type, a.body); } } - if (state.remote) |*remote| remote.pump(); + if (state.remote) |*remote| { + remote.pump(); + // A close waits `Link.close_grace_ms` for a peer to read; an idle app has no frame to cut + // it off, so ask for one until none is waiting. + if (remote.anyClosing()) server.wake(); + } if (state.sessions.list.items.len > 0) state.sessions.expire(server.gpa, host, std.Io.Clock.awake.now(server.io).nanoseconds); state.follow(host, server, streaming); } diff --git a/app/remote/Service.zig b/app/remote/Service.zig index a795da79a..3bfce89df 100644 --- a/app/remote/Service.zig +++ b/app/remote/Service.zig @@ -79,6 +79,12 @@ pub fn pump(self: *Service) void { } } +/// Some connection has been closed and is not yet gone (`Link.reapStuck` is waiting on it). +pub fn anyClosing(self: *const Service) bool { + for (self.sessions.items) |s| if (s.link.closed_at.load(.acquire) != 0) return true; + return false; +} + /// End every connection, waiting for their tasks: at quit, or when the control server stops. pub fn closeAll(self: *Service) void { for (self.sessions.items) |s| s.link.close(); @@ -86,7 +92,9 @@ pub fn closeAll(self: *Service) void { // Bounded: a peer that does not read is cut off after `Link.close_grace_ms`. while (!s.link.isGone()) { _ = s.link.reapStuck(); - s.link.io.sleep(.fromMilliseconds(1), .awake) catch break; + // Keep waiting if the sleep is cancelled: the link's tasks may still run, and the + // session is not ours to free until they are done. + s.link.io.sleep(.fromMilliseconds(1), .awake) catch {}; } s.destroy(); } diff --git a/app/remote/tests.zig b/app/remote/tests.zig index 4b8d8bf47..71c47e466 100644 --- a/app/remote/tests.zig +++ b/app/remote/tests.zig @@ -585,8 +585,9 @@ fn upgradeRaw(path: []const u8) !std.Io.net.Stream { return stream; } -fn refusedStatus(path: []const u8, out: *[128]u8, result: *[]const u8) void { +fn refusedStatus(path: []const u8, out: *[128]u8, result: *[]const u8, replied: *std.atomic.Value(bool)) void { result.* = statusOf(path, Peer.request, out) catch "error"; + replied.store(true, .release); } test "on the socket, the ninth client is refused with 503, and a peer that stops reading is cut off to free its slot" { @@ -631,13 +632,12 @@ test "on the socket, the ninth client is refused with 503, and a peer that stops // The next is refused with a plain 503, not a 101. var head: [128]u8 = undefined; var status: []const u8 = ""; - const t = try std.Thread.spawn(.{}, refusedStatus, .{ path, &head, &status }); + var replied: std.atomic.Value(bool) = .init(false); + const t = try std.Thread.spawn(.{}, refusedStatus, .{ path, &head, &status, &replied }); frames = 0; - var done = false; - while (!done and frames < 5000) : (frames += 1) { + while (!replied.load(.acquire) and frames < 5000) : (frames += 1) { routes.answerPending(&server, &state, &host); io.sleep(.fromMilliseconds(1), .awake) catch {}; - done = status.len > 0; } t.join(); try std.testing.expectEqualStrings("HTTP/1.1 503 Service Unavailable", status);