2ae7c94b
fix: a client that never answers its first agent request is hung up on
a73x 2026-08-22 17:46
Commit message
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -334,6 +334,12 @@ const AgentChan = struct { | |||
| 334 | id: u32, | 334 | id: u32, |
| 335 | client: usize, | 335 | client: usize, |
| 336 | session: usize, | 336 | session: usize, |
| 337 | /// The answer clock: when the first request was handed to the client, | ||
| 338 | /// until the client's first reply lands. Started by the request and not | ||
| 339 | /// by the open, because until ssh asks the client owes nothing; stopped | ||
| 340 | /// for good by one reply, because that reply is the proof the offer | ||
| 341 | /// claimed (`Server.agent_answer_ms`). | ||
| 342 | answer: union(enum) { unasked, asked: i64, proven } = .unasked, | ||
| 337 | }; | 343 | }; |
| 338 | 344 | ||
| 339 | /// Whether closing a channel owes its client an `agent_close`. `.silent` is | 345 | /// Whether closing a channel owes its client an `agent_close`. `.silent` is |
| @@ -620,6 +626,18 @@ pub const Server = struct { | |||
| 620 | /// confused with an absent one at a glance, and wraps — `nextAgentId` | 626 | /// confused with an absent one at a glance, and wraps — `nextAgentId` |
| 621 | /// is what keeps a wrap from colliding with a live channel. | 627 | /// is what keeps a wrap from colliding with a live channel. |
| 622 | next_agent_id: u32 = 1, | 628 | next_agent_id: u32 = 1, |
| 629 | /// How long a client may sit on a channel's FIRST forwarded request | ||
| 630 | /// before the daemon hangs up for it and stops routing to it. An | ||
| 631 | /// `agent_offer` is a declaration, not a capability: a peer that | ||
| 632 | /// offered and cannot answer does not degrade forwarding, it wedges | ||
| 633 | /// it — ssh on a socket that accepts and never replies blocks past 8s | ||
| 634 | /// where one that closes falls through in 2ms (decisions.md). Only the | ||
| 635 | /// first request is clocked: one reply proves the peer speaks for an | ||
| 636 | /// agent, and a later SIGN may legitimately wait on a human's touch. | ||
| 637 | /// Ten times the preflight's 500ms round-trip bound (decisions.md), so | ||
| 638 | /// it never separates slow from refused. A field rather than a const so | ||
| 639 | /// a test does not wait it out. | ||
| 640 | agent_answer_ms: i64 = 5000, | ||
| 623 | stats: Stats = .{}, | 641 | stats: Stats = .{}, |
| 624 | 642 | ||
| 625 | pub const Options = struct { | 643 | pub const Options = struct { |
| @@ -1330,6 +1348,7 @@ pub const Server = struct { | |||
| 1330 | if (self.agent_chans[s] == null) continue; | 1348 | if (self.agent_chans[s] == null) continue; |
| 1331 | if (fds[agent_chan_base + s].revents != 0) self.serviceAgentChan(s); | 1349 | if (fds[agent_chan_base + s].revents != 0) self.serviceAgentChan(s); |
| 1332 | } | 1350 | } |
| 1351 | self.sweepMuteAgentChans(); | ||
| 1333 | for (0..max_sessions) |si| { | 1352 | for (0..max_sessions) |si| { |
| 1334 | if (self.sessions[si] == null) continue; | 1353 | if (self.sessions[si] == null) continue; |
| 1335 | if (fds[agent_listener_base + si].revents & std.posix.POLL.IN != 0) { | 1354 | if (fds[agent_listener_base + si].revents & std.posix.POLL.IN != 0) { |
| @@ -1512,6 +1531,9 @@ pub const Server = struct { | |||
| 1512 | self.closeAgentChan(s, .notify); | 1531 | self.closeAgentChan(s, .notify); |
| 1513 | return; | 1532 | return; |
| 1514 | } | 1533 | } |
| 1534 | if (ch.answer == .unasked) { | ||
| 1535 | self.agent_chans[s].?.answer = .{ .asked = std.time.milliTimestamp() }; | ||
| 1536 | } | ||
| 1515 | // Drop-on-backpressure, per queueFrame's standing contract: an agent | 1537 | // Drop-on-backpressure, per queueFrame's standing contract: an agent |
| 1516 | // exchange is one or two KB against an 8 MiB cap, so tripping it | 1538 | // exchange is one or two KB against an 8 MiB cap, so tripping it |
| 1517 | // means the peer stopped reading, not that the agent is chatty. A | 1539 | // means the peer stopped reading, not that the agent is chatty. A |
| @@ -1535,6 +1557,24 @@ pub const Server = struct { | |||
| 1535 | } | 1557 | } |
| 1536 | } | 1558 | } |
| 1537 | 1559 | ||
| 1560 | /// Hang up on every channel whose client has sat on its first request | ||
| 1561 | /// past `agent_answer_ms`, and take that client's offer away: the next | ||
| 1562 | /// dial must route to a peer that answers, or every ssh pays the bound | ||
| 1563 | /// before falling through. Granularity is the pump, like checkAwaits. | ||
| 1564 | fn sweepMuteAgentChans(self: *Server) void { | ||
| 1565 | const now = std.time.milliTimestamp(); | ||
| 1566 | for (0..max_agent_chans) |s| { | ||
| 1567 | const ch = self.agent_chans[s] orelse continue; | ||
| 1568 | const since = switch (ch.answer) { | ||
| 1569 | .asked => |t| now - t, | ||
| 1570 | else => continue, | ||
| 1571 | }; | ||
| 1572 | if (since < self.agent_answer_ms) continue; | ||
| 1573 | if (self.clients[ch.client]) |*c| c.agent_offer = false; | ||
| 1574 | self.closeAgentChan(s, .notify); | ||
| 1575 | } | ||
| 1576 | } | ||
| 1577 | |||
| 1538 | /// Close every channel this client owns, silently: the peer that would | 1578 | /// Close every channel this client owns, silently: the peer that would |
| 1539 | /// be told is the one that has gone. Called from dropClient, which is | 1579 | /// be told is the one that has gone. Called from dropClient, which is |
| 1540 | /// every way a client can leave. | 1580 | /// every way a client can leave. |
| @@ -2427,7 +2467,10 @@ pub const Server = struct { | |||
| 2427 | .agent_offer => { | 2467 | .agent_offer => { |
| 2428 | // Re-sent after every attach, so this is idempotent by | 2468 | // Re-sent after every attach, so this is idempotent by |
| 2429 | // design: a redial re-offers on a slot that may already be | 2469 | // design: a redial re-offers on a slot that may already be |
| 2430 | // flagged, and nothing here is allowed to care. | 2470 | // flagged, and nothing here is allowed to care. That |
| 2471 | // includes an offer sweepMuteAgentChans took away — a | ||
| 2472 | // reattach may bring a working agent, and a mute one costs | ||
| 2473 | // one more agent_answer_ms per attach, not per dial. | ||
| 2431 | self.clients[i].?.agent_offer = true; | 2474 | self.clients[i].?.agent_offer = true; |
| 2432 | }, | 2475 | }, |
| 2433 | .agent_data => { | 2476 | .agent_data => { |
| @@ -2446,6 +2489,9 @@ pub const Server = struct { | |||
| 2446 | self.closeAgentChan(s, .notify); | 2489 | self.closeAgentChan(s, .notify); |
| 2447 | return; | 2490 | return; |
| 2448 | } | 2491 | } |
| 2492 | // A reply proves; a volley before any question does not, or | ||
| 2493 | // a mute peer clears the clock by talking first. | ||
| 2494 | if (self.agent_chans[s].?.answer == .asked) self.agent_chans[s].?.answer = .proven; | ||
| 2449 | // The daemon's one deliberate blocking write, and it is not | 2495 | // The daemon's one deliberate blocking write, and it is not |
| 2450 | // the doctrine's exception it looks like. "Never block on a | 2496 | // the doctrine's exception it looks like. "Never block on a |
| 2451 | // client" holds because a client is a stranger across a | 2497 | // client" holds because a client is a stranger across a |
| @@ -10954,6 +11000,185 @@ test "Server: a client's agent channels die with the client" { | |||
| 10954 | try std.testing.expect(srv.agent_chans[0] == null); | 11000 | try std.testing.expect(srv.agent_chans[0] == null); |
| 10955 | } | 11001 | } |
| 10956 | 11002 | ||
| 11003 | /// Test helper: a channel brought to the moment the answer clock starts. | ||
| 11004 | fn openAndAsk( | ||
| 11005 | alloc: std.mem.Allocator, | ||
| 11006 | srv: *Server, | ||
| 11007 | client_fd: std.posix.fd_t, | ||
| 11008 | agent_fd: std.posix.fd_t, | ||
| 11009 | ) !u32 { | ||
| 11010 | const open = (try awaitFrame(alloc, srv, client_fd, .agent_open, 200)) orelse | ||
| 11011 | return error.NoAgentOpen; | ||
| 11012 | defer open.deinit(alloc); | ||
| 11013 | const id = try proto.decodeAgentId(open.payload); | ||
| 11014 | try proto.writeAllFd(agent_fd, "req"); | ||
| 11015 | const data = (try awaitFrame(alloc, srv, client_fd, .agent_data, 200)) orelse | ||
| 11016 | return error.RequestNeverForwarded; | ||
| 11017 | data.deinit(alloc); | ||
| 11018 | return id; | ||
| 11019 | } | ||
| 11020 | |||
| 11021 | test "Server: an offerer that never answers its first request is hung up on and stops offering" { | ||
| 11022 | const alloc = std.testing.allocator; | ||
| 11023 | |||
| 11024 | var tmp = try TmpDir.make(); | ||
| 11025 | defer tmp.cleanup(); | ||
| 11026 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/agentmute.sock", .{tmp.path()}); | ||
| 11027 | defer alloc.free(sock_path); | ||
| 11028 | |||
| 11029 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); | ||
| 11030 | defer srv.deinit(); | ||
| 11031 | srv.agent_answer_ms = 150; | ||
| 11032 | |||
| 11033 | const c = try std.net.connectUnixSocket(sock_path); | ||
| 11034 | defer c.close(); | ||
| 11035 | try attachOffering(&srv, c.handle, 0, ""); | ||
| 11036 | |||
| 11037 | const path = srv.ses(0).agent_path orelse return error.NoAgentSocket; | ||
| 11038 | const agent = try std.net.connectUnixSocket(path); | ||
| 11039 | defer agent.close(); | ||
| 11040 | const id = try openAndAsk(alloc, &srv, c.handle, agent.handle); | ||
| 11041 | |||
| 11042 | // The client says nothing. Measured (decisions.md): ssh on a socket | ||
| 11043 | // that accepts and never replies blocks past 8s; on one that accepts | ||
| 11044 | // and closes it falls through to its next auth method in 2ms. So the | ||
| 11045 | // daemon's answer to silence is the close the client should have sent. | ||
| 11046 | var buf: [16]u8 = undefined; | ||
| 11047 | const n = try pumpUntilReadable(&srv, agent.handle, &buf, 200); | ||
| 11048 | try std.testing.expectEqual(@as(?usize, 0), n); | ||
| 11049 | try std.testing.expect(srv.agent_chans[0] == null); | ||
| 11050 | // The client is told, like any other far-end close it did not ask for. | ||
| 11051 | const closed = (try awaitFrame(alloc, &srv, c.handle, .agent_close, 200)) orelse | ||
| 11052 | return error.NoAgentClose; | ||
| 11053 | defer closed.deinit(alloc); | ||
| 11054 | try std.testing.expectEqual(id, try proto.decodeAgentId(closed.payload)); | ||
| 11055 | // And it is no longer an offerer: the next dial must not route here | ||
| 11056 | // again, or every ssh pays the bound before falling through. | ||
| 11057 | try std.testing.expect(!srv.clients[0].?.agent_offer); | ||
| 11058 | const again = try std.net.connectUnixSocket(path); | ||
| 11059 | defer again.close(); | ||
| 11060 | try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&srv, again.handle, &buf, 60)); | ||
| 11061 | try std.testing.expectEqual(@as(u32, 1), srv.agent_refused_no_offer); | ||
| 11062 | } | ||
| 11063 | |||
| 11064 | test "Server: a channel that has answered once is never timed out" { | ||
| 11065 | const alloc = std.testing.allocator; | ||
| 11066 | |||
| 11067 | var tmp = try TmpDir.make(); | ||
| 11068 | defer tmp.cleanup(); | ||
| 11069 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/agentslow.sock", .{tmp.path()}); | ||
| 11070 | defer alloc.free(sock_path); | ||
| 11071 | |||
| 11072 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); | ||
| 11073 | defer srv.deinit(); | ||
| 11074 | srv.agent_answer_ms = 150; | ||
| 11075 | |||
| 11076 | const c = try std.net.connectUnixSocket(sock_path); | ||
| 11077 | defer c.close(); | ||
| 11078 | try attachOffering(&srv, c.handle, 0, ""); | ||
| 11079 | |||
| 11080 | const path = srv.ses(0).agent_path orelse return error.NoAgentSocket; | ||
| 11081 | const agent = try std.net.connectUnixSocket(path); | ||
| 11082 | defer agent.close(); | ||
| 11083 | const id = try openAndAsk(alloc, &srv, c.handle, agent.handle); | ||
| 11084 | |||
| 11085 | // One reply proves the client speaks for an agent. | ||
| 11086 | var resp: [proto.agent_id_len + 4]u8 = undefined; | ||
| 11087 | @memcpy(resp[0..proto.agent_id_len], &proto.encodeAgentId(id)); | ||
| 11088 | @memcpy(resp[proto.agent_id_len..], "resp"); | ||
| 11089 | try proto.writeFrame(c.handle, .agent_data, &resp); | ||
| 11090 | var buf: [64]u8 = undefined; | ||
| 11091 | _ = (try pumpUntilReadable(&srv, agent.handle, &buf, 200)) orelse return error.NoReply; | ||
| 11092 | |||
| 11093 | // A second request the client takes its time over — a SIGN against a | ||
| 11094 | // token waiting for a touch. The bound is for a peer that cannot | ||
| 11095 | // answer, and must not separate slow from refused (the preflight's | ||
| 11096 | // rule, decisions.md): this is the arm that would pass silently if the | ||
| 11097 | // clock were anchored on the open or re-armed per request. | ||
| 11098 | try proto.writeAllFd(agent.handle, "req2"); | ||
| 11099 | const fwd = (try awaitFrame(alloc, &srv, c.handle, .agent_data, 200)) orelse | ||
| 11100 | return error.RequestNeverForwarded; | ||
| 11101 | fwd.deinit(alloc); | ||
| 11102 | try std.testing.expectEqual(@as(?usize, null), try pumpUntilReadable(&srv, agent.handle, &buf, 100)); | ||
| 11103 | try std.testing.expect(srv.agent_chans[0] != null); | ||
| 11104 | try std.testing.expect(srv.clients[0].?.agent_offer); | ||
| 11105 | } | ||
| 11106 | |||
| 11107 | test "Server: bytes a client sends before it was asked prove nothing" { | ||
| 11108 | const alloc = std.testing.allocator; | ||
| 11109 | |||
| 11110 | var tmp = try TmpDir.make(); | ||
| 11111 | defer tmp.cleanup(); | ||
| 11112 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/agenteager.sock", .{tmp.path()}); | ||
| 11113 | defer alloc.free(sock_path); | ||
| 11114 | |||
| 11115 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); | ||
| 11116 | defer srv.deinit(); | ||
| 11117 | srv.agent_answer_ms = 150; | ||
| 11118 | |||
| 11119 | const c = try std.net.connectUnixSocket(sock_path); | ||
| 11120 | defer c.close(); | ||
| 11121 | try attachOffering(&srv, c.handle, 0, ""); | ||
| 11122 | |||
| 11123 | const path = srv.ses(0).agent_path orelse return error.NoAgentSocket; | ||
| 11124 | const agent = try std.net.connectUnixSocket(path); | ||
| 11125 | defer agent.close(); | ||
| 11126 | const open = (try awaitFrame(alloc, &srv, c.handle, .agent_open, 200)) orelse | ||
| 11127 | return error.NoAgentOpen; | ||
| 11128 | defer open.deinit(alloc); | ||
| 11129 | const id = try proto.decodeAgentId(open.payload); | ||
| 11130 | |||
| 11131 | // Unsolicited bytes on the fresh channel. A reply is the proof; a | ||
| 11132 | // volley before any question is not, or a mute peer clears the clock | ||
| 11133 | // by talking first and then wedging the real request. | ||
| 11134 | var eager: [proto.agent_id_len + 2]u8 = undefined; | ||
| 11135 | @memcpy(eager[0..proto.agent_id_len], &proto.encodeAgentId(id)); | ||
| 11136 | @memcpy(eager[proto.agent_id_len..], "hi"); | ||
| 11137 | try proto.writeFrame(c.handle, .agent_data, &eager); | ||
| 11138 | var buf: [64]u8 = undefined; | ||
| 11139 | _ = (try pumpUntilReadable(&srv, agent.handle, &buf, 200)) orelse return error.NotForwarded; | ||
| 11140 | |||
| 11141 | // Then ssh asks and the client goes quiet: the clock must still run. | ||
| 11142 | try proto.writeAllFd(agent.handle, "req"); | ||
| 11143 | const fwd = (try awaitFrame(alloc, &srv, c.handle, .agent_data, 200)) orelse | ||
| 11144 | return error.RequestNeverForwarded; | ||
| 11145 | fwd.deinit(alloc); | ||
| 11146 | try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&srv, agent.handle, &buf, 200)); | ||
| 11147 | try std.testing.expect(!srv.clients[0].?.agent_offer); | ||
| 11148 | } | ||
| 11149 | |||
| 11150 | test "Server: a channel nobody has asked anything on is not timed out" { | ||
| 11151 | const alloc = std.testing.allocator; | ||
| 11152 | |||
| 11153 | var tmp = try TmpDir.make(); | ||
| 11154 | defer tmp.cleanup(); | ||
| 11155 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/agentidle.sock", .{tmp.path()}); | ||
| 11156 | defer alloc.free(sock_path); | ||
| 11157 | |||
| 11158 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); | ||
| 11159 | defer srv.deinit(); | ||
| 11160 | srv.agent_answer_ms = 150; | ||
| 11161 | |||
| 11162 | const c = try std.net.connectUnixSocket(sock_path); | ||
| 11163 | defer c.close(); | ||
| 11164 | try attachOffering(&srv, c.handle, 0, ""); | ||
| 11165 | |||
| 11166 | const path = srv.ses(0).agent_path orelse return error.NoAgentSocket; | ||
| 11167 | const agent = try std.net.connectUnixSocket(path); | ||
| 11168 | defer agent.close(); | ||
| 11169 | const open = (try awaitFrame(alloc, &srv, c.handle, .agent_open, 200)) orelse | ||
| 11170 | return error.NoAgentOpen; | ||
| 11171 | open.deinit(alloc); | ||
| 11172 | |||
| 11173 | // ssh dialled and has not asked yet. The client owes nothing until it | ||
| 11174 | // has been handed a request, so a clock anchored on the open would | ||
| 11175 | // hang up on a working client for the peer's pause. | ||
| 11176 | var buf: [16]u8 = undefined; | ||
| 11177 | try std.testing.expectEqual(@as(?usize, null), try pumpUntilReadable(&srv, agent.handle, &buf, 100)); | ||
| 11178 | try std.testing.expect(srv.agent_chans[0] != null); | ||
| 11179 | try std.testing.expect(srv.clients[0].?.agent_offer); | ||
| 11180 | } | ||
| 11181 | |||
| 10957 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 11182 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
| 10958 | // unreferenced decl must at least compile (the silent-module-loss hazard, | 11183 | // unreferenced decl must at least compile (the silent-module-loss hazard, |
| 10959 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | 11184 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -1288,6 +1288,9 @@ cleanup() { | |||
| 1288 | # the leak sweep's business, but they are daemons this file forked: left | 1288 | # the leak sweep's business, but they are daemons this file forked: left |
| 1289 | # alive they outlive the suite holding a private key, which is the one | 1289 | # alive they outlive the suite holding a private key, which is the one |
| 1290 | # kind of leak worth chasing even on a green run. | 1290 | # kind of leak worth chasing even on a green run. |
| 1291 | # CONT first: the mute-offerer leg holds this agent under SIGSTOP, and | ||
| 1292 | # a stopped process does not see SIGTERM until it runs again. | ||
| 1293 | [ -n "$AGENT48PID" ] && kill -CONT "$AGENT48PID" 2>/dev/null || true | ||
| 1291 | [ -n "$AGENT48PID" ] && kill "$AGENT48PID" 2>/dev/null || true | 1294 | [ -n "$AGENT48PID" ] && kill "$AGENT48PID" 2>/dev/null || true |
| 1292 | [ -n "$AGENT49APID" ] && kill "$AGENT49APID" 2>/dev/null || true | 1295 | [ -n "$AGENT49APID" ] && kill "$AGENT49APID" 2>/dev/null || true |
| 1293 | [ -n "$AGENT49BPID" ] && kill "$AGENT49BPID" 2>/dev/null || true | 1296 | [ -n "$AGENT49BPID" ] && kill "$AGENT49BPID" 2>/dev/null || true |
| @@ -1516,6 +1519,9 @@ cleanup() { | |||
| 1516 | # agent forwarding: the nested-agent leg and the three no-offer captures. | 1519 | # agent forwarding: the nested-agent leg and the three no-offer captures. |
| 1517 | rm -f "$OUT.agtnest" "$OUT.agtnest.err" "$OUT.agtnest.log" "$OUT.anoag" \ | 1520 | rm -f "$OUT.agtnest" "$OUT.agtnest.err" "$OUT.agtnest.log" "$OUT.anoag" \ |
| 1518 | "$OUT.anoag2" "$OUT.anoag3" | 1521 | "$OUT.anoag2" "$OUT.anoag3" |
| 1522 | # agent forwarding: the mute-offerer leg. | ||
| 1523 | rm -f "$OUT.agtmute" "$OUT.agtmute.err" "$OUT.agtmute.log" "$OUT.agtmute.env" \ | ||
| 1524 | "$OUT.agtmute.d" | ||
| 1519 | # the CLI wall: both halves (cw*, wc*) — captures, injections and stop files. | 1525 | # the CLI wall: both halves (cw*, wc*) — captures, injections and stop files. |
| 1520 | rm -f "$OUT.cwa" "$OUT.cwa.err" "$OUT.cwall.d" "$OUT.cwb" "$OUT.cwb.err" \ | 1526 | rm -f "$OUT.cwa" "$OUT.cwa.err" "$OUT.cwall.d" "$OUT.cwb" "$OUT.cwb.err" \ |
| 1521 | "$OUT.cwcap" "$OUT.cwcap.err" "$OUT.cwinj" "$OUT.cwpc" "$OUT.cwst" \ | 1527 | "$OUT.cwcap" "$OUT.cwcap.err" "$OUT.cwinj" "$OUT.cwpc" "$OUT.cwst" \ |
| @@ -7542,6 +7548,7 @@ kill "$AGENT49BPID" 2>/dev/null || true | |||
| 7542 | AGENT49BPID="" | 7548 | AGENT49BPID="" |
| 7543 | ok "agent forwarding: the agent that answers is whoever typed last" | 7549 | ok "agent forwarding: the agent that answers is whoever typed last" |
| 7544 | 7550 | ||
| 7551 | |||
| 7545 | # ---- a mouse report at the UNZOOMED wall is discarded, not typed -------- | 7552 | # ---- a mouse report at the UNZOOMED wall is discarded, not typed -------- |
| 7546 | # | 7553 | # |
| 7547 | # The wall asks its own terminal for mouse reporting now, so reports reach | 7554 | # The wall asks its own terminal for mouse reporting now, so reports reach |
| @@ -7825,6 +7832,86 @@ assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop" | |||
| 7825 | D48PID="" | 7832 | D48PID="" |
| 7826 | ok "a drag copies on release, and a click copies nothing" | 7833 | ok "a drag copies on release, and a click copies nothing" |
| 7827 | 7834 | ||
| 7835 | # --- an offerer that cannot answer is hung up on, and ssh falls through ---- | ||
| 7836 | # | ||
| 7837 | # An agent_offer is a declaration, not a capability (decisions.md | ||
| 7838 | # 2026-08-22). The offerer here is a real `mux -A` whose agent is a real | ||
| 7839 | # ssh-agent under SIGSTOP: the kernel still completes the connect from the | ||
| 7840 | # listen backlog, so the -A preflight — which fails OPEN on silence, by | ||
| 7841 | # design — lets the client through, and every request the session forwards | ||
| 7842 | # to it then vanishes. That is the mute peer the daemon's answer clock | ||
| 7843 | # exists for, produced with nothing but the binaries this suite already | ||
| 7844 | # requires. | ||
| 7845 | # | ||
| 7846 | # Measured by hand for the issue: ssh-add against a socket that accepts and | ||
| 7847 | # never replies blocks past 8s, against one that accepts and closes it | ||
| 7848 | # fails in 2ms. So the two things asserted are the two halves of the fix: | ||
| 7849 | # the FIRST ssh-add comes back inside `timeout 8` (the daemon closed the | ||
| 7850 | # channel for the client that would not), and the SECOND is refused in | ||
| 7851 | # milliseconds (the offer was taken away, so nobody routes to the mute | ||
| 7852 | # client again and ssh pays nothing). | ||
| 7853 | "$MUXD" run --sock "$SOCK48" --shell /bin/sh > "$OUT.agtmute.d" 2>&1 & | ||
| 7854 | D42PID=$! | ||
| 7855 | wait_sock "$SOCK48" "$OUT.agtmute.d" "agent-mute daemon never bound" | ||
| 7856 | ssh-agent -a "$AGENT48" > "$OUT.agtmute.env" 2>&1 | ||
| 7857 | AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agtmute.env") | ||
| 7858 | [ -n "$AGENT48PID" ] || { | ||
| 7859 | echo "e2e FAIL: agent-mute: ssh-agent printed no pid to stop:" | ||
| 7860 | cat "$OUT.agtmute.env"; exit 1; } | ||
| 7861 | kill -STOP "$AGENT48PID" | ||
| 7862 | set +e | ||
| 7863 | SSH_AUTH_SOCK="$AGENT48" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 7864 | --out "$OUT.agtmute" --err "$OUT.agtmute.err" \ | ||
| 7865 | -- "$MUX" -A --sock "$SOCK48" > "$OUT.agtmute.log" 2>&1 <<'EOF' | ||
| 7866 | expect \x1b[?1049h 15000 | ||
| 7867 | settle 400 15000 | ||
| 7868 | send timeout 8 ssh-add -l; echo mu""te1=$?\n | ||
| 7869 | expect mute1= 20000 | ||
| 7870 | send S=$(date +%s%N); ssh-add -l; R=$?; echo mu""te2=$R ms=$(( ($(date +%s%N)-S)/1000000 ))\n | ||
| 7871 | expect mute2= 15000 | ||
| 7872 | settle 400 15000 | ||
| 7873 | send exit\n | ||
| 7874 | waitexit 10000 | ||
| 7875 | EOF | ||
| 7876 | RC=$? | ||
| 7877 | set -e | ||
| 7878 | # The agent is resumed before it is killed: a stopped process ignores | ||
| 7879 | # SIGTERM until it runs again, and the trap's plain kill would leave it. | ||
| 7880 | kill -CONT "$AGENT48PID" 2>/dev/null || true | ||
| 7881 | kill "$AGENT48PID" 2>/dev/null || true | ||
| 7882 | AGENT48PID="" | ||
| 7883 | # The needles are split in the typed lines (`mu""te1=`) so an expect can | ||
| 7884 | # only be satisfied by the command's OUTPUT, never by the pty's echo of the | ||
| 7885 | # command — with an 8s wait in play, matching the echo runs the script | ||
| 7886 | # ahead into a shell that is still busy. The wedge is checked before the | ||
| 7887 | # exit code, because a wedged second ssh-add also swallows the `exit`. | ||
| 7888 | grep -q "mute1=124" "$OUT.agtmute" && { | ||
| 7889 | echo "e2e FAIL: agent-mute: the first ssh-add wedged for the whole 8s — the" | ||
| 7890 | echo " daemon never hung up on the client that would not answer:" | ||
| 7891 | cat -v "$OUT.agtmute"; exit 1; } | ||
| 7892 | [ "$RC" -eq 0 ] || { | ||
| 7893 | echo "e2e FAIL: agent-mute: the leg exited $RC:" | ||
| 7894 | cat "$OUT.agtmute.log"; exit 1; } | ||
| 7895 | grep -qE "mute1=[1-9]" "$OUT.agtmute" || { | ||
| 7896 | echo "e2e FAIL: agent-mute: the first ssh-add did not fail at all — something" | ||
| 7897 | echo " answered for a stopped agent:" | ||
| 7898 | cat -v "$OUT.agtmute"; exit 1; } | ||
| 7899 | MUTE2MS=$(sed -n 's/.*mute2=[0-9]* ms=\([0-9]*\).*/\1/p' "$OUT.agtmute" | head -1) | ||
| 7900 | [ -n "$MUTE2MS" ] || { | ||
| 7901 | echo "e2e FAIL: agent-mute: the second ssh-add reported no timing:" | ||
| 7902 | cat -v "$OUT.agtmute"; exit 1; } | ||
| 7903 | # Well under the 5s bound, not merely under 8s: a refusal is the dial being | ||
| 7904 | # closed at accept, and the only way to take 5s here is to be routed to the | ||
| 7905 | # mute client again, which means the offer was not cleared. | ||
| 7906 | [ "$MUTE2MS" -lt 2000 ] || { | ||
| 7907 | echo "e2e FAIL: agent-mute: the second ssh-add took ${MUTE2MS}ms — the mute" | ||
| 7908 | echo " client kept its offer and ssh paid the bound again" | ||
| 7909 | cat -v "$OUT.agtmute"; exit 1; } | ||
| 7910 | wait_pid_gone "$D42PID" "agent-mute: the session ended and the daemon should follow" | ||
| 7911 | D42PID="" | ||
| 7912 | ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)" | ||
| 7913 | |||
| 7914 | |||
| 7828 | 7915 | ||
| 7829 | # The long-lived daemon has served every scenario that wanted it; stop it | 7916 | # The long-lived daemon has served every scenario that wanted it; stop it |
| 7830 | # NOW so its allocator verdict is written while the suite is still running | 7917 | # NOW so its allocator verdict is written while the suite is still running |
| @@ -7955,8 +8042,15 @@ DPID="" | |||
| 7955 | # supposed to emit are all still present, and the row is a column too wide. | 8042 | # supposed to emit are all still present, and the row is a column too wide. |
| 7956 | # That shipped and was found by hand. This is the check that would have | 8043 | # That shipped and was found by hand. This is the check that would have |
| 7957 | # caught it. | 8044 | # caught it. |
| 7958 | [ "$OK_COUNT" = "64" ] || { | 8045 | # |
| 7959 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 64 —" | 8046 | # The 65th is the mute offerer, and no convergence point for the 58th's |
| 8047 | # reason: its subject is whether ssh in the session got an answer, and how | ||
| 8048 | # fast, which is a side channel no grid carries. Last in the file because | ||
| 8049 | # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that | ||
| 8050 | # has to CONT before it kills is cheaper to reason about with nothing | ||
| 8051 | # after it. | ||
| 8052 | [ "$OK_COUNT" = "65" ] || { | ||
| 8053 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 65 —" | ||
| 7960 | echo " a scenario was added (update the pin) or silently lost" | 8054 | echo " a scenario was added (update the pin) or silently lost" |
| 7961 | exit 1 | 8055 | exit 1 |
| 7962 | } | 8056 | } |