85152686
fix: the review round's two should-fixes and four nits
a73x 2026-08-21 08:33
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -185,6 +185,14 @@ died. Without that check the flag was kept silently — the offer is a | |||
| 185 | declaration, not a capability — and the first sign was `permission denied | 185 | declaration, not a capability — and the first sign was `permission denied |
| 186 | (publickey)` from a git remote inside the session. | 186 | (publickey)` from a git remote inside the session. |
| 187 | 187 | ||
| 188 | One case the preflight cannot catch: run `mux -A` from *inside* a mux | ||
| 189 | session and `SSH_AUTH_SOCK` is the daemon's own socket, which accepts every | ||
| 190 | connection and only refuses afterwards — so the dial succeeds and the inner | ||
| 191 | client attaches. If an `-A` client is attached to the outer session the | ||
| 192 | chain works end to end; if not, the inner one is the silent agentless | ||
| 193 | offerer this check exists to catch. Nesting is the one place to check by | ||
| 194 | hand, with `ssh-add -l`. | ||
| 195 | |||
| 188 | `-A` goes on the attach form (`mux -A HOST`, `mux -A quic://HOST`, | 196 | `-A` goes on the attach form (`mux -A HOST`, `mux -A quic://HOST`, |
| 189 | `mux -A --sock PATH`), not on `mux wall`, which refuses a flag where a | 197 | `mux -A --sock PATH`), not on `mux wall`, which refuses a flag where a |
| 190 | target belongs. Sibling tiles grown from an `-A` attach by chord (`Ctrl-\ c`, | 198 | target belongs. Sibling tiles grown from an `-A` attach by chord (`Ctrl-\ c`, |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -273,7 +273,7 @@ const mod_table = [_]ModSpec{ | |||
| 273 | // typed-at session; the overlay machinery itself is interact's, shared — | 273 | // typed-at session; the overlay machinery itself is interact's, shared — |
| 274 | // and phase 3 promotes the tile into that core rather than growing a | 274 | // and phase 3 promotes the tile into that core rather than growing a |
| 275 | // second copy of it. | 275 | // second copy of it. |
| 276 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint", "testtmp" }, .quic_tests = true }, | 276 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 277 | // ---- layer 5 ---- | 277 | // ---- layer 5 ---- |
| 278 | // wall owns the spelling grammar and the state file, so argv is parsed | 278 | // wall owns the spelling grammar and the state file, so argv is parsed |
| 279 | // by the SAME rules the page's POST /tiles and the restored file are — | 279 | // by the SAME rules the page's POST /tiles and the restored file are — |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -868,10 +868,17 @@ test "agentReachable: a live socket answers, a stale or unset path does not" { | |||
| 868 | 868 | ||
| 869 | try std.testing.expect(agentReachable(sock)); | 869 | try std.testing.expect(agentReachable(sock)); |
| 870 | 870 | ||
| 871 | // The stale case, which is why this dials rather than reading the | 871 | // The stale case, and the reason this dials rather than reading the |
| 872 | // variable: the path is still spelled, the socket file may even still | 872 | // variable: the agent is gone but its socket FILE is still there, so the |
| 873 | // be there, and nothing is listening. | 873 | // path stats fine and the connect is refused. `deinit` closes the |
| 874 | // listener without unlinking, which is exactly what a killed agent | ||
| 875 | // leaves behind. | ||
| 874 | listener.deinit(); | 876 | listener.deinit(); |
| 877 | try std.fs.accessAbsolute(sock, .{}); | ||
| 878 | try std.testing.expect(!agentReachable(sock)); | ||
| 879 | |||
| 880 | // And the two cheaper absences, so all three of a user's states are | ||
| 881 | // covered by the one probe. | ||
| 875 | std.fs.deleteFileAbsolute(sock) catch {}; | 882 | std.fs.deleteFileAbsolute(sock) catch {}; |
| 876 | try std.testing.expect(!agentReachable(sock)); | 883 | try std.testing.expect(!agentReachable(sock)); |
| 877 | try std.testing.expect(!agentReachable("")); | 884 | try std.testing.expect(!agentReachable("")); |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -1397,6 +1397,18 @@ pub const Server = struct { | |||
| 1397 | return best; | 1397 | return best; |
| 1398 | } | 1398 | } |
| 1399 | 1399 | ||
| 1400 | /// Channels open right now, for `stats`. The refusal counters only mean | ||
| 1401 | /// something next to it: "refused 40, holding 8" is a full table, while | ||
| 1402 | /// "refused 40, holding 0" is nobody offering, and those are different | ||
| 1403 | /// problems with the same symptom. | ||
| 1404 | fn liveAgentChans(self: *const Server) usize { | ||
| 1405 | var n: usize = 0; | ||
| 1406 | for (self.agent_chans) |slot| { | ||
| 1407 | if (slot != null) n += 1; | ||
| 1408 | } | ||
| 1409 | return n; | ||
| 1410 | } | ||
| 1411 | |||
| 1400 | fn freeAgentChan(self: *const Server) ?usize { | 1412 | fn freeAgentChan(self: *const Server) ?usize { |
| 1401 | for (self.agent_chans, 0..) |slot, s| { | 1413 | for (self.agent_chans, 0..) |slot, s| { |
| 1402 | if (slot == null) return s; | 1414 | if (slot == null) return s; |
| @@ -3479,12 +3491,15 @@ pub const Server = struct { | |||
| 3479 | var w: std.Io.Writer = .fixed(buf); | 3491 | var w: std.Io.Writer = .fixed(buf); |
| 3480 | try w.print( | 3492 | try w.print( |
| 3481 | "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++ | 3493 | "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++ |
| 3482 | " snapshot_equiv_bytes={d} clients={d} attaches={d} sessions={d}", | 3494 | " snapshot_equiv_bytes={d} clients={d} attaches={d} sessions={d}" ++ |
| 3495 | " agent_chans={d} agent_refused_no_offer={d} agent_refused_full={d}", | ||
| 3483 | .{ | 3496 | .{ |
| 3484 | self.stats.snapshots, self.stats.snapshot_bytes, | 3497 | self.stats.snapshots, self.stats.snapshot_bytes, |
| 3485 | self.stats.deltas, self.stats.delta_bytes, | 3498 | self.stats.deltas, self.stats.delta_bytes, |
| 3486 | self.stats.snapshot_equiv_bytes, self.liveClients(), | 3499 | self.stats.snapshot_equiv_bytes, self.liveClients(), |
| 3487 | self.stats.attaches, self.liveSessions(), | 3500 | self.stats.attaches, self.liveSessions(), |
| 3501 | self.liveAgentChans(), self.agent_refused_no_offer, | ||
| 3502 | self.agent_refused_full, | ||
| 3488 | }, | 3503 | }, |
| 3489 | ); | 3504 | ); |
| 3490 | for (self.sessions, 0..) |slot, si| { | 3505 | for (self.sessions, 0..) |slot, si| { |
| @@ -10498,6 +10513,20 @@ test "Server: a full channel table refuses the newest dial and says so once" { | |||
| 10498 | for (seen, 0..) |id, i| for (seen[i + 1 ..]) |other| try std.testing.expect(id != other); | 10513 | for (seen, 0..) |id, i| for (seen[i + 1 ..]) |other| try std.testing.expect(id != other); |
| 10499 | srv.next_agent_id = seen[3]; | 10514 | srv.next_agent_id = seen[3]; |
| 10500 | try std.testing.expect(srv.nextAgentId() != seen[3]); | 10515 | try std.testing.expect(srv.nextAgentId() != seen[3]); |
| 10516 | |||
| 10517 | // "Once", which is the half the name claims and nothing asserted: a | ||
| 10518 | // second dial against the same full table counts again and says nothing, | ||
| 10519 | // and only a freed slot re-arms the line. ssh retries, so a log that | ||
| 10520 | // scrolled would be its own outage. | ||
| 10521 | try std.testing.expect(srv.agent_full_said); | ||
| 10522 | const again = try std.net.connectUnixSocket(path); | ||
| 10523 | defer again.close(); | ||
| 10524 | spun = 0; | ||
| 10525 | while (spun < 400 and srv.agent_refused_full < 2) : (spun += 1) _ = try srv.pumpOnce(5); | ||
| 10526 | try std.testing.expectEqual(@as(u32, 2), srv.agent_refused_full); | ||
| 10527 | try std.testing.expect(srv.agent_full_said); | ||
| 10528 | srv.closeAgentChan(0, .notify); | ||
| 10529 | try std.testing.expect(!srv.agent_full_said); | ||
| 10501 | } | 10530 | } |
| 10502 | 10531 | ||
| 10503 | test "Server: an agent connection with nobody offering is refused fast" { | 10532 | test "Server: an agent connection with nobody offering is refused fast" { |
src/wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -97,8 +97,9 @@ pub fn spellingFromArgv( | |||
| 97 | if (i + 1 >= args.len) return error.MissingSockPath; | 97 | if (i + 1 >= args.len) return error.MissingSockPath; |
| 98 | return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 }; | 98 | return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 }; |
| 99 | } | 99 | } |
| 100 | // A wall takes targets, and no target starts with a dash: the two | 100 | // A wall takes targets, and no target starts with a dash: bare `--sock` |
| 101 | // spellings that do are handled above. Left to fall through, `mux wall | 101 | // took its path and returned above, and the one-piece `--sock PATH` |
| 102 | // dialect is exempted on the next line. Left to fall through, `mux wall | ||
| 102 | // -A host` became a tile for a host named `-A` and failed to resolve | 103 | // -A host` became a tile for a host named `-A` and failed to resolve |
| 103 | // somewhere far from the typo — and a wall has no per-tile agent flag | 104 | // somewhere far from the typo — and a wall has no per-tile agent flag |
| 104 | // to have meant, `-A` belonging to a single attach. | 105 | // to have meant, `-A` belonging to a single attach. |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -949,20 +949,29 @@ fn openAgentChan( | |||
| 949 | /// stance for the same frame, and here it is simply a channel we closed a | 949 | /// stance for the same frame, and here it is simply a channel we closed a |
| 950 | /// moment before this arrived. Oversize hangs the channel up instead: the | 950 | /// moment before this arrived. Oversize hangs the channel up instead: the |
| 951 | /// write below is blocking, and a truncated agent reply is worse than none. | 951 | /// write below is blocking, and a truncated agent reply is worse than none. |
| 952 | /// | ||
| 953 | /// False means the payload was too short to name a channel at all, and the | ||
| 954 | /// caller abandons the batch on it — the stance its sibling `agent_open` | ||
| 955 | /// and `agent_close` arms take for the same malformation, and the one this | ||
| 956 | /// arm took before it was lifted out of the pump. | ||
| 952 | fn deliverAgentData( | 957 | fn deliverAgentData( |
| 953 | locals: []?AgentLocal, | 958 | locals: []?AgentLocal, |
| 954 | payload: []const u8, | 959 | payload: []const u8, |
| 955 | transport: *client.Transport, | 960 | transport: *client.Transport, |
| 956 | ) void { | 961 | ) bool { |
| 957 | const id = proto.decodeAgentId(payload) catch return; | 962 | const id = proto.decodeAgentId(payload) catch return false; |
| 958 | const s = findLocal(locals, id) orelse return; | 963 | const s = findLocal(locals, id) orelse return true; |
| 959 | if (proto.agentDataOversize(payload)) return closeLocal(locals, s, transport); | 964 | if (proto.agentDataOversize(payload)) { |
| 965 | closeLocal(locals, s, transport); | ||
| 966 | return true; | ||
| 967 | } | ||
| 960 | // Bytes onto the fd in order, never parsed and never reassembled: the | 968 | // Bytes onto the fd in order, never parsed and never reassembled: the |
| 961 | // daemon reads the far end in `agent_data_max` bites, so one agent | 969 | // daemon reads the far end in `agent_data_max` bites, so one agent |
| 962 | // message can arrive as several frames and several messages as one. The | 970 | // message can arrive as several frames and several messages as one. The |
| 963 | // agent protocol delimits itself over a stream, and this end is a pipe. | 971 | // agent protocol delimits itself over a stream, and this end is a pipe. |
| 964 | proto.writeAllFd(locals[s].?.fd, payload[proto.agent_id_len..]) catch | 972 | proto.writeAllFd(locals[s].?.fd, payload[proto.agent_id_len..]) catch |
| 965 | closeLocal(locals, s, transport); | 973 | closeLocal(locals, s, transport); |
| 974 | return true; | ||
| 966 | } | 975 | } |
| 967 | 976 | ||
| 968 | /// Every channel, silently. For the redial only: these channels belonged to | 977 | /// Every channel, silently. For the redial only: these channels belonged to |
| @@ -1432,11 +1441,11 @@ fn pumpTile(t: *Tile) void { | |||
| 1432 | &proto.encodeAgentId(id), | 1441 | &proto.encodeAgentId(id), |
| 1433 | ) catch {}; | 1442 | ) catch {}; |
| 1434 | }, | 1443 | }, |
| 1435 | .agent_data => deliverAgentData( | 1444 | .agent_data => if (!deliverAgentData( |
| 1436 | &agent_locals, | 1445 | &agent_locals, |
| 1437 | frame.payload, | 1446 | frame.payload, |
| 1438 | &transport, | 1447 | &transport, |
| 1439 | ), | 1448 | )) break :frames, |
| 1440 | .agent_close => { | 1449 | .agent_close => { |
| 1441 | const id = proto.decodeAgentId(frame.payload) catch break :frames; | 1450 | const id = proto.decodeAgentId(frame.payload) catch break :frames; |
| 1442 | // Silent, mirroring the daemon: it has already | 1451 | // Silent, mirroring the daemon: it has already |
| @@ -3356,7 +3365,7 @@ test "agent channels: an oversize frame hangs the channel up, a full one lands" | |||
| 3356 | var small: [proto.agent_id_len + 2]u8 = undefined; | 3365 | var small: [proto.agent_id_len + 2]u8 = undefined; |
| 3357 | small[0..proto.agent_id_len].* = proto.encodeAgentId(5); | 3366 | small[0..proto.agent_id_len].* = proto.encodeAgentId(5); |
| 3358 | @memcpy(small[proto.agent_id_len..], "hi"); | 3367 | @memcpy(small[proto.agent_id_len..], "hi"); |
| 3359 | deliverAgentData(&locals, &small, &transport); | 3368 | try std.testing.expect(deliverAgentData(&locals, &small, &transport)); |
| 3360 | var got: [8]u8 = undefined; | 3369 | var got: [8]u8 = undefined; |
| 3361 | try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got)); | 3370 | try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got)); |
| 3362 | try std.testing.expectEqualSlices(u8, "hi", got[0..2]); | 3371 | try std.testing.expectEqualSlices(u8, "hi", got[0..2]); |
| @@ -3367,12 +3376,17 @@ test "agent channels: an oversize frame hangs the channel up, a full one lands" | |||
| 3367 | defer std.testing.allocator.free(over); | 3376 | defer std.testing.allocator.free(over); |
| 3368 | @memset(over, 'x'); | 3377 | @memset(over, 'x'); |
| 3369 | over[0..proto.agent_id_len].* = proto.encodeAgentId(5); | 3378 | over[0..proto.agent_id_len].* = proto.encodeAgentId(5); |
| 3370 | deliverAgentData(&locals, over, &transport); | 3379 | try std.testing.expect(deliverAgentData(&locals, over, &transport)); |
| 3371 | try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]); | 3380 | try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]); |
| 3372 | try std.testing.expect(peerClosed(chan[0])); | 3381 | try std.testing.expect(peerClosed(chan[0])); |
| 3373 | 3382 | ||
| 3374 | // And the daemon is told, because it is holding the far socket open for | 3383 | // And the daemon is told, because it is holding the far socket open for |
| 3375 | // an answer this end will never write. | 3384 | // an answer this end will never write. |
| 3385 | // A payload too short to name a channel: false, and the pump abandons the | ||
| 3386 | // batch on it rather than skipping one frame, because a stream that has | ||
| 3387 | // lost frame alignment is not one to keep reading. | ||
| 3388 | try std.testing.expect(!deliverAgentData(&locals, "ab", &transport)); | ||
| 3389 | |||
| 3376 | const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse | 3390 | const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse |
| 3377 | return error.NoAgentCloseOnOversize; | 3391 | return error.NoAgentCloseOnOversize; |
| 3378 | defer frame.deinit(std.testing.allocator); | 3392 | defer frame.deinit(std.testing.allocator); |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -7050,8 +7050,10 @@ ok "agent forwarding: ssh-add -l in the session lists the client's key" | |||
| 7050 | # transport is touched. | 7050 | # transport is touched. |
| 7051 | # | 7051 | # |
| 7052 | # Both spellings of "no agent", because reading the variable would only | 7052 | # Both spellings of "no agent", because reading the variable would only |
| 7053 | # catch the first: unset, and set to a path nothing is listening on — the | 7053 | # catch the first: unset, and set to a path nothing answers on. The unit |
| 7054 | # ordinary leftover of an agent that has died. | 7054 | # test covers the subtler third — an agent killed leaving its socket file |
| 7055 | # behind, which stats fine and refuses the connect — since arranging a | ||
| 7056 | # listener that dies mid-leg buys nothing here. | ||
| 7055 | set +e | 7057 | set +e |
| 7056 | env -u SSH_AUTH_SOCK "$MUX" -A --sock "$SOCK48" > "$OUT.anoag" 2>&1 | 7058 | env -u SSH_AUTH_SOCK "$MUX" -A --sock "$SOCK48" > "$OUT.anoag" 2>&1 |
| 7057 | RC=$? | 7059 | RC=$? |