a73x

f973892a

feat: upgrade_req/upgrade_reply on the wire

a73x   2026-08-26 15:01

Commit message
feat: upgrade_req/upgrade_reply on the wire

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -546,12 +546,12 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
546 /// escape pins. mux and exe are executable roots but carry the argument 546 /// escape pins. mux and exe are executable roots but carry the argument
547 /// parsers — a test that is never built is not a test (decisions.md). 547 /// parsers — a test that is never built is not a test (decisions.md).
548 const test_order = [_][]const u8{ 548 const test_order = [_][]const u8{
549 "script", "select", "protocol", "client_core", "interact", "engine", "pty", 549 "script", "select", "protocol", "client_core", "interact", "engine", "pty",
550 "delta", "cmd", "wall", "upgrade", "shellint", "replica", "keymap", "webhub", 550 "delta", "cmd", "wall", "upgrade", "shellint", "replica", "keymap",
551 "wallview", "sockpath", "muxa", "server", "client", "proxy", "mux", 551 "webhub", "wallview", "sockpath", "muxa", "server", "client", "proxy",
552 "quic", "quic_server", "exe", "testtmp", "quic_client", "predict", "rawmode", 552 "mux", "quic", "quic_server", "exe", "testtmp", "quic_client", "predict",
553 "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", "render", 553 "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout",
554 "ptyclient", "webhub_main", "wsclient", 554 "render", "ptyclient", "webhub_main", "wsclient",
555 }; 555 };
556 556
557 comptime { 557 comptime {
docs/superpowers/plans/2026-08-26-muxd-upgrade-reexec.md
Old New
@@ -167,7 +167,7 @@ test "manifest: a session with no agent and no title round-trips its absences" {
167 ```zig 167 ```zig
168 // in FrameType (free ranges: client→daemon 0x10–0x7e, daemon→client 0x90+): 168 // in FrameType (free ranges: client→daemon 0x10–0x7e, daemon→client 0x90+):
169 upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version NUL abs-path 169 upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version NUL abs-path
170 upgrade_reply = 0x90, // payload: u8 status (0 accepted, 1 refused) ++ reason text 170 upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text
171 171
172 pub const UpgradeReq = struct { allow_same_version: bool, version: []const u8, path: []const u8 }; 172 pub const UpgradeReq = struct { allow_same_version: bool, version: []const u8, path: []const u8 };
173 pub fn encodeUpgradeReq(buf: []u8, req: UpgradeReq) ![]const u8; 173 pub fn encodeUpgradeReq(buf: []u8, req: UpgradeReq) ![]const u8;
docscheck.budget
Old New
@@ -27,6 +27,7 @@ shellint.zig 0
27 sockpath.zig 0 27 sockpath.zig 0
28 spawn.zig 0 28 spawn.zig 0
29 testtmp.zig 0 29 testtmp.zig 0
30 upgrade.zig 269
30 wallview.zig 1841 31 wallview.zig 1841
31 wall.zig 0 32 wall.zig 0
32 wasm_core.zig 0 33 wasm_core.zig 0
src/interact.zig
Old New
@@ -1796,6 +1796,8 @@ pub const Core = struct {
1796 .sessions_req, 1796 .sessions_req,
1797 .agent_offer, 1797 .agent_offer,
1798 .debug_dump, 1798 .debug_dump,
1799 .upgrade_req,
1800 .upgrade_reply,
1799 => return .skip, 1801 => return .skip,
1800 // MsgType is open (`_`): a daemon newer than this client can 1802 // MsgType is open (`_`): a daemon newer than this client can
1801 // send a type this build has never heard of. Ignoring it is the 1803 // send a type this build has never heard of. Ignoring it is the
src/protocol.zig
Old New
@@ -26,6 +26,7 @@ pub const MsgType = enum(u8) {
26 agent_offer = 0x0d, // payload: empty; re-sent after EVERY attach when -A (a redial re-attaches) 26 agent_offer = 0x0d, // payload: empty; re-sent after EVERY attach when -A (a redial re-attaches)
27 agent_data = 0x0e, // payload: u32 LE channel id ++ up to agent_data_max opaque agent bytes; BOTH directions 27 agent_data = 0x0e, // payload: u32 LE channel id ++ up to agent_data_max opaque agent bytes; BOTH directions
28 agent_close = 0x0f, // payload: u32 LE channel id; BOTH directions 28 agent_close = 0x0f, // payload: u32 LE channel id; BOTH directions
29 upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version bytes ++ NUL ++ absolute path bytes
29 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session) 30 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session)
30 // daemon -> client 31 // daemon -> client
31 snapshot = 0x81, // payload: SnapshotPrefix ++ full-state vt dump 32 snapshot = 0x81, // payload: SnapshotPrefix ++ full-state vt dump
@@ -48,6 +49,7 @@ pub const MsgType = enum(u8) {
48 selection_reply = 0x90, // payload: SelectionReply (see encodeSelectionReply) 49 selection_reply = 0x90, // payload: SelectionReply (see encodeSelectionReply)
49 sessions_reply = 0x91, // payload: the live session names, '\n'-separated, in slot order; empty payload = no sessions. A name can hold no whitespace (validSessionName), so the separator needs no escaping and no codec — the same reason stats_reply is plain text. 50 sessions_reply = 0x91, // payload: the live session names, '\n'-separated, in slot order; empty payload = no sessions. A name can hold no whitespace (validSessionName), so the separator needs no escaping and no codec — the same reason stats_reply is plain text.
50 agent_open = 0x92, // payload: u32 LE channel id; daemon allocates ids, only the daemon opens 51 agent_open = 0x92, // payload: u32 LE channel id; daemon allocates ids, only the daemon opens
52 upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text
51 dump_reply = 0xff, // payload: requested dump bytes 53 dump_reply = 0xff, // payload: requested dump bytes
52 _, 54 _,
53 }; 55 };
@@ -255,6 +257,44 @@ pub fn agentDataOversize(payload: []const u8) bool {
255 return payload.len > agent_id_len + agent_data_max; 257 return payload.len > agent_id_len + agent_data_max;
256 } 258 }
257 259
260 // `upgrade_req`: the client asks the daemon to exec a new binary. The path
261 // is absolute because the daemon must never resolve a relative path against
262 // ITS cwd — the requester's intent and the daemon's cwd are two things.
263 pub const UpgradeReq = struct {
264 allow_same_version: bool,
265 version: []const u8,
266 path: []const u8,
267 };
268
269 pub fn encodeUpgradeReq(buf: []u8, req: UpgradeReq) ![]const u8 {
270 if (req.version.len == 0) return error.BadPayload;
271 if (req.path.len == 0 or req.path[0] != '/') return error.BadPayload;
272 const total = 1 + req.version.len + 1 + req.path.len;
273 if (buf.len < total) return error.NoSpaceLeft;
274 buf[0] = @intFromBool(req.allow_same_version);
275 @memcpy(buf[1..][0..req.version.len], req.version);
276 buf[1 + req.version.len] = 0; // NUL separator
277 @memcpy(buf[2 + req.version.len ..][0..req.path.len], req.path);
278 return buf[0..total];
279 }
280
281 pub fn parseUpgradeReq(payload: []const u8) error{BadPayload}!UpgradeReq {
282 if (payload.len < 3) return error.BadPayload; // flag + at least 1 ver + NUL + at least 1 path
283 const flags = payload[0];
284 // Find the NUL that separates version from path.
285 const nul_idx = std.mem.indexOfScalar(u8, payload[1..], 0) orelse
286 return error.BadPayload;
287 const version = payload[1 .. 1 + nul_idx];
288 if (version.len == 0) return error.BadPayload;
289 const path = payload[2 + nul_idx ..];
290 if (path.len == 0 or path[0] != '/') return error.BadPayload;
291 return .{
292 .allow_same_version = (flags & 1) != 0,
293 .version = version,
294 .path = path,
295 };
296 }
297
258 /// Screen-space rows; the grid's owner normalizes. 298 /// Screen-space rows; the grid's owner normalizes.
259 pub const SelectionPoint = struct { 299 pub const SelectionPoint = struct {
260 row: u32, 300 row: u32,
@@ -1944,6 +1984,36 @@ test "decodeAgentId refuses a short payload" {
1944 try std.testing.expectError(error.BadPayload, decodeAgentId("abc")); 1984 try std.testing.expectError(error.BadPayload, decodeAgentId("abc"));
1945 } 1985 }
1946 1986
1987 test "encodeUpgradeReq/parseUpgradeReq round-trip preserves flag, version, path" {
1988 var buf: [256]u8 = undefined;
1989 const encoded = try encodeUpgradeReq(&buf, .{
1990 .allow_same_version = true,
1991 .version = "0.0.1-14",
1992 .path = "/home/user/bin/muxd",
1993 });
1994 const decoded = try parseUpgradeReq(encoded);
1995 try std.testing.expect(decoded.allow_same_version);
1996 try std.testing.expectEqualStrings("0.0.1-14", decoded.version);
1997 try std.testing.expectEqualStrings("/home/user/bin/muxd", decoded.path);
1998 }
1999
2000 test "parseUpgradeReq: a payload with no NUL is BadPayload" {
2001 // flag + version bytes, but no NUL terminator before the path
2002 try std.testing.expectError(error.BadPayload, parseUpgradeReq(&.{ 0, '0', '.', '0', '.', '1' }));
2003 }
2004
2005 test "parseUpgradeReq: a relative path is BadPayload" {
2006 // Manually crafted: flag ++ version ++ NUL ++ relative-path
2007 const bad = &[_]u8{ 0, '0', '.', '0', '.', '1', 0, 'r', 'e', 'l', '/', 'm' };
2008 try std.testing.expectError(error.BadPayload, parseUpgradeReq(bad));
2009 }
2010
2011 test "parseUpgradeReq: an empty version is BadPayload" {
2012 // Manually crafted: flag ++ NUL ++ path (version is zero-length)
2013 const bad = &[_]u8{ 0, 0, '/', 'a' };
2014 try std.testing.expectError(error.BadPayload, parseUpgradeReq(bad));
2015 }
2016
1947 // Forces semantic analysis of every pub decl under `zig build test`, so an 2017 // Forces semantic analysis of every pub decl under `zig build test`, so an
1948 // unreferenced decl must at least compile (the silent-module-loss hazard, 2018 // unreferenced decl must at least compile (the silent-module-loss hazard,
1949 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 2019 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.