f973892a
feat: upgrade_req/upgrade_reply on the wire
a73x 2026-08-26 15:01
Commit message
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. |