a73x

2ae7c94b

fix: a client that never answers its first agent request is hung up on

a73x   2026-08-22 17:46

Commit message
fix: a client that never answers its first agent request is hung up on

An agent_offer is a declaration, not a capability, and the daemon took
every client's word for it. Measured: ssh on a socket that accepts and
never replies blocks past 8s, where one that accepts and closes falls
through to the next auth method in 2ms. So an offerer that cannot
answer does not degrade forwarding, it wedges it — and eight such dials
fill the channel table against the real offerer too.

The daemon now clocks a channel from its FIRST forwarded request until
the client's first reply. Past 5s it closes the fd — ssh reads the
refusal it would have got from no offerer at all — and clears the
client's offer, so the next dial routes to a peer that answers.

Two things the clock deliberately does not do, each pinned by a test
watched to fire under its mutation:

- It is not anchored on the open. Until ssh asks, the client owes
  nothing, and a working client would lose its offer for ssh's pause.
- It does not run again after one reply. One answer is the proof the
  offer claimed; a later SIGN may wait on a token's touch, and the
  preflight's rule stands — never separate slow from refused.

An honest client already hangs up courteously (agent_close on any
reason it cannot serve). This is the backstop for a peer that does not
speak at all, which is what a browser-authored offer would be.

Pinned end to end as well, with no new fixture: the offerer is a real
`mux -A` whose ssh-agent is under SIGSTOP. The kernel completes the
connect from the listen backlog, so the fail-open preflight lets it
through, and ssh-add in the session is the oracle — the first comes back
inside `timeout 8`, the second is refused in milliseconds. Watched to
fail with the clock disabled. Two traps the leg found: expect needles
matched the pty's echo of the typed command (now split, `mu""te1=`),
and a plain kill cannot reap a stopped agent (the trap CONTs first).

Collab: b316df53

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

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 }