detached 50dc2b1

Merge pull request 'feat: keep remote sessions responsive and resumable on flaky links' (#6) from feat/quic-resilience into main

commit
50dc2b1af6f69930cc81c8e1fce7f45aeb746e55
author
bnema · · 2026-10-07 08:16 UTC
merge of
93f7e39 + 12ef17b · diff against the first parent
signature
unsigned
39 files · +3160 -98
M Makefile +5 -0
@@ -9,6 +9,7 @@ install:
99
1010 test:
1111 go test ./... -race
12+ python3 -m unittest discover -s scripts/remote-resilience-harness -p '*_test.py'
1213
1314 test-installer:
1415 sh scripts/install_platform_test.sh
@@ -31,6 +32,10 @@ mocks:
3132 remote-acceptance:
3233 scripts/remote-picker-harness/run.sh
3334
35+.PHONY: remote-resilience-visual
36+remote-resilience-visual:
37+ python3 scripts/remote-resilience-harness/visual.py
38+
3439 # protocol regenerates the Protobuf wire contract from
3540 # internal/protocol/wire/schema using the pinned Go tools (buf + protoc-gen-go).
3641 protocol:
@@ -9,6 +9,7 @@ install:
9 9
10 test:10 test:
11 go test ./... -race11 go test ./... -race
12+ python3 -m unittest discover -s scripts/remote-resilience-harness -p '*_test.py'
12 13
13 test-installer:14 test-installer:
14 sh scripts/install_platform_test.sh15 sh scripts/install_platform_test.sh
@@ -31,6 +32,10 @@ mocks:
31 remote-acceptance:32 remote-acceptance:
32 scripts/remote-picker-harness/run.sh33 scripts/remote-picker-harness/run.sh
33 34
35+.PHONY: remote-resilience-visual
36+remote-resilience-visual:
37+ python3 scripts/remote-resilience-harness/visual.py
38+
34 # protocol regenerates the Protobuf wire contract from39 # protocol regenerates the Protobuf wire contract from
35 # internal/protocol/wire/schema using the pinned Go tools (buf + protoc-gen-go).40 # internal/protocol/wire/schema using the pinned Go tools (buf + protoc-gen-go).
36 protocol:41 protocol:
M docs/configuration.md +1 -1
@@ -187,7 +187,7 @@ Configure this in `~/.config/vev/broker.json`. The file is created on first use
187187 - `warmTransports`: how many idle connections to keep (0–64, default 8). The oldest one closes first. `0` disables it.
188188 - `warmIdleTimeout`: close an idle connection after this long, such as `5m` (max `24h`). Default `off`: no time limit.
189189
190-Warm connections never keep the broker running.
190+Warm connections never keep the broker running. Each open QUIC connection, warm ones included, sends a small keepalive every 2 seconds so mobile and NAT paths stay open; on battery, set `warmIdleTimeout` to close idle ones.
191191
192192 ## Logs and durable state
193193
@@ -187,7 +187,7 @@ Configure this in `~/.config/vev/broker.json`. The file is created on first use
187 - `warmTransports`: how many idle connections to keep (0–64, default 8). The oldest one closes first. `0` disables it.187 - `warmTransports`: how many idle connections to keep (0–64, default 8). The oldest one closes first. `0` disables it.
188 - `warmIdleTimeout`: close an idle connection after this long, such as `5m` (max `24h`). Default `off`: no time limit.188 - `warmIdleTimeout`: close an idle connection after this long, such as `5m` (max `24h`). Default `off`: no time limit.
189 189
190-Warm connections never keep the broker running.190+Warm connections never keep the broker running. Each open QUIC connection, warm ones included, sends a small keepalive every 2 seconds so mobile and NAT paths stay open; on battery, set `warmIdleTimeout` to close idle ones.
191 191
192 ## Logs and durable state192 ## Logs and durable state
193 193
A docs/plans/quic-resilience.md +74 -0
@@ -0,0 +1,74 @@
1+# Plan: mosh-like remote resilience
2+
3+Goal: on a lossy or intermittent link (4G, roaming, sleep), the UI never
4+freezes, typed input is kept and delivered when the link returns, and SSH is
5+used only to authenticate the first QUIC handshake.
6+
7+Reference: [mosh](https://github.com/mobile-shell/mosh)
8+(`src/network/transportsender-impl.h`, `src/frontend/terminaloverlay.cc`).
9+
10+## Diagnosis (before Phases 1 and 2)
11+
12+1. Input path blocks on the network: `sessionAttachmentWorker.send` waits for
13+ `SendClient`, which waits for the carriage write. While QUIC stalls, the
14+ worker loop stops reading keys, servicing overlays, and painting.
15+2. Every reconnect re-runs SSH: `_broker-mux-quic-proxy` serves one
16+ connection with one-time credentials and exits with it.
17+3. No connection migration: one UDP socket per connection, no `AddPath`.
18+4. Slow detection, early give-up: keepalive 15 s / idle 60 s; 5 resume
19+ attempts over a few seconds, then back to the picker.
20+5. `LinkState` is only ever `Offline` at teardown; `degraded`/`probing` are
21+ documented but never produced.
22+6. Daemon ignores `Input.InputSeq`, so input in flight at a drop is lost or
23+ would be duplicated by a replay.
24+
25+## Phase 1 — non-blocking client send (done)
26+
27+- Per-attachment ordered outbox drained by one sender goroutine; the worker
28+ loop enqueues and keeps running. Write failure settles the run as before.
29+- Bounded by count and bytes. An input delivery stays undecided until the
30+ outbox wrote it, so the pump reads no newer keys meanwhile (backpressure)
31+ and a link failure preserves exactly the unwritten keys for the resume.
32+- Lifecycle exits drain the outbox for at most 2 s.
33+
34+## Phase 2 — patient resume (done, except status text)
35+
36+- QUIC keepalive 2 s; idle timeout stays 60 s.
37+- A route that stays down keeps resuming for the 15-minute park window; a
38+ session that drops right after attaching still gives up after 5 flaps.
39+- During the resume backoff, Ctrl-C or a lone Esc returns to the picker;
40+ other keys are kept (bounded) and replayed into the resumed session.
41+- TODO: status bar "last contact Ns ago" while resuming.
42+
43+## Phase 3 — exactly-once input across resume (protocol bump)
44+
45+- Daemon tracks last applied `InputSeq` per attachment; drops `<=` seqs.
46+- `Welcome` on resume carries the last applied seq; client replays unacked
47+ input after it. Bump `protocol.Version`.
48+
49+## Phase 4 — SSH only for first handshake (remote helpers, trust)
50+
51+- Remote QUIC proxy outlives one connection for the park window and issues a
52+ resume ticket (bearer secret, bound to cert fingerprint + session, expiry,
53+ single active use, memory only).
54+- Reconnect dials QUIC directly with the ticket; SSH only on rejection/expiry.
55+- Picker host health uses the live link + a ticket-based QUIC probe, never
56+ SSH.
57+- Needs a security review (ticket theft, replay, expiry) before merge.
58+
59+## Phase 5 — real link states and path migration
60+
61+- Derive `degraded`/`probing` from RTT and last-packet age in the adapter.
62+- On stall or local address change, `AddPath` + probe + switch.
63+
64+## Phase 6 — client-rendered palette on remote sessions
65+
66+- The command palette is rendered by the serving daemon, so each keystroke
67+ costs a full round trip on a remote session. Render it client-side like
68+ the session picker, fed by the daemon's command catalogue, and send only
69+ the chosen command.
70+
71+## Phase 7 — optional predictive local echo
72+
73+- mosh-style overlay with underline-until-confirmed. Breaks the "thin client
74+ interprets nothing" rule; needs an explicit design decision.
@@ -0,0 +1,74 @@
1+# Plan: mosh-like remote resilience
2+
3+Goal: on a lossy or intermittent link (4G, roaming, sleep), the UI never
4+freezes, typed input is kept and delivered when the link returns, and SSH is
5+used only to authenticate the first QUIC handshake.
6+
7+Reference: [mosh](https://github.com/mobile-shell/mosh)
8+(`src/network/transportsender-impl.h`, `src/frontend/terminaloverlay.cc`).
9+
10+## Diagnosis (before Phases 1 and 2)
11+
12+1. Input path blocks on the network: `sessionAttachmentWorker.send` waits for
13+ `SendClient`, which waits for the carriage write. While QUIC stalls, the
14+ worker loop stops reading keys, servicing overlays, and painting.
15+2. Every reconnect re-runs SSH: `_broker-mux-quic-proxy` serves one
16+ connection with one-time credentials and exits with it.
17+3. No connection migration: one UDP socket per connection, no `AddPath`.
18+4. Slow detection, early give-up: keepalive 15 s / idle 60 s; 5 resume
19+ attempts over a few seconds, then back to the picker.
20+5. `LinkState` is only ever `Offline` at teardown; `degraded`/`probing` are
21+ documented but never produced.
22+6. Daemon ignores `Input.InputSeq`, so input in flight at a drop is lost or
23+ would be duplicated by a replay.
24+
25+## Phase 1 — non-blocking client send (done)
26+
27+- Per-attachment ordered outbox drained by one sender goroutine; the worker
28+ loop enqueues and keeps running. Write failure settles the run as before.
29+- Bounded by count and bytes. An input delivery stays undecided until the
30+ outbox wrote it, so the pump reads no newer keys meanwhile (backpressure)
31+ and a link failure preserves exactly the unwritten keys for the resume.
32+- Lifecycle exits drain the outbox for at most 2 s.
33+
34+## Phase 2 — patient resume (done, except status text)
35+
36+- QUIC keepalive 2 s; idle timeout stays 60 s.
37+- A route that stays down keeps resuming for the 15-minute park window; a
38+ session that drops right after attaching still gives up after 5 flaps.
39+- During the resume backoff, Ctrl-C or a lone Esc returns to the picker;
40+ other keys are kept (bounded) and replayed into the resumed session.
41+- TODO: status bar "last contact Ns ago" while resuming.
42+
43+## Phase 3 — exactly-once input across resume (protocol bump)
44+
45+- Daemon tracks last applied `InputSeq` per attachment; drops `<=` seqs.
46+- `Welcome` on resume carries the last applied seq; client replays unacked
47+ input after it. Bump `protocol.Version`.
48+
49+## Phase 4 — SSH only for first handshake (remote helpers, trust)
50+
51+- Remote QUIC proxy outlives one connection for the park window and issues a
52+ resume ticket (bearer secret, bound to cert fingerprint + session, expiry,
53+ single active use, memory only).
54+- Reconnect dials QUIC directly with the ticket; SSH only on rejection/expiry.
55+- Picker host health uses the live link + a ticket-based QUIC probe, never
56+ SSH.
57+- Needs a security review (ticket theft, replay, expiry) before merge.
58+
59+## Phase 5 — real link states and path migration
60+
61+- Derive `degraded`/`probing` from RTT and last-packet age in the adapter.
62+- On stall or local address change, `AddPath` + probe + switch.
63+
64+## Phase 6 — client-rendered palette on remote sessions
65+
66+- The command palette is rendered by the serving daemon, so each keystroke
67+ costs a full round trip on a remote session. Render it client-side like
68+ the session picker, fed by the daemon's command catalogue, and send only
69+ the chosen command.
70+
71+## Phase 7 — optional predictive local echo
72+
73+- mosh-style overlay with underline-until-confirmed. Breaks the "thin client
74+ interprets nothing" rule; needs an explicit design decision.
M docs/remote-resilience.md +10 -9
@@ -25,21 +25,22 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru
2525 ## What survives a network problem
2626
2727 - The remote session and its shells keep running while you are offline.
28-- The client reconnects by itself after a network change, a VPN switch, or a laptop sleep.
28+- The client reconnects by itself after a network change, a VPN switch, or a laptop sleep, and keeps retrying for up to 15 minutes of outage before it returns to the picker. Press Ctrl-C or Esc while it reconnects to give up and return to the picker.
29+- A stalled link does not block the client loop. Keys typed while the link stalls or reconnects are kept and sent in order once it recovers. Keys vev had not yet sent when the link dropped are replayed to the resumed session; keys already in flight at the drop may be lost.
2930 - If the reconnect window has expired but the session still exists, the client opens a fresh attachment to it.
3031 - Several clients can attach to the same session. Each keeps its own window, tab, focus, and copy mode.
3132
33+The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage.
34+
3235 ## Connection states
3336
34-| State | Meaning |
37+| What you see | Meaning |
3538 |---|---|
36-| `connected` | Everything works. |
37-| `degraded` | Packets arrive, but updates are slow. |
38-| `probing` | No contact; vev is looking for a working path. |
39-| `offline` | No contact for a while; vev is reconnecting. |
40-| `dead` | The connection is gone for good. |
39+| The session | The link works. |
40+| `Connecting to session…` | The link dropped; vev is reconnecting and keeps your keys. |
41+| The picker with an error | The session could not be reached again, or you cancelled. |
4142
42-States appear in the vev status bar, never inside your shell output.
43+vev does not yet report a slow (`degraded`) link separately. Connection notices never appear inside your shell output.
4344
4445 ## Hybrid mode: local and remote together
4546
@@ -78,6 +79,6 @@ VEV_LOG=debug vev attach user@host
7879 scripts/debug-remote-attach.sh user@host # collects redacted connection health
7980 ```
8081
81-Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session.
82+Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session. For isolated QUIC/SSH impairment and real Wayland terminal captures, see the [visual resilience harness](../scripts/remote-resilience-harness/README.md).
8283
8384 Keep hostnames, usernames, and keys out of shared logs and screenshots.
@@ -25,21 +25,22 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru
25 ## What survives a network problem25 ## What survives a network problem
26 26
27 - The remote session and its shells keep running while you are offline.27 - The remote session and its shells keep running while you are offline.
28-- The client reconnects by itself after a network change, a VPN switch, or a laptop sleep.28+- The client reconnects by itself after a network change, a VPN switch, or a laptop sleep, and keeps retrying for up to 15 minutes of outage before it returns to the picker. Press Ctrl-C or Esc while it reconnects to give up and return to the picker.
29+- A stalled link does not block the client loop. Keys typed while the link stalls or reconnects are kept and sent in order once it recovers. Keys vev had not yet sent when the link dropped are replayed to the resumed session; keys already in flight at the drop may be lost.
29 - If the reconnect window has expired but the session still exists, the client opens a fresh attachment to it.30 - If the reconnect window has expired but the session still exists, the client opens a fresh attachment to it.
30 - Several clients can attach to the same session. Each keeps its own window, tab, focus, and copy mode.31 - Several clients can attach to the same session. Each keeps its own window, tab, focus, and copy mode.
31 32
33+The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage.
34+
32 ## Connection states35 ## Connection states
33 36
34-| State | Meaning |37+| What you see | Meaning |
35 |---|---|38 |---|---|
36-| `connected` | Everything works. |39+| The session | The link works. |
37-| `degraded` | Packets arrive, but updates are slow. |40+| `Connecting to session…` | The link dropped; vev is reconnecting and keeps your keys. |
38-| `probing` | No contact; vev is looking for a working path. |41+| The picker with an error | The session could not be reached again, or you cancelled. |
39-| `offline` | No contact for a while; vev is reconnecting. |
40-| `dead` | The connection is gone for good. |
41 42
42-States appear in the vev status bar, never inside your shell output.43+vev does not yet report a slow (`degraded`) link separately. Connection notices never appear inside your shell output.
43 44
44 ## Hybrid mode: local and remote together45 ## Hybrid mode: local and remote together
45 46
@@ -78,6 +79,6 @@ VEV_LOG=debug vev attach user@host
78 scripts/debug-remote-attach.sh user@host # collects redacted connection health79 scripts/debug-remote-attach.sh user@host # collects redacted connection health
79 ```80 ```
80 81
81-Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session.82+Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session. For isolated QUIC/SSH impairment and real Wayland terminal captures, see the [visual resilience harness](../scripts/remote-resilience-harness/README.md).
82 83
83 Keep hostnames, usernames, and keys out of shared logs and screenshots.84 Keep hostnames, usernames, and keys out of shared logs and screenshots.
M internal/adapters/quic/quic.go +9 -3
@@ -61,8 +61,14 @@ var (
6161 // listener and none on the dialer, no unidirectional streams, keepalive /
6262 // idle limits, bounded admission, no 0-RTT.
6363 type Config struct {
64- // KeepAlivePeriod defaults to 15s; MaxIdleTimeout defaults to 60s;
65- // HandshakeIdleTimeout defaults to 5s.
64+ // KeepAlivePeriod defaults to 2s; MaxIdleTimeout defaults to 60s;
65+ // HandshakeIdleTimeout defaults to 5s. The short keepalive keeps mobile
66+ // CGNAT bindings open and probes the path often enough that loss
67+ // recovery restarts promptly once the link returns. It does not shorten
68+ // failure detection: a dead path is still declared only after
69+ // MaxIdleTimeout, which stays long so one connection rides out short
70+ // outages and delivers queued input without reconnecting. Both roles use
71+ // it; a 2s PING is a few dozen bytes per connection.
6672 KeepAlivePeriod time.Duration
6773 MaxIdleTimeout time.Duration
6874 HandshakeIdleTimeout time.Duration
@@ -86,7 +92,7 @@ const noIncomingStreams int64 = -1
8692 func baseQUICConfig(config Config) *quicgo.Config {
8793 keepAlive := config.KeepAlivePeriod
8894 if keepAlive == 0 {
89- keepAlive = 15 * time.Second
95+ keepAlive = 2 * time.Second
9096 }
9197 maxIdle := config.MaxIdleTimeout
9298 if maxIdle == 0 {
@@ -61,8 +61,14 @@ var (
61 // listener and none on the dialer, no unidirectional streams, keepalive /61 // listener and none on the dialer, no unidirectional streams, keepalive /
62 // idle limits, bounded admission, no 0-RTT.62 // idle limits, bounded admission, no 0-RTT.
63 type Config struct {63 type Config struct {
64- // KeepAlivePeriod defaults to 15s; MaxIdleTimeout defaults to 60s;64+ // KeepAlivePeriod defaults to 2s; MaxIdleTimeout defaults to 60s;
65- // HandshakeIdleTimeout defaults to 5s.65+ // HandshakeIdleTimeout defaults to 5s. The short keepalive keeps mobile
66+ // CGNAT bindings open and probes the path often enough that loss
67+ // recovery restarts promptly once the link returns. It does not shorten
68+ // failure detection: a dead path is still declared only after
69+ // MaxIdleTimeout, which stays long so one connection rides out short
70+ // outages and delivers queued input without reconnecting. Both roles use
71+ // it; a 2s PING is a few dozen bytes per connection.
66 KeepAlivePeriod time.Duration72 KeepAlivePeriod time.Duration
67 MaxIdleTimeout time.Duration73 MaxIdleTimeout time.Duration
68 HandshakeIdleTimeout time.Duration74 HandshakeIdleTimeout time.Duration
@@ -86,7 +92,7 @@ const noIncomingStreams int64 = -1
86 func baseQUICConfig(config Config) *quicgo.Config {92 func baseQUICConfig(config Config) *quicgo.Config {
87 keepAlive := config.KeepAlivePeriod93 keepAlive := config.KeepAlivePeriod
88 if keepAlive == 0 {94 if keepAlive == 0 {
89- keepAlive = 15 * time.Second95+ keepAlive = 2 * time.Second
90 }96 }
91 maxIdle := config.MaxIdleTimeout97 maxIdle := config.MaxIdleTimeout
92 if maxIdle == 0 {98 if maxIdle == 0 {
A internal/adapters/quicnettest/listen_test.go +65 -0
@@ -0,0 +1,65 @@
1+package quicnettest
2+
3+import (
4+ "net"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestExplicitListenSurvivesRebind(t *testing.T) {
12+ for _, ip := range []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback} {
13+ t.Run(ip.String(), func(t *testing.T) {
14+ server, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip})
15+ if err != nil && ip.To4() == nil {
16+ t.Skipf("IPv6 loopback unavailable: %v", err)
17+ }
18+ require.NoError(t, err)
19+ defer server.Close()
20+ proxy, err := New(Config{ServerAddr: server.LocalAddr().(*net.UDPAddr), ListenAddr: &net.UDPAddr{IP: ip}})
21+ require.NoError(t, err)
22+ defer proxy.Close()
23+ require.True(t, proxy.Addr().IP.Equal(ip), "requested listener address")
24+ client, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip})
25+ require.NoError(t, err)
26+ defer client.Close()
27+ var sources []string
28+ for attempt := 0; attempt < 2; attempt++ {
29+ if attempt == 1 {
30+ require.NoError(t, proxy.Rebind())
31+ }
32+ _, err = client.WriteToUDP([]byte("fixture"), proxy.Addr())
33+ require.NoError(t, err)
34+ require.NoError(t, server.SetReadDeadline(time.Now().Add(time.Second)))
35+ data := make([]byte, 32)
36+ n, from, err := server.ReadFromUDP(data)
37+ require.NoError(t, err)
38+ require.Equal(t, "fixture", string(data[:n]))
39+ sources = append(sources, from.String())
40+ _, err = server.WriteToUDP(data[:n], from)
41+ require.NoError(t, err)
42+ require.NoError(t, client.SetReadDeadline(time.Now().Add(time.Second)))
43+ n, _, err = client.ReadFromUDP(data)
44+ require.NoError(t, err)
45+ require.Equal(t, "fixture", string(data[:n]))
46+ }
47+ require.NotEqual(t, sources[0], sources[1], "rebind must change the upstream source address")
48+ })
49+ }
50+}
51+
52+func TestUpstreamBindAddrMatchesServerFamily(t *testing.T) {
53+ for _, tt := range []struct {
54+ server, want net.IP
55+ }{
56+ {net.IPv4(127, 0, 0, 1), net.IPv4(127, 0, 0, 1)},
57+ {net.IPv4(192, 0, 2, 7), net.IPv4zero},
58+ {net.IPv6loopback, net.IPv6loopback},
59+ {net.ParseIP("2001:db8::7"), net.IPv6unspecified},
60+ } {
61+ t.Run(tt.server.String(), func(t *testing.T) {
62+ require.True(t, upstreamBindAddr(tt.server).IP.Equal(tt.want))
63+ })
64+ }
65+}
@@ -0,0 +1,65 @@
1+package quicnettest
2+
3+import (
4+ "net"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestExplicitListenSurvivesRebind(t *testing.T) {
12+ for _, ip := range []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback} {
13+ t.Run(ip.String(), func(t *testing.T) {
14+ server, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip})
15+ if err != nil && ip.To4() == nil {
16+ t.Skipf("IPv6 loopback unavailable: %v", err)
17+ }
18+ require.NoError(t, err)
19+ defer server.Close()
20+ proxy, err := New(Config{ServerAddr: server.LocalAddr().(*net.UDPAddr), ListenAddr: &net.UDPAddr{IP: ip}})
21+ require.NoError(t, err)
22+ defer proxy.Close()
23+ require.True(t, proxy.Addr().IP.Equal(ip), "requested listener address")
24+ client, err := net.ListenUDP("udp", &net.UDPAddr{IP: ip})
25+ require.NoError(t, err)
26+ defer client.Close()
27+ var sources []string
28+ for attempt := 0; attempt < 2; attempt++ {
29+ if attempt == 1 {
30+ require.NoError(t, proxy.Rebind())
31+ }
32+ _, err = client.WriteToUDP([]byte("fixture"), proxy.Addr())
33+ require.NoError(t, err)
34+ require.NoError(t, server.SetReadDeadline(time.Now().Add(time.Second)))
35+ data := make([]byte, 32)
36+ n, from, err := server.ReadFromUDP(data)
37+ require.NoError(t, err)
38+ require.Equal(t, "fixture", string(data[:n]))
39+ sources = append(sources, from.String())
40+ _, err = server.WriteToUDP(data[:n], from)
41+ require.NoError(t, err)
42+ require.NoError(t, client.SetReadDeadline(time.Now().Add(time.Second)))
43+ n, _, err = client.ReadFromUDP(data)
44+ require.NoError(t, err)
45+ require.Equal(t, "fixture", string(data[:n]))
46+ }
47+ require.NotEqual(t, sources[0], sources[1], "rebind must change the upstream source address")
48+ })
49+ }
50+}
51+
52+func TestUpstreamBindAddrMatchesServerFamily(t *testing.T) {
53+ for _, tt := range []struct {
54+ server, want net.IP
55+ }{
56+ {net.IPv4(127, 0, 0, 1), net.IPv4(127, 0, 0, 1)},
57+ {net.IPv4(192, 0, 2, 7), net.IPv4zero},
58+ {net.IPv6loopback, net.IPv6loopback},
59+ {net.ParseIP("2001:db8::7"), net.IPv6unspecified},
60+ } {
61+ t.Run(tt.server.String(), func(t *testing.T) {
62+ require.True(t, upstreamBindAddr(tt.server).IP.Equal(tt.want))
63+ })
64+ }
65+}
M internal/adapters/quicnettest/proxy.go +41 -33
@@ -2,7 +2,6 @@ package quicnettest
22
33 import (
44 "container/heap"
5- "context"
65 "errors"
76 "fmt"
87 "net"
@@ -14,14 +13,31 @@ import (
1413 // ErrClosed is returned by operations on a closed proxy.
1514 var ErrClosed = errors.New("quicnettest: proxy is closed")
1615
16+// upstreamBindAddr returns the ephemeral upstream bind address in the server's
17+// address family: loopback for a loopback server, unspecified otherwise.
18+func upstreamBindAddr(server net.IP) *net.UDPAddr {
19+ switch {
20+ case server.To4() != nil && server.IsLoopback():
21+ return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}
22+ case server.To4() != nil:
23+ return &net.UDPAddr{IP: net.IPv4zero}
24+ case server.IsLoopback():
25+ return &net.UDPAddr{IP: net.IPv6loopback}
26+ default:
27+ return &net.UDPAddr{IP: net.IPv6unspecified}
28+ }
29+}
30+
1731 // Proxy is a bounded, deterministic UDP relay with per-direction impairment. It
18-// listens on an ephemeral loopback port for clients and forwards to
32+// listens on Config.ListenAddr (default: ephemeral IPv4 loopback) for clients
33+// and forwards to
1934 // Config.ServerAddr from a separate upstream socket that can be rebound to a
2035 // new source address at any time.
2136 type Proxy struct {
22- cfg Config
23- clock Clock
24- server *net.UDPAddr
37+ cfg Config
38+ clock Clock
39+ server *net.UDPAddr
40+ upstreamAddr *net.UDPAddr
2541
2642 clientConn *net.UDPConn
2743 upstream atomic.Pointer[net.UDPConn]
@@ -128,11 +144,16 @@ func New(cfg Config) (*Proxy, error) {
128144 if err := cfg.validate(); err != nil {
129145 return nil, err
130146 }
131- clientConn, err := listenLoopback()
147+ listenAddr := cfg.ListenAddr
148+ if listenAddr == nil {
149+ listenAddr = &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}
150+ }
151+ clientConn, err := net.ListenUDP("udp", listenAddr)
132152 if err != nil {
133153 return nil, fmt.Errorf("quicnettest: listen client socket: %w", err)
134154 }
135- upstream, err := listenLoopback()
155+ upstreamAddr := upstreamBindAddr(cfg.ServerAddr.IP)
156+ upstream, err := net.ListenUDP("udp", upstreamAddr)
136157 if err != nil {
137158 _ = clientConn.Close()
138159 return nil, fmt.Errorf("quicnettest: listen upstream socket: %w", err)
@@ -149,17 +170,18 @@ func New(cfg Config) (*Proxy, error) {
149170 return newRNG(cfg.Seed, direction)
150171 }
151172 p := &Proxy{
152- cfg: cfg,
153- clock: cfg.Clock,
154- server: cfg.ServerAddr,
155- clientConn: clientConn,
156- done: make(chan struct{}),
157- slots: make(chan struct{}, cfg.QueueCapacity),
158- queue: make(chan packet, cfg.QueueCapacity),
159- start: start,
160- toServer: impairer{link: cfg.ToServer, rng: rngFor(ToServer), start: start},
161- toClient: impairer{link: cfg.ToClient, rng: rngFor(ToClient), start: start},
162- onPacket: cfg.OnPacket,
173+ cfg: cfg,
174+ clock: cfg.Clock,
175+ server: cfg.ServerAddr,
176+ upstreamAddr: upstreamAddr,
177+ clientConn: clientConn,
178+ done: make(chan struct{}),
179+ slots: make(chan struct{}, cfg.QueueCapacity),
180+ queue: make(chan packet, cfg.QueueCapacity),
181+ start: start,
182+ toServer: impairer{link: cfg.ToServer, rng: rngFor(ToServer), start: start},
183+ toClient: impairer{link: cfg.ToClient, rng: rngFor(ToClient), start: start},
184+ onPacket: cfg.OnPacket,
163185 }
164186 p.upstream.Store(upstream)
165187 p.wg.Add(3)
@@ -206,7 +228,7 @@ func (p *Proxy) Rebind() error {
206228 if p.stopping() {
207229 return ErrClosed
208230 }
209- conn, err := listenLoopback()
231+ conn, err := net.ListenUDP("udp", p.upstreamAddr)
210232 if err != nil {
211233 return fmt.Errorf("quicnettest: rebind: %w", err)
212234 }
@@ -530,20 +552,6 @@ func directionOf(toServer bool) Direction {
530552 return ToClient
531553 }
532554
533-func listenLoopback() (*net.UDPConn, error) {
534- var lc net.ListenConfig
535- conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
536- if err != nil {
537- return nil, err
538- }
539- udp, ok := conn.(*net.UDPConn)
540- if !ok {
541- _ = conn.Close()
542- return nil, fmt.Errorf("quicnettest: udp listener returned %T", conn)
543- }
544- return udp, nil
545-}
546-
547555 func sameUDPAddr(a, b *net.UDPAddr) bool {
548556 return a != nil && b != nil && a.Port == b.Port && a.IP.Equal(b.IP)
549557 }
@@ -2,7 +2,6 @@ package quicnettest
2 2
3 import (3 import (
4 "container/heap"4 "container/heap"
5- "context"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "net"7 "net"
@@ -14,14 +13,31 @@ import (
14 // ErrClosed is returned by operations on a closed proxy.13 // ErrClosed is returned by operations on a closed proxy.
15 var ErrClosed = errors.New("quicnettest: proxy is closed")14 var ErrClosed = errors.New("quicnettest: proxy is closed")
16 15
16+// upstreamBindAddr returns the ephemeral upstream bind address in the server's
17+// address family: loopback for a loopback server, unspecified otherwise.
18+func upstreamBindAddr(server net.IP) *net.UDPAddr {
19+ switch {
20+ case server.To4() != nil && server.IsLoopback():
21+ return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}
22+ case server.To4() != nil:
23+ return &net.UDPAddr{IP: net.IPv4zero}
24+ case server.IsLoopback():
25+ return &net.UDPAddr{IP: net.IPv6loopback}
26+ default:
27+ return &net.UDPAddr{IP: net.IPv6unspecified}
28+ }
29+}
30+
17 // Proxy is a bounded, deterministic UDP relay with per-direction impairment. It31 // Proxy is a bounded, deterministic UDP relay with per-direction impairment. It
18-// listens on an ephemeral loopback port for clients and forwards to32+// listens on Config.ListenAddr (default: ephemeral IPv4 loopback) for clients
33+// and forwards to
19 // Config.ServerAddr from a separate upstream socket that can be rebound to a34 // Config.ServerAddr from a separate upstream socket that can be rebound to a
20 // new source address at any time.35 // new source address at any time.
21 type Proxy struct {36 type Proxy struct {
22- cfg Config37+ cfg Config
23- clock Clock38+ clock Clock
24- server *net.UDPAddr39+ server *net.UDPAddr
40+ upstreamAddr *net.UDPAddr
25 41
26 clientConn *net.UDPConn42 clientConn *net.UDPConn
27 upstream atomic.Pointer[net.UDPConn]43 upstream atomic.Pointer[net.UDPConn]
@@ -128,11 +144,16 @@ func New(cfg Config) (*Proxy, error) {
128 if err := cfg.validate(); err != nil {144 if err := cfg.validate(); err != nil {
129 return nil, err145 return nil, err
130 }146 }
131- clientConn, err := listenLoopback()147+ listenAddr := cfg.ListenAddr
148+ if listenAddr == nil {
149+ listenAddr = &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}
150+ }
151+ clientConn, err := net.ListenUDP("udp", listenAddr)
132 if err != nil {152 if err != nil {
133 return nil, fmt.Errorf("quicnettest: listen client socket: %w", err)153 return nil, fmt.Errorf("quicnettest: listen client socket: %w", err)
134 }154 }
135- upstream, err := listenLoopback()155+ upstreamAddr := upstreamBindAddr(cfg.ServerAddr.IP)
156+ upstream, err := net.ListenUDP("udp", upstreamAddr)
136 if err != nil {157 if err != nil {
137 _ = clientConn.Close()158 _ = clientConn.Close()
138 return nil, fmt.Errorf("quicnettest: listen upstream socket: %w", err)159 return nil, fmt.Errorf("quicnettest: listen upstream socket: %w", err)
@@ -149,17 +170,18 @@ func New(cfg Config) (*Proxy, error) {
149 return newRNG(cfg.Seed, direction)170 return newRNG(cfg.Seed, direction)
150 }171 }
151 p := &Proxy{172 p := &Proxy{
152- cfg: cfg,173+ cfg: cfg,
153- clock: cfg.Clock,174+ clock: cfg.Clock,
154- server: cfg.ServerAddr,175+ server: cfg.ServerAddr,
155- clientConn: clientConn,176+ upstreamAddr: upstreamAddr,
156- done: make(chan struct{}),177+ clientConn: clientConn,
157- slots: make(chan struct{}, cfg.QueueCapacity),178+ done: make(chan struct{}),
158- queue: make(chan packet, cfg.QueueCapacity),179+ slots: make(chan struct{}, cfg.QueueCapacity),
159- start: start,180+ queue: make(chan packet, cfg.QueueCapacity),
160- toServer: impairer{link: cfg.ToServer, rng: rngFor(ToServer), start: start},181+ start: start,
161- toClient: impairer{link: cfg.ToClient, rng: rngFor(ToClient), start: start},182+ toServer: impairer{link: cfg.ToServer, rng: rngFor(ToServer), start: start},
162- onPacket: cfg.OnPacket,183+ toClient: impairer{link: cfg.ToClient, rng: rngFor(ToClient), start: start},
184+ onPacket: cfg.OnPacket,
163 }185 }
164 p.upstream.Store(upstream)186 p.upstream.Store(upstream)
165 p.wg.Add(3)187 p.wg.Add(3)
@@ -206,7 +228,7 @@ func (p *Proxy) Rebind() error {
206 if p.stopping() {228 if p.stopping() {
207 return ErrClosed229 return ErrClosed
208 }230 }
209- conn, err := listenLoopback()231+ conn, err := net.ListenUDP("udp", p.upstreamAddr)
210 if err != nil {232 if err != nil {
211 return fmt.Errorf("quicnettest: rebind: %w", err)233 return fmt.Errorf("quicnettest: rebind: %w", err)
212 }234 }
@@ -530,20 +552,6 @@ func directionOf(toServer bool) Direction {
530 return ToClient552 return ToClient
531 }553 }
532 554
533-func listenLoopback() (*net.UDPConn, error) {
534- var lc net.ListenConfig
535- conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
536- if err != nil {
537- return nil, err
538- }
539- udp, ok := conn.(*net.UDPConn)
540- if !ok {
541- _ = conn.Close()
542- return nil, fmt.Errorf("quicnettest: udp listener returned %T", conn)
543- }
544- return udp, nil
545-}
546-
547 func sameUDPAddr(a, b *net.UDPAddr) bool {555 func sameUDPAddr(a, b *net.UDPAddr) bool {
548 return a != nil && b != nil && a.Port == b.Port && a.IP.Equal(b.IP)556 return a != nil && b != nil && a.Port == b.Port && a.IP.Equal(b.IP)
549 }557 }
M internal/adapters/quicnettest/simulation.go +3 -0
@@ -143,6 +143,9 @@ func (l LinkConfig) validate(direction Direction) error {
143143
144144 // Config configures a [Proxy].
145145 type Config struct {
146+ // ListenAddr optionally selects the client-facing fixture address.
147+ // Nil retains the ephemeral loopback default.
148+ ListenAddr *net.UDPAddr
146149 // ServerAddr is the UDP server the proxy forwards to. Required.
147150 ServerAddr *net.UDPAddr
148151 // Clock drives all scheduling. Defaults to [SystemClock].
@@ -143,6 +143,9 @@ func (l LinkConfig) validate(direction Direction) error {
143 143
144 // Config configures a [Proxy].144 // Config configures a [Proxy].
145 type Config struct {145 type Config struct {
146+ // ListenAddr optionally selects the client-facing fixture address.
147+ // Nil retains the ephemeral loopback default.
148+ ListenAddr *net.UDPAddr
146 // ServerAddr is the UDP server the proxy forwards to. Required.149 // ServerAddr is the UDP server the proxy forwards to. Required.
147 ServerAddr *net.UDPAddr150 ServerAddr *net.UDPAddr
148 // Clock drives all scheduling. Defaults to [SystemClock].151 // Clock drives all scheduling. Defaults to [SystemClock].
M internal/usecase/client/attachment_input_test.go +14 -2
@@ -33,6 +33,7 @@ func (r *inputTestReader) Read(p []byte) (int, error) {
3333
3434 type inputHarness struct {
3535 stream *sessionTestStream
36+ pump *terminalInputPump
3637 term *workerTestTerminal
3738 clock *supervisorTestClock
3839 reader *inputTestReader
@@ -49,6 +50,16 @@ func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
4950
5051 func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader) *inputHarness {
5152 t.Helper()
53+ return startInputHarnessOn(t, themes, request, clipboard, newSessionTestStream(), nil)
54+}
55+
56+// startInputHarnessOn runs the harness over stream; transport, when non-nil,
57+// is what the worker writes through (a wrapper over stream).
58+func startInputHarnessOn(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader, stream *sessionTestStream, transport ports.BrokerLogicalConnection) *inputHarness {
59+ t.Helper()
60+ if transport == nil {
61+ transport = stream
62+ }
5263 reader := &inputTestReader{chunks: make(chan []byte, 16)}
5364 pump := newTerminalInputPump(reader)
5465 pump.start()
@@ -57,7 +68,8 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
5768 close(reader.chunks)
5869 })
5970 h := &inputHarness{
60- stream: newSessionTestStream(),
71+ stream: stream,
72+ pump: pump,
6173 term: newWorkerTestTerminal(),
6274 clock: newSupervisorTestClock(),
6375 reader: reader,
@@ -72,7 +84,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
7284 worker, err := newSessionAttachmentWorker(cfg)
7385 require.NoError(t, err)
7486 go func() {
75- event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, h.stream)
87+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, transport)
7688 h.events <- event
7789 }()
7890 awaitHello(t, h.stream)
@@ -33,6 +33,7 @@ func (r *inputTestReader) Read(p []byte) (int, error) {
33 33
34 type inputHarness struct {34 type inputHarness struct {
35 stream *sessionTestStream35 stream *sessionTestStream
36+ pump *terminalInputPump
36 term *workerTestTerminal37 term *workerTestTerminal
37 clock *supervisorTestClock38 clock *supervisorTestClock
38 reader *inputTestReader39 reader *inputTestReader
@@ -49,6 +50,16 @@ func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
49 50
50 func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader) *inputHarness {51 func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader) *inputHarness {
51 t.Helper()52 t.Helper()
53+ return startInputHarnessOn(t, themes, request, clipboard, newSessionTestStream(), nil)
54+}
55+
56+// startInputHarnessOn runs the harness over stream; transport, when non-nil,
57+// is what the worker writes through (a wrapper over stream).
58+func startInputHarnessOn(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader, stream *sessionTestStream, transport ports.BrokerLogicalConnection) *inputHarness {
59+ t.Helper()
60+ if transport == nil {
61+ transport = stream
62+ }
52 reader := &inputTestReader{chunks: make(chan []byte, 16)}63 reader := &inputTestReader{chunks: make(chan []byte, 16)}
53 pump := newTerminalInputPump(reader)64 pump := newTerminalInputPump(reader)
54 pump.start()65 pump.start()
@@ -57,7 +68,8 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
57 close(reader.chunks)68 close(reader.chunks)
58 })69 })
59 h := &inputHarness{70 h := &inputHarness{
60- stream: newSessionTestStream(),71+ stream: stream,
72+ pump: pump,
61 term: newWorkerTestTerminal(),73 term: newWorkerTestTerminal(),
62 clock: newSupervisorTestClock(),74 clock: newSupervisorTestClock(),
63 reader: reader,75 reader: reader,
@@ -72,7 +84,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
72 worker, err := newSessionAttachmentWorker(cfg)84 worker, err := newSessionAttachmentWorker(cfg)
73 require.NoError(t, err)85 require.NoError(t, err)
74 go func() {86 go func() {
75- event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, h.stream)87+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, transport)
76 h.events <- event88 h.events <- event
77 }()89 }()
78 awaitHello(t, h.stream)90 awaitHello(t, h.stream)
A internal/usecase/client/attachment_outbox.go +246 -0
@@ -0,0 +1,246 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// Outbox bounds. Past either, enqueue blocks until the sender makes progress
13+// or the caller is cancelled. Input is acknowledged to the terminal pump only
14+// once written, so the pump stops reading new keys long before these limits;
15+// they bound acks, focus reports, and other control traffic.
16+const (
17+ outboxMaxPending = 4096
18+ outboxMaxBytes = 4 << 20
19+)
20+
21+// errOutboxStopped is returned by SendClient after the outbox stopped.
22+var errOutboxStopped = errors.New("client: attachment outbox stopped")
23+
24+// attachmentOutbox decouples the attachment worker loop from the carriage:
25+// enqueue returns once the message is accepted, and one sender goroutine
26+// writes messages in order. A stalled link therefore no longer freezes key
27+// handling, overlays, or painting. The first write failure is sticky: it is
28+// reported on Failed and returned by every later enqueue.
29+type attachmentOutbox struct {
30+ ports.BrokerLogicalConnection
31+
32+ mu sync.Mutex
33+ queue []protocol.ClientMessage
34+ bytes int // payload bytes queued
35+ queued uint64 // messages ever accepted
36+ written uint64 // messages written successfully
37+ err error
38+ stopped bool
39+ wake chan struct{} // sender wake-up, capacity 1
40+ space chan struct{} // closed-and-replaced after each write, closed on failure
41+ idle chan struct{} // closed-and-replaced when the queue drains
42+ failed chan struct{} // closed once on the first write failure
43+ done chan struct{} // closed when the sender exits
44+}
45+
46+func newAttachmentOutbox(stream ports.BrokerLogicalConnection) *attachmentOutbox {
47+ b := &attachmentOutbox{
48+ BrokerLogicalConnection: stream,
49+ wake: make(chan struct{}, 1),
50+ space: make(chan struct{}),
51+ idle: make(chan struct{}),
52+ failed: make(chan struct{}),
53+ done: make(chan struct{}),
54+ }
55+ close(b.idle)
56+ go b.run()
57+ return b
58+}
59+
60+// outboxPayload is the variable-size part of a message counted against
61+// outboxMaxBytes.
62+func outboxPayload(message protocol.ClientMessage) int {
63+ switch m := message.(type) {
64+ case protocol.Input:
65+ return len(m.Data)
66+ case protocol.ImagePush:
67+ return len(m.Data)
68+ }
69+ return 0
70+}
71+
72+// SendClient enqueues message without a cancellation source.
73+func (b *attachmentOutbox) SendClient(message protocol.ClientMessage) error {
74+ return b.enqueue(context.Background(), nil, message)
75+}
76+
77+// enqueue accepts message for ordered transmission. It blocks only while the
78+// outbox is full, until space frees, ctx ends, or cancel closes. It returns
79+// the sticky failure once the sender failed.
80+func (b *attachmentOutbox) enqueue(ctx context.Context, cancel <-chan struct{}, message protocol.ClientMessage) error {
81+ size := outboxPayload(message)
82+ b.mu.Lock()
83+ for {
84+ if b.err != nil {
85+ err := b.err
86+ b.mu.Unlock()
87+ return err
88+ }
89+ if b.stopped {
90+ b.mu.Unlock()
91+ return errOutboxStopped
92+ }
93+ // An empty queue always admits, so one oversized message still goes.
94+ if len(b.queue) == 0 || (len(b.queue) < outboxMaxPending && b.bytes+size <= outboxMaxBytes) {
95+ break
96+ }
97+ space := b.space
98+ b.mu.Unlock()
99+ select {
100+ case <-space:
101+ case <-b.done:
102+ case <-ctx.Done():
103+ return ctx.Err()
104+ case <-cancel:
105+ return errAttachmentForegroundRevoked
106+ }
107+ b.mu.Lock()
108+ }
109+ if len(b.queue) == 0 {
110+ b.idle = make(chan struct{})
111+ }
112+ b.queue = append(b.queue, message)
113+ b.bytes += size
114+ b.queued++
115+ b.mu.Unlock()
116+ select {
117+ case b.wake <- struct{}{}:
118+ default:
119+ }
120+ return nil
121+}
122+
123+// Close stops the sender and closes the underlying stream, which unblocks a
124+// write stalled on the link.
125+func (b *attachmentOutbox) Close() error {
126+ b.stop()
127+ return b.BrokerLogicalConnection.Close()
128+}
129+
130+// stop ends the sender after its current write; queued messages are dropped.
131+// Session input among them was never acknowledged to the terminal pump, so
132+// the worker preserves it like a failed synchronous send.
133+func (b *attachmentOutbox) stop() {
134+ b.mu.Lock()
135+ b.stopped = true
136+ b.mu.Unlock()
137+ select {
138+ case b.wake <- struct{}{}:
139+ default:
140+ }
141+}
142+
143+// unsentInput returns the session input bytes of accepted messages numbered
144+// (from, to] that were never written. It is final only after join.
145+func (b *attachmentOutbox) unsentInput(from, to uint64) []byte {
146+ b.mu.Lock()
147+ defer b.mu.Unlock()
148+ var data []byte
149+ // queue[i] is accepted message number written+1+i.
150+ for i, message := range b.queue {
151+ n := b.written + 1 + uint64(i)
152+ if n <= from || n > to {
153+ continue
154+ }
155+ if input, ok := message.(protocol.Input); ok {
156+ data = append(data, input.Data...)
157+ }
158+ }
159+ return data
160+}
161+
162+// join stops the sender, closes the stream so a stalled write returns, and
163+// waits for the sender to exit. Afterwards writtenCount is final.
164+func (b *attachmentOutbox) join() {
165+ _ = b.Close()
166+ <-b.done
167+}
168+
169+// accepted reports how many messages were ever accepted.
170+func (b *attachmentOutbox) accepted() uint64 {
171+ b.mu.Lock()
172+ defer b.mu.Unlock()
173+ return b.queued
174+}
175+
176+// writtenCount reports how many messages were written successfully.
177+func (b *attachmentOutbox) writtenCount() uint64 {
178+ b.mu.Lock()
179+ defer b.mu.Unlock()
180+ return b.written
181+}
182+
183+// Progress is closed after the next successful write or on failure.
184+func (b *attachmentOutbox) Progress() <-chan struct{} {
185+ b.mu.Lock()
186+ defer b.mu.Unlock()
187+ return b.space
188+}
189+
190+// Failed is closed once a write failed; failure returns the cause.
191+func (b *attachmentOutbox) Failed() <-chan struct{} { return b.failed }
192+
193+func (b *attachmentOutbox) failure() error {
194+ b.mu.Lock()
195+ defer b.mu.Unlock()
196+ return b.err
197+}
198+
199+// Idle is closed while nothing is queued or being written.
200+func (b *attachmentOutbox) Idle() <-chan struct{} {
201+ b.mu.Lock()
202+ defer b.mu.Unlock()
203+ return b.idle
204+}
205+
206+func (b *attachmentOutbox) run() {
207+ defer close(b.done)
208+ for {
209+ b.mu.Lock()
210+ if b.stopped {
211+ b.mu.Unlock()
212+ return
213+ }
214+ if len(b.queue) == 0 {
215+ b.mu.Unlock()
216+ <-b.wake
217+ continue
218+ }
219+ message := b.queue[0]
220+ b.mu.Unlock()
221+
222+ err := b.BrokerLogicalConnection.SendClient(message)
223+
224+ b.mu.Lock()
225+ if err != nil {
226+ // The queue is kept, failed write first, so unsentInput can
227+ // hand undelivered keys back to the terminal pump.
228+ b.err = err
229+ close(b.failed)
230+ close(b.idle)
231+ close(b.space)
232+ b.mu.Unlock()
233+ return
234+ }
235+ b.queue[0] = nil
236+ b.queue = b.queue[1:]
237+ b.bytes -= outboxPayload(message)
238+ b.written++
239+ close(b.space)
240+ b.space = make(chan struct{})
241+ if len(b.queue) == 0 {
242+ close(b.idle)
243+ }
244+ b.mu.Unlock()
245+ }
246+}
@@ -0,0 +1,246 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// Outbox bounds. Past either, enqueue blocks until the sender makes progress
13+// or the caller is cancelled. Input is acknowledged to the terminal pump only
14+// once written, so the pump stops reading new keys long before these limits;
15+// they bound acks, focus reports, and other control traffic.
16+const (
17+ outboxMaxPending = 4096
18+ outboxMaxBytes = 4 << 20
19+)
20+
21+// errOutboxStopped is returned by SendClient after the outbox stopped.
22+var errOutboxStopped = errors.New("client: attachment outbox stopped")
23+
24+// attachmentOutbox decouples the attachment worker loop from the carriage:
25+// enqueue returns once the message is accepted, and one sender goroutine
26+// writes messages in order. A stalled link therefore no longer freezes key
27+// handling, overlays, or painting. The first write failure is sticky: it is
28+// reported on Failed and returned by every later enqueue.
29+type attachmentOutbox struct {
30+ ports.BrokerLogicalConnection
31+
32+ mu sync.Mutex
33+ queue []protocol.ClientMessage
34+ bytes int // payload bytes queued
35+ queued uint64 // messages ever accepted
36+ written uint64 // messages written successfully
37+ err error
38+ stopped bool
39+ wake chan struct{} // sender wake-up, capacity 1
40+ space chan struct{} // closed-and-replaced after each write, closed on failure
41+ idle chan struct{} // closed-and-replaced when the queue drains
42+ failed chan struct{} // closed once on the first write failure
43+ done chan struct{} // closed when the sender exits
44+}
45+
46+func newAttachmentOutbox(stream ports.BrokerLogicalConnection) *attachmentOutbox {
47+ b := &attachmentOutbox{
48+ BrokerLogicalConnection: stream,
49+ wake: make(chan struct{}, 1),
50+ space: make(chan struct{}),
51+ idle: make(chan struct{}),
52+ failed: make(chan struct{}),
53+ done: make(chan struct{}),
54+ }
55+ close(b.idle)
56+ go b.run()
57+ return b
58+}
59+
60+// outboxPayload is the variable-size part of a message counted against
61+// outboxMaxBytes.
62+func outboxPayload(message protocol.ClientMessage) int {
63+ switch m := message.(type) {
64+ case protocol.Input:
65+ return len(m.Data)
66+ case protocol.ImagePush:
67+ return len(m.Data)
68+ }
69+ return 0
70+}
71+
72+// SendClient enqueues message without a cancellation source.
73+func (b *attachmentOutbox) SendClient(message protocol.ClientMessage) error {
74+ return b.enqueue(context.Background(), nil, message)
75+}
76+
77+// enqueue accepts message for ordered transmission. It blocks only while the
78+// outbox is full, until space frees, ctx ends, or cancel closes. It returns
79+// the sticky failure once the sender failed.
80+func (b *attachmentOutbox) enqueue(ctx context.Context, cancel <-chan struct{}, message protocol.ClientMessage) error {
81+ size := outboxPayload(message)
82+ b.mu.Lock()
83+ for {
84+ if b.err != nil {
85+ err := b.err
86+ b.mu.Unlock()
87+ return err
88+ }
89+ if b.stopped {
90+ b.mu.Unlock()
91+ return errOutboxStopped
92+ }
93+ // An empty queue always admits, so one oversized message still goes.
94+ if len(b.queue) == 0 || (len(b.queue) < outboxMaxPending && b.bytes+size <= outboxMaxBytes) {
95+ break
96+ }
97+ space := b.space
98+ b.mu.Unlock()
99+ select {
100+ case <-space:
101+ case <-b.done:
102+ case <-ctx.Done():
103+ return ctx.Err()
104+ case <-cancel:
105+ return errAttachmentForegroundRevoked
106+ }
107+ b.mu.Lock()
108+ }
109+ if len(b.queue) == 0 {
110+ b.idle = make(chan struct{})
111+ }
112+ b.queue = append(b.queue, message)
113+ b.bytes += size
114+ b.queued++
115+ b.mu.Unlock()
116+ select {
117+ case b.wake <- struct{}{}:
118+ default:
119+ }
120+ return nil
121+}
122+
123+// Close stops the sender and closes the underlying stream, which unblocks a
124+// write stalled on the link.
125+func (b *attachmentOutbox) Close() error {
126+ b.stop()
127+ return b.BrokerLogicalConnection.Close()
128+}
129+
130+// stop ends the sender after its current write; queued messages are dropped.
131+// Session input among them was never acknowledged to the terminal pump, so
132+// the worker preserves it like a failed synchronous send.
133+func (b *attachmentOutbox) stop() {
134+ b.mu.Lock()
135+ b.stopped = true
136+ b.mu.Unlock()
137+ select {
138+ case b.wake <- struct{}{}:
139+ default:
140+ }
141+}
142+
143+// unsentInput returns the session input bytes of accepted messages numbered
144+// (from, to] that were never written. It is final only after join.
145+func (b *attachmentOutbox) unsentInput(from, to uint64) []byte {
146+ b.mu.Lock()
147+ defer b.mu.Unlock()
148+ var data []byte
149+ // queue[i] is accepted message number written+1+i.
150+ for i, message := range b.queue {
151+ n := b.written + 1 + uint64(i)
152+ if n <= from || n > to {
153+ continue
154+ }
155+ if input, ok := message.(protocol.Input); ok {
156+ data = append(data, input.Data...)
157+ }
158+ }
159+ return data
160+}
161+
162+// join stops the sender, closes the stream so a stalled write returns, and
163+// waits for the sender to exit. Afterwards writtenCount is final.
164+func (b *attachmentOutbox) join() {
165+ _ = b.Close()
166+ <-b.done
167+}
168+
169+// accepted reports how many messages were ever accepted.
170+func (b *attachmentOutbox) accepted() uint64 {
171+ b.mu.Lock()
172+ defer b.mu.Unlock()
173+ return b.queued
174+}
175+
176+// writtenCount reports how many messages were written successfully.
177+func (b *attachmentOutbox) writtenCount() uint64 {
178+ b.mu.Lock()
179+ defer b.mu.Unlock()
180+ return b.written
181+}
182+
183+// Progress is closed after the next successful write or on failure.
184+func (b *attachmentOutbox) Progress() <-chan struct{} {
185+ b.mu.Lock()
186+ defer b.mu.Unlock()
187+ return b.space
188+}
189+
190+// Failed is closed once a write failed; failure returns the cause.
191+func (b *attachmentOutbox) Failed() <-chan struct{} { return b.failed }
192+
193+func (b *attachmentOutbox) failure() error {
194+ b.mu.Lock()
195+ defer b.mu.Unlock()
196+ return b.err
197+}
198+
199+// Idle is closed while nothing is queued or being written.
200+func (b *attachmentOutbox) Idle() <-chan struct{} {
201+ b.mu.Lock()
202+ defer b.mu.Unlock()
203+ return b.idle
204+}
205+
206+func (b *attachmentOutbox) run() {
207+ defer close(b.done)
208+ for {
209+ b.mu.Lock()
210+ if b.stopped {
211+ b.mu.Unlock()
212+ return
213+ }
214+ if len(b.queue) == 0 {
215+ b.mu.Unlock()
216+ <-b.wake
217+ continue
218+ }
219+ message := b.queue[0]
220+ b.mu.Unlock()
221+
222+ err := b.BrokerLogicalConnection.SendClient(message)
223+
224+ b.mu.Lock()
225+ if err != nil {
226+ // The queue is kept, failed write first, so unsentInput can
227+ // hand undelivered keys back to the terminal pump.
228+ b.err = err
229+ close(b.failed)
230+ close(b.idle)
231+ close(b.space)
232+ b.mu.Unlock()
233+ return
234+ }
235+ b.queue[0] = nil
236+ b.queue = b.queue[1:]
237+ b.bytes -= outboxPayload(message)
238+ b.written++
239+ close(b.space)
240+ b.space = make(chan struct{})
241+ if len(b.queue) == 0 {
242+ close(b.idle)
243+ }
244+ b.mu.Unlock()
245+ }
246+}
A internal/usecase/client/attachment_outbox_test.go +398 -0
@@ -0,0 +1,398 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "strings"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+// gatedSessionStream blocks every write until release is closed, like a
17+// carriage stalled on a dead link, and can fail writes after release.
18+type gatedSessionStream struct {
19+ *sessionTestStream
20+ release chan struct{}
21+ fail error
22+}
23+
24+func (s *gatedSessionStream) SendClient(message protocol.ClientMessage) error {
25+ select {
26+ case <-s.release:
27+ case <-s.closed:
28+ return errSessionTestClosed
29+ }
30+ if s.fail != nil {
31+ return s.fail
32+ }
33+ return s.sessionTestStream.SendClient(message)
34+}
35+
36+func TestAttachmentOutbox(t *testing.T) {
37+ writeErr := errors.New("link lost")
38+ tests := []struct {
39+ name string
40+ fail error
41+ wantErr error
42+ }{
43+ {name: "stalled link does not block enqueue and preserves order"},
44+ {name: "write failure is sticky and signalled", fail: writeErr, wantErr: writeErr},
45+ }
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{}), fail: tt.fail}
49+ outbox := newAttachmentOutbox(stream)
50+ defer outbox.stop()
51+
52+ messages := []protocol.ClientMessage{
53+ protocol.Input{InputSeq: 1, Data: []byte("a")},
54+ protocol.Input{InputSeq: 2, Data: []byte("b")},
55+ protocol.Input{InputSeq: 3, Data: []byte("c")},
56+ }
57+ enqueued := make(chan struct{})
58+ go func() {
59+ for _, m := range messages {
60+ if err := outbox.SendClient(m); err != nil {
61+ t.Errorf("SendClient: %v", err)
62+ }
63+ }
64+ close(enqueued)
65+ }()
66+ select {
67+ case <-enqueued:
68+ case <-time.After(5 * time.Second):
69+ t.Fatal("SendClient blocked on a stalled link")
70+ }
71+
72+ close(stream.release)
73+ if tt.wantErr != nil {
74+ <-outbox.Failed()
75+ require.ErrorIs(t, outbox.failure(), tt.wantErr)
76+ require.ErrorIs(t, outbox.SendClient(protocol.Detach{}), tt.wantErr)
77+ return
78+ }
79+ <-outbox.Idle()
80+ stream.mu.Lock()
81+ defer stream.mu.Unlock()
82+ require.Equal(t, messages, stream.sent)
83+ })
84+ }
85+}
86+
87+func TestAttachmentOutboxCloseUnblocksStalledWrite(t *testing.T) {
88+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
89+ outbox := newAttachmentOutbox(stream)
90+ require.NoError(t, outbox.SendClient(protocol.Detach{}))
91+
92+ require.NoError(t, outbox.Close())
93+ select {
94+ case <-outbox.done:
95+ case <-time.After(5 * time.Second):
96+ t.Fatal("sender did not exit after Close")
97+ }
98+}
99+
100+// TestResumeWatch proves the resume backoff reads the terminal: Ctrl-C or a
101+// lone Esc cancels, anything else is replayed to the next owner in order.
102+func TestResumeWatch(t *testing.T) {
103+ tests := []struct {
104+ name string
105+ reads []string
106+ wantCancel bool
107+ fireEscape bool
108+ wantResidual string
109+ }{
110+ {name: "ctrl-c cancels and drops held keys", reads: []string{"ls", "\x03"}, wantCancel: true},
111+ {name: "lone esc cancels", reads: []string{"\x1b"}, wantCancel: true, fireEscape: true},
112+ {name: "kitty esc cancels", reads: []string{"\x1b[27u"}, wantCancel: true},
113+ {name: "kitty ctrl-c cancels", reads: []string{"\x1b[99;5u"}, wantCancel: true},
114+ {name: "kitty ctrl-c with lock bits cancels", reads: []string{"\x1b[99;69u"}, wantCancel: true},
115+ {name: "escape sequence is kept", reads: []string{"\x1b[A"}, wantResidual: "\x1b[A"},
116+ {name: "typed keys are kept in order", reads: []string{"l", "s", "\r"}, wantResidual: "ls\r"},
117+ }
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ reader := &inputTestReader{chunks: make(chan []byte, len(tt.reads))}
121+ pump := newTerminalInputPump(reader)
122+ pump.start()
123+ t.Cleanup(func() {
124+ pump.stop()
125+ close(reader.chunks)
126+ })
127+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
128+ clock := newSupervisorTestClock()
129+ watch := lifetime.watchResume(clock)
130+ for _, read := range tt.reads {
131+ reader.chunks <- []byte(read)
132+ }
133+ if tt.wantCancel {
134+ if tt.fireEscape {
135+ clock.awaitTimer(t).fire()
136+ }
137+ select {
138+ case <-watch.cancelled:
139+ case <-time.After(5 * time.Second):
140+ t.Fatal("cancel key ignored")
141+ }
142+ } else {
143+ require.Eventually(t, func() bool { return watch.heldLen() == len(tt.wantResidual) }, 5*time.Second, time.Millisecond)
144+ }
145+ watch.release()
146+ pump.mu.Lock()
147+ defer pump.mu.Unlock()
148+ require.Equal(t, tt.wantResidual, string(pump.residual))
149+ require.Zero(t, pump.consumer, "release returns the claim")
150+ })
151+ }
152+}
153+
154+// TestResumeWatchKeepsBacklogVerbatim starts a watch while session input the
155+// lost attachment kept is still waiting: a Ctrl-C or trailing Esc in it was
156+// typed for the remote shell, so it is replayed, not read as a cancel key.
157+// Keys typed after the watch started still cancel.
158+func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
159+ for _, tt := range []struct {
160+ name, residual, pending, typed string
161+ wantCancel bool
162+ wantResidual string
163+ }{
164+ {name: "kept ctrl-c", residual: "\x03", wantResidual: "\x03"},
165+ {name: "kept trailing esc", residual: "ihello\x1b", wantResidual: "ihello\x1b"},
166+ {name: "pending ctrl-c", pending: "x\x03", wantResidual: "x\x03"},
167+ {name: "residual then pending", residual: "a", pending: "\x03", wantResidual: "a\x03"},
168+ {name: "new ctrl-c still cancels", residual: "\x03", typed: "\x03", wantCancel: true},
169+ } {
170+ t.Run(tt.name, func(t *testing.T) {
171+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
172+ pump := newTerminalInputPump(reader)
173+ pump.start()
174+ t.Cleanup(func() {
175+ pump.stop()
176+ close(reader.chunks)
177+ })
178+ pump.mu.Lock()
179+ pump.residual = []byte(tt.residual)
180+ pump.mu.Unlock()
181+ if tt.pending != "" {
182+ reader.chunks <- []byte(tt.pending)
183+ require.Eventually(t, func() bool {
184+ pump.mu.Lock()
185+ defer pump.mu.Unlock()
186+ return pump.pending != nil
187+ }, 5*time.Second, time.Millisecond)
188+ }
189+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
190+ watch := lifetime.watchResume(newSupervisorTestClock())
191+ require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond)
192+ if tt.typed != "" {
193+ reader.chunks <- []byte(tt.typed)
194+ }
195+ if tt.wantCancel {
196+ select {
197+ case <-watch.cancelled:
198+ case <-time.After(5 * time.Second):
199+ t.Fatal("new cancel key ignored")
200+ }
201+ }
202+ watch.release()
203+ select {
204+ case <-watch.cancelled:
205+ require.True(t, tt.wantCancel, "backlog cancelled the resume")
206+ default:
207+ }
208+ pump.mu.Lock()
209+ defer pump.mu.Unlock()
210+ require.Equal(t, tt.wantResidual, string(pump.residual))
211+ })
212+ }
213+}
214+
215+// TestResumeWatchResolvesEscapeAtRelease presses Esc just before the phase
216+// ends: release waits for the escape delay, so a lone Esc still cancels and an
217+// escape sequence completed in time is kept for the session.
218+func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
219+ for _, tt := range []struct {
220+ name, after string
221+ wantCancel bool
222+ wantResidual string
223+ }{
224+ {name: "lone esc cancels", wantCancel: true},
225+ {name: "arrow key is kept", after: "[A", wantResidual: "\x1b[A"},
226+ } {
227+ t.Run(tt.name, func(t *testing.T) {
228+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
229+ pump := newTerminalInputPump(reader)
230+ pump.start()
231+ t.Cleanup(func() {
232+ pump.stop()
233+ close(reader.chunks)
234+ })
235+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
236+ clock := newSupervisorTestClock()
237+ watch := lifetime.watchResume(clock)
238+ reader.chunks <- []byte("\x1b")
239+ escape := clock.awaitTimer(t)
240+ released := make(chan struct{})
241+ go func() { watch.release(); close(released) }()
242+ select {
243+ case <-released:
244+ t.Fatal("release returned before the escape was resolved")
245+ case <-time.After(50 * time.Millisecond):
246+ }
247+ if tt.after != "" {
248+ reader.chunks <- []byte(tt.after)
249+ } else {
250+ escape.fire()
251+ }
252+ select {
253+ case <-released:
254+ case <-time.After(5 * time.Second):
255+ t.Fatal("release never returned")
256+ }
257+ select {
258+ case <-watch.cancelled:
259+ require.True(t, tt.wantCancel, "escape sequence cancelled the resume")
260+ default:
261+ require.False(t, tt.wantCancel, "lone esc at release did not cancel")
262+ }
263+ pump.mu.Lock()
264+ defer pump.mu.Unlock()
265+ require.Equal(t, tt.wantResidual, string(pump.residual))
266+ })
267+ }
268+}
269+
270+func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
271+ w := &resumeWatch{}
272+ w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
273+ w.hold([]byte("bb"))
274+ w.hold([]byte("c"))
275+ require.Equal(t, resumeHeldInputLimit-1, w.heldLen(), "keys after an overflow must be dropped too")
276+}
277+
278+// stallingSessionStream passes writes through until stall is set; stalled
279+// writes block until release closes, then fail with failErr when set.
280+type stallingSessionStream struct {
281+ *sessionTestStream
282+ stall atomic.Bool
283+ entered chan struct{}
284+ release chan struct{}
285+ failErr error
286+}
287+
288+func (s *stallingSessionStream) SendClient(message protocol.ClientMessage) error {
289+ if s.stall.Load() {
290+ select {
291+ case s.entered <- struct{}{}:
292+ default:
293+ }
294+ select {
295+ case <-s.release:
296+ case <-s.closed:
297+ return errSessionTestClosed
298+ }
299+ if s.failErr != nil {
300+ return s.failErr
301+ }
302+ }
303+ return s.sessionTestStream.SendClient(message)
304+}
305+
306+// TestAttachmentWorkerStalledLink drives the real worker over a link that
307+// stops accepting writes after attach.
308+func TestAttachmentWorkerStalledLink(t *testing.T) {
309+ linkErr := errors.New("link lost")
310+ tests := []struct {
311+ name string
312+ run func(t *testing.T, h *inputHarness, s *stallingSessionStream)
313+ }{
314+ {
315+ name: "output keeps painting while a write is stalled",
316+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
317+ h.send("a")
318+ <-s.entered
319+ later := sessionTestOutput(2, "\x1b[Hlater")
320+ later.Epoch = 2
321+ h.stream.deliver(later)
322+ require.Eventually(t, func() bool { return strings.Contains(h.term.written(), "later") }, 5*time.Second, time.Millisecond, "a stalled write blocked painting")
323+ close(s.release)
324+ require.Equal(t, []string{"a"}, h.awaitInputs(t, 1))
325+ h.end(t)
326+ },
327+ },
328+ {
329+ name: "a read that queues nothing is acked while an earlier write is stalled",
330+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
331+ // A queued output Ack stalls behind the link.
332+ next := sessionTestOutput(2, "\x1b[Hmore")
333+ next.Epoch = 2
334+ h.stream.deliver(next)
335+ <-s.entered
336+ // An open bracketed paste is held by the coalescer and queues
337+ // nothing, so there is no write to wait for.
338+ h.send("\x1b[200~abc")
339+ h.send("def")
340+ require.Eventually(t, func() bool {
341+ h.pump.mu.Lock()
342+ defer h.pump.mu.Unlock()
343+ return h.pump.pending == nil && h.pump.delivering == 0 && len(h.reader.chunks) == 0
344+ }, 5*time.Second, time.Millisecond, "an unqueued read held the pump behind a stalled write")
345+ close(s.release)
346+ h.end(t)
347+ },
348+ },
349+ {
350+ name: "keys behind a failed write are preserved for the next attempt",
351+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
352+ s.failErr = linkErr
353+ h.send("a")
354+ <-s.entered
355+ close(s.release)
356+ select {
357+ case event := <-h.events:
358+ require.Equal(t, AttachmentEventFailed, event.Kind)
359+ require.ErrorIs(t, event.Err, linkErr)
360+ case <-time.After(5 * time.Second):
361+ t.Fatal("the failed write never settled the run")
362+ }
363+ h.pump.mu.Lock()
364+ defer h.pump.mu.Unlock()
365+ require.Equal(t, "a", string(h.pump.residual), "the unsent key is kept for the resume")
366+ },
367+ },
368+ {
369+ name: "detach on a stalled link is bounded",
370+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
371+ h.send("a")
372+ <-s.entered
373+ h.stream.deliver(protocol.Detached{Reason: protocol.ReasonDetach})
374+ select {
375+ case event := <-h.events:
376+ require.Equal(t, AttachmentEventEnded, event.Kind)
377+ case <-time.After(5 * time.Second):
378+ t.Fatal("the daemon-confirmed detach never settled")
379+ }
380+ h.pump.mu.Lock()
381+ defer h.pump.mu.Unlock()
382+ require.Equal(t, "a", string(h.pump.residual), "a key the link never took is not lost")
383+ },
384+ },
385+ }
386+ for _, tt := range tests {
387+ t.Run(tt.name, func(t *testing.T) {
388+ base := newSessionTestStream()
389+ s := &stallingSessionStream{sessionTestStream: base, entered: make(chan struct{}, 1), release: make(chan struct{})}
390+ h := startInputHarnessOn(t, nil, sessionTestRequest(true), nil, base, s)
391+ require.Eventually(t, func() bool { return strings.Contains(h.term.written(), "ready") }, 5*time.Second, time.Millisecond)
392+ // Let the initial Ack and focus report through, then stall.
393+ require.Eventually(t, func() bool { return len(base.messages()) >= 2 }, 5*time.Second, time.Millisecond)
394+ s.stall.Store(true)
395+ tt.run(t, h, s)
396+ })
397+ }
398+}
@@ -0,0 +1,398 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "strings"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+// gatedSessionStream blocks every write until release is closed, like a
17+// carriage stalled on a dead link, and can fail writes after release.
18+type gatedSessionStream struct {
19+ *sessionTestStream
20+ release chan struct{}
21+ fail error
22+}
23+
24+func (s *gatedSessionStream) SendClient(message protocol.ClientMessage) error {
25+ select {
26+ case <-s.release:
27+ case <-s.closed:
28+ return errSessionTestClosed
29+ }
30+ if s.fail != nil {
31+ return s.fail
32+ }
33+ return s.sessionTestStream.SendClient(message)
34+}
35+
36+func TestAttachmentOutbox(t *testing.T) {
37+ writeErr := errors.New("link lost")
38+ tests := []struct {
39+ name string
40+ fail error
41+ wantErr error
42+ }{
43+ {name: "stalled link does not block enqueue and preserves order"},
44+ {name: "write failure is sticky and signalled", fail: writeErr, wantErr: writeErr},
45+ }
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{}), fail: tt.fail}
49+ outbox := newAttachmentOutbox(stream)
50+ defer outbox.stop()
51+
52+ messages := []protocol.ClientMessage{
53+ protocol.Input{InputSeq: 1, Data: []byte("a")},
54+ protocol.Input{InputSeq: 2, Data: []byte("b")},
55+ protocol.Input{InputSeq: 3, Data: []byte("c")},
56+ }
57+ enqueued := make(chan struct{})
58+ go func() {
59+ for _, m := range messages {
60+ if err := outbox.SendClient(m); err != nil {
61+ t.Errorf("SendClient: %v", err)
62+ }
63+ }
64+ close(enqueued)
65+ }()
66+ select {
67+ case <-enqueued:
68+ case <-time.After(5 * time.Second):
69+ t.Fatal("SendClient blocked on a stalled link")
70+ }
71+
72+ close(stream.release)
73+ if tt.wantErr != nil {
74+ <-outbox.Failed()
75+ require.ErrorIs(t, outbox.failure(), tt.wantErr)
76+ require.ErrorIs(t, outbox.SendClient(protocol.Detach{}), tt.wantErr)
77+ return
78+ }
79+ <-outbox.Idle()
80+ stream.mu.Lock()
81+ defer stream.mu.Unlock()
82+ require.Equal(t, messages, stream.sent)
83+ })
84+ }
85+}
86+
87+func TestAttachmentOutboxCloseUnblocksStalledWrite(t *testing.T) {
88+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
89+ outbox := newAttachmentOutbox(stream)
90+ require.NoError(t, outbox.SendClient(protocol.Detach{}))
91+
92+ require.NoError(t, outbox.Close())
93+ select {
94+ case <-outbox.done:
95+ case <-time.After(5 * time.Second):
96+ t.Fatal("sender did not exit after Close")
97+ }
98+}
99+
100+// TestResumeWatch proves the resume backoff reads the terminal: Ctrl-C or a
101+// lone Esc cancels, anything else is replayed to the next owner in order.
102+func TestResumeWatch(t *testing.T) {
103+ tests := []struct {
104+ name string
105+ reads []string
106+ wantCancel bool
107+ fireEscape bool
108+ wantResidual string
109+ }{
110+ {name: "ctrl-c cancels and drops held keys", reads: []string{"ls", "\x03"}, wantCancel: true},
111+ {name: "lone esc cancels", reads: []string{"\x1b"}, wantCancel: true, fireEscape: true},
112+ {name: "kitty esc cancels", reads: []string{"\x1b[27u"}, wantCancel: true},
113+ {name: "kitty ctrl-c cancels", reads: []string{"\x1b[99;5u"}, wantCancel: true},
114+ {name: "kitty ctrl-c with lock bits cancels", reads: []string{"\x1b[99;69u"}, wantCancel: true},
115+ {name: "escape sequence is kept", reads: []string{"\x1b[A"}, wantResidual: "\x1b[A"},
116+ {name: "typed keys are kept in order", reads: []string{"l", "s", "\r"}, wantResidual: "ls\r"},
117+ }
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ reader := &inputTestReader{chunks: make(chan []byte, len(tt.reads))}
121+ pump := newTerminalInputPump(reader)
122+ pump.start()
123+ t.Cleanup(func() {
124+ pump.stop()
125+ close(reader.chunks)
126+ })
127+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
128+ clock := newSupervisorTestClock()
129+ watch := lifetime.watchResume(clock)
130+ for _, read := range tt.reads {
131+ reader.chunks <- []byte(read)
132+ }
133+ if tt.wantCancel {
134+ if tt.fireEscape {
135+ clock.awaitTimer(t).fire()
136+ }
137+ select {
138+ case <-watch.cancelled:
139+ case <-time.After(5 * time.Second):
140+ t.Fatal("cancel key ignored")
141+ }
142+ } else {
143+ require.Eventually(t, func() bool { return watch.heldLen() == len(tt.wantResidual) }, 5*time.Second, time.Millisecond)
144+ }
145+ watch.release()
146+ pump.mu.Lock()
147+ defer pump.mu.Unlock()
148+ require.Equal(t, tt.wantResidual, string(pump.residual))
149+ require.Zero(t, pump.consumer, "release returns the claim")
150+ })
151+ }
152+}
153+
154+// TestResumeWatchKeepsBacklogVerbatim starts a watch while session input the
155+// lost attachment kept is still waiting: a Ctrl-C or trailing Esc in it was
156+// typed for the remote shell, so it is replayed, not read as a cancel key.
157+// Keys typed after the watch started still cancel.
158+func TestResumeWatchKeepsBacklogVerbatim(t *testing.T) {
159+ for _, tt := range []struct {
160+ name, residual, pending, typed string
161+ wantCancel bool
162+ wantResidual string
163+ }{
164+ {name: "kept ctrl-c", residual: "\x03", wantResidual: "\x03"},
165+ {name: "kept trailing esc", residual: "ihello\x1b", wantResidual: "ihello\x1b"},
166+ {name: "pending ctrl-c", pending: "x\x03", wantResidual: "x\x03"},
167+ {name: "residual then pending", residual: "a", pending: "\x03", wantResidual: "a\x03"},
168+ {name: "new ctrl-c still cancels", residual: "\x03", typed: "\x03", wantCancel: true},
169+ } {
170+ t.Run(tt.name, func(t *testing.T) {
171+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
172+ pump := newTerminalInputPump(reader)
173+ pump.start()
174+ t.Cleanup(func() {
175+ pump.stop()
176+ close(reader.chunks)
177+ })
178+ pump.mu.Lock()
179+ pump.residual = []byte(tt.residual)
180+ pump.mu.Unlock()
181+ if tt.pending != "" {
182+ reader.chunks <- []byte(tt.pending)
183+ require.Eventually(t, func() bool {
184+ pump.mu.Lock()
185+ defer pump.mu.Unlock()
186+ return pump.pending != nil
187+ }, 5*time.Second, time.Millisecond)
188+ }
189+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
190+ watch := lifetime.watchResume(newSupervisorTestClock())
191+ require.Eventually(t, func() bool { return watch.heldLen() == len(tt.residual)+len(tt.pending) }, 5*time.Second, time.Millisecond)
192+ if tt.typed != "" {
193+ reader.chunks <- []byte(tt.typed)
194+ }
195+ if tt.wantCancel {
196+ select {
197+ case <-watch.cancelled:
198+ case <-time.After(5 * time.Second):
199+ t.Fatal("new cancel key ignored")
200+ }
201+ }
202+ watch.release()
203+ select {
204+ case <-watch.cancelled:
205+ require.True(t, tt.wantCancel, "backlog cancelled the resume")
206+ default:
207+ }
208+ pump.mu.Lock()
209+ defer pump.mu.Unlock()
210+ require.Equal(t, tt.wantResidual, string(pump.residual))
211+ })
212+ }
213+}
214+
215+// TestResumeWatchResolvesEscapeAtRelease presses Esc just before the phase
216+// ends: release waits for the escape delay, so a lone Esc still cancels and an
217+// escape sequence completed in time is kept for the session.
218+func TestResumeWatchResolvesEscapeAtRelease(t *testing.T) {
219+ for _, tt := range []struct {
220+ name, after string
221+ wantCancel bool
222+ wantResidual string
223+ }{
224+ {name: "lone esc cancels", wantCancel: true},
225+ {name: "arrow key is kept", after: "[A", wantResidual: "\x1b[A"},
226+ } {
227+ t.Run(tt.name, func(t *testing.T) {
228+ reader := &inputTestReader{chunks: make(chan []byte, 2)}
229+ pump := newTerminalInputPump(reader)
230+ pump.start()
231+ t.Cleanup(func() {
232+ pump.stop()
233+ close(reader.chunks)
234+ })
235+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), pump: pump}
236+ clock := newSupervisorTestClock()
237+ watch := lifetime.watchResume(clock)
238+ reader.chunks <- []byte("\x1b")
239+ escape := clock.awaitTimer(t)
240+ released := make(chan struct{})
241+ go func() { watch.release(); close(released) }()
242+ select {
243+ case <-released:
244+ t.Fatal("release returned before the escape was resolved")
245+ case <-time.After(50 * time.Millisecond):
246+ }
247+ if tt.after != "" {
248+ reader.chunks <- []byte(tt.after)
249+ } else {
250+ escape.fire()
251+ }
252+ select {
253+ case <-released:
254+ case <-time.After(5 * time.Second):
255+ t.Fatal("release never returned")
256+ }
257+ select {
258+ case <-watch.cancelled:
259+ require.True(t, tt.wantCancel, "escape sequence cancelled the resume")
260+ default:
261+ require.False(t, tt.wantCancel, "lone esc at release did not cancel")
262+ }
263+ pump.mu.Lock()
264+ defer pump.mu.Unlock()
265+ require.Equal(t, tt.wantResidual, string(pump.residual))
266+ })
267+ }
268+}
269+
270+func TestResumeWatchHeldOverflowIsSticky(t *testing.T) {
271+ w := &resumeWatch{}
272+ w.hold(bytes.Repeat([]byte("a"), resumeHeldInputLimit-1))
273+ w.hold([]byte("bb"))
274+ w.hold([]byte("c"))
275+ require.Equal(t, resumeHeldInputLimit-1, w.heldLen(), "keys after an overflow must be dropped too")
276+}
277+
278+// stallingSessionStream passes writes through until stall is set; stalled
279+// writes block until release closes, then fail with failErr when set.
280+type stallingSessionStream struct {
281+ *sessionTestStream
282+ stall atomic.Bool
283+ entered chan struct{}
284+ release chan struct{}
285+ failErr error
286+}
287+
288+func (s *stallingSessionStream) SendClient(message protocol.ClientMessage) error {
289+ if s.stall.Load() {
290+ select {
291+ case s.entered <- struct{}{}:
292+ default:
293+ }
294+ select {
295+ case <-s.release:
296+ case <-s.closed:
297+ return errSessionTestClosed
298+ }
299+ if s.failErr != nil {
300+ return s.failErr
301+ }
302+ }
303+ return s.sessionTestStream.SendClient(message)
304+}
305+
306+// TestAttachmentWorkerStalledLink drives the real worker over a link that
307+// stops accepting writes after attach.
308+func TestAttachmentWorkerStalledLink(t *testing.T) {
309+ linkErr := errors.New("link lost")
310+ tests := []struct {
311+ name string
312+ run func(t *testing.T, h *inputHarness, s *stallingSessionStream)
313+ }{
314+ {
315+ name: "output keeps painting while a write is stalled",
316+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
317+ h.send("a")
318+ <-s.entered
319+ later := sessionTestOutput(2, "\x1b[Hlater")
320+ later.Epoch = 2
321+ h.stream.deliver(later)
322+ require.Eventually(t, func() bool { return strings.Contains(h.term.written(), "later") }, 5*time.Second, time.Millisecond, "a stalled write blocked painting")
323+ close(s.release)
324+ require.Equal(t, []string{"a"}, h.awaitInputs(t, 1))
325+ h.end(t)
326+ },
327+ },
328+ {
329+ name: "a read that queues nothing is acked while an earlier write is stalled",
330+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
331+ // A queued output Ack stalls behind the link.
332+ next := sessionTestOutput(2, "\x1b[Hmore")
333+ next.Epoch = 2
334+ h.stream.deliver(next)
335+ <-s.entered
336+ // An open bracketed paste is held by the coalescer and queues
337+ // nothing, so there is no write to wait for.
338+ h.send("\x1b[200~abc")
339+ h.send("def")
340+ require.Eventually(t, func() bool {
341+ h.pump.mu.Lock()
342+ defer h.pump.mu.Unlock()
343+ return h.pump.pending == nil && h.pump.delivering == 0 && len(h.reader.chunks) == 0
344+ }, 5*time.Second, time.Millisecond, "an unqueued read held the pump behind a stalled write")
345+ close(s.release)
346+ h.end(t)
347+ },
348+ },
349+ {
350+ name: "keys behind a failed write are preserved for the next attempt",
351+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
352+ s.failErr = linkErr
353+ h.send("a")
354+ <-s.entered
355+ close(s.release)
356+ select {
357+ case event := <-h.events:
358+ require.Equal(t, AttachmentEventFailed, event.Kind)
359+ require.ErrorIs(t, event.Err, linkErr)
360+ case <-time.After(5 * time.Second):
361+ t.Fatal("the failed write never settled the run")
362+ }
363+ h.pump.mu.Lock()
364+ defer h.pump.mu.Unlock()
365+ require.Equal(t, "a", string(h.pump.residual), "the unsent key is kept for the resume")
366+ },
367+ },
368+ {
369+ name: "detach on a stalled link is bounded",
370+ run: func(t *testing.T, h *inputHarness, s *stallingSessionStream) {
371+ h.send("a")
372+ <-s.entered
373+ h.stream.deliver(protocol.Detached{Reason: protocol.ReasonDetach})
374+ select {
375+ case event := <-h.events:
376+ require.Equal(t, AttachmentEventEnded, event.Kind)
377+ case <-time.After(5 * time.Second):
378+ t.Fatal("the daemon-confirmed detach never settled")
379+ }
380+ h.pump.mu.Lock()
381+ defer h.pump.mu.Unlock()
382+ require.Equal(t, "a", string(h.pump.residual), "a key the link never took is not lost")
383+ },
384+ },
385+ }
386+ for _, tt := range tests {
387+ t.Run(tt.name, func(t *testing.T) {
388+ base := newSessionTestStream()
389+ s := &stallingSessionStream{sessionTestStream: base, entered: make(chan struct{}, 1), release: make(chan struct{})}
390+ h := startInputHarnessOn(t, nil, sessionTestRequest(true), nil, base, s)
391+ require.Eventually(t, func() bool { return strings.Contains(h.term.written(), "ready") }, 5*time.Second, time.Millisecond)
392+ // Let the initial Ack and focus report through, then stall.
393+ require.Eventually(t, func() bool { return len(base.messages()) >= 2 }, 5*time.Second, time.Millisecond)
394+ s.stall.Store(true)
395+ tt.run(t, h, s)
396+ })
397+ }
398+}
M internal/usecase/client/attachment_session.go +141 -7
@@ -301,9 +301,13 @@ var (
301301
302302 // pumpAttached publishes output and concurrently forwards the foreground's
303303 // authorized input, latest geometry and explicit lifecycle decision.
304-func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
304+func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
305305 pumpCtx, cancel := context.WithCancel(ctx)
306306 defer cancel()
307+ // Every send while attached goes through the outbox so a stalled link
308+ // never blocks key handling, overlays, or painting.
309+ stream := newAttachmentOutbox(raw)
310+ defer stream.stop()
307311 outgoing := make(chan attachmentClientEvent)
308312 go pumpAttachmentInput(pumpCtx, fg, outgoing)
309313 go pumpAttachmentGeometry(pumpCtx, fg, outgoing)
@@ -345,7 +349,73 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
345349 if err := sendFocus(); err != nil {
346350 return w.settle(ctx, fg, stream, token, err)
347351 }
352+ // pending is the one undecided input delivery waiting on the outbox.
353+ var pending *pendingInputAck
354+ defer func() {
355+ if pending == nil {
356+ return
357+ }
358+ // Join the sender before deciding which input reached the carriage.
359+ // A written automation action remains OutcomeUnknown if teardown
360+ // prevents its UIFence receipt; acknowledging input is not proof
361+ // that the daemon processed the action.
362+ stream.join()
363+ if unsent := stream.unsentInput(pending.from, pending.mark); len(unsent) != 0 {
364+ fg.PreserveInput(unsent)
365+ return
366+ }
367+ fg.AckInput()
368+ }()
369+ // ackWritten commits pending once the outbox wrote it, then fences its
370+ // UI action after it.
371+ ackWritten := func() error {
372+ if pending == nil || stream.writtenCount() < pending.mark {
373+ return nil
374+ }
375+ actionID := pending.actionID
376+ pending = nil
377+ fg.AckInput()
378+ if actionID != 0 {
379+ return w.send(ctx, fg, stream, protocol.UIFence{ActionID: actionID})
380+ }
381+ return nil
382+ }
383+ // await makes the delivery just routed pending until the outbox wrote
384+ // every message accepted since from.
385+ await := func(from, actionID uint64) error {
386+ mark := stream.accepted()
387+ if mark == from {
388+ // Nothing was queued (an overlay consumed the keys), so there is
389+ // no write to wait for: an earlier stalled message must not hold
390+ // the overlay's next key.
391+ fg.AckInput()
392+ if actionID != 0 {
393+ return w.send(ctx, fg, stream, protocol.UIFence{ActionID: actionID})
394+ }
395+ return nil
396+ }
397+ pending = &pendingInputAck{from: from, mark: mark, actionID: actionID}
398+ return ackWritten()
399+ }
348400 for {
401+ // Failure also closes progress. Settle it before rearming that
402+ // channel, rather than spinning on an acknowledgement that cannot
403+ // advance.
404+ select {
405+ case <-stream.Failed():
406+ return w.settle(ctx, fg, stream, token, stream.failure())
407+ default:
408+ }
409+ var progress <-chan struct{}
410+ if pending != nil {
411+ progress = stream.Progress()
412+ if err := ackWritten(); err != nil {
413+ return w.settle(ctx, fg, stream, token, err)
414+ }
415+ if pending == nil {
416+ progress = nil
417+ }
418+ }
349419 select {
350420 case <-focus.changed:
351421 if err := sendFocus(); err != nil {
@@ -517,13 +587,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
517587 if event.input != nil && event.input.actionID == 0 {
518588 // A physical read: replies are stripped and ordinary bytes
519589 // reach the overlay or the session inside read.
590+ from := stream.accepted()
520591 if err := input.read(ctx, state, event.input.Data); err != nil {
521592 // Part of the read may already be delivered or held, so
522593 // it is committed rather than replayed.
523594 fg.AckInput()
524595 return w.settle(ctx, fg, stream, token, err)
525596 }
526- fg.AckInput()
597+ if err := await(from, 0); err != nil {
598+ return w.settle(ctx, fg, stream, token, err)
599+ }
527600 continue
528601 }
529602 if event.input != nil {
@@ -557,6 +630,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
557630 return w.settle(ctx, fg, stream, token, err)
558631 }
559632 }
633+ from := stream.accepted()
560634 if err := w.send(ctx, fg, stream, event.message); err != nil {
561635 if event.input != nil {
562636 fg.PreserveInput(event.input.Data)
@@ -564,16 +638,28 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
564638 return w.settle(ctx, fg, stream, token, err)
565639 }
566640 if event.input != nil {
567- fg.AckInput()
568- if event.input.actionID != 0 {
569- if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil {
570- return w.settle(ctx, fg, stream, token, err)
571- }
641+ // The delivery stays undecided until the outbox wrote it, so
642+ // a link failure preserves it for the next attempt exactly as
643+ // a failed synchronous send did. The pump hands out no newer
644+ // delivery meanwhile, which bounds queued input.
645+ if err := await(from, event.input.actionID); err != nil {
646+ return w.settle(ctx, fg, stream, token, err)
572647 }
573648 }
574649 if event.action != 0 {
650+ // The lifecycle message must reach the daemon before the
651+ // supervisor closes the stream.
652+ if err := w.drainOutbox(ctx, fg, stream); err != nil {
653+ return w.settle(ctx, fg, stream, token, err)
654+ }
575655 return attachmentLifecycleEnd(token, event.action)
576656 }
657+ case <-progress:
658+ if err := ackWritten(); err != nil {
659+ return w.settle(ctx, fg, stream, token, err)
660+ }
661+ case <-stream.Failed():
662+ return w.settle(ctx, fg, stream, token, stream.failure())
577663 case <-ctx.Done():
578664 return w.settle(ctx, fg, stream, token, ctx.Err())
579665 case <-fg.Done():
@@ -582,6 +668,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
582668 }
583669 }
584670
671+// pendingInputAck is an input delivery whose messages the outbox accepted but
672+// has not written yet: accepted messages numbered (from, mark].
673+type pendingInputAck struct {
674+ from uint64
675+ mark uint64
676+ actionID uint64
677+}
678+
585679 // attachmentLifecycleEnd gives local and daemon-triggered lifecycle actions
586680 // the same settlement path: drain/revoke foreground before publishing picker.
587681 func attachmentLifecycleEnd(token AttachmentToken, action AttachmentLifecycleActionKind) AttachmentEvent {
@@ -800,6 +894,15 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
800894 // wins a blocked send, closing the stream interrupts the carriage and the
801895 // worker joins the send before returning, so no per-send goroutine is leaked.
802896 func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, message protocol.ClientMessage) error {
897+ if outbox, ok := stream.(*attachmentOutbox); ok {
898+ // Enqueue returns at once unless the outbox is full, and then still
899+ // honours cancellation, so no helper goroutine is needed.
900+ err := outbox.enqueue(ctx, fg.Done(), message)
901+ if errors.Is(err, errAttachmentForegroundRevoked) || (ctx.Err() != nil && errors.Is(err, ctx.Err())) {
902+ _ = outbox.Close()
903+ }
904+ return err
905+ }
803906 sent := make(chan error, 1)
804907 go func() { sent <- stream.SendClient(message) }()
805908 select {
@@ -816,6 +919,32 @@ func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForegro
816919 }
817920 }
818921
922+// drainOutboxTimeout bounds how long a lifecycle exit waits for queued
923+// messages on a stalled link. Past it the client leaves anyway; the daemon
924+// parks the attachment when the detach never arrives.
925+const drainOutboxTimeout = 2 * time.Second
926+
927+// drainOutbox waits until every queued message was written, the bound
928+// elapses, or the run ends.
929+func (w *sessionAttachmentWorker) drainOutbox(ctx context.Context, fg AttachmentForeground, outbox *attachmentOutbox) error {
930+ timer := w.cfg.Clock.NewTimer(drainOutboxTimeout)
931+ defer stopSupervisorTimer(timer)
932+ select {
933+ case <-timer.C():
934+ return nil
935+ case <-outbox.Idle():
936+ return outbox.failure()
937+ case <-outbox.Failed():
938+ return outbox.failure()
939+ case <-ctx.Done():
940+ _ = outbox.Close()
941+ return ctx.Err()
942+ case <-fg.Done():
943+ _ = outbox.Close()
944+ return errAttachmentForegroundRevoked
945+ }
946+}
947+
819948 // awaitServer waits for the next server message while the run is live.
820949 func (w *sessionAttachmentWorker) awaitServer(ctx context.Context, fg AttachmentForeground, incoming <-chan serverReceive) (protocol.ServerMessage, error) {
821950 select {
@@ -861,6 +990,11 @@ func (w *sessionAttachmentWorker) sendClosed(stream ports.BrokerLogicalConnectio
861990 if supervisorNil(stream) {
862991 return
863992 }
993+ if outbox, ok := stream.(*attachmentOutbox); ok {
994+ // Bypass the queue: the run is ending and its sender stops with it.
995+ outbox.stop()
996+ stream = outbox.BrokerLogicalConnection
997+ }
864998 sent := make(chan error, 1)
865999 go func() { sent <- stream.SendClient(protocol.Detach{Closed: true}) }()
8661000 timer := w.cfg.Clock.NewTimer(closedDetachTimeout)
@@ -301,9 +301,13 @@ var (
301 301
302 // pumpAttached publishes output and concurrently forwards the foreground's302 // pumpAttached publishes output and concurrently forwards the foreground's
303 // authorized input, latest geometry and explicit lifecycle decision.303 // authorized input, latest geometry and explicit lifecycle decision.
304-func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {304+func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
305 pumpCtx, cancel := context.WithCancel(ctx)305 pumpCtx, cancel := context.WithCancel(ctx)
306 defer cancel()306 defer cancel()
307+ // Every send while attached goes through the outbox so a stalled link
308+ // never blocks key handling, overlays, or painting.
309+ stream := newAttachmentOutbox(raw)
310+ defer stream.stop()
307 outgoing := make(chan attachmentClientEvent)311 outgoing := make(chan attachmentClientEvent)
308 go pumpAttachmentInput(pumpCtx, fg, outgoing)312 go pumpAttachmentInput(pumpCtx, fg, outgoing)
309 go pumpAttachmentGeometry(pumpCtx, fg, outgoing)313 go pumpAttachmentGeometry(pumpCtx, fg, outgoing)
@@ -345,7 +349,73 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
345 if err := sendFocus(); err != nil {349 if err := sendFocus(); err != nil {
346 return w.settle(ctx, fg, stream, token, err)350 return w.settle(ctx, fg, stream, token, err)
347 }351 }
352+ // pending is the one undecided input delivery waiting on the outbox.
353+ var pending *pendingInputAck
354+ defer func() {
355+ if pending == nil {
356+ return
357+ }
358+ // Join the sender before deciding which input reached the carriage.
359+ // A written automation action remains OutcomeUnknown if teardown
360+ // prevents its UIFence receipt; acknowledging input is not proof
361+ // that the daemon processed the action.
362+ stream.join()
363+ if unsent := stream.unsentInput(pending.from, pending.mark); len(unsent) != 0 {
364+ fg.PreserveInput(unsent)
365+ return
366+ }
367+ fg.AckInput()
368+ }()
369+ // ackWritten commits pending once the outbox wrote it, then fences its
370+ // UI action after it.
371+ ackWritten := func() error {
372+ if pending == nil || stream.writtenCount() < pending.mark {
373+ return nil
374+ }
375+ actionID := pending.actionID
376+ pending = nil
377+ fg.AckInput()
378+ if actionID != 0 {
379+ return w.send(ctx, fg, stream, protocol.UIFence{ActionID: actionID})
380+ }
381+ return nil
382+ }
383+ // await makes the delivery just routed pending until the outbox wrote
384+ // every message accepted since from.
385+ await := func(from, actionID uint64) error {
386+ mark := stream.accepted()
387+ if mark == from {
388+ // Nothing was queued (an overlay consumed the keys), so there is
389+ // no write to wait for: an earlier stalled message must not hold
390+ // the overlay's next key.
391+ fg.AckInput()
392+ if actionID != 0 {
393+ return w.send(ctx, fg, stream, protocol.UIFence{ActionID: actionID})
394+ }
395+ return nil
396+ }
397+ pending = &pendingInputAck{from: from, mark: mark, actionID: actionID}
398+ return ackWritten()
399+ }
348 for {400 for {
401+ // Failure also closes progress. Settle it before rearming that
402+ // channel, rather than spinning on an acknowledgement that cannot
403+ // advance.
404+ select {
405+ case <-stream.Failed():
406+ return w.settle(ctx, fg, stream, token, stream.failure())
407+ default:
408+ }
409+ var progress <-chan struct{}
410+ if pending != nil {
411+ progress = stream.Progress()
412+ if err := ackWritten(); err != nil {
413+ return w.settle(ctx, fg, stream, token, err)
414+ }
415+ if pending == nil {
416+ progress = nil
417+ }
418+ }
349 select {419 select {
350 case <-focus.changed:420 case <-focus.changed:
351 if err := sendFocus(); err != nil {421 if err := sendFocus(); err != nil {
@@ -517,13 +587,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
517 if event.input != nil && event.input.actionID == 0 {587 if event.input != nil && event.input.actionID == 0 {
518 // A physical read: replies are stripped and ordinary bytes588 // A physical read: replies are stripped and ordinary bytes
519 // reach the overlay or the session inside read.589 // reach the overlay or the session inside read.
590+ from := stream.accepted()
520 if err := input.read(ctx, state, event.input.Data); err != nil {591 if err := input.read(ctx, state, event.input.Data); err != nil {
521 // Part of the read may already be delivered or held, so592 // Part of the read may already be delivered or held, so
522 // it is committed rather than replayed.593 // it is committed rather than replayed.
523 fg.AckInput()594 fg.AckInput()
524 return w.settle(ctx, fg, stream, token, err)595 return w.settle(ctx, fg, stream, token, err)
525 }596 }
526- fg.AckInput()597+ if err := await(from, 0); err != nil {
598+ return w.settle(ctx, fg, stream, token, err)
599+ }
527 continue600 continue
528 }601 }
529 if event.input != nil {602 if event.input != nil {
@@ -557,6 +630,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
557 return w.settle(ctx, fg, stream, token, err)630 return w.settle(ctx, fg, stream, token, err)
558 }631 }
559 }632 }
633+ from := stream.accepted()
560 if err := w.send(ctx, fg, stream, event.message); err != nil {634 if err := w.send(ctx, fg, stream, event.message); err != nil {
561 if event.input != nil {635 if event.input != nil {
562 fg.PreserveInput(event.input.Data)636 fg.PreserveInput(event.input.Data)
@@ -564,16 +638,28 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
564 return w.settle(ctx, fg, stream, token, err)638 return w.settle(ctx, fg, stream, token, err)
565 }639 }
566 if event.input != nil {640 if event.input != nil {
567- fg.AckInput()641+ // The delivery stays undecided until the outbox wrote it, so
568- if event.input.actionID != 0 {642+ // a link failure preserves it for the next attempt exactly as
569- if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil {643+ // a failed synchronous send did. The pump hands out no newer
570- return w.settle(ctx, fg, stream, token, err)644+ // delivery meanwhile, which bounds queued input.
571- }645+ if err := await(from, event.input.actionID); err != nil {
646+ return w.settle(ctx, fg, stream, token, err)
572 }647 }
573 }648 }
574 if event.action != 0 {649 if event.action != 0 {
650+ // The lifecycle message must reach the daemon before the
651+ // supervisor closes the stream.
652+ if err := w.drainOutbox(ctx, fg, stream); err != nil {
653+ return w.settle(ctx, fg, stream, token, err)
654+ }
575 return attachmentLifecycleEnd(token, event.action)655 return attachmentLifecycleEnd(token, event.action)
576 }656 }
657+ case <-progress:
658+ if err := ackWritten(); err != nil {
659+ return w.settle(ctx, fg, stream, token, err)
660+ }
661+ case <-stream.Failed():
662+ return w.settle(ctx, fg, stream, token, stream.failure())
577 case <-ctx.Done():663 case <-ctx.Done():
578 return w.settle(ctx, fg, stream, token, ctx.Err())664 return w.settle(ctx, fg, stream, token, ctx.Err())
579 case <-fg.Done():665 case <-fg.Done():
@@ -582,6 +668,14 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
582 }668 }
583 }669 }
584 670
671+// pendingInputAck is an input delivery whose messages the outbox accepted but
672+// has not written yet: accepted messages numbered (from, mark].
673+type pendingInputAck struct {
674+ from uint64
675+ mark uint64
676+ actionID uint64
677+}
678+
585 // attachmentLifecycleEnd gives local and daemon-triggered lifecycle actions679 // attachmentLifecycleEnd gives local and daemon-triggered lifecycle actions
586 // the same settlement path: drain/revoke foreground before publishing picker.680 // the same settlement path: drain/revoke foreground before publishing picker.
587 func attachmentLifecycleEnd(token AttachmentToken, action AttachmentLifecycleActionKind) AttachmentEvent {681 func attachmentLifecycleEnd(token AttachmentToken, action AttachmentLifecycleActionKind) AttachmentEvent {
@@ -800,6 +894,15 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
800 // wins a blocked send, closing the stream interrupts the carriage and the894 // wins a blocked send, closing the stream interrupts the carriage and the
801 // worker joins the send before returning, so no per-send goroutine is leaked.895 // worker joins the send before returning, so no per-send goroutine is leaked.
802 func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, message protocol.ClientMessage) error {896 func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, message protocol.ClientMessage) error {
897+ if outbox, ok := stream.(*attachmentOutbox); ok {
898+ // Enqueue returns at once unless the outbox is full, and then still
899+ // honours cancellation, so no helper goroutine is needed.
900+ err := outbox.enqueue(ctx, fg.Done(), message)
901+ if errors.Is(err, errAttachmentForegroundRevoked) || (ctx.Err() != nil && errors.Is(err, ctx.Err())) {
902+ _ = outbox.Close()
903+ }
904+ return err
905+ }
803 sent := make(chan error, 1)906 sent := make(chan error, 1)
804 go func() { sent <- stream.SendClient(message) }()907 go func() { sent <- stream.SendClient(message) }()
805 select {908 select {
@@ -816,6 +919,32 @@ func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForegro
816 }919 }
817 }920 }
818 921
922+// drainOutboxTimeout bounds how long a lifecycle exit waits for queued
923+// messages on a stalled link. Past it the client leaves anyway; the daemon
924+// parks the attachment when the detach never arrives.
925+const drainOutboxTimeout = 2 * time.Second
926+
927+// drainOutbox waits until every queued message was written, the bound
928+// elapses, or the run ends.
929+func (w *sessionAttachmentWorker) drainOutbox(ctx context.Context, fg AttachmentForeground, outbox *attachmentOutbox) error {
930+ timer := w.cfg.Clock.NewTimer(drainOutboxTimeout)
931+ defer stopSupervisorTimer(timer)
932+ select {
933+ case <-timer.C():
934+ return nil
935+ case <-outbox.Idle():
936+ return outbox.failure()
937+ case <-outbox.Failed():
938+ return outbox.failure()
939+ case <-ctx.Done():
940+ _ = outbox.Close()
941+ return ctx.Err()
942+ case <-fg.Done():
943+ _ = outbox.Close()
944+ return errAttachmentForegroundRevoked
945+ }
946+}
947+
819 // awaitServer waits for the next server message while the run is live.948 // awaitServer waits for the next server message while the run is live.
820 func (w *sessionAttachmentWorker) awaitServer(ctx context.Context, fg AttachmentForeground, incoming <-chan serverReceive) (protocol.ServerMessage, error) {949 func (w *sessionAttachmentWorker) awaitServer(ctx context.Context, fg AttachmentForeground, incoming <-chan serverReceive) (protocol.ServerMessage, error) {
821 select {950 select {
@@ -861,6 +990,11 @@ func (w *sessionAttachmentWorker) sendClosed(stream ports.BrokerLogicalConnectio
861 if supervisorNil(stream) {990 if supervisorNil(stream) {
862 return991 return
863 }992 }
993+ if outbox, ok := stream.(*attachmentOutbox); ok {
994+ // Bypass the queue: the run is ending and its sender stops with it.
995+ outbox.stop()
996+ stream = outbox.BrokerLogicalConnection
997+ }
864 sent := make(chan error, 1)998 sent := make(chan error, 1)
865 go func() { sent <- stream.SendClient(protocol.Detach{Closed: true}) }()999 go func() { sent <- stream.SendClient(protocol.Detach{Closed: true}) }()
866 timer := w.cfg.Clock.NewTimer(closedDetachTimeout)1000 timer := w.cfg.Clock.NewTimer(closedDetachTimeout)
M internal/usecase/client/attachment_worker.go +4 -6
@@ -1231,12 +1231,10 @@ func (f *attachmentForeground) finish() {
12311231 return
12321232 }
12331233 f.finishedOnce.Do(func() {
1234- // Autonomous supervisor foregrounds have a host-lifetime geometry
1235- // collector. At that owner-class boundary, no attachment byte may reach
1236- // the picker. Attachment-to-attachment replacement instead delivers pending input.
1237- if f.input != nil && f.host != nil && f.host.ownerBoundary {
1238- f.input.dropOwned(f.consumer)
1239- }
1234+ // Finish is lossless: input kept for a resume must survive it. The
1235+ // supervisor drops unclaimed bytes (dropUnclaimed) when the terminal
1236+ // returns to the picker, the owner-class boundary no attachment byte
1237+ // may cross.
12401238 f.authority.revoke(f)
12411239 })
12421240 }
@@ -1231,12 +1231,10 @@ func (f *attachmentForeground) finish() {
1231 return1231 return
1232 }1232 }
1233 f.finishedOnce.Do(func() {1233 f.finishedOnce.Do(func() {
1234- // Autonomous supervisor foregrounds have a host-lifetime geometry1234+ // Finish is lossless: input kept for a resume must survive it. The
1235- // collector. At that owner-class boundary, no attachment byte may reach1235+ // supervisor drops unclaimed bytes (dropUnclaimed) when the terminal
1236- // the picker. Attachment-to-attachment replacement instead delivers pending input.1236+ // returns to the picker, the owner-class boundary no attachment byte
1237- if f.input != nil && f.host != nil && f.host.ownerBoundary {1237+ // may cross.
1238- f.input.dropOwned(f.consumer)
1239- }
1240 f.authority.revoke(f)1238 f.authority.revoke(f)
1241 })1239 })
1242 }1240 }
M internal/usecase/client/lifecycle_p54a_test.go +32 -0
@@ -311,6 +311,9 @@ func TestP54AttachmentFinalizeDropsUnleasedPendingBeforePicker(t *testing.T) {
311311 return lifetime.pump.pending != nil && lifetime.pump.delivering == 0
312312 }, 5*time.Second, time.Millisecond)
313313 fg.finish()
314+ // The supervisor drops unclaimed session input as the terminal returns to
315+ // the picker; finish itself stays lossless for a resume.
316+ lifetime.pump.dropUnclaimed()
314317 lifetime.acquirePicker()
315318 assertNoPickerRead(t, consumer.reads)
316319 }
@@ -330,6 +333,9 @@ func TestP54AttachmentFinalizeDropsPreservedResidualBeforePicker(t *testing.T) {
330333 require.True(t, ok)
331334 fg.PreserveInput(event.Data)
332335 fg.finish()
336+ // The supervisor drops unclaimed session input as the terminal returns to
337+ // the picker; finish itself stays lossless for a resume.
338+ lifetime.pump.dropUnclaimed()
333339 lifetime.acquirePicker()
334340 assertNoPickerRead(t, consumer.reads)
335341 }
@@ -468,3 +474,29 @@ func TestP54LifecycleInterleavingsRace(t *testing.T) {
468474 host.stopGeometry()
469475 }
470476 }
477+
478+// TestResumeKeysSurviveFinish runs the production owner boundary: input kept
479+// for a resume survives the attachment's finish and reaches the next
480+// foreground. TestResumeHeldKeysNeverReachThePicker covers the picker side.
481+func TestResumeKeysSurviveFinish(t *testing.T) {
482+ reader := newAttachTestReader()
483+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
484+ lifetime := startTerminalInputLifetime(reader, consumer)
485+ defer lifetime.stop()
486+ lifetime.releasePicker()
487+ host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump})
488+ host.ownerBoundary = true
489+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
490+ require.True(t, host.authority.grant(fg))
491+ reader.push([]byte("ls\r"))
492+ event, ok := fg.Input(context.Background())
493+ require.True(t, ok)
494+ fg.PreserveInput(event.Data)
495+ fg.finish()
496+
497+ next := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream())
498+ require.True(t, host.authority.grant(next))
499+ replayed, ok := next.Input(context.Background())
500+ require.True(t, ok)
501+ require.Equal(t, []byte("ls\r"), replayed.Data)
502+}
@@ -311,6 +311,9 @@ func TestP54AttachmentFinalizeDropsUnleasedPendingBeforePicker(t *testing.T) {
311 return lifetime.pump.pending != nil && lifetime.pump.delivering == 0311 return lifetime.pump.pending != nil && lifetime.pump.delivering == 0
312 }, 5*time.Second, time.Millisecond)312 }, 5*time.Second, time.Millisecond)
313 fg.finish()313 fg.finish()
314+ // The supervisor drops unclaimed session input as the terminal returns to
315+ // the picker; finish itself stays lossless for a resume.
316+ lifetime.pump.dropUnclaimed()
314 lifetime.acquirePicker()317 lifetime.acquirePicker()
315 assertNoPickerRead(t, consumer.reads)318 assertNoPickerRead(t, consumer.reads)
316 }319 }
@@ -330,6 +333,9 @@ func TestP54AttachmentFinalizeDropsPreservedResidualBeforePicker(t *testing.T) {
330 require.True(t, ok)333 require.True(t, ok)
331 fg.PreserveInput(event.Data)334 fg.PreserveInput(event.Data)
332 fg.finish()335 fg.finish()
336+ // The supervisor drops unclaimed session input as the terminal returns to
337+ // the picker; finish itself stays lossless for a resume.
338+ lifetime.pump.dropUnclaimed()
333 lifetime.acquirePicker()339 lifetime.acquirePicker()
334 assertNoPickerRead(t, consumer.reads)340 assertNoPickerRead(t, consumer.reads)
335 }341 }
@@ -468,3 +474,29 @@ func TestP54LifecycleInterleavingsRace(t *testing.T) {
468 host.stopGeometry()474 host.stopGeometry()
469 }475 }
470 }476 }
477+
478+// TestResumeKeysSurviveFinish runs the production owner boundary: input kept
479+// for a resume survives the attachment's finish and reaches the next
480+// foreground. TestResumeHeldKeysNeverReachThePicker covers the picker side.
481+func TestResumeKeysSurviveFinish(t *testing.T) {
482+ reader := newAttachTestReader()
483+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
484+ lifetime := startTerminalInputLifetime(reader, consumer)
485+ defer lifetime.stop()
486+ lifetime.releasePicker()
487+ host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump})
488+ host.ownerBoundary = true
489+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
490+ require.True(t, host.authority.grant(fg))
491+ reader.push([]byte("ls\r"))
492+ event, ok := fg.Input(context.Background())
493+ require.True(t, ok)
494+ fg.PreserveInput(event.Data)
495+ fg.finish()
496+
497+ next := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream())
498+ require.True(t, host.authority.grant(next))
499+ replayed, ok := next.Input(context.Background())
500+ require.True(t, ok)
501+ require.Equal(t, []byte("ls\r"), replayed.Data)
502+}
A internal/usecase/client/outbox_bounds_test.go +80 -0
@@ -0,0 +1,80 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestOutboxFullCancellation(t *testing.T) {
13+ for _, foreground := range []bool{false, true} {
14+ t.Run(map[bool]string{false: "context", true: "foreground"}[foreground], func(t *testing.T) {
15+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
16+ box := newAttachmentOutbox(stream)
17+ defer box.join()
18+ for range outboxMaxPending {
19+ require.NoError(t, box.SendClient(protocol.Ack{}))
20+ }
21+ ctx, cancel := context.WithCancel(t.Context())
22+ defer cancel()
23+ revoked := make(chan struct{})
24+ result := make(chan error, 1)
25+ go func() { result <- box.enqueue(ctx, revoked, protocol.Ack{}) }()
26+ if foreground {
27+ close(revoked)
28+ } else {
29+ cancel()
30+ }
31+ select {
32+ case err := <-result:
33+ if foreground {
34+ require.ErrorIs(t, err, errAttachmentForegroundRevoked)
35+ } else {
36+ require.ErrorIs(t, err, context.Canceled)
37+ }
38+ case <-time.After(time.Second):
39+ t.Fatal("full outbox ignored cancellation")
40+ }
41+ })
42+ }
43+}
44+
45+// TestOutboxFullAdmitsAfterProgress checks that a caller blocked on a full
46+// outbox is admitted once the sender writes and frees space.
47+func TestOutboxFullAdmitsAfterProgress(t *testing.T) {
48+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
49+ box := newAttachmentOutbox(stream)
50+ defer box.join()
51+ for range outboxMaxPending {
52+ require.NoError(t, box.SendClient(protocol.Ack{}))
53+ }
54+ result := make(chan error, 1)
55+ go func() { result <- box.enqueue(t.Context(), nil, protocol.Detach{}) }()
56+ select {
57+ case err := <-result:
58+ t.Fatalf("full outbox admitted before progress: %v", err)
59+ case <-time.After(50 * time.Millisecond):
60+ }
61+ close(stream.release)
62+ select {
63+ case err := <-result:
64+ require.NoError(t, err)
65+ case <-time.After(time.Second):
66+ t.Fatal("blocked caller was not admitted after progress")
67+ }
68+ require.Eventually(t, func() bool { return len(stream.messages()) == outboxMaxPending+1 }, time.Second, time.Millisecond)
69+}
70+
71+func TestOutboxMixedUnsentInput(t *testing.T) {
72+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
73+ box := newAttachmentOutbox(stream)
74+ defer box.join()
75+ for _, message := range []protocol.ClientMessage{protocol.Ack{}, protocol.Input{Data: []byte("a")}, protocol.TerminalFocus{}, protocol.Input{Data: []byte("b")}} {
76+ require.NoError(t, box.SendClient(message))
77+ }
78+ require.Equal(t, []byte("ab"), box.unsentInput(0, box.accepted()))
79+ require.Equal(t, []byte("b"), box.unsentInput(2, box.accepted()))
80+}
@@ -0,0 +1,80 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestOutboxFullCancellation(t *testing.T) {
13+ for _, foreground := range []bool{false, true} {
14+ t.Run(map[bool]string{false: "context", true: "foreground"}[foreground], func(t *testing.T) {
15+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
16+ box := newAttachmentOutbox(stream)
17+ defer box.join()
18+ for range outboxMaxPending {
19+ require.NoError(t, box.SendClient(protocol.Ack{}))
20+ }
21+ ctx, cancel := context.WithCancel(t.Context())
22+ defer cancel()
23+ revoked := make(chan struct{})
24+ result := make(chan error, 1)
25+ go func() { result <- box.enqueue(ctx, revoked, protocol.Ack{}) }()
26+ if foreground {
27+ close(revoked)
28+ } else {
29+ cancel()
30+ }
31+ select {
32+ case err := <-result:
33+ if foreground {
34+ require.ErrorIs(t, err, errAttachmentForegroundRevoked)
35+ } else {
36+ require.ErrorIs(t, err, context.Canceled)
37+ }
38+ case <-time.After(time.Second):
39+ t.Fatal("full outbox ignored cancellation")
40+ }
41+ })
42+ }
43+}
44+
45+// TestOutboxFullAdmitsAfterProgress checks that a caller blocked on a full
46+// outbox is admitted once the sender writes and frees space.
47+func TestOutboxFullAdmitsAfterProgress(t *testing.T) {
48+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
49+ box := newAttachmentOutbox(stream)
50+ defer box.join()
51+ for range outboxMaxPending {
52+ require.NoError(t, box.SendClient(protocol.Ack{}))
53+ }
54+ result := make(chan error, 1)
55+ go func() { result <- box.enqueue(t.Context(), nil, protocol.Detach{}) }()
56+ select {
57+ case err := <-result:
58+ t.Fatalf("full outbox admitted before progress: %v", err)
59+ case <-time.After(50 * time.Millisecond):
60+ }
61+ close(stream.release)
62+ select {
63+ case err := <-result:
64+ require.NoError(t, err)
65+ case <-time.After(time.Second):
66+ t.Fatal("blocked caller was not admitted after progress")
67+ }
68+ require.Eventually(t, func() bool { return len(stream.messages()) == outboxMaxPending+1 }, time.Second, time.Millisecond)
69+}
70+
71+func TestOutboxMixedUnsentInput(t *testing.T) {
72+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
73+ box := newAttachmentOutbox(stream)
74+ defer box.join()
75+ for _, message := range []protocol.ClientMessage{protocol.Ack{}, protocol.Input{Data: []byte("a")}, protocol.TerminalFocus{}, protocol.Input{Data: []byte("b")}} {
76+ require.NoError(t, box.SendClient(message))
77+ }
78+ require.Equal(t, []byte("ab"), box.unsentInput(0, box.accepted()))
79+ require.Equal(t, []byte("b"), box.unsentInput(2, box.accepted()))
80+}
A internal/usecase/client/outbox_drain_test.go +64 -0
@@ -0,0 +1,64 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestOutboxDrain(t *testing.T) {
14+ linkErr := errors.New("link lost")
15+ for _, mode := range []string{"written", "timeout", "cancelled", "failed", "revoked"} {
16+ t.Run(mode, func(t *testing.T) {
17+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
18+ box := newAttachmentOutbox(stream)
19+ defer box.join()
20+ require.NoError(t, box.SendClient(protocol.Detach{}))
21+ clock := newSupervisorTestClock()
22+ worker := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{Clock: clock}}
23+ host := newAttachmentHost(attachmentHostConfig{})
24+ fg := host.newForeground(AttachmentToken{Generation: 1}, stream)
25+ ctx, cancel := context.WithCancel(t.Context())
26+ defer cancel()
27+ result := make(chan error, 1)
28+ go func() { result <- worker.drainOutbox(ctx, fg, box) }()
29+ timer := clock.awaitTimer(t)
30+ require.Equal(t, 2*time.Second, timer.delay)
31+ switch mode {
32+ case "written":
33+ close(stream.release)
34+ case "timeout":
35+ timer.fire()
36+ case "cancelled":
37+ cancel()
38+ case "failed":
39+ stream.fail = linkErr
40+ close(stream.release)
41+ case "revoked":
42+ fg.finalize()
43+ }
44+ select {
45+ case err := <-result:
46+ switch mode {
47+ case "cancelled":
48+ require.ErrorIs(t, err, context.Canceled)
49+ case "failed":
50+ require.ErrorIs(t, err, linkErr)
51+ case "revoked":
52+ require.ErrorIs(t, err, errAttachmentForegroundRevoked)
53+ default:
54+ require.NoError(t, err)
55+ }
56+ case <-time.After(time.Second):
57+ t.Fatal("drain did not settle")
58+ }
59+ if mode == "written" {
60+ require.Len(t, stream.messages(), 1)
61+ }
62+ })
63+ }
64+}
@@ -0,0 +1,64 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestOutboxDrain(t *testing.T) {
14+ linkErr := errors.New("link lost")
15+ for _, mode := range []string{"written", "timeout", "cancelled", "failed", "revoked"} {
16+ t.Run(mode, func(t *testing.T) {
17+ stream := &gatedSessionStream{sessionTestStream: newSessionTestStream(), release: make(chan struct{})}
18+ box := newAttachmentOutbox(stream)
19+ defer box.join()
20+ require.NoError(t, box.SendClient(protocol.Detach{}))
21+ clock := newSupervisorTestClock()
22+ worker := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{Clock: clock}}
23+ host := newAttachmentHost(attachmentHostConfig{})
24+ fg := host.newForeground(AttachmentToken{Generation: 1}, stream)
25+ ctx, cancel := context.WithCancel(t.Context())
26+ defer cancel()
27+ result := make(chan error, 1)
28+ go func() { result <- worker.drainOutbox(ctx, fg, box) }()
29+ timer := clock.awaitTimer(t)
30+ require.Equal(t, 2*time.Second, timer.delay)
31+ switch mode {
32+ case "written":
33+ close(stream.release)
34+ case "timeout":
35+ timer.fire()
36+ case "cancelled":
37+ cancel()
38+ case "failed":
39+ stream.fail = linkErr
40+ close(stream.release)
41+ case "revoked":
42+ fg.finalize()
43+ }
44+ select {
45+ case err := <-result:
46+ switch mode {
47+ case "cancelled":
48+ require.ErrorIs(t, err, context.Canceled)
49+ case "failed":
50+ require.ErrorIs(t, err, linkErr)
51+ case "revoked":
52+ require.ErrorIs(t, err, errAttachmentForegroundRevoked)
53+ default:
54+ require.NoError(t, err)
55+ }
56+ case <-time.After(time.Second):
57+ t.Fatal("drain did not settle")
58+ }
59+ if mode == "written" {
60+ require.Len(t, stream.messages(), 1)
61+ }
62+ })
63+ }
64+}
A internal/usecase/client/resume_held_input_test.go +115 -0
@@ -0,0 +1,115 @@
1+package client
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// sentInput joins every Input message a stream carried, in order.
15+func sentInput(stream *sessionTestStream) string {
16+ var out strings.Builder
17+ for _, message := range stream.messages() {
18+ if input, ok := message.(protocol.Input); ok {
19+ out.Write(input.Data)
20+ }
21+ }
22+ return out.String()
23+}
24+
25+// pushKeys feeds the terminal one byte per read, like a keyboard, and waits
26+// until the shared reader consumed them all.
27+func pushKeys(t *testing.T, h *attachTestHarness, keys string) {
28+ t.Helper()
29+ for _, b := range []byte(keys) {
30+ h.reader.push([]byte{b})
31+ }
32+ require.Eventually(t, func() bool { return len(h.reader.chunks) == 0 }, 5*time.Second, time.Millisecond)
33+}
34+
35+// TestResumeReplaysKeysHeldWhileConnecting types while a lost attachment waits
36+// for its backoff and while the resume stream opens (including a failed open
37+// that is retried), then proves the resumed session receives every key, once,
38+// in order, and the lost attachment none.
39+func TestResumeReplaysKeysHeldWhileConnecting(t *testing.T) {
40+ for _, tt := range []struct {
41+ name string
42+ backoffKeys string
43+ openKeys string
44+ failFirst bool
45+ retryKeys string
46+ }{
47+ {name: "typed during backoff", backoffKeys: "HELD_ONE"},
48+ {name: "typed during stream open", openKeys: "HELD_TWO\r"},
49+ {name: "typed during backoff and open", backoffKeys: "HELD_ONE", openKeys: "HELD_TWO\r"},
50+ {name: "kept across a failed open", openKeys: "HELD_ONE", failFirst: true, retryKeys: "HELD_TWO\r"},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ picker := newAttachTestPicker()
54+ h := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { cfg.Jitter = resumeTestJitter })
55+ first := newSessionTestStream()
56+ second := newSessionTestStream()
57+ opened := make(chan int, 4)
58+ failed := make(chan struct{})
59+ release := make(chan struct{})
60+ count := 0
61+ h.service.setOpenStream(func(ctx context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
62+ count++
63+ switch {
64+ case count == 1:
65+ return first, nil
66+ case tt.failFirst && count == 2:
67+ opened <- count
68+ select {
69+ case <-failed:
70+ case <-ctx.Done():
71+ }
72+ return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route down"}
73+ }
74+ opened <- count
75+ select {
76+ case <-release:
77+ case <-ctx.Done():
78+ return nil, ctx.Err()
79+ }
80+ return second, nil
81+ })
82+ awaitOpen := func() {
83+ select {
84+ case <-opened:
85+ case <-time.After(5 * time.Second):
86+ t.Fatal("resume did not open")
87+ }
88+ }
89+ picker.commit(sessionTestRequest(false))
90+ deliverReadyStream(t, first)
91+ awaitAttachedState(t, h.sup)
92+ first.fail(testStreamLoss())
93+
94+ require.Eventually(t, func() bool { return h.sup.State().Presentation == PresentConnecting }, 5*time.Second, time.Millisecond)
95+ pushKeys(t, h, tt.backoffKeys)
96+ fireResumeTimerAfter(t, h.clock, 1, func() {})
97+ awaitOpen()
98+ pushKeys(t, h, tt.openKeys)
99+ if tt.failFirst {
100+ close(failed)
101+ fireResumeTimerAfter(t, h.clock, 2, func() {})
102+ awaitOpen()
103+ pushKeys(t, h, tt.retryKeys)
104+ }
105+ close(release)
106+ deliverReadyStream(t, second)
107+ awaitAttachedState(t, h.sup)
108+
109+ want := tt.backoffKeys + tt.openKeys + tt.retryKeys
110+ require.Eventually(t, func() bool { return sentInput(second) == want }, 5*time.Second, time.Millisecond,
111+ "resumed session input = %q, want %q", sentInput(second), want)
112+ require.Empty(t, sentInput(first))
113+ })
114+ }
115+}
@@ -0,0 +1,115 @@
1+package client
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// sentInput joins every Input message a stream carried, in order.
15+func sentInput(stream *sessionTestStream) string {
16+ var out strings.Builder
17+ for _, message := range stream.messages() {
18+ if input, ok := message.(protocol.Input); ok {
19+ out.Write(input.Data)
20+ }
21+ }
22+ return out.String()
23+}
24+
25+// pushKeys feeds the terminal one byte per read, like a keyboard, and waits
26+// until the shared reader consumed them all.
27+func pushKeys(t *testing.T, h *attachTestHarness, keys string) {
28+ t.Helper()
29+ for _, b := range []byte(keys) {
30+ h.reader.push([]byte{b})
31+ }
32+ require.Eventually(t, func() bool { return len(h.reader.chunks) == 0 }, 5*time.Second, time.Millisecond)
33+}
34+
35+// TestResumeReplaysKeysHeldWhileConnecting types while a lost attachment waits
36+// for its backoff and while the resume stream opens (including a failed open
37+// that is retried), then proves the resumed session receives every key, once,
38+// in order, and the lost attachment none.
39+func TestResumeReplaysKeysHeldWhileConnecting(t *testing.T) {
40+ for _, tt := range []struct {
41+ name string
42+ backoffKeys string
43+ openKeys string
44+ failFirst bool
45+ retryKeys string
46+ }{
47+ {name: "typed during backoff", backoffKeys: "HELD_ONE"},
48+ {name: "typed during stream open", openKeys: "HELD_TWO\r"},
49+ {name: "typed during backoff and open", backoffKeys: "HELD_ONE", openKeys: "HELD_TWO\r"},
50+ {name: "kept across a failed open", openKeys: "HELD_ONE", failFirst: true, retryKeys: "HELD_TWO\r"},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ picker := newAttachTestPicker()
54+ h := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { cfg.Jitter = resumeTestJitter })
55+ first := newSessionTestStream()
56+ second := newSessionTestStream()
57+ opened := make(chan int, 4)
58+ failed := make(chan struct{})
59+ release := make(chan struct{})
60+ count := 0
61+ h.service.setOpenStream(func(ctx context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
62+ count++
63+ switch {
64+ case count == 1:
65+ return first, nil
66+ case tt.failFirst && count == 2:
67+ opened <- count
68+ select {
69+ case <-failed:
70+ case <-ctx.Done():
71+ }
72+ return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route down"}
73+ }
74+ opened <- count
75+ select {
76+ case <-release:
77+ case <-ctx.Done():
78+ return nil, ctx.Err()
79+ }
80+ return second, nil
81+ })
82+ awaitOpen := func() {
83+ select {
84+ case <-opened:
85+ case <-time.After(5 * time.Second):
86+ t.Fatal("resume did not open")
87+ }
88+ }
89+ picker.commit(sessionTestRequest(false))
90+ deliverReadyStream(t, first)
91+ awaitAttachedState(t, h.sup)
92+ first.fail(testStreamLoss())
93+
94+ require.Eventually(t, func() bool { return h.sup.State().Presentation == PresentConnecting }, 5*time.Second, time.Millisecond)
95+ pushKeys(t, h, tt.backoffKeys)
96+ fireResumeTimerAfter(t, h.clock, 1, func() {})
97+ awaitOpen()
98+ pushKeys(t, h, tt.openKeys)
99+ if tt.failFirst {
100+ close(failed)
101+ fireResumeTimerAfter(t, h.clock, 2, func() {})
102+ awaitOpen()
103+ pushKeys(t, h, tt.retryKeys)
104+ }
105+ close(release)
106+ deliverReadyStream(t, second)
107+ awaitAttachedState(t, h.sup)
108+
109+ want := tt.backoffKeys + tt.openKeys + tt.retryKeys
110+ require.Eventually(t, func() bool { return sentInput(second) == want }, 5*time.Second, time.Millisecond,
111+ "resumed session input = %q, want %q", sentInput(second), want)
112+ require.Empty(t, sentInput(first))
113+ })
114+ }
115+}
A internal/usecase/client/resume_input.go +96 -0
@@ -0,0 +1,96 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
8+)
9+
10+const resumeEscapeLimit = 256
11+
12+// resumeEscapeDelay is how long a lone Esc stays undecided before it counts
13+// as a cancel rather than the start of an escape sequence.
14+const resumeEscapeDelay = 50 * time.Millisecond
15+
16+// resumeInputDecoder retains only an undecided escape prefix. Held bytes stay
17+// in their original terminal encoding; paste content is never a cancel key.
18+type resumeInputDecoder struct {
19+ prefix []byte
20+ paste bool
21+}
22+
23+func (d *resumeInputDecoder) feed(data []byte) (held []byte, cancel bool) {
24+ for _, b := range data {
25+ if len(d.prefix) == 0 {
26+ if b == 0x1b {
27+ d.prefix = append(d.prefix, b)
28+ continue
29+ }
30+ if b == 3 && !d.paste {
31+ return held, true
32+ }
33+ held = append(held, b)
34+ continue
35+ }
36+ if b == 0x1b || (b < 0x20 && !d.paste) {
37+ held = append(held, d.prefix...)
38+ d.prefix = d.prefix[:0]
39+ if b == 3 {
40+ return held, true
41+ }
42+ if b == 0x1b {
43+ d.prefix = append(d.prefix, b)
44+ } else {
45+ held = append(held, b)
46+ }
47+ continue
48+ }
49+ d.prefix = append(d.prefix, b)
50+ if d.paste {
51+ end := []byte("\x1b[201~")
52+ if bytes.Equal(d.prefix, end) {
53+ d.paste = false
54+ } else if bytes.HasPrefix(end, d.prefix) {
55+ continue
56+ }
57+ } else {
58+ start := []byte("\x1b[200~")
59+ if bytes.Equal(d.prefix, start) {
60+ d.paste = true
61+ } else if bytes.HasPrefix(start, d.prefix) {
62+ continue
63+ } else {
64+ _, n, ok, partial := kittykey.Parse(d.prefix)
65+ if ok {
66+ translated := kittykey.Translate(d.prefix[:n], 0)
67+ if len(translated) == 1 && (translated[0] == 3 || translated[0] == 0x1b) {
68+ return held, true
69+ }
70+ } else if partial && len(d.prefix) < resumeEscapeLimit {
71+ continue
72+ } else if len(d.prefix) >= 2 && (d.prefix[1] == '[' || d.prefix[1] == 'O') && b >= 0x20 && b < 0x40 && len(d.prefix) < resumeEscapeLimit {
73+ continue
74+ }
75+ }
76+ }
77+ held = append(held, d.prefix...)
78+ d.prefix = d.prefix[:0]
79+ }
80+ return held, false
81+}
82+
83+// undecided reports whether an escape prefix is still waiting for more bytes.
84+func (d *resumeInputDecoder) undecided() bool {
85+ return len(d.prefix) != 0
86+}
87+
88+func (d *resumeInputDecoder) loneEscape() bool {
89+ return !d.paste && bytes.Equal(d.prefix, []byte{0x1b})
90+}
91+
92+func (d *resumeInputDecoder) flush() []byte {
93+ data := d.prefix
94+ d.prefix = nil
95+ return data
96+}
@@ -0,0 +1,96 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/usecase/keys/kittykey"
8+)
9+
10+const resumeEscapeLimit = 256
11+
12+// resumeEscapeDelay is how long a lone Esc stays undecided before it counts
13+// as a cancel rather than the start of an escape sequence.
14+const resumeEscapeDelay = 50 * time.Millisecond
15+
16+// resumeInputDecoder retains only an undecided escape prefix. Held bytes stay
17+// in their original terminal encoding; paste content is never a cancel key.
18+type resumeInputDecoder struct {
19+ prefix []byte
20+ paste bool
21+}
22+
23+func (d *resumeInputDecoder) feed(data []byte) (held []byte, cancel bool) {
24+ for _, b := range data {
25+ if len(d.prefix) == 0 {
26+ if b == 0x1b {
27+ d.prefix = append(d.prefix, b)
28+ continue
29+ }
30+ if b == 3 && !d.paste {
31+ return held, true
32+ }
33+ held = append(held, b)
34+ continue
35+ }
36+ if b == 0x1b || (b < 0x20 && !d.paste) {
37+ held = append(held, d.prefix...)
38+ d.prefix = d.prefix[:0]
39+ if b == 3 {
40+ return held, true
41+ }
42+ if b == 0x1b {
43+ d.prefix = append(d.prefix, b)
44+ } else {
45+ held = append(held, b)
46+ }
47+ continue
48+ }
49+ d.prefix = append(d.prefix, b)
50+ if d.paste {
51+ end := []byte("\x1b[201~")
52+ if bytes.Equal(d.prefix, end) {
53+ d.paste = false
54+ } else if bytes.HasPrefix(end, d.prefix) {
55+ continue
56+ }
57+ } else {
58+ start := []byte("\x1b[200~")
59+ if bytes.Equal(d.prefix, start) {
60+ d.paste = true
61+ } else if bytes.HasPrefix(start, d.prefix) {
62+ continue
63+ } else {
64+ _, n, ok, partial := kittykey.Parse(d.prefix)
65+ if ok {
66+ translated := kittykey.Translate(d.prefix[:n], 0)
67+ if len(translated) == 1 && (translated[0] == 3 || translated[0] == 0x1b) {
68+ return held, true
69+ }
70+ } else if partial && len(d.prefix) < resumeEscapeLimit {
71+ continue
72+ } else if len(d.prefix) >= 2 && (d.prefix[1] == '[' || d.prefix[1] == 'O') && b >= 0x20 && b < 0x40 && len(d.prefix) < resumeEscapeLimit {
73+ continue
74+ }
75+ }
76+ }
77+ held = append(held, d.prefix...)
78+ d.prefix = d.prefix[:0]
79+ }
80+ return held, false
81+}
82+
83+// undecided reports whether an escape prefix is still waiting for more bytes.
84+func (d *resumeInputDecoder) undecided() bool {
85+ return len(d.prefix) != 0
86+}
87+
88+func (d *resumeInputDecoder) loneEscape() bool {
89+ return !d.paste && bytes.Equal(d.prefix, []byte{0x1b})
90+}
91+
92+func (d *resumeInputDecoder) flush() []byte {
93+ data := d.prefix
94+ d.prefix = nil
95+ return data
96+}
A internal/usecase/client/resume_input_test.go +54 -0
@@ -0,0 +1,54 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestResumeInputDecoder(t *testing.T) {
10+ for _, tt := range []struct {
11+ name, input string
12+ cancel bool
13+ }{
14+ {"grouped ctrl-c", "text\x03tail", true},
15+ {"paste escape followed by cancel", "\x1b[200~a\x1b\x1b[201~\x03", true},
16+ {"back to back escape", "\x1b\x1b[27u", true},
17+ {"ctrl-c in unfinished CSI", "\x1b[\x03", true},
18+ {"kitty ctrl-c", "\x1b[99;5u", true},
19+ {"kitty escape", "\x1b[27u", true},
20+ {"arrow", "\x1b[A", false},
21+ {"function", "\x1bOP", false},
22+ {"paste", "\x1b[200~a\x03\x1b[27u\x1b[201~", false},
23+ {"text", "hello", false},
24+ {"alt enter", "\x1b\r", false},
25+ {"alt tab", "\x1b\t", false},
26+ } {
27+ t.Run(tt.name, func(t *testing.T) {
28+ for split := 0; split <= len(tt.input); split++ {
29+ var d resumeInputDecoder
30+ first, cancelled := d.feed([]byte(tt.input[:split]))
31+ second := []byte(nil)
32+ if !cancelled {
33+ second, cancelled = d.feed([]byte(tt.input[split:]))
34+ }
35+ require.Equal(t, tt.cancel, cancelled, "split %d", split)
36+ if !tt.cancel {
37+ got := append(first, second...)
38+ got = append(got, d.flush()...)
39+ require.Equal(t, tt.input, string(got), "split %d", split)
40+ }
41+ }
42+ })
43+ }
44+}
45+
46+func TestResumeInputDecoderUndecidedEscape(t *testing.T) {
47+ var d resumeInputDecoder
48+ held, cancel := d.feed([]byte("\x1b"))
49+ require.Empty(t, held)
50+ require.False(t, cancel)
51+ require.True(t, d.loneEscape())
52+ require.Equal(t, []byte{0x1b}, d.flush())
53+ require.False(t, d.loneEscape())
54+}
@@ -0,0 +1,54 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestResumeInputDecoder(t *testing.T) {
10+ for _, tt := range []struct {
11+ name, input string
12+ cancel bool
13+ }{
14+ {"grouped ctrl-c", "text\x03tail", true},
15+ {"paste escape followed by cancel", "\x1b[200~a\x1b\x1b[201~\x03", true},
16+ {"back to back escape", "\x1b\x1b[27u", true},
17+ {"ctrl-c in unfinished CSI", "\x1b[\x03", true},
18+ {"kitty ctrl-c", "\x1b[99;5u", true},
19+ {"kitty escape", "\x1b[27u", true},
20+ {"arrow", "\x1b[A", false},
21+ {"function", "\x1bOP", false},
22+ {"paste", "\x1b[200~a\x03\x1b[27u\x1b[201~", false},
23+ {"text", "hello", false},
24+ {"alt enter", "\x1b\r", false},
25+ {"alt tab", "\x1b\t", false},
26+ } {
27+ t.Run(tt.name, func(t *testing.T) {
28+ for split := 0; split <= len(tt.input); split++ {
29+ var d resumeInputDecoder
30+ first, cancelled := d.feed([]byte(tt.input[:split]))
31+ second := []byte(nil)
32+ if !cancelled {
33+ second, cancelled = d.feed([]byte(tt.input[split:]))
34+ }
35+ require.Equal(t, tt.cancel, cancelled, "split %d", split)
36+ if !tt.cancel {
37+ got := append(first, second...)
38+ got = append(got, d.flush()...)
39+ require.Equal(t, tt.input, string(got), "split %d", split)
40+ }
41+ }
42+ })
43+ }
44+}
45+
46+func TestResumeInputDecoderUndecidedEscape(t *testing.T) {
47+ var d resumeInputDecoder
48+ held, cancel := d.feed([]byte("\x1b"))
49+ require.Empty(t, held)
50+ require.False(t, cancel)
51+ require.True(t, d.loneEscape())
52+ require.Equal(t, []byte{0x1b}, d.flush())
53+ require.False(t, d.loneEscape())
54+}
A internal/usecase/client/resume_open_test.go +134 -0
@@ -0,0 +1,134 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestResumeCancelDuringStreamOpen(t *testing.T) {
13+ for _, tt := range []struct {
14+ name, input string
15+ escape bool
16+ }{
17+ {name: "ctrl-c", input: "\x03"},
18+ {name: "kitty escape", input: "\x1b[27u"},
19+ {name: "legacy escape", input: "\x1b", escape: true},
20+ } {
21+ t.Run(tt.name, func(t *testing.T) {
22+ picker := newAttachTestPicker()
23+ h := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { cfg.Jitter = resumeTestJitter })
24+ first := newSessionTestStream()
25+ opened := make(chan struct{})
26+ cancelled := make(chan struct{})
27+ count := 0
28+ h.service.setOpenStream(func(ctx context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
29+ count++
30+ if count == 1 {
31+ return first, nil
32+ }
33+ close(opened)
34+ <-ctx.Done()
35+ close(cancelled)
36+ return nil, ctx.Err()
37+ })
38+ picker.commit(sessionTestRequest(false))
39+ deliverReadyStream(t, first)
40+ awaitAttachedState(t, h.sup)
41+ first.fail(testStreamLoss())
42+ fireResumeTimerAfter(t, h.clock, 1, func() {})
43+ select {
44+ case <-opened:
45+ case <-time.After(5 * time.Second):
46+ t.Fatal("resume did not open")
47+ }
48+ h.reader.push([]byte(tt.input))
49+ if tt.escape {
50+ for {
51+ timer := h.clock.awaitTimer(t)
52+ if timer.delay == 50*time.Millisecond {
53+ timer.fire()
54+ break
55+ }
56+ }
57+ }
58+ select {
59+ case <-cancelled:
60+ case <-time.After(5 * time.Second):
61+ t.Fatal("cancel did not reach open context")
62+ }
63+ require.Eventually(t, picker.owns, 5*time.Second, time.Millisecond)
64+ require.Equal(t, 2, len(h.service.openedRequests()))
65+ })
66+ }
67+}
68+
69+// TestResumeHeldKeysNeverReachThePicker types while a lost attachment
70+// resumes, then returns to the picker either by cancelling or because the
71+// resume fails for good, and proves the picker never receives the held keys
72+// while it still receives keys typed after it took the terminal back.
73+func TestResumeHeldKeysNeverReachThePicker(t *testing.T) {
74+ for _, tt := range []struct {
75+ name string
76+ cancel bool
77+ }{
78+ {name: "cancelled", cancel: true},
79+ {name: "resume refused"},
80+ } {
81+ t.Run(tt.name, func(t *testing.T) {
82+ assertHeldKeysDroppedAtPicker(t, tt.cancel)
83+ })
84+ }
85+}
86+
87+func assertHeldKeysDroppedAtPicker(t *testing.T, cancel bool) {
88+ picker := newAttachTestPicker()
89+ h := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { cfg.Jitter = resumeTestJitter })
90+ first := newSessionTestStream()
91+ opened := make(chan struct{}, 1)
92+ refuse := make(chan struct{})
93+ count := 0
94+ h.service.setOpenStream(func(ctx context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
95+ count++
96+ if count == 1 {
97+ return first, nil
98+ }
99+ opened <- struct{}{}
100+ select {
101+ case <-ctx.Done():
102+ return nil, ctx.Err()
103+ case <-refuse:
104+ return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "refused"}
105+ }
106+ })
107+ picker.commit(sessionTestRequest(false))
108+ deliverReadyStream(t, first)
109+ awaitAttachedState(t, h.sup)
110+ first.fail(testStreamLoss())
111+ require.Eventually(t, func() bool { return h.sup.State().Presentation == PresentConnecting }, 5*time.Second, time.Millisecond)
112+ pushKeys(t, h, "HELD\r")
113+ if cancel {
114+ h.reader.push([]byte("\x03"))
115+ } else {
116+ fireResumeTimerAfter(t, h.clock, 1, func() {})
117+ select {
118+ case <-opened:
119+ case <-time.After(5 * time.Second):
120+ t.Fatal("resume did not open")
121+ }
122+ close(refuse)
123+ }
124+ require.Eventually(t, picker.owns, 5*time.Second, time.Millisecond)
125+ h.reader.push([]byte("j"))
126+ require.Eventually(t, func() bool {
127+ picker.mu.Lock()
128+ defer picker.mu.Unlock()
129+ return len(picker.consumedBytes) > 0
130+ }, 5*time.Second, time.Millisecond)
131+ picker.mu.Lock()
132+ defer picker.mu.Unlock()
133+ require.Equal(t, "j", string(picker.consumedBytes), "held resume keys reached the picker")
134+}
@@ -0,0 +1,134 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestResumeCancelDuringStreamOpen(t *testing.T) {
13+ for _, tt := range []struct {
14+ name, input string
15+ escape bool
16+ }{
17+ {name: "ctrl-c", input: "\x03"},
18+ {name: "kitty escape", input: "\x1b[27u"},
19+ {name: "legacy escape", input: "\x1b", escape: true},
20+ } {
21+ t.Run(tt.name, func(t *testing.T) {
22+ picker := newAttachTestPicker()
23+ h := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { cfg.Jitter = resumeTestJitter })
24+ first := newSessionTestStream()
25+ opened := make(chan struct{})
26+ cancelled := make(chan struct{})
27+ count := 0
28+ h.service.setOpenStream(func(ctx context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
29+ count++
30+ if count == 1 {
31+ return first, nil
32+ }
33+ close(opened)
34+ <-ctx.Done()
35+ close(cancelled)
36+ return nil, ctx.Err()
37+ })
38+ picker.commit(sessionTestRequest(false))
39+ deliverReadyStream(t, first)
40+ awaitAttachedState(t, h.sup)
41+ first.fail(testStreamLoss())
42+ fireResumeTimerAfter(t, h.clock, 1, func() {})
43+ select {
44+ case <-opened:
45+ case <-time.After(5 * time.Second):
46+ t.Fatal("resume did not open")
47+ }
48+ h.reader.push([]byte(tt.input))
49+ if tt.escape {
50+ for {
51+ timer := h.clock.awaitTimer(t)
52+ if timer.delay == 50*time.Millisecond {
53+ timer.fire()
54+ break
55+ }
56+ }
57+ }
58+ select {
59+ case <-cancelled:
60+ case <-time.After(5 * time.Second):
61+ t.Fatal("cancel did not reach open context")
62+ }
63+ require.Eventually(t, picker.owns, 5*time.Second, time.Millisecond)
64+ require.Equal(t, 2, len(h.service.openedRequests()))
65+ })
66+ }
67+}
68+
69+// TestResumeHeldKeysNeverReachThePicker types while a lost attachment
70+// resumes, then returns to the picker either by cancelling or because the
71+// resume fails for good, and proves the picker never receives the held keys
72+// while it still receives keys typed after it took the terminal back.
73+func TestResumeHeldKeysNeverReachThePicker(t *testing.T) {
74+ for _, tt := range []struct {
75+ name string
76+ cancel bool
77+ }{
78+ {name: "cancelled", cancel: true},
79+ {name: "resume refused"},
80+ } {
81+ t.Run(tt.name, func(t *testing.T) {
82+ assertHeldKeysDroppedAtPicker(t, tt.cancel)
83+ })
84+ }
85+}
86+
87+func assertHeldKeysDroppedAtPicker(t *testing.T, cancel bool) {
88+ picker := newAttachTestPicker()
89+ h := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { cfg.Jitter = resumeTestJitter })
90+ first := newSessionTestStream()
91+ opened := make(chan struct{}, 1)
92+ refuse := make(chan struct{})
93+ count := 0
94+ h.service.setOpenStream(func(ctx context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
95+ count++
96+ if count == 1 {
97+ return first, nil
98+ }
99+ opened <- struct{}{}
100+ select {
101+ case <-ctx.Done():
102+ return nil, ctx.Err()
103+ case <-refuse:
104+ return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "refused"}
105+ }
106+ })
107+ picker.commit(sessionTestRequest(false))
108+ deliverReadyStream(t, first)
109+ awaitAttachedState(t, h.sup)
110+ first.fail(testStreamLoss())
111+ require.Eventually(t, func() bool { return h.sup.State().Presentation == PresentConnecting }, 5*time.Second, time.Millisecond)
112+ pushKeys(t, h, "HELD\r")
113+ if cancel {
114+ h.reader.push([]byte("\x03"))
115+ } else {
116+ fireResumeTimerAfter(t, h.clock, 1, func() {})
117+ select {
118+ case <-opened:
119+ case <-time.After(5 * time.Second):
120+ t.Fatal("resume did not open")
121+ }
122+ close(refuse)
123+ }
124+ require.Eventually(t, picker.owns, 5*time.Second, time.Millisecond)
125+ h.reader.push([]byte("j"))
126+ require.Eventually(t, func() bool {
127+ picker.mu.Lock()
128+ defer picker.mu.Unlock()
129+ return len(picker.consumedBytes) > 0
130+ }, 5*time.Second, time.Millisecond)
131+ picker.mu.Lock()
132+ defer picker.mu.Unlock()
133+ require.Equal(t, "j", string(picker.consumedBytes), "held resume keys reached the picker")
134+}
M internal/usecase/client/supervisor.go +165 -0
@@ -1490,6 +1490,171 @@ func (l *terminalInputLifetime) stop() {
14901490 l.pump.suspend()
14911491 }
14921492
1493+// resumeHeldInputLimit bounds keys kept while a lost attachment resumes.
1494+// Past it further keys are dropped, like a full terminal buffer.
1495+// TODO: the overflow flag is per resume watch; a later phase can accept keys
1496+// again and leave a gap. Make it sticky on the input lifetime until attach.
1497+const resumeHeldInputLimit = 64 << 10
1498+
1499+// resumeWatch reads the terminal while a lost attachment waits to resume.
1500+// cancelled closes on Ctrl-C or a lone Esc; any other bytes are handed back
1501+// to the pump on release, so the resumed attachment receives them in order.
1502+type resumeWatch struct {
1503+ cancelled chan struct{}
1504+ stop chan struct{}
1505+ done chan struct{}
1506+ lifetime *terminalInputLifetime
1507+ id uint64
1508+ clock ports.Clock
1509+
1510+ // backlog counts deliveries already waiting at claim: session input the
1511+ // lost attachment kept, held verbatim and never read as a cancel key.
1512+ backlog int
1513+ releaseOnce sync.Once
1514+
1515+ mu sync.Mutex
1516+ held []byte
1517+ // overflowed drops every key after the first one past the bound, so the
1518+ // resumed session never receives input with a gap in the middle.
1519+ overflowed bool
1520+}
1521+
1522+// heldLen reports how many kept bytes the watch holds.
1523+func (w *resumeWatch) heldLen() int {
1524+ w.mu.Lock()
1525+ defer w.mu.Unlock()
1526+ return len(w.held)
1527+}
1528+
1529+// hold keeps data within the bound.
1530+func (w *resumeWatch) hold(data []byte) {
1531+ w.mu.Lock()
1532+ defer w.mu.Unlock()
1533+ if w.overflowed || len(w.held)+len(data) > resumeHeldInputLimit {
1534+ w.overflowed = w.overflowed || len(data) != 0
1535+ return
1536+ }
1537+ w.held = append(w.held, data...)
1538+}
1539+
1540+// watchResume claims terminal input for one resume backoff. When the claim
1541+// is unavailable it returns a watch that never cancels.
1542+func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {
1543+ w := &resumeWatch{cancelled: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), lifetime: l, clock: clock}
1544+ if l == nil || l.pump == nil {
1545+ close(w.done)
1546+ return w
1547+ }
1548+ id, backlog, ok := l.pump.claimWithBacklog()
1549+ if !ok {
1550+ close(w.done)
1551+ return w
1552+ }
1553+ w.id = id
1554+ w.backlog = backlog
1555+ go w.run()
1556+ return w
1557+}
1558+
1559+func (w *resumeWatch) run() {
1560+ defer close(w.done)
1561+ pump := w.lifetime.pump
1562+ var decoder resumeInputDecoder
1563+ var escape ports.Timer
1564+ defer func() {
1565+ if escape != nil {
1566+ escape.Stop()
1567+ }
1568+ w.hold(decoder.flush())
1569+ }()
1570+ stop := w.stop
1571+ for {
1572+ var escapeC <-chan time.Time
1573+ if escape != nil {
1574+ escapeC = escape.C()
1575+ }
1576+ result, ok := pump.take(context.Background(), w.id)
1577+ if !ok {
1578+ select {
1579+ case <-pump.readyFor(w.id):
1580+ case <-escapeC:
1581+ if decoder.loneEscape() {
1582+ close(w.cancelled)
1583+ }
1584+ return
1585+ case <-stop:
1586+ if !decoder.undecided() {
1587+ return
1588+ }
1589+ // Resolve an escape typed just before the phase ended: the
1590+ // next watch would hold it as backlog, so a cancel pressed at
1591+ // the boundary would reach the session instead.
1592+ stop = nil
1593+ if escape == nil {
1594+ escape = w.clock.NewTimer(resumeEscapeDelay)
1595+ }
1596+ case <-pump.done:
1597+ return
1598+ }
1599+ continue
1600+ }
1601+ if result.err != nil {
1602+ // Leave the final read for the terminal EOF watcher.
1603+ pump.ack(w.id)
1604+ w.hold(decoder.flush())
1605+ w.hold(result.data)
1606+ w.lifetime.finish(result.err)
1607+ return
1608+ }
1609+ pump.ack(w.id)
1610+ if w.backlog > 0 {
1611+ w.backlog--
1612+ w.hold(result.data)
1613+ continue
1614+ }
1615+ held, cancel := decoder.feed(result.data)
1616+ w.hold(held)
1617+ if cancel {
1618+ close(w.cancelled)
1619+ return
1620+ }
1621+ if escape != nil {
1622+ escape.Stop()
1623+ escape = nil
1624+ }
1625+ if stop == nil && !decoder.undecided() {
1626+ return
1627+ }
1628+ if decoder.loneEscape() || stop == nil {
1629+ escape = w.clock.NewTimer(resumeEscapeDelay)
1630+ }
1631+ }
1632+}
1633+
1634+// release stops the watch and hands kept keys back to the pump, unless the
1635+// user cancelled, in which case they are dropped with the session. An escape
1636+// still undecided is resolved first, so it may cancel. Later calls are no-ops.
1637+func (w *resumeWatch) release() {
1638+ if w.id == 0 {
1639+ return
1640+ }
1641+ w.releaseOnce.Do(w.releaseClaim)
1642+}
1643+
1644+func (w *resumeWatch) releaseClaim() {
1645+ close(w.stop)
1646+ <-w.done
1647+ pump := w.lifetime.pump
1648+ select {
1649+ case <-w.cancelled:
1650+ default:
1651+ w.mu.Lock()
1652+ pump.appendResidual(w.id, w.held, resumeHeldInputLimit)
1653+ w.mu.Unlock()
1654+ }
1655+ pump.revoke(w.id)
1656+}
1657+
14931658 // finish publishes the terminal cause exactly once.
14941659 func (l *terminalInputLifetime) finish(err error) {
14951660 l.once.Do(func() { l.eof <- err })
@@ -1490,6 +1490,171 @@ func (l *terminalInputLifetime) stop() {
1490 l.pump.suspend()1490 l.pump.suspend()
1491 }1491 }
1492 1492
1493+// resumeHeldInputLimit bounds keys kept while a lost attachment resumes.
1494+// Past it further keys are dropped, like a full terminal buffer.
1495+// TODO: the overflow flag is per resume watch; a later phase can accept keys
1496+// again and leave a gap. Make it sticky on the input lifetime until attach.
1497+const resumeHeldInputLimit = 64 << 10
1498+
1499+// resumeWatch reads the terminal while a lost attachment waits to resume.
1500+// cancelled closes on Ctrl-C or a lone Esc; any other bytes are handed back
1501+// to the pump on release, so the resumed attachment receives them in order.
1502+type resumeWatch struct {
1503+ cancelled chan struct{}
1504+ stop chan struct{}
1505+ done chan struct{}
1506+ lifetime *terminalInputLifetime
1507+ id uint64
1508+ clock ports.Clock
1509+
1510+ // backlog counts deliveries already waiting at claim: session input the
1511+ // lost attachment kept, held verbatim and never read as a cancel key.
1512+ backlog int
1513+ releaseOnce sync.Once
1514+
1515+ mu sync.Mutex
1516+ held []byte
1517+ // overflowed drops every key after the first one past the bound, so the
1518+ // resumed session never receives input with a gap in the middle.
1519+ overflowed bool
1520+}
1521+
1522+// heldLen reports how many kept bytes the watch holds.
1523+func (w *resumeWatch) heldLen() int {
1524+ w.mu.Lock()
1525+ defer w.mu.Unlock()
1526+ return len(w.held)
1527+}
1528+
1529+// hold keeps data within the bound.
1530+func (w *resumeWatch) hold(data []byte) {
1531+ w.mu.Lock()
1532+ defer w.mu.Unlock()
1533+ if w.overflowed || len(w.held)+len(data) > resumeHeldInputLimit {
1534+ w.overflowed = w.overflowed || len(data) != 0
1535+ return
1536+ }
1537+ w.held = append(w.held, data...)
1538+}
1539+
1540+// watchResume claims terminal input for one resume backoff. When the claim
1541+// is unavailable it returns a watch that never cancels.
1542+func (l *terminalInputLifetime) watchResume(clock ports.Clock) *resumeWatch {
1543+ w := &resumeWatch{cancelled: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), lifetime: l, clock: clock}
1544+ if l == nil || l.pump == nil {
1545+ close(w.done)
1546+ return w
1547+ }
1548+ id, backlog, ok := l.pump.claimWithBacklog()
1549+ if !ok {
1550+ close(w.done)
1551+ return w
1552+ }
1553+ w.id = id
1554+ w.backlog = backlog
1555+ go w.run()
1556+ return w
1557+}
1558+
1559+func (w *resumeWatch) run() {
1560+ defer close(w.done)
1561+ pump := w.lifetime.pump
1562+ var decoder resumeInputDecoder
1563+ var escape ports.Timer
1564+ defer func() {
1565+ if escape != nil {
1566+ escape.Stop()
1567+ }
1568+ w.hold(decoder.flush())
1569+ }()
1570+ stop := w.stop
1571+ for {
1572+ var escapeC <-chan time.Time
1573+ if escape != nil {
1574+ escapeC = escape.C()
1575+ }
1576+ result, ok := pump.take(context.Background(), w.id)
1577+ if !ok {
1578+ select {
1579+ case <-pump.readyFor(w.id):
1580+ case <-escapeC:
1581+ if decoder.loneEscape() {
1582+ close(w.cancelled)
1583+ }
1584+ return
1585+ case <-stop:
1586+ if !decoder.undecided() {
1587+ return
1588+ }
1589+ // Resolve an escape typed just before the phase ended: the
1590+ // next watch would hold it as backlog, so a cancel pressed at
1591+ // the boundary would reach the session instead.
1592+ stop = nil
1593+ if escape == nil {
1594+ escape = w.clock.NewTimer(resumeEscapeDelay)
1595+ }
1596+ case <-pump.done:
1597+ return
1598+ }
1599+ continue
1600+ }
1601+ if result.err != nil {
1602+ // Leave the final read for the terminal EOF watcher.
1603+ pump.ack(w.id)
1604+ w.hold(decoder.flush())
1605+ w.hold(result.data)
1606+ w.lifetime.finish(result.err)
1607+ return
1608+ }
1609+ pump.ack(w.id)
1610+ if w.backlog > 0 {
1611+ w.backlog--
1612+ w.hold(result.data)
1613+ continue
1614+ }
1615+ held, cancel := decoder.feed(result.data)
1616+ w.hold(held)
1617+ if cancel {
1618+ close(w.cancelled)
1619+ return
1620+ }
1621+ if escape != nil {
1622+ escape.Stop()
1623+ escape = nil
1624+ }
1625+ if stop == nil && !decoder.undecided() {
1626+ return
1627+ }
1628+ if decoder.loneEscape() || stop == nil {
1629+ escape = w.clock.NewTimer(resumeEscapeDelay)
1630+ }
1631+ }
1632+}
1633+
1634+// release stops the watch and hands kept keys back to the pump, unless the
1635+// user cancelled, in which case they are dropped with the session. An escape
1636+// still undecided is resolved first, so it may cancel. Later calls are no-ops.
1637+func (w *resumeWatch) release() {
1638+ if w.id == 0 {
1639+ return
1640+ }
1641+ w.releaseOnce.Do(w.releaseClaim)
1642+}
1643+
1644+func (w *resumeWatch) releaseClaim() {
1645+ close(w.stop)
1646+ <-w.done
1647+ pump := w.lifetime.pump
1648+ select {
1649+ case <-w.cancelled:
1650+ default:
1651+ w.mu.Lock()
1652+ pump.appendResidual(w.id, w.held, resumeHeldInputLimit)
1653+ w.mu.Unlock()
1654+ }
1655+ pump.revoke(w.id)
1656+}
1657+
1493 // finish publishes the terminal cause exactly once.1658 // finish publishes the terminal cause exactly once.
1494 func (l *terminalInputLifetime) finish(err error) {1659 func (l *terminalInputLifetime) finish(err error) {
1495 l.once.Do(func() { l.eof <- err })1660 l.once.Do(func() { l.eof <- err })
M internal/usecase/client/supervisor_attach.go +101 -14
@@ -583,6 +583,9 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
583583 // picker catalogue.
584584 picker.ApplySnapshot(service.Snapshot())
585585 picker.SetOwnsInput(true)
586+ if input != nil && input.pump != nil {
587+ input.pump.dropUnclaimed()
588+ }
586589 input.acquirePicker()
587590 }()
588591 // budget.resuming is true while the loop reconnects a lost attachment:
@@ -604,6 +607,11 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
604607 // attachment detached cleanly, and the swap goes through Connecting
605608 // exactly like any committed selection.
606609 budget = attachmentResumeBudget{}
610+ // Keys kept for the previous session must not run in the next one;
611+ // only a resume of the same target replays them.
612+ if input != nil && input.pump != nil {
613+ input.pump.dropUnclaimed()
614+ }
607615 next, ok := s.withFreshStream(service, outcome.target)
608616 if !ok {
609617 return false, nil
@@ -616,7 +624,8 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
616624 // same target again.
617625 outcome.target = target
618626 }
619- attempt, ok := budget.spend(outcome.kind, s.cfg.Clock.Now().Sub(attemptStart))
627+ now := s.cfg.Clock.Now()
628+ attempt, ok := budget.spend(outcome.kind, now.Sub(attemptStart), now)
620629 if !ok {
621630 s.logger.Warn("client_attachment_resume_exhausted", "endpoint", outcome.target.request.Endpoint, "session", outcome.target.request.Target.SessionName, "attempts", attempt-1, "error", outcome.cause)
622631 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: outcome.cause})
@@ -624,7 +633,7 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
624633 s.notifyAttachment(outcome.cause)
625634 return false, nil
626635 }
627- if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, attempt); terminated || brokerLost {
636+ if terminated, termErr, stop := s.waitResume(ctx, input, service, attempt); terminated || stop {
628637 return terminated, termErr
629638 }
630639 next, ok := s.withFreshStream(service, outcome.target)
@@ -640,6 +649,12 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
640649 type attachmentResumeBudget struct {
641650 used int
642651 resuming bool
652+ // flaps counts attachments lost before they became stable. They prove
653+ // the route works but the session drops, so they are capped by count
654+ // regardless of the outage window.
655+ flaps int
656+ // since is when the current outage began; zero before the first spend.
657+ since time.Time
643658 }
644659
645660 // spend records one resume after an attempt of the given kind that ran for
@@ -647,14 +662,29 @@ type attachmentResumeBudget struct {
647662 // fresh budget; one that drops right after attaching keeps counting, so a
648663 // flapping session gives up.
649664 // It returns the 1-based resume attempt, and false once the budget is
650-// exhausted.
651-func (b *attachmentResumeBudget) spend(kind attachmentOutcomeKind, up time.Duration) (int, bool) {
652- if kind == attachmentResume && up >= resumeStableAttachment {
653- b.used = 0
665+// exhausted: more than maxAttachmentResumes flaps, or more than
666+// maxAttachmentResumes attempts once resumeOutageWindow has passed since the
667+// outage began. A route that stays down therefore keeps resuming for the
668+// whole window, while a session that keeps dropping right after it attaches
669+// gives up quickly.
670+func (b *attachmentResumeBudget) spend(kind attachmentOutcomeKind, up time.Duration, now time.Time) (int, bool) {
671+ if kind == attachmentResume {
672+ if up >= resumeStableAttachment {
673+ b.used = 0
674+ b.flaps = 0
675+ } else {
676+ b.flaps++
677+ }
678+ }
679+ if b.used == 0 {
680+ b.since = now
654681 }
655682 b.used++
656683 b.resuming = true
657- return b.used, b.used <= maxAttachmentResumes
684+ if b.flaps > maxAttachmentResumes {
685+ return b.used, false
686+ }
687+ return b.used, b.used <= maxAttachmentResumes || now.Sub(b.since) < resumeOutageWindow
658688 }
659689
660690 // withFreshStream gives target a newly allocated stream identity. It is
@@ -696,7 +726,39 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
696726 request.Env = append([]string(nil), sessionEnv.Env...)
697727
698728 s.logger.Debug("client_stream_open_begin", "local", request.Local, "endpoint", request.Endpoint, "admission", request.Admission, "generation", s.State().Generation)
699- stream, err := service.OpenStream(deadline.Context(), request)
729+ openCtx := deadline.Context()
730+ var keys *resumeWatch
731+ var cancelOpen context.CancelFunc
732+ var watchDone chan struct{}
733+ if resuming {
734+ keys = input.watchResume(s.cfg.Clock)
735+ openCtx, cancelOpen = context.WithCancel(openCtx)
736+ watchDone = make(chan struct{})
737+ go func() {
738+ defer close(watchDone)
739+ select {
740+ case <-keys.cancelled:
741+ cancelOpen()
742+ case <-openCtx.Done():
743+ }
744+ }()
745+ }
746+ stream, err := service.OpenStream(openCtx, request)
747+ if keys != nil {
748+ keys.release()
749+ cancelOpen()
750+ <-watchDone
751+ select {
752+ case <-keys.cancelled:
753+ s.logger.Info("client_attachment_resume_cancelled", "phase", "stream_open")
754+ if !supervisorNil(stream) {
755+ _ = stream.Close()
756+ }
757+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
758+ return attachmentEndedOutcome()
759+ default:
760+ }
761+ }
700762 if err != nil {
701763 s.logger.Warn("client_stream_open_failed", "local", request.Local, "endpoint", request.Endpoint, "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err))
702764 return s.attemptFailed(deadline.cause(err), resuming)
@@ -898,11 +960,17 @@ settlement:
898960 return attachmentEndedOutcome()
899961 }
900962
901-// maxAttachmentResumes bounds how many times one lost attachment reconnects
902-// before the client gives up and returns to the picker. With the supervisor
903-// backoff this spans a few seconds, enough to ride out a transient drop.
963+// maxAttachmentResumes is the minimum number of resume attempts before the
964+// client may give up on a lost attachment. Giving up also requires the outage
965+// to have lasted resumeOutageWindow, so a flaky mobile link keeps resuming
966+// instead of dropping to the picker after a few seconds.
904967 const maxAttachmentResumes = 5
905968
969+// resumeOutageWindow is how long one outage keeps resuming. It matches the
970+// daemon's park window for a detached remote attachment: past it the session
971+// view is gone anyway.
972+const resumeOutageWindow = 15 * time.Minute
973+
906974 // resumeStableAttachment is how long a resumed attachment must stay up before
907975 // its next loss gets a fresh resume budget.
908976 const resumeStableAttachment = 30 * time.Second
@@ -924,16 +992,35 @@ func resumeTarget(request ports.BrokerOpenStreamRequest, fg *attachmentForegroun
924992
925993 // waitResume presents Connecting and waits one backoff interval before the
926994 // next resume attempt. It settles early on cancellation or terminal EOF
927-// (terminated), or on broker loss, which ends the resume and leaves the ready
928-// loop to observe and retire the connection.
929-func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, attempt int) (terminated bool, termErr error, brokerLost bool) {
995+// (terminated), or with stop on broker loss, which leaves the ready loop to
996+// observe and retire the connection, or when the user cancels the resume
997+// with Ctrl-C or Esc, which returns to the picker. Other keys typed during
998+// the outage are kept and replayed into the session once it resumes.
999+func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, attempt int) (terminated bool, termErr error, stop bool) {
9301000 s.transition(supervisorEvent{kind: supervisorAttachBegin})
9311001 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)
9321002 timer := s.cfg.Clock.NewTimer(delay)
9331003 defer stopSupervisorTimer(timer)
1004+ keys := input.watchResume(s.cfg.Clock)
1005+ defer keys.release()
9341006 for {
9351007 select {
1008+ case <-keys.cancelled:
1009+ // The user gave up on the outage: back to the picker, quietly.
1010+ s.logger.Info("client_attachment_resume_cancelled", "attempt", attempt)
1011+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
1012+ return false, nil, true
9361013 case <-timer.C():
1014+ // Release resolves an Esc typed just before the backoff ended,
1015+ // which may still cancel.
1016+ keys.release()
1017+ select {
1018+ case <-keys.cancelled:
1019+ s.logger.Info("client_attachment_resume_cancelled", "attempt", attempt)
1020+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
1021+ return false, nil, true
1022+ default:
1023+ }
9371024 return false, nil, false
9381025 case <-ctx.Done():
9391026 return true, ctx.Err(), false
@@ -583,6 +583,9 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
583 // picker catalogue.583 // picker catalogue.
584 picker.ApplySnapshot(service.Snapshot())584 picker.ApplySnapshot(service.Snapshot())
585 picker.SetOwnsInput(true)585 picker.SetOwnsInput(true)
586+ if input != nil && input.pump != nil {
587+ input.pump.dropUnclaimed()
588+ }
586 input.acquirePicker()589 input.acquirePicker()
587 }()590 }()
588 // budget.resuming is true while the loop reconnects a lost attachment:591 // budget.resuming is true while the loop reconnects a lost attachment:
@@ -604,6 +607,11 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
604 // attachment detached cleanly, and the swap goes through Connecting607 // attachment detached cleanly, and the swap goes through Connecting
605 // exactly like any committed selection.608 // exactly like any committed selection.
606 budget = attachmentResumeBudget{}609 budget = attachmentResumeBudget{}
610+ // Keys kept for the previous session must not run in the next one;
611+ // only a resume of the same target replays them.
612+ if input != nil && input.pump != nil {
613+ input.pump.dropUnclaimed()
614+ }
607 next, ok := s.withFreshStream(service, outcome.target)615 next, ok := s.withFreshStream(service, outcome.target)
608 if !ok {616 if !ok {
609 return false, nil617 return false, nil
@@ -616,7 +624,8 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
616 // same target again.624 // same target again.
617 outcome.target = target625 outcome.target = target
618 }626 }
619- attempt, ok := budget.spend(outcome.kind, s.cfg.Clock.Now().Sub(attemptStart))627+ now := s.cfg.Clock.Now()
628+ attempt, ok := budget.spend(outcome.kind, now.Sub(attemptStart), now)
620 if !ok {629 if !ok {
621 s.logger.Warn("client_attachment_resume_exhausted", "endpoint", outcome.target.request.Endpoint, "session", outcome.target.request.Target.SessionName, "attempts", attempt-1, "error", outcome.cause)630 s.logger.Warn("client_attachment_resume_exhausted", "endpoint", outcome.target.request.Endpoint, "session", outcome.target.request.Target.SessionName, "attempts", attempt-1, "error", outcome.cause)
622 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: outcome.cause})631 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: outcome.cause})
@@ -624,7 +633,7 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
624 s.notifyAttachment(outcome.cause)633 s.notifyAttachment(outcome.cause)
625 return false, nil634 return false, nil
626 }635 }
627- if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, attempt); terminated || brokerLost {636+ if terminated, termErr, stop := s.waitResume(ctx, input, service, attempt); terminated || stop {
628 return terminated, termErr637 return terminated, termErr
629 }638 }
630 next, ok := s.withFreshStream(service, outcome.target)639 next, ok := s.withFreshStream(service, outcome.target)
@@ -640,6 +649,12 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
640 type attachmentResumeBudget struct {649 type attachmentResumeBudget struct {
641 used int650 used int
642 resuming bool651 resuming bool
652+ // flaps counts attachments lost before they became stable. They prove
653+ // the route works but the session drops, so they are capped by count
654+ // regardless of the outage window.
655+ flaps int
656+ // since is when the current outage began; zero before the first spend.
657+ since time.Time
643 }658 }
644 659
645 // spend records one resume after an attempt of the given kind that ran for660 // spend records one resume after an attempt of the given kind that ran for
@@ -647,14 +662,29 @@ type attachmentResumeBudget struct {
647 // fresh budget; one that drops right after attaching keeps counting, so a662 // fresh budget; one that drops right after attaching keeps counting, so a
648 // flapping session gives up.663 // flapping session gives up.
649 // It returns the 1-based resume attempt, and false once the budget is664 // It returns the 1-based resume attempt, and false once the budget is
650-// exhausted.665+// exhausted: more than maxAttachmentResumes flaps, or more than
651-func (b *attachmentResumeBudget) spend(kind attachmentOutcomeKind, up time.Duration) (int, bool) {666+// maxAttachmentResumes attempts once resumeOutageWindow has passed since the
652- if kind == attachmentResume && up >= resumeStableAttachment {667+// outage began. A route that stays down therefore keeps resuming for the
653- b.used = 0668+// whole window, while a session that keeps dropping right after it attaches
669+// gives up quickly.
670+func (b *attachmentResumeBudget) spend(kind attachmentOutcomeKind, up time.Duration, now time.Time) (int, bool) {
671+ if kind == attachmentResume {
672+ if up >= resumeStableAttachment {
673+ b.used = 0
674+ b.flaps = 0
675+ } else {
676+ b.flaps++
677+ }
678+ }
679+ if b.used == 0 {
680+ b.since = now
654 }681 }
655 b.used++682 b.used++
656 b.resuming = true683 b.resuming = true
657- return b.used, b.used <= maxAttachmentResumes684+ if b.flaps > maxAttachmentResumes {
685+ return b.used, false
686+ }
687+ return b.used, b.used <= maxAttachmentResumes || now.Sub(b.since) < resumeOutageWindow
658 }688 }
659 689
660 // withFreshStream gives target a newly allocated stream identity. It is690 // withFreshStream gives target a newly allocated stream identity. It is
@@ -696,7 +726,39 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
696 request.Env = append([]string(nil), sessionEnv.Env...)726 request.Env = append([]string(nil), sessionEnv.Env...)
697 727
698 s.logger.Debug("client_stream_open_begin", "local", request.Local, "endpoint", request.Endpoint, "admission", request.Admission, "generation", s.State().Generation)728 s.logger.Debug("client_stream_open_begin", "local", request.Local, "endpoint", request.Endpoint, "admission", request.Admission, "generation", s.State().Generation)
699- stream, err := service.OpenStream(deadline.Context(), request)729+ openCtx := deadline.Context()
730+ var keys *resumeWatch
731+ var cancelOpen context.CancelFunc
732+ var watchDone chan struct{}
733+ if resuming {
734+ keys = input.watchResume(s.cfg.Clock)
735+ openCtx, cancelOpen = context.WithCancel(openCtx)
736+ watchDone = make(chan struct{})
737+ go func() {
738+ defer close(watchDone)
739+ select {
740+ case <-keys.cancelled:
741+ cancelOpen()
742+ case <-openCtx.Done():
743+ }
744+ }()
745+ }
746+ stream, err := service.OpenStream(openCtx, request)
747+ if keys != nil {
748+ keys.release()
749+ cancelOpen()
750+ <-watchDone
751+ select {
752+ case <-keys.cancelled:
753+ s.logger.Info("client_attachment_resume_cancelled", "phase", "stream_open")
754+ if !supervisorNil(stream) {
755+ _ = stream.Close()
756+ }
757+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
758+ return attachmentEndedOutcome()
759+ default:
760+ }
761+ }
700 if err != nil {762 if err != nil {
701 s.logger.Warn("client_stream_open_failed", "local", request.Local, "endpoint", request.Endpoint, "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err))763 s.logger.Warn("client_stream_open_failed", "local", request.Local, "endpoint", request.Endpoint, "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err))
702 return s.attemptFailed(deadline.cause(err), resuming)764 return s.attemptFailed(deadline.cause(err), resuming)
@@ -898,11 +960,17 @@ settlement:
898 return attachmentEndedOutcome()960 return attachmentEndedOutcome()
899 }961 }
900 962
901-// maxAttachmentResumes bounds how many times one lost attachment reconnects963+// maxAttachmentResumes is the minimum number of resume attempts before the
902-// before the client gives up and returns to the picker. With the supervisor964+// client may give up on a lost attachment. Giving up also requires the outage
903-// backoff this spans a few seconds, enough to ride out a transient drop.965+// to have lasted resumeOutageWindow, so a flaky mobile link keeps resuming
966+// instead of dropping to the picker after a few seconds.
904 const maxAttachmentResumes = 5967 const maxAttachmentResumes = 5
905 968
969+// resumeOutageWindow is how long one outage keeps resuming. It matches the
970+// daemon's park window for a detached remote attachment: past it the session
971+// view is gone anyway.
972+const resumeOutageWindow = 15 * time.Minute
973+
906 // resumeStableAttachment is how long a resumed attachment must stay up before974 // resumeStableAttachment is how long a resumed attachment must stay up before
907 // its next loss gets a fresh resume budget.975 // its next loss gets a fresh resume budget.
908 const resumeStableAttachment = 30 * time.Second976 const resumeStableAttachment = 30 * time.Second
@@ -924,16 +992,35 @@ func resumeTarget(request ports.BrokerOpenStreamRequest, fg *attachmentForegroun
924 992
925 // waitResume presents Connecting and waits one backoff interval before the993 // waitResume presents Connecting and waits one backoff interval before the
926 // next resume attempt. It settles early on cancellation or terminal EOF994 // next resume attempt. It settles early on cancellation or terminal EOF
927-// (terminated), or on broker loss, which ends the resume and leaves the ready995+// (terminated), or with stop on broker loss, which leaves the ready loop to
928-// loop to observe and retire the connection.996+// observe and retire the connection, or when the user cancels the resume
929-func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, attempt int) (terminated bool, termErr error, brokerLost bool) {997+// with Ctrl-C or Esc, which returns to the picker. Other keys typed during
998+// the outage are kept and replayed into the session once it resumes.
999+func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, attempt int) (terminated bool, termErr error, stop bool) {
930 s.transition(supervisorEvent{kind: supervisorAttachBegin})1000 s.transition(supervisorEvent{kind: supervisorAttachBegin})
931 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)1001 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)
932 timer := s.cfg.Clock.NewTimer(delay)1002 timer := s.cfg.Clock.NewTimer(delay)
933 defer stopSupervisorTimer(timer)1003 defer stopSupervisorTimer(timer)
1004+ keys := input.watchResume(s.cfg.Clock)
1005+ defer keys.release()
934 for {1006 for {
935 select {1007 select {
1008+ case <-keys.cancelled:
1009+ // The user gave up on the outage: back to the picker, quietly.
1010+ s.logger.Info("client_attachment_resume_cancelled", "attempt", attempt)
1011+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
1012+ return false, nil, true
936 case <-timer.C():1013 case <-timer.C():
1014+ // Release resolves an Esc typed just before the backoff ended,
1015+ // which may still cancel.
1016+ keys.release()
1017+ select {
1018+ case <-keys.cancelled:
1019+ s.logger.Info("client_attachment_resume_cancelled", "attempt", attempt)
1020+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
1021+ return false, nil, true
1022+ default:
1023+ }
937 return false, nil, false1024 return false, nil, false
938 case <-ctx.Done():1025 case <-ctx.Done():
939 return true, ctx.Err(), false1026 return true, ctx.Err(), false
M internal/usecase/client/supervisor_attach_test.go +82 -12
@@ -265,8 +265,10 @@ type attachTestPicker struct {
265265 opsReady chan struct{}
266266 ownsInput bool
267267 consumed int
268- op pickerOp
269- key string
268+ // consumedBytes records every byte the picker consumed, in order.
269+ consumedBytes []byte
270+ op pickerOp
271+ key string
270272 // resolveErr makes ResolveCommit refuse locally, which is how a test reaches
271273 // the local-refusal classification without dialing anything.
272274 resolveErr error
@@ -288,13 +290,14 @@ func newAttachTestPicker() *attachTestPicker {
288290
289291 func (p *attachTestPicker) ApplySnapshot(ports.BrokerSnapshot) {}
290292
291-func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool {
293+func (p *attachTestPicker) ConsumeTerminalRead(data []byte) bool {
292294 p.mu.Lock()
293295 defer p.mu.Unlock()
294296 if !p.ownsInput {
295297 return false
296298 }
297299 p.consumed++
300+ p.consumedBytes = append(p.consumedBytes, data...)
298301 if p.consumeOp != (pickerOp{}) {
299302 p.op, p.key, p.request = p.consumeOp, "row", p.consumeRequest
300303 p.consumeOp = pickerOp{}
@@ -601,6 +604,56 @@ func TestSupervisorPendingSwapContinuesRatherThanExiting(t *testing.T) {
601604 }
602605 }
603606
607+// TestSupervisorSwapDropsKeysKeptForPreviousSession types into a session whose
608+// link stalls, then swaps to another session: the stalled keys were meant for
609+// the first session and must never run in the second.
610+func TestSupervisorSwapDropsKeysKeptForPreviousSession(t *testing.T) {
611+ picker := newAttachTestPicker()
612+ harness := startAttachHarness(t, picker)
613+ base := newSessionTestStream()
614+ first := &stallingSessionStream{sessionTestStream: base, entered: make(chan struct{}, 1), release: make(chan struct{})}
615+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
616+ return first, nil
617+ })
618+ picker.commit(sessionTestRequest(true))
619+ deliverReadyStream(t, base)
620+ awaitAttachedState(t, harness.sup)
621+ require.Eventually(t, func() bool { return len(base.messages()) >= 2 }, 5*time.Second, time.Millisecond)
622+ first.stall.Store(true)
623+ harness.reader.push([]byte("rm -rf build\r"))
624+ <-first.entered
625+
626+ var mu sync.Mutex
627+ second := newSessionTestStream()
628+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
629+ return second, nil
630+ })
631+ base.deliver(navigationOffer(1))
632+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
633+ other := sessionTestRequest(true)
634+ other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
635+ picker.recordOp(pickerOp{commit: true}, "row", other)
636+ // The Detach queues behind the stalled write; let the bounded drain expire
637+ // so the unsent keys are preserved, as on a dead link.
638+ for {
639+ timer := harness.clock.awaitTimer(t)
640+ if timer.delay == drainOutboxTimeout {
641+ timer.fire()
642+ break
643+ }
644+ }
645+ awaitStreamHello(t, &mu, &second)
646+ second.deliver(protocol.Welcome{SessionName: "beta"})
647+ output := sessionTestOutput(1, "\x1b[Hbeta")
648+ output.Context.Route.Target = other.Target
649+ second.deliver(output)
650+ awaitAttachedState(t, harness.sup)
651+
652+ harness.reader.push([]byte("z"))
653+ require.Eventually(t, func() bool { return sentInput(second) == "z" }, 5*time.Second, time.Millisecond,
654+ "second session input = %q", sentInput(second))
655+}
656+
604657 // awaitStreamHello waits until the admitted stream has sent its first client
605658 // message, polling the guarded stream pointer rather than a stale snapshot.
606659 func awaitStreamHello(t *testing.T, mu *sync.Mutex, stream **sessionTestStream) {
@@ -1304,14 +1357,15 @@ func testStreamLoss() ports.BrokerStreamLost {
13041357 // 15s attachment deadline and the 200ms palette deadline).
13051358 func resumeTestJitter() float64 { return 0.5 }
13061359
1307-// fireResumeTimer fires the resume backoff timer for one attempt. Other timers
1308-// share the clock, so it matches the exact backoff delay.
1309-func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) {
1360+// fireResumeTimerAfter waits for the attempt's backoff timer, runs before, then
1361+// fires it.
1362+func fireResumeTimerAfter(t *testing.T, clock *supervisorTestClock, attempt int, before func()) {
13101363 t.Helper()
13111364 want := supervisorBackoffDelay(uint64(attempt), resumeTestJitter)
13121365 for {
13131366 timer := clock.awaitTimer(t)
13141367 if timer.delay == want {
1368+ before()
13151369 timer.fire()
13161370 return
13171371 }
@@ -1320,22 +1374,30 @@ func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) {
13201374
13211375 func TestAttachmentResumeBudget(t *testing.T) {
13221376 brief := time.Second
1377+ // step is the clock advance between spends: past the outage window, the
1378+ // attempt count alone decides; within it, resuming never gives up.
1379+ past := resumeOutageWindow / maxAttachmentResumes
13231380 tests := []struct {
13241381 name string
13251382 steps []attachmentOutcomeKind
13261383 ups []time.Duration
1384+ step time.Duration
13271385 want []bool
13281386 }{
1329- {name: "flapping resumes exhaust", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},
1330- {name: "stable attachment restores the budget", steps: repeatKinds(attachmentResume, maxAttachmentResumes+2), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+2), want: repeatBools(true, maxAttachmentResumes+2)},
1331- {name: "retries never restore the budget", steps: repeatKinds(attachmentRetry, maxAttachmentResumes+1), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},
1387+ {name: "flapping resumes exhaust", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), step: past, want: append(repeatBools(true, maxAttachmentResumes), false)},
1388+ {name: "stable attachment restores the budget", steps: repeatKinds(attachmentResume, maxAttachmentResumes+2), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+2), step: past, want: repeatBools(true, maxAttachmentResumes+2)},
1389+ {name: "retries never restore the budget", steps: repeatKinds(attachmentRetry, maxAttachmentResumes+1), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+1), step: past, want: append(repeatBools(true, maxAttachmentResumes), false)},
1390+ {name: "flaps exhaust inside the outage window", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), step: time.Second, want: append(repeatBools(true, maxAttachmentResumes), false)},
1391+ {name: "outage window keeps resuming", steps: repeatKinds(attachmentRetry, 3*maxAttachmentResumes), ups: repeatDurations(brief, 3*maxAttachmentResumes), step: time.Second, want: repeatBools(true, 3*maxAttachmentResumes)},
13321392 }
13331393 for _, tt := range tests {
13341394 t.Run(tt.name, func(t *testing.T) {
13351395 var budget attachmentResumeBudget
13361396 require.False(t, budget.resuming)
1397+ now := time.Unix(0, 0)
13371398 for i, kind := range tt.steps {
1338- _, ok := budget.spend(kind, tt.ups[i])
1399+ _, ok := budget.spend(kind, tt.ups[i], now)
1400+ now = now.Add(tt.step)
13391401 require.Equal(t, tt.want[i], ok, "step %d", i)
13401402 require.True(t, budget.resuming)
13411403 }
@@ -1443,7 +1505,7 @@ func TestSupervisorAttachmentLossResumesSameSession(t *testing.T) {
14431505 first.fail(testStreamLoss())
14441506
14451507 attempt := 1
1446- for _, s := range tc.steps {
1508+ for i, s := range tc.steps {
14471509 if s == cancelDuringBackoff {
14481510 backoff := supervisorBackoffDelay(1, resumeTestJitter)
14491511 for harness.clock.awaitTimer(t).delay != backoff {
@@ -1456,7 +1518,15 @@ func TestSupervisorAttachmentLossResumesSameSession(t *testing.T) {
14561518 refusing.Store(s == refuse)
14571519 unavailable.Store(s == openUnavailable)
14581520 opened := len(harness.service.openedRequests())
1459- fireResumeTimer(t, harness.clock, attempt)
1521+ // Giving up also requires the outage window to pass. The
1522+ // clock moves only once the last attempt's backoff is
1523+ // scheduled, so no attempt measures it as uptime.
1524+ last := tc.wantPicker && i == len(tc.steps)-1
1525+ fireResumeTimerAfter(t, harness.clock, attempt, func() {
1526+ if last {
1527+ advancePickerClock(harness.clock, resumeOutageWindow)
1528+ }
1529+ })
14601530 if s == refuse {
14611531 break
14621532 }
@@ -265,8 +265,10 @@ type attachTestPicker struct {
265 opsReady chan struct{}265 opsReady chan struct{}
266 ownsInput bool266 ownsInput bool
267 consumed int267 consumed int
268- op pickerOp268+ // consumedBytes records every byte the picker consumed, in order.
269- key string269+ consumedBytes []byte
270+ op pickerOp
271+ key string
270 // resolveErr makes ResolveCommit refuse locally, which is how a test reaches272 // resolveErr makes ResolveCommit refuse locally, which is how a test reaches
271 // the local-refusal classification without dialing anything.273 // the local-refusal classification without dialing anything.
272 resolveErr error274 resolveErr error
@@ -288,13 +290,14 @@ func newAttachTestPicker() *attachTestPicker {
288 290
289 func (p *attachTestPicker) ApplySnapshot(ports.BrokerSnapshot) {}291 func (p *attachTestPicker) ApplySnapshot(ports.BrokerSnapshot) {}
290 292
291-func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool {293+func (p *attachTestPicker) ConsumeTerminalRead(data []byte) bool {
292 p.mu.Lock()294 p.mu.Lock()
293 defer p.mu.Unlock()295 defer p.mu.Unlock()
294 if !p.ownsInput {296 if !p.ownsInput {
295 return false297 return false
296 }298 }
297 p.consumed++299 p.consumed++
300+ p.consumedBytes = append(p.consumedBytes, data...)
298 if p.consumeOp != (pickerOp{}) {301 if p.consumeOp != (pickerOp{}) {
299 p.op, p.key, p.request = p.consumeOp, "row", p.consumeRequest302 p.op, p.key, p.request = p.consumeOp, "row", p.consumeRequest
300 p.consumeOp = pickerOp{}303 p.consumeOp = pickerOp{}
@@ -601,6 +604,56 @@ func TestSupervisorPendingSwapContinuesRatherThanExiting(t *testing.T) {
601 }604 }
602 }605 }
603 606
607+// TestSupervisorSwapDropsKeysKeptForPreviousSession types into a session whose
608+// link stalls, then swaps to another session: the stalled keys were meant for
609+// the first session and must never run in the second.
610+func TestSupervisorSwapDropsKeysKeptForPreviousSession(t *testing.T) {
611+ picker := newAttachTestPicker()
612+ harness := startAttachHarness(t, picker)
613+ base := newSessionTestStream()
614+ first := &stallingSessionStream{sessionTestStream: base, entered: make(chan struct{}, 1), release: make(chan struct{})}
615+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
616+ return first, nil
617+ })
618+ picker.commit(sessionTestRequest(true))
619+ deliverReadyStream(t, base)
620+ awaitAttachedState(t, harness.sup)
621+ require.Eventually(t, func() bool { return len(base.messages()) >= 2 }, 5*time.Second, time.Millisecond)
622+ first.stall.Store(true)
623+ harness.reader.push([]byte("rm -rf build\r"))
624+ <-first.entered
625+
626+ var mu sync.Mutex
627+ second := newSessionTestStream()
628+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
629+ return second, nil
630+ })
631+ base.deliver(navigationOffer(1))
632+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
633+ other := sessionTestRequest(true)
634+ other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
635+ picker.recordOp(pickerOp{commit: true}, "row", other)
636+ // The Detach queues behind the stalled write; let the bounded drain expire
637+ // so the unsent keys are preserved, as on a dead link.
638+ for {
639+ timer := harness.clock.awaitTimer(t)
640+ if timer.delay == drainOutboxTimeout {
641+ timer.fire()
642+ break
643+ }
644+ }
645+ awaitStreamHello(t, &mu, &second)
646+ second.deliver(protocol.Welcome{SessionName: "beta"})
647+ output := sessionTestOutput(1, "\x1b[Hbeta")
648+ output.Context.Route.Target = other.Target
649+ second.deliver(output)
650+ awaitAttachedState(t, harness.sup)
651+
652+ harness.reader.push([]byte("z"))
653+ require.Eventually(t, func() bool { return sentInput(second) == "z" }, 5*time.Second, time.Millisecond,
654+ "second session input = %q", sentInput(second))
655+}
656+
604 // awaitStreamHello waits until the admitted stream has sent its first client657 // awaitStreamHello waits until the admitted stream has sent its first client
605 // message, polling the guarded stream pointer rather than a stale snapshot.658 // message, polling the guarded stream pointer rather than a stale snapshot.
606 func awaitStreamHello(t *testing.T, mu *sync.Mutex, stream **sessionTestStream) {659 func awaitStreamHello(t *testing.T, mu *sync.Mutex, stream **sessionTestStream) {
@@ -1304,14 +1357,15 @@ func testStreamLoss() ports.BrokerStreamLost {
1304 // 15s attachment deadline and the 200ms palette deadline).1357 // 15s attachment deadline and the 200ms palette deadline).
1305 func resumeTestJitter() float64 { return 0.5 }1358 func resumeTestJitter() float64 { return 0.5 }
1306 1359
1307-// fireResumeTimer fires the resume backoff timer for one attempt. Other timers1360+// fireResumeTimerAfter waits for the attempt's backoff timer, runs before, then
1308-// share the clock, so it matches the exact backoff delay.1361+// fires it.
1309-func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) {1362+func fireResumeTimerAfter(t *testing.T, clock *supervisorTestClock, attempt int, before func()) {
1310 t.Helper()1363 t.Helper()
1311 want := supervisorBackoffDelay(uint64(attempt), resumeTestJitter)1364 want := supervisorBackoffDelay(uint64(attempt), resumeTestJitter)
1312 for {1365 for {
1313 timer := clock.awaitTimer(t)1366 timer := clock.awaitTimer(t)
1314 if timer.delay == want {1367 if timer.delay == want {
1368+ before()
1315 timer.fire()1369 timer.fire()
1316 return1370 return
1317 }1371 }
@@ -1320,22 +1374,30 @@ func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) {
1320 1374
1321 func TestAttachmentResumeBudget(t *testing.T) {1375 func TestAttachmentResumeBudget(t *testing.T) {
1322 brief := time.Second1376 brief := time.Second
1377+ // step is the clock advance between spends: past the outage window, the
1378+ // attempt count alone decides; within it, resuming never gives up.
1379+ past := resumeOutageWindow / maxAttachmentResumes
1323 tests := []struct {1380 tests := []struct {
1324 name string1381 name string
1325 steps []attachmentOutcomeKind1382 steps []attachmentOutcomeKind
1326 ups []time.Duration1383 ups []time.Duration
1384+ step time.Duration
1327 want []bool1385 want []bool
1328 }{1386 }{
1329- {name: "flapping resumes exhaust", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},1387+ {name: "flapping resumes exhaust", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), step: past, want: append(repeatBools(true, maxAttachmentResumes), false)},
1330- {name: "stable attachment restores the budget", steps: repeatKinds(attachmentResume, maxAttachmentResumes+2), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+2), want: repeatBools(true, maxAttachmentResumes+2)},1388+ {name: "stable attachment restores the budget", steps: repeatKinds(attachmentResume, maxAttachmentResumes+2), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+2), step: past, want: repeatBools(true, maxAttachmentResumes+2)},
1331- {name: "retries never restore the budget", steps: repeatKinds(attachmentRetry, maxAttachmentResumes+1), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},1389+ {name: "retries never restore the budget", steps: repeatKinds(attachmentRetry, maxAttachmentResumes+1), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+1), step: past, want: append(repeatBools(true, maxAttachmentResumes), false)},
1390+ {name: "flaps exhaust inside the outage window", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), step: time.Second, want: append(repeatBools(true, maxAttachmentResumes), false)},
1391+ {name: "outage window keeps resuming", steps: repeatKinds(attachmentRetry, 3*maxAttachmentResumes), ups: repeatDurations(brief, 3*maxAttachmentResumes), step: time.Second, want: repeatBools(true, 3*maxAttachmentResumes)},
1332 }1392 }
1333 for _, tt := range tests {1393 for _, tt := range tests {
1334 t.Run(tt.name, func(t *testing.T) {1394 t.Run(tt.name, func(t *testing.T) {
1335 var budget attachmentResumeBudget1395 var budget attachmentResumeBudget
1336 require.False(t, budget.resuming)1396 require.False(t, budget.resuming)
1397+ now := time.Unix(0, 0)
1337 for i, kind := range tt.steps {1398 for i, kind := range tt.steps {
1338- _, ok := budget.spend(kind, tt.ups[i])1399+ _, ok := budget.spend(kind, tt.ups[i], now)
1400+ now = now.Add(tt.step)
1339 require.Equal(t, tt.want[i], ok, "step %d", i)1401 require.Equal(t, tt.want[i], ok, "step %d", i)
1340 require.True(t, budget.resuming)1402 require.True(t, budget.resuming)
1341 }1403 }
@@ -1443,7 +1505,7 @@ func TestSupervisorAttachmentLossResumesSameSession(t *testing.T) {
1443 first.fail(testStreamLoss())1505 first.fail(testStreamLoss())
1444 1506
1445 attempt := 11507 attempt := 1
1446- for _, s := range tc.steps {1508+ for i, s := range tc.steps {
1447 if s == cancelDuringBackoff {1509 if s == cancelDuringBackoff {
1448 backoff := supervisorBackoffDelay(1, resumeTestJitter)1510 backoff := supervisorBackoffDelay(1, resumeTestJitter)
1449 for harness.clock.awaitTimer(t).delay != backoff {1511 for harness.clock.awaitTimer(t).delay != backoff {
@@ -1456,7 +1518,15 @@ func TestSupervisorAttachmentLossResumesSameSession(t *testing.T) {
1456 refusing.Store(s == refuse)1518 refusing.Store(s == refuse)
1457 unavailable.Store(s == openUnavailable)1519 unavailable.Store(s == openUnavailable)
1458 opened := len(harness.service.openedRequests())1520 opened := len(harness.service.openedRequests())
1459- fireResumeTimer(t, harness.clock, attempt)1521+ // Giving up also requires the outage window to pass. The
1522+ // clock moves only once the last attempt's backoff is
1523+ // scheduled, so no attempt measures it as uptime.
1524+ last := tc.wantPicker && i == len(tc.steps)-1
1525+ fireResumeTimerAfter(t, harness.clock, attempt, func() {
1526+ if last {
1527+ advancePickerClock(harness.clock, resumeOutageWindow)
1528+ }
1529+ })
1460 if s == refuse {1530 if s == refuse {
1461 break1531 break
1462 }1532 }
M internal/usecase/client/terminal_input_pump.go +51 -2
@@ -105,10 +105,25 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
105105
106106 // tryClaim lets competing foreground owners decline admission without panicking.
107107 func (p *terminalInputPump) tryClaim() (uint64, bool) {
108+ consumer, _, ok := p.claimWithBacklog()
109+ return consumer, ok
110+}
111+
112+// claimWithBacklog claims the pump and reports, atomically with the claim, how
113+// many deliveries were already waiting: the residual, then the pending read.
114+// They were read for an earlier owner, not typed for this one.
115+func (p *terminalInputPump) claimWithBacklog() (uint64, int, bool) {
108116 p.mu.Lock()
109117 if p.consumer != 0 {
110118 p.mu.Unlock()
111- return 0, false
119+ return 0, 0, false
120+ }
121+ backlog := 0
122+ if len(p.residual) != 0 {
123+ backlog++
124+ }
125+ if p.pending != nil {
126+ backlog++
112127 }
113128 p.nextID++
114129 p.consumer = p.nextID
@@ -122,7 +137,7 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
122137 if ready {
123138 p.signalReady(consumer)
124139 }
125- return consumer, true
140+ return consumer, backlog, true
126141 }
127142
128143 // revoke invalidates an attempt before its replacement is allowed to claim
@@ -242,6 +257,19 @@ func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
242257 p.residual = append(p.residual[:0], data...)
243258 }
244259
260+// appendResidual appends bytes the consumer took but did not deliver, so the
261+// next owner replays them after any earlier residual. It reports false when
262+// the bound would be exceeded; the bytes are then dropped.
263+func (p *terminalInputPump) appendResidual(consumer uint64, data []byte, limit int) bool {
264+ p.mu.Lock()
265+ defer p.mu.Unlock()
266+ if p.consumer != consumer || len(p.residual)+len(data) > limit {
267+ return false
268+ }
269+ p.residual = append(p.residual, data...)
270+ return true
271+}
272+
245273 // ack commits scanner delivery of a leased read and lets the lifecycle reader
246274 // accept the next result. Only the consumer that holds the lease can ack it.
247275 // dropOwned disposes every undecided byte at an autonomous owner-class
@@ -271,6 +299,27 @@ func (p *terminalInputPump) dropOwned(consumer uint64) {
271299 }
272300 }
273301
302+// dropUnclaimed disposes pending and preserved bytes while no consumer holds
303+// the claim. The supervisor calls it when the terminal goes back to the
304+// picker, so session input kept for a resume never reaches the picker.
305+func (p *terminalInputPump) dropUnclaimed() {
306+ p.mu.Lock()
307+ if p.consumer != 0 {
308+ p.mu.Unlock()
309+ return
310+ }
311+ hadPending := p.pending != nil
312+ p.pending = nil
313+ p.residual = nil
314+ p.mu.Unlock()
315+ if hadPending {
316+ select {
317+ case p.space <- struct{}{}:
318+ default:
319+ }
320+ }
321+}
322+
274323 func (p *terminalInputPump) ack(consumer uint64) {
275324 p.mu.Lock()
276325 if p.consumer != consumer || p.delivering != consumer {
@@ -105,10 +105,25 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
105 105
106 // tryClaim lets competing foreground owners decline admission without panicking.106 // tryClaim lets competing foreground owners decline admission without panicking.
107 func (p *terminalInputPump) tryClaim() (uint64, bool) {107 func (p *terminalInputPump) tryClaim() (uint64, bool) {
108+ consumer, _, ok := p.claimWithBacklog()
109+ return consumer, ok
110+}
111+
112+// claimWithBacklog claims the pump and reports, atomically with the claim, how
113+// many deliveries were already waiting: the residual, then the pending read.
114+// They were read for an earlier owner, not typed for this one.
115+func (p *terminalInputPump) claimWithBacklog() (uint64, int, bool) {
108 p.mu.Lock()116 p.mu.Lock()
109 if p.consumer != 0 {117 if p.consumer != 0 {
110 p.mu.Unlock()118 p.mu.Unlock()
111- return 0, false119+ return 0, 0, false
120+ }
121+ backlog := 0
122+ if len(p.residual) != 0 {
123+ backlog++
124+ }
125+ if p.pending != nil {
126+ backlog++
112 }127 }
113 p.nextID++128 p.nextID++
114 p.consumer = p.nextID129 p.consumer = p.nextID
@@ -122,7 +137,7 @@ func (p *terminalInputPump) tryClaim() (uint64, bool) {
122 if ready {137 if ready {
123 p.signalReady(consumer)138 p.signalReady(consumer)
124 }139 }
125- return consumer, true140+ return consumer, backlog, true
126 }141 }
127 142
128 // revoke invalidates an attempt before its replacement is allowed to claim143 // revoke invalidates an attempt before its replacement is allowed to claim
@@ -242,6 +257,19 @@ func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
242 p.residual = append(p.residual[:0], data...)257 p.residual = append(p.residual[:0], data...)
243 }258 }
244 259
260+// appendResidual appends bytes the consumer took but did not deliver, so the
261+// next owner replays them after any earlier residual. It reports false when
262+// the bound would be exceeded; the bytes are then dropped.
263+func (p *terminalInputPump) appendResidual(consumer uint64, data []byte, limit int) bool {
264+ p.mu.Lock()
265+ defer p.mu.Unlock()
266+ if p.consumer != consumer || len(p.residual)+len(data) > limit {
267+ return false
268+ }
269+ p.residual = append(p.residual, data...)
270+ return true
271+}
272+
245 // ack commits scanner delivery of a leased read and lets the lifecycle reader273 // ack commits scanner delivery of a leased read and lets the lifecycle reader
246 // accept the next result. Only the consumer that holds the lease can ack it.274 // accept the next result. Only the consumer that holds the lease can ack it.
247 // dropOwned disposes every undecided byte at an autonomous owner-class275 // dropOwned disposes every undecided byte at an autonomous owner-class
@@ -271,6 +299,27 @@ func (p *terminalInputPump) dropOwned(consumer uint64) {
271 }299 }
272 }300 }
273 301
302+// dropUnclaimed disposes pending and preserved bytes while no consumer holds
303+// the claim. The supervisor calls it when the terminal goes back to the
304+// picker, so session input kept for a resume never reaches the picker.
305+func (p *terminalInputPump) dropUnclaimed() {
306+ p.mu.Lock()
307+ if p.consumer != 0 {
308+ p.mu.Unlock()
309+ return
310+ }
311+ hadPending := p.pending != nil
312+ p.pending = nil
313+ p.residual = nil
314+ p.mu.Unlock()
315+ if hadPending {
316+ select {
317+ case p.space <- struct{}{}:
318+ default:
319+ }
320+ }
321+}
322+
274 func (p *terminalInputPump) ack(consumer uint64) {323 func (p *terminalInputPump) ack(consumer uint64) {
275 p.mu.Lock()324 p.mu.Lock()
276 if p.consumer != consumer || p.delivering != consumer {325 if p.consumer != consumer || p.delivering != consumer {
M internal/usecase/daemon/render_pipeline.go +6 -1
@@ -335,7 +335,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
335335 modal := promptModalFor(snap.promptModel.Title())
336336 presentation := modal.Resolve(size)
337337 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
338- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
338+ selection := styles.Selection
339+ // Reduced copy selection is plain reverse; prompts retain bold emphasis.
340+ if styles.Reduced {
341+ selection = styles.SurfaceActive
342+ }
343+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
339344 }
340345 state.cursor.hiddenByOverlay = o.active()
341346 }
@@ -335,7 +335,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
335 modal := promptModalFor(snap.promptModel.Title())335 modal := promptModalFor(snap.promptModel.Title())
336 presentation := modal.Resolve(size)336 presentation := modal.Resolve(size)
337 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}337 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
338- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})338+ selection := styles.Selection
339+ // Reduced copy selection is plain reverse; prompts retain bold emphasis.
340+ if styles.Reduced {
341+ selection = styles.SurfaceActive
342+ }
343+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
339 }344 }
340 state.cursor.hiddenByOverlay = o.active()345 state.cursor.hiddenByOverlay = o.active()
341 }346 }
M internal/usecase/daemon/theme_integration_test.go +2 -2
@@ -161,12 +161,12 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
161161 {
162162 name: "forced dark",
163163 config: domain.Config{Theme: domain.ThemeDark},
164- want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;216;216;216;48;2;72;72;72mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
164+ want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;24;24;24;48;2;216;216;216mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
165165 },
166166 {
167167 name: "forced light",
168168 config: domain.Config{Theme: domain.ThemeLight},
169- want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;32;32;32;48;2;194;194;194mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
169+ want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;248;248;248;48;2;32;32;32mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
170170 },
171171 }
172172 for _, tt := range tests {
@@ -161,12 +161,12 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
161 {161 {
162 name: "forced dark",162 name: "forced dark",
163 config: domain.Config{Theme: domain.ThemeDark},163 config: domain.Config{Theme: domain.ThemeDark},
164- want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;216;216;216;48;2;72;72;72mD\x1b[0;38;2;139;139;139mEF\x1b[0m",164+ want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;24;24;24;48;2;216;216;216mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
165 },165 },
166 {166 {
167 name: "forced light",167 name: "forced light",
168 config: domain.Config{Theme: domain.ThemeLight},168 config: domain.Config{Theme: domain.ThemeLight},
169- want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;32;32;32;48;2;194;194;194mD\x1b[0;38;2;118;118;118mEF\x1b[0m",169+ want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;248;248;248;48;2;32;32;32mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
170 },170 },
171 }171 }
172 for _, tt := range tests {172 for _, tt := range tests {
A internal/usecase/theme/neutral_session_test.go +40 -0
@@ -0,0 +1,40 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNeutralSessionSurfaceHierarchy(t *testing.T) {
11+ black := renderer.RGB{}
12+ white := renderer.RGB{R: 255, G: 255, B: 255}
13+ for _, tt := range []struct {
14+ name string
15+ foreground, background renderer.RGB
16+ }{
17+ {name: "dark", foreground: white, background: black},
18+ {name: "light", foreground: black, background: white},
19+ } {
20+ t.Run(tt.name, func(t *testing.T) {
21+ theme := Theme{Known: true, Foreground: tt.foreground, Background: tt.background, HasFG: true, HasBG: true}
22+ styles := neutralStyles(theme)
23+ require.Equal(t, tt.foreground, styles.SurfaceActive.BackgroundRGB)
24+ require.Equal(t, tt.background, styles.SurfaceActive.ForegroundRGB)
25+ require.Equal(t, styles.TabActive.BackgroundRGB, styles.SurfaceActive.BackgroundRGB)
26+ require.Equal(t, styles.SurfaceActive.BackgroundRGB, styles.TabActiveTitle.BackgroundRGB)
27+ soft := Blend(tt.background, tt.foreground, neutralAccentBlend)
28+ for _, selection := range []renderer.Style{styles.PickerSelection, styles.Selection, styles.SearchSelection} {
29+ require.Equal(t, soft, selection.BackgroundRGB)
30+ }
31+ require.Equal(t, Blend(styles.SurfaceBar.BackgroundRGB, tt.foreground, 0.5), styles.MRUStyle(0, 3).BackgroundRGB)
32+ for count := 1; count <= 9; count++ {
33+ for index := range count {
34+ want := Blend(styles.SurfaceActive.BackgroundRGB, styles.SurfaceBar.BackgroundRGB, 1-mruWeight(index, count))
35+ require.Equal(t, want, styles.MRUStyle(index, count).BackgroundRGB)
36+ }
37+ }
38+ })
39+ }
40+}
@@ -0,0 +1,40 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNeutralSessionSurfaceHierarchy(t *testing.T) {
11+ black := renderer.RGB{}
12+ white := renderer.RGB{R: 255, G: 255, B: 255}
13+ for _, tt := range []struct {
14+ name string
15+ foreground, background renderer.RGB
16+ }{
17+ {name: "dark", foreground: white, background: black},
18+ {name: "light", foreground: black, background: white},
19+ } {
20+ t.Run(tt.name, func(t *testing.T) {
21+ theme := Theme{Known: true, Foreground: tt.foreground, Background: tt.background, HasFG: true, HasBG: true}
22+ styles := neutralStyles(theme)
23+ require.Equal(t, tt.foreground, styles.SurfaceActive.BackgroundRGB)
24+ require.Equal(t, tt.background, styles.SurfaceActive.ForegroundRGB)
25+ require.Equal(t, styles.TabActive.BackgroundRGB, styles.SurfaceActive.BackgroundRGB)
26+ require.Equal(t, styles.SurfaceActive.BackgroundRGB, styles.TabActiveTitle.BackgroundRGB)
27+ soft := Blend(tt.background, tt.foreground, neutralAccentBlend)
28+ for _, selection := range []renderer.Style{styles.PickerSelection, styles.Selection, styles.SearchSelection} {
29+ require.Equal(t, soft, selection.BackgroundRGB)
30+ }
31+ require.Equal(t, Blend(styles.SurfaceBar.BackgroundRGB, tt.foreground, 0.5), styles.MRUStyle(0, 3).BackgroundRGB)
32+ for count := 1; count <= 9; count++ {
33+ for index := range count {
34+ want := Blend(styles.SurfaceActive.BackgroundRGB, styles.SurfaceBar.BackgroundRGB, 1-mruWeight(index, count))
35+ require.Equal(t, want, styles.MRUStyle(index, count).BackgroundRGB)
36+ }
37+ }
38+ })
39+ }
40+}
M internal/usecase/theme/phase3_review_test.go +3 -1
@@ -120,8 +120,10 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
120120 // PickerSeparator is now a semantic role with the SurfaceBar
121121 // background; it was never a compatibility alias.
122122 delete(want, "PickerSeparator")
123- // Active neutral tab names use a high-contrast history anchor.
123+ // Active neutral sessions and tab names share the history anchor.
124+ want["Accent"] = rgbSurface(tt.theme.Background, tt.theme.Foreground)
124125 want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
126+ want["TabTitleActive"] = MutedVariantStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
125127 for name, style := range want {
126128 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
127129 }
@@ -120,8 +120,10 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
120 // PickerSeparator is now a semantic role with the SurfaceBar120 // PickerSeparator is now a semantic role with the SurfaceBar
121 // background; it was never a compatibility alias.121 // background; it was never a compatibility alias.
122 delete(want, "PickerSeparator")122 delete(want, "PickerSeparator")
123- // Active neutral tab names use a high-contrast history anchor.123+ // Active neutral sessions and tab names share the history anchor.
124+ want["Accent"] = rgbSurface(tt.theme.Background, tt.theme.Foreground)
124 want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)125 want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
126+ want["TabTitleActive"] = MutedVariantStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
125 for name, style := range want {127 for name, style := range want {
126 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)128 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
127 }129 }
M internal/usecase/theme/resolved_styles.go +9 -3
@@ -117,8 +117,8 @@ func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
117117 return style
118118 }
119119
120-// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It
121-// is used for palette-off, forced themes, incomplete defaults, and RGB-less
120+// neutralStyles uses full-strength session surfaces and softer overlay selections.
121+// It is used for palette-off, forced themes, incomplete defaults, and RGB-less
122122 // indexed fallbacks; it never performs a palette slot lookup.
123123 func neutralStyles(t Theme) Styles {
124124 status := renderer.DefaultStyle()
@@ -162,8 +162,14 @@ func neutralStyles(t Theme) Styles {
162162 styles.PaletteDesc = legacyMutedText(t)
163163 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
164164 if usable(t) {
165- styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)
165+ // Sessions share the full-strength neutral endpoint with active tabs;
166+ // overlay selections retain their softer neutral surface.
167+ styles.SurfaceActive = rgbSurface(t.Background, t.Foreground)
168+ styles.Accent = styles.SurfaceActive
169+ styles.TabActive = EmphasisStyle(styles.SurfaceActive, t)
166170 styles.TabNameActive = styles.TabActive
171+ styles.TabActiveTitle = MutedVariantStyle(styles.SurfaceActive, t)
172+ styles.TabTitleActive = styles.TabActiveTitle
167173 // Without a palette accent the history uses the same mruWeight
168174 // placement as MRUStyle, between the active tab and the bar.
169175 for count := 1; count <= len(styles.mruStyles); count++ {
@@ -117,8 +117,8 @@ func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
117 return style117 return style
118 }118 }
119 119
120-// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It120+// neutralStyles uses full-strength session surfaces and softer overlay selections.
121-// is used for palette-off, forced themes, incomplete defaults, and RGB-less121+// It is used for palette-off, forced themes, incomplete defaults, and RGB-less
122 // indexed fallbacks; it never performs a palette slot lookup.122 // indexed fallbacks; it never performs a palette slot lookup.
123 func neutralStyles(t Theme) Styles {123 func neutralStyles(t Theme) Styles {
124 status := renderer.DefaultStyle()124 status := renderer.DefaultStyle()
@@ -162,8 +162,14 @@ func neutralStyles(t Theme) Styles {
162 styles.PaletteDesc = legacyMutedText(t)162 styles.PaletteDesc = legacyMutedText(t)
163 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})163 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
164 if usable(t) {164 if usable(t) {
165- styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)165+ // Sessions share the full-strength neutral endpoint with active tabs;
166+ // overlay selections retain their softer neutral surface.
167+ styles.SurfaceActive = rgbSurface(t.Background, t.Foreground)
168+ styles.Accent = styles.SurfaceActive
169+ styles.TabActive = EmphasisStyle(styles.SurfaceActive, t)
166 styles.TabNameActive = styles.TabActive170 styles.TabNameActive = styles.TabActive
171+ styles.TabActiveTitle = MutedVariantStyle(styles.SurfaceActive, t)
172+ styles.TabTitleActive = styles.TabActiveTitle
167 // Without a palette accent the history uses the same mruWeight173 // Without a palette accent the history uses the same mruWeight
168 // placement as MRUStyle, between the active tab and the bar.174 // placement as MRUStyle, between the active tab and the bar.
169 for count := 1; count <= len(styles.mruStyles); count++ {175 for count := 1; count <= len(styles.mruStyles); count++ {
M internal/usecase/theme/theme_test.go +1 -1
@@ -592,7 +592,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
592592 t.Fatalf("status themed=%+v", status)
593593 }
594594 accent := AccentStyle(theme)
595- if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) {
595+ if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != theme.Foreground || accent.ForegroundRGB != theme.Background {
596596 t.Fatalf("accent themed=%+v", accent)
597597 }
598598 border := BorderStyle(theme)
@@ -592,7 +592,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
592 t.Fatalf("status themed=%+v", status)592 t.Fatalf("status themed=%+v", status)
593 }593 }
594 accent := AccentStyle(theme)594 accent := AccentStyle(theme)
595- if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) {595+ if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != theme.Foreground || accent.ForegroundRGB != theme.Background {
596 t.Fatalf("accent themed=%+v", accent)596 t.Fatalf("accent themed=%+v", accent)
597 }597 }
598 border := BorderStyle(theme)598 border := BorderStyle(theme)
M scripts/demo/Dockerfile +1 -1
@@ -7,7 +7,7 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
77 vhs ttyd ffmpeg chromium \
88 htop fastfetch gifsicle imagemagick \
99 eza fzf neovim man-db man-pages coreutils \
10- ncurses openssh util-linux \
10+ ncurses openssh util-linux python \
1111 ttf-jetbrains-mono-nerd
1212 RUN useradd -m -u 1000 demo
1313 # P8.2 uses the exact worktree binary built by sandbox-run.sh, never a
@@ -7,7 +7,7 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
7 vhs ttyd ffmpeg chromium \7 vhs ttyd ffmpeg chromium \
8 htop fastfetch gifsicle imagemagick \8 htop fastfetch gifsicle imagemagick \
9 eza fzf neovim man-db man-pages coreutils \9 eza fzf neovim man-db man-pages coreutils \
10- ncurses openssh util-linux \10+ ncurses openssh util-linux python \
11 ttf-jetbrains-mono-nerd11 ttf-jetbrains-mono-nerd
12 RUN useradd -m -u 1000 demo12 RUN useradd -m -u 1000 demo
13 # P8.2 uses the exact worktree binary built by sandbox-run.sh, never a13 # P8.2 uses the exact worktree binary built by sandbox-run.sh, never a
A scripts/remote-resilience-harness/README.md +25 -0
@@ -0,0 +1,25 @@
1+# Visual remote resilience acceptance
2+
3+`make remote-resilience-visual` runs a disposable remote session in a real foot terminal under NeferWL headless. Physical keys enter through the compositor. PNG differences and passive vev captures jointly verify local picker response within two seconds. A stopped client is the negative control.
4+
5+Prerequisites: Go 1.27, rootless Docker, Python 3, ssh-keygen, foot, and a GPU supported by NeferWL. Set `NEFERWL_SOURCE` to the compositor checkout or `NEFERWL_BIN` to its built binary. No desktop Wayland socket, user credentials or host network configuration is shared with containers.
6+
7+```sh
8+make remote-resilience-visual
9+VEV_RESILIENCE_TRANSPORT=ssh make remote-resilience-visual
10+VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual
11+VEV_RESILIENCE_TRANSPORT=ssh VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual
12+VEV_RESILIENCE_LINK=udp make remote-resilience-visual
13+VEV_RESILIENCE_LINK=tcp make remote-resilience-visual
14+VEV_RESILIENCE_LONG=1 make remote-resilience-visual
15+VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_RESUME=1 make remote-resilience-visual
16+VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_CANCEL=Escape make remote-resilience-visual
17+```
18+
19+QUIC is routed through the existing bounded seeded UDP simulator. A fixture-only SSH wrapper preserves authentication fields and rewrites the dynamic bootstrap port to its relay. SSH runs through a TCP relay. The degraded profile applies 150 ms delay, up to 50 ms jitter and 5% datagram loss to UDP; TCP waits 150 ms per forwarded read and is paced to 64 KiB/s, without dropping stream bytes; many small reads therefore add up to more than 150 ms. Both transports can be blacked out together without privileges.
20+
21+The short scenario opens the client picker while connected, edits it during blackout, restores the route and checks the named session identity and recovery output. Opening the daemon-rendered palette during blackout is not a local responsiveness guarantee. The optional long scenario asserts actual Connecting state and Ctrl-C or Esc cancellation. It cannot tell backoff from a blocked stream open; fake-clock client tests cover cancellation during stream opening. It fails if cancellation cannot reach the resume owner.
22+
23+Artifacts are printed at exit: captures, pixel counts, observation contexts, relay counters and fixture logs. Containers, image, network and temporary credentials are removed. Unsupported rendering and failed assertions exit nonzero.
24+
25+This is not a mobile-network emulator: TCP impairments model stream backpressure, not packet retransmissions; IP migration, CGNAT and exactly-once replay are not validated. `VEV_RESILIENCE_LINK=udp|tcp` isolates short asymmetric blackouts. `VEV_RESILIENCE_RESUME=1` restores a long outage and checks two held fixture commands in order. Detach timing is covered by fake-clock client tests, not this visual entrypoint.
@@ -0,0 +1,25 @@
1+# Visual remote resilience acceptance
2+
3+`make remote-resilience-visual` runs a disposable remote session in a real foot terminal under NeferWL headless. Physical keys enter through the compositor. PNG differences and passive vev captures jointly verify local picker response within two seconds. A stopped client is the negative control.
4+
5+Prerequisites: Go 1.27, rootless Docker, Python 3, ssh-keygen, foot, and a GPU supported by NeferWL. Set `NEFERWL_SOURCE` to the compositor checkout or `NEFERWL_BIN` to its built binary. No desktop Wayland socket, user credentials or host network configuration is shared with containers.
6+
7+```sh
8+make remote-resilience-visual
9+VEV_RESILIENCE_TRANSPORT=ssh make remote-resilience-visual
10+VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual
11+VEV_RESILIENCE_TRANSPORT=ssh VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual
12+VEV_RESILIENCE_LINK=udp make remote-resilience-visual
13+VEV_RESILIENCE_LINK=tcp make remote-resilience-visual
14+VEV_RESILIENCE_LONG=1 make remote-resilience-visual
15+VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_RESUME=1 make remote-resilience-visual
16+VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_CANCEL=Escape make remote-resilience-visual
17+```
18+
19+QUIC is routed through the existing bounded seeded UDP simulator. A fixture-only SSH wrapper preserves authentication fields and rewrites the dynamic bootstrap port to its relay. SSH runs through a TCP relay. The degraded profile applies 150 ms delay, up to 50 ms jitter and 5% datagram loss to UDP; TCP waits 150 ms per forwarded read and is paced to 64 KiB/s, without dropping stream bytes; many small reads therefore add up to more than 150 ms. Both transports can be blacked out together without privileges.
20+
21+The short scenario opens the client picker while connected, edits it during blackout, restores the route and checks the named session identity and recovery output. Opening the daemon-rendered palette during blackout is not a local responsiveness guarantee. The optional long scenario asserts actual Connecting state and Ctrl-C or Esc cancellation. It cannot tell backoff from a blocked stream open; fake-clock client tests cover cancellation during stream opening. It fails if cancellation cannot reach the resume owner.
22+
23+Artifacts are printed at exit: captures, pixel counts, observation contexts, relay counters and fixture logs. Containers, image, network and temporary credentials are removed. Unsupported rendering and failed assertions exit nonzero.
24+
25+This is not a mobile-network emulator: TCP impairments model stream backpressure, not packet retransmissions; IP migration, CGNAT and exactly-once replay are not validated. `VEV_RESILIENCE_LINK=udp|tcp` isolates short asymmetric blackouts. `VEV_RESILIENCE_RESUME=1` restores a long outage and checks two held fixture commands in order. Detach timing is covered by fake-clock client tests, not this visual entrypoint.
A scripts/remote-resilience-harness/pixels/main.go +77 -0
@@ -0,0 +1,77 @@
1+// Command pixels compares the fixed 1280x720 fixture's terminal content.
2+package main
3+
4+import (
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "image"
9+ _ "image/png"
10+ "os"
11+)
12+
13+const fixtureMargin = 100
14+const minimumChangedPixels = 20
15+
16+func load(path string) (image.Image, error) {
17+ f, err := os.Open(path)
18+ if err != nil {
19+ return nil, err
20+ }
21+ defer f.Close()
22+ im, _, err := image.Decode(f)
23+ return im, err
24+}
25+
26+func compare(a, b image.Image) (int, error) {
27+ if a.Bounds() != b.Bounds() {
28+ return 0, errors.New("capture dimensions changed")
29+ }
30+ bounds := a.Bounds()
31+ if bounds.Dx() <= 2*fixtureMargin || bounds.Dy() <= 2*fixtureMargin {
32+ return 0, errors.New("capture has no comparison region")
33+ }
34+ changed := 0
35+ for y := bounds.Min.Y + fixtureMargin; y < bounds.Max.Y-fixtureMargin; y++ {
36+ for x := bounds.Min.X + fixtureMargin; x < bounds.Max.X-fixtureMargin; x++ {
37+ ar, ag, ab, _ := a.At(x, y).RGBA()
38+ br, bg, bb, _ := b.At(x, y).RGBA()
39+ if ar != br || ag != bg || ab != bb {
40+ changed++
41+ }
42+ }
43+ }
44+ return changed, nil
45+}
46+
47+func execute(args []string) (int, error) {
48+ if len(args) != 2 {
49+ return 2, errors.New("usage: pixels before.png after.png")
50+ }
51+ a, err := load(args[0])
52+ if err != nil {
53+ return 2, err
54+ }
55+ b, err := load(args[1])
56+ if err != nil {
57+ return 2, err
58+ }
59+ changed, err := compare(a, b)
60+ if err != nil {
61+ return 2, err
62+ }
63+ if err := json.NewEncoder(os.Stdout).Encode(map[string]int{"changed_pixels": changed}); err != nil {
64+ return 2, err
65+ }
66+ if changed < minimumChangedPixels {
67+ return 1, nil
68+ }
69+ return 0, nil
70+}
71+func main() {
72+ code, err := execute(os.Args[1:])
73+ if err != nil {
74+ fmt.Fprintln(os.Stderr, err)
75+ }
76+ os.Exit(code)
77+}
@@ -0,0 +1,77 @@
1+// Command pixels compares the fixed 1280x720 fixture's terminal content.
2+package main
3+
4+import (
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "image"
9+ _ "image/png"
10+ "os"
11+)
12+
13+const fixtureMargin = 100
14+const minimumChangedPixels = 20
15+
16+func load(path string) (image.Image, error) {
17+ f, err := os.Open(path)
18+ if err != nil {
19+ return nil, err
20+ }
21+ defer f.Close()
22+ im, _, err := image.Decode(f)
23+ return im, err
24+}
25+
26+func compare(a, b image.Image) (int, error) {
27+ if a.Bounds() != b.Bounds() {
28+ return 0, errors.New("capture dimensions changed")
29+ }
30+ bounds := a.Bounds()
31+ if bounds.Dx() <= 2*fixtureMargin || bounds.Dy() <= 2*fixtureMargin {
32+ return 0, errors.New("capture has no comparison region")
33+ }
34+ changed := 0
35+ for y := bounds.Min.Y + fixtureMargin; y < bounds.Max.Y-fixtureMargin; y++ {
36+ for x := bounds.Min.X + fixtureMargin; x < bounds.Max.X-fixtureMargin; x++ {
37+ ar, ag, ab, _ := a.At(x, y).RGBA()
38+ br, bg, bb, _ := b.At(x, y).RGBA()
39+ if ar != br || ag != bg || ab != bb {
40+ changed++
41+ }
42+ }
43+ }
44+ return changed, nil
45+}
46+
47+func execute(args []string) (int, error) {
48+ if len(args) != 2 {
49+ return 2, errors.New("usage: pixels before.png after.png")
50+ }
51+ a, err := load(args[0])
52+ if err != nil {
53+ return 2, err
54+ }
55+ b, err := load(args[1])
56+ if err != nil {
57+ return 2, err
58+ }
59+ changed, err := compare(a, b)
60+ if err != nil {
61+ return 2, err
62+ }
63+ if err := json.NewEncoder(os.Stdout).Encode(map[string]int{"changed_pixels": changed}); err != nil {
64+ return 2, err
65+ }
66+ if changed < minimumChangedPixels {
67+ return 1, nil
68+ }
69+ return 0, nil
70+}
71+func main() {
72+ code, err := execute(os.Args[1:])
73+ if err != nil {
74+ fmt.Fprintln(os.Stderr, err)
75+ }
76+ os.Exit(code)
77+}
A scripts/remote-resilience-harness/pixels/main_test.go +28 -0
@@ -0,0 +1,28 @@
1+package main
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestCompare(t *testing.T) {
12+ for _, n := range []int{0, 19, 20} {
13+ a := image.NewRGBA(image.Rect(0, 0, 300, 300))
14+ b := image.NewRGBA(a.Bounds())
15+ for x := 0; x < n; x++ {
16+ b.Set(100+x, 100, color.White)
17+ }
18+ got, err := compare(a, b)
19+ require.NoError(t, err)
20+ require.Equal(t, n, got)
21+ }
22+ for _, bounds := range []image.Rectangle{image.Rect(0, 0, 200, 300), image.Rect(0, 0, 300, 200)} {
23+ _, err := compare(image.NewRGBA(bounds), image.NewRGBA(bounds))
24+ require.Error(t, err)
25+ }
26+ _, err := compare(image.NewRGBA(image.Rect(0, 0, 300, 300)), image.NewRGBA(image.Rect(0, 0, 400, 300)))
27+ require.Error(t, err)
28+}
@@ -0,0 +1,28 @@
1+package main
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestCompare(t *testing.T) {
12+ for _, n := range []int{0, 19, 20} {
13+ a := image.NewRGBA(image.Rect(0, 0, 300, 300))
14+ b := image.NewRGBA(a.Bounds())
15+ for x := 0; x < n; x++ {
16+ b.Set(100+x, 100, color.White)
17+ }
18+ got, err := compare(a, b)
19+ require.NoError(t, err)
20+ require.Equal(t, n, got)
21+ }
22+ for _, bounds := range []image.Rectangle{image.Rect(0, 0, 200, 300), image.Rect(0, 0, 300, 200)} {
23+ _, err := compare(image.NewRGBA(bounds), image.NewRGBA(bounds))
24+ require.Error(t, err)
25+ }
26+ _, err := compare(image.NewRGBA(image.Rect(0, 0, 300, 300)), image.NewRGBA(image.Rect(0, 0, 400, 300)))
27+ require.Error(t, err)
28+}
A scripts/remote-resilience-harness/relay/main.go +289 -0
@@ -0,0 +1,289 @@
1+// Command relay is disposable acceptance infrastructure, never a production proxy.
2+package main
3+
4+import (
5+ "bufio"
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "flag"
11+ "fmt"
12+ "io"
13+ "net"
14+ "os"
15+ "os/signal"
16+ "path/filepath"
17+ "sync"
18+ "sync/atomic"
19+ "syscall"
20+ "time"
21+
22+ "github.com/bnema/vev/internal/adapters/quicnettest"
23+)
24+
25+type command struct {
26+ ID uint64 `json:"id"`
27+ Op string `json:"op"`
28+ Port int `json:"port,omitempty"`
29+ UDP bool `json:"udp,omitempty"`
30+ TCP bool `json:"tcp,omitempty"`
31+ Active bool `json:"active,omitempty"`
32+}
33+
34+type relay struct {
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ remote string
38+ degraded bool
39+ blackout atomic.Bool
40+ bytes atomic.Uint64
41+ mu sync.Mutex
42+ connections map[net.Conn]struct{}
43+ proxies []*quicnettest.Proxy
44+ udpBlackout bool
45+ wg sync.WaitGroup
46+}
47+
48+func main() {
49+ remote := flag.String("remote", "remote", "fixture upstream host")
50+ control := flag.String("control", "", "required private Unix control path")
51+ tcp := flag.String("tcp", ":2222", "SSH listener")
52+ degraded := flag.Bool("degraded", false, "enable seeded degraded profile")
53+ flag.Parse()
54+ if *control == "" {
55+ fmt.Fprintln(os.Stderr, "-control is required")
56+ os.Exit(2)
57+ }
58+ ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
59+ r := &relay{ctx: ctx, cancel: cancel, remote: *remote, degraded: *degraded, connections: make(map[net.Conn]struct{})}
60+ if err := r.serve(*tcp, *control); err != nil {
61+ fmt.Fprintln(os.Stderr, err)
62+ os.Exit(1)
63+ }
64+}
65+
66+func (r *relay) serve(addr, path string) error {
67+ listener, err := net.Listen("tcp", addr)
68+ if err != nil {
69+ return err
70+ }
71+ defer listener.Close()
72+ parent, err := os.Stat(filepath.Dir(path))
73+ if err != nil {
74+ return err
75+ }
76+ if !parent.IsDir() || parent.Mode().Perm() != 0700 {
77+ return errors.New("control socket requires a private 0700 directory")
78+ }
79+ owner, ok := parent.Sys().(*syscall.Stat_t)
80+ if !ok || owner.Uid != uint32(os.Getuid()) {
81+ return errors.New("control directory must belong to the current user")
82+ }
83+ control, err := net.Listen("unix", path)
84+ if err != nil {
85+ return err
86+ }
87+ defer control.Close()
88+ watchDone := make(chan struct{})
89+ defer close(watchDone)
90+ go func() {
91+ select {
92+ case <-r.ctx.Done():
93+ listener.Close()
94+ control.Close()
95+ case <-watchDone:
96+ }
97+ }()
98+ defer os.Remove(path)
99+ if err := os.Chmod(path, 0600); err != nil {
100+ return err
101+ }
102+ defer func() { listener.Close(); r.close() }()
103+ r.wg.Add(1)
104+ go func() {
105+ defer r.wg.Done()
106+ for {
107+ conn, err := listener.Accept()
108+ if err != nil {
109+ return
110+ }
111+ r.mu.Lock()
112+ if len(r.connections) >= 64 {
113+ r.mu.Unlock()
114+ conn.Close()
115+ continue
116+ }
117+ r.connections[conn] = struct{}{}
118+ r.mu.Unlock()
119+ r.wg.Add(1)
120+ go func() { defer r.wg.Done(); r.forward(conn) }()
121+ }
122+ }()
123+ for {
124+ conn, err := control.Accept()
125+ if err != nil {
126+ if r.ctx.Err() != nil {
127+ return nil
128+ }
129+ return err
130+ }
131+ // Commands are bounded and handled serially; a stalled caller gets 2s.
132+ conn.SetDeadline(time.Now().Add(2 * time.Second))
133+ scanner := bufio.NewScanner(conn)
134+ scanner.Buffer(make([]byte, 4096), 4096)
135+ if scanner.Scan() {
136+ var c command
137+ err := json.Unmarshal(scanner.Bytes(), &c)
138+ var result any
139+ if err == nil {
140+ result, err = r.apply(c)
141+ }
142+ reply := map[string]any{"id": c.ID, "result": result}
143+ if err != nil {
144+ reply["error"] = err.Error()
145+ }
146+ json.NewEncoder(conn).Encode(reply)
147+ }
148+ conn.Close()
149+ if r.ctx.Err() != nil {
150+ listener.Close()
151+ return nil
152+ }
153+ }
154+}
155+
156+func (r *relay) apply(c command) (any, error) {
157+ switch c.Op {
158+ case "udp":
159+ if c.Port <= 0 || c.Port > 65535 {
160+ return nil, errors.New("invalid port")
161+ }
162+ upstream, err := net.ResolveUDPAddr("udp", net.JoinHostPort(r.remote, fmt.Sprint(c.Port)))
163+ if err != nil {
164+ return nil, err
165+ }
166+ link := quicnettest.LinkConfig{}
167+ if r.degraded {
168+ link = quicnettest.LinkConfig{BaseLatency: 150 * time.Millisecond, Jitter: 50 * time.Millisecond, LossPercent: 5}
169+ }
170+ r.mu.Lock()
171+ defer r.mu.Unlock()
172+ if len(r.proxies) >= 32 {
173+ return nil, errors.New("UDP listener limit")
174+ }
175+ p, err := quicnettest.New(quicnettest.Config{ServerAddr: upstream, ListenAddr: &net.UDPAddr{IP: net.IPv4zero}, Seed: 317, ToServer: link, ToClient: link})
176+ if err != nil {
177+ return nil, err
178+ }
179+ p.SetBlackout(r.udpBlackout)
180+ r.proxies = append(r.proxies, p)
181+ return p.Addr().Port, nil
182+ case "blackout":
183+ if c.TCP {
184+ r.blackout.Store(c.Active)
185+ }
186+ if c.UDP {
187+ r.mu.Lock()
188+ r.udpBlackout = c.Active
189+ for _, p := range r.proxies {
190+ p.SetBlackout(c.Active)
191+ }
192+ r.mu.Unlock()
193+ }
194+ return nil, nil
195+ case "disconnect":
196+ r.mu.Lock()
197+ for conn := range r.connections {
198+ conn.Close()
199+ }
200+ r.mu.Unlock()
201+ return nil, nil
202+ case "stats":
203+ r.mu.Lock()
204+ defer r.mu.Unlock()
205+ stats := make([]quicnettest.Stats, 0, len(r.proxies))
206+ for _, p := range r.proxies {
207+ stats = append(stats, p.Stats())
208+ }
209+ return map[string]any{"udp": stats, "tcp_bytes": r.bytes.Load()}, nil
210+ case "stop":
211+ r.cancel()
212+ return nil, nil
213+ default:
214+ return nil, errors.New("unknown command")
215+ }
216+}
217+
218+func (r *relay) forward(client net.Conn) {
219+ defer func() { client.Close(); r.mu.Lock(); delete(r.connections, client); r.mu.Unlock() }()
220+ server, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(r.ctx, "tcp", net.JoinHostPort(r.remote, "22"))
221+ if err != nil {
222+ return
223+ }
224+ defer server.Close()
225+ r.mu.Lock()
226+ r.connections[server] = struct{}{}
227+ r.mu.Unlock()
228+ defer func() { r.mu.Lock(); delete(r.connections, server); r.mu.Unlock() }()
229+ done := make(chan struct{})
230+ go func() { r.copy(server, client); server.Close(); client.Close(); close(done) }()
231+ r.copy(client, server)
232+ server.Close()
233+ client.Close()
234+ <-done
235+}
236+
237+func (r *relay) wait(delay time.Duration) bool {
238+ timer := time.NewTimer(delay)
239+ defer timer.Stop()
240+ select {
241+ case <-timer.C:
242+ return true
243+ case <-r.ctx.Done():
244+ return false
245+ }
246+}
247+
248+func (r *relay) copy(dst, src net.Conn) {
249+ buf := make([]byte, 32<<10)
250+ for {
251+ for r.blackout.Load() {
252+ if !r.wait(10 * time.Millisecond) {
253+ return
254+ }
255+ }
256+ n, err := src.Read(buf)
257+ if n > 0 {
258+ for r.blackout.Load() {
259+ if !r.wait(10 * time.Millisecond) {
260+ return
261+ }
262+ }
263+ if r.degraded && !r.wait(150*time.Millisecond+time.Duration(n)*time.Second/(64<<10)) {
264+ return
265+ }
266+ written, writeErr := io.Copy(dst, bytes.NewReader(buf[:n]))
267+ r.bytes.Add(uint64(written))
268+ if writeErr != nil {
269+ return
270+ }
271+ }
272+ if err != nil {
273+ return
274+ }
275+ }
276+}
277+
278+func (r *relay) close() {
279+ r.cancel()
280+ r.mu.Lock()
281+ for conn := range r.connections {
282+ conn.Close()
283+ }
284+ for _, p := range r.proxies {
285+ p.Close()
286+ }
287+ r.mu.Unlock()
288+ r.wg.Wait()
289+}
@@ -0,0 +1,289 @@
1+// Command relay is disposable acceptance infrastructure, never a production proxy.
2+package main
3+
4+import (
5+ "bufio"
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "flag"
11+ "fmt"
12+ "io"
13+ "net"
14+ "os"
15+ "os/signal"
16+ "path/filepath"
17+ "sync"
18+ "sync/atomic"
19+ "syscall"
20+ "time"
21+
22+ "github.com/bnema/vev/internal/adapters/quicnettest"
23+)
24+
25+type command struct {
26+ ID uint64 `json:"id"`
27+ Op string `json:"op"`
28+ Port int `json:"port,omitempty"`
29+ UDP bool `json:"udp,omitempty"`
30+ TCP bool `json:"tcp,omitempty"`
31+ Active bool `json:"active,omitempty"`
32+}
33+
34+type relay struct {
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ remote string
38+ degraded bool
39+ blackout atomic.Bool
40+ bytes atomic.Uint64
41+ mu sync.Mutex
42+ connections map[net.Conn]struct{}
43+ proxies []*quicnettest.Proxy
44+ udpBlackout bool
45+ wg sync.WaitGroup
46+}
47+
48+func main() {
49+ remote := flag.String("remote", "remote", "fixture upstream host")
50+ control := flag.String("control", "", "required private Unix control path")
51+ tcp := flag.String("tcp", ":2222", "SSH listener")
52+ degraded := flag.Bool("degraded", false, "enable seeded degraded profile")
53+ flag.Parse()
54+ if *control == "" {
55+ fmt.Fprintln(os.Stderr, "-control is required")
56+ os.Exit(2)
57+ }
58+ ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
59+ r := &relay{ctx: ctx, cancel: cancel, remote: *remote, degraded: *degraded, connections: make(map[net.Conn]struct{})}
60+ if err := r.serve(*tcp, *control); err != nil {
61+ fmt.Fprintln(os.Stderr, err)
62+ os.Exit(1)
63+ }
64+}
65+
66+func (r *relay) serve(addr, path string) error {
67+ listener, err := net.Listen("tcp", addr)
68+ if err != nil {
69+ return err
70+ }
71+ defer listener.Close()
72+ parent, err := os.Stat(filepath.Dir(path))
73+ if err != nil {
74+ return err
75+ }
76+ if !parent.IsDir() || parent.Mode().Perm() != 0700 {
77+ return errors.New("control socket requires a private 0700 directory")
78+ }
79+ owner, ok := parent.Sys().(*syscall.Stat_t)
80+ if !ok || owner.Uid != uint32(os.Getuid()) {
81+ return errors.New("control directory must belong to the current user")
82+ }
83+ control, err := net.Listen("unix", path)
84+ if err != nil {
85+ return err
86+ }
87+ defer control.Close()
88+ watchDone := make(chan struct{})
89+ defer close(watchDone)
90+ go func() {
91+ select {
92+ case <-r.ctx.Done():
93+ listener.Close()
94+ control.Close()
95+ case <-watchDone:
96+ }
97+ }()
98+ defer os.Remove(path)
99+ if err := os.Chmod(path, 0600); err != nil {
100+ return err
101+ }
102+ defer func() { listener.Close(); r.close() }()
103+ r.wg.Add(1)
104+ go func() {
105+ defer r.wg.Done()
106+ for {
107+ conn, err := listener.Accept()
108+ if err != nil {
109+ return
110+ }
111+ r.mu.Lock()
112+ if len(r.connections) >= 64 {
113+ r.mu.Unlock()
114+ conn.Close()
115+ continue
116+ }
117+ r.connections[conn] = struct{}{}
118+ r.mu.Unlock()
119+ r.wg.Add(1)
120+ go func() { defer r.wg.Done(); r.forward(conn) }()
121+ }
122+ }()
123+ for {
124+ conn, err := control.Accept()
125+ if err != nil {
126+ if r.ctx.Err() != nil {
127+ return nil
128+ }
129+ return err
130+ }
131+ // Commands are bounded and handled serially; a stalled caller gets 2s.
132+ conn.SetDeadline(time.Now().Add(2 * time.Second))
133+ scanner := bufio.NewScanner(conn)
134+ scanner.Buffer(make([]byte, 4096), 4096)
135+ if scanner.Scan() {
136+ var c command
137+ err := json.Unmarshal(scanner.Bytes(), &c)
138+ var result any
139+ if err == nil {
140+ result, err = r.apply(c)
141+ }
142+ reply := map[string]any{"id": c.ID, "result": result}
143+ if err != nil {
144+ reply["error"] = err.Error()
145+ }
146+ json.NewEncoder(conn).Encode(reply)
147+ }
148+ conn.Close()
149+ if r.ctx.Err() != nil {
150+ listener.Close()
151+ return nil
152+ }
153+ }
154+}
155+
156+func (r *relay) apply(c command) (any, error) {
157+ switch c.Op {
158+ case "udp":
159+ if c.Port <= 0 || c.Port > 65535 {
160+ return nil, errors.New("invalid port")
161+ }
162+ upstream, err := net.ResolveUDPAddr("udp", net.JoinHostPort(r.remote, fmt.Sprint(c.Port)))
163+ if err != nil {
164+ return nil, err
165+ }
166+ link := quicnettest.LinkConfig{}
167+ if r.degraded {
168+ link = quicnettest.LinkConfig{BaseLatency: 150 * time.Millisecond, Jitter: 50 * time.Millisecond, LossPercent: 5}
169+ }
170+ r.mu.Lock()
171+ defer r.mu.Unlock()
172+ if len(r.proxies) >= 32 {
173+ return nil, errors.New("UDP listener limit")
174+ }
175+ p, err := quicnettest.New(quicnettest.Config{ServerAddr: upstream, ListenAddr: &net.UDPAddr{IP: net.IPv4zero}, Seed: 317, ToServer: link, ToClient: link})
176+ if err != nil {
177+ return nil, err
178+ }
179+ p.SetBlackout(r.udpBlackout)
180+ r.proxies = append(r.proxies, p)
181+ return p.Addr().Port, nil
182+ case "blackout":
183+ if c.TCP {
184+ r.blackout.Store(c.Active)
185+ }
186+ if c.UDP {
187+ r.mu.Lock()
188+ r.udpBlackout = c.Active
189+ for _, p := range r.proxies {
190+ p.SetBlackout(c.Active)
191+ }
192+ r.mu.Unlock()
193+ }
194+ return nil, nil
195+ case "disconnect":
196+ r.mu.Lock()
197+ for conn := range r.connections {
198+ conn.Close()
199+ }
200+ r.mu.Unlock()
201+ return nil, nil
202+ case "stats":
203+ r.mu.Lock()
204+ defer r.mu.Unlock()
205+ stats := make([]quicnettest.Stats, 0, len(r.proxies))
206+ for _, p := range r.proxies {
207+ stats = append(stats, p.Stats())
208+ }
209+ return map[string]any{"udp": stats, "tcp_bytes": r.bytes.Load()}, nil
210+ case "stop":
211+ r.cancel()
212+ return nil, nil
213+ default:
214+ return nil, errors.New("unknown command")
215+ }
216+}
217+
218+func (r *relay) forward(client net.Conn) {
219+ defer func() { client.Close(); r.mu.Lock(); delete(r.connections, client); r.mu.Unlock() }()
220+ server, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(r.ctx, "tcp", net.JoinHostPort(r.remote, "22"))
221+ if err != nil {
222+ return
223+ }
224+ defer server.Close()
225+ r.mu.Lock()
226+ r.connections[server] = struct{}{}
227+ r.mu.Unlock()
228+ defer func() { r.mu.Lock(); delete(r.connections, server); r.mu.Unlock() }()
229+ done := make(chan struct{})
230+ go func() { r.copy(server, client); server.Close(); client.Close(); close(done) }()
231+ r.copy(client, server)
232+ server.Close()
233+ client.Close()
234+ <-done
235+}
236+
237+func (r *relay) wait(delay time.Duration) bool {
238+ timer := time.NewTimer(delay)
239+ defer timer.Stop()
240+ select {
241+ case <-timer.C:
242+ return true
243+ case <-r.ctx.Done():
244+ return false
245+ }
246+}
247+
248+func (r *relay) copy(dst, src net.Conn) {
249+ buf := make([]byte, 32<<10)
250+ for {
251+ for r.blackout.Load() {
252+ if !r.wait(10 * time.Millisecond) {
253+ return
254+ }
255+ }
256+ n, err := src.Read(buf)
257+ if n > 0 {
258+ for r.blackout.Load() {
259+ if !r.wait(10 * time.Millisecond) {
260+ return
261+ }
262+ }
263+ if r.degraded && !r.wait(150*time.Millisecond+time.Duration(n)*time.Second/(64<<10)) {
264+ return
265+ }
266+ written, writeErr := io.Copy(dst, bytes.NewReader(buf[:n]))
267+ r.bytes.Add(uint64(written))
268+ if writeErr != nil {
269+ return
270+ }
271+ }
272+ if err != nil {
273+ return
274+ }
275+ }
276+}
277+
278+func (r *relay) close() {
279+ r.cancel()
280+ r.mu.Lock()
281+ for conn := range r.connections {
282+ conn.Close()
283+ }
284+ for _, p := range r.proxies {
285+ p.Close()
286+ }
287+ r.mu.Unlock()
288+ r.wg.Wait()
289+}
A scripts/remote-resilience-harness/relay/main_test.go +200 -0
@@ -0,0 +1,200 @@
1+package main
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestTCPRelayPreservesBytesAndStops(t *testing.T) {
18+ ctx, cancel := context.WithCancel(t.Context())
19+ defer cancel()
20+ r := &relay{ctx: ctx, cancel: cancel}
21+ source, writer := net.Pipe()
22+ reader, destination := net.Pipe()
23+ defer source.Close()
24+ defer writer.Close()
25+ defer reader.Close()
26+ defer destination.Close()
27+ done := make(chan struct{})
28+ go func() { defer close(done); r.copy(destination, source) }()
29+ payload := []byte("fixture input in order")
30+ go func() { writer.Write(payload); writer.Close() }()
31+ reader.SetReadDeadline(time.Now().Add(time.Second))
32+ got := make([]byte, len(payload))
33+ _, err := io.ReadFull(reader, got)
34+ require.NoError(t, err)
35+ require.Equal(t, payload, got)
36+ select {
37+ case <-done:
38+ case <-time.After(time.Second):
39+ t.Fatal("relay did not stop after EOF")
40+ }
41+ require.Equal(t, uint64(len(payload)), r.bytes.Load())
42+}
43+
44+// controlSocketPath returns a socket path in a short private directory: the
45+// macOS t.TempDir path exceeds the 104-byte AF_UNIX limit.
46+func controlSocketPath(t *testing.T) string {
47+ t.Helper()
48+ directory, err := os.MkdirTemp("/tmp", "relay")
49+ require.NoError(t, err)
50+ t.Cleanup(func() { _ = os.RemoveAll(directory) })
51+ require.NoError(t, os.Chmod(directory, 0700))
52+ return filepath.Join(directory, "control.sock")
53+}
54+
55+func TestRelayShutdownClosesListeners(t *testing.T) {
56+ ctx, cancel := context.WithCancel(t.Context())
57+ r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})}
58+ path := controlSocketPath(t)
59+ done := make(chan error, 1)
60+ go func() { done <- r.serve("127.0.0.1:0", path) }()
61+ require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond)
62+ cancel()
63+ select {
64+ case err := <-done:
65+ require.NoError(t, err)
66+ case <-time.After(time.Second):
67+ t.Fatal("relay shutdown hung")
68+ }
69+ _, err := os.Stat(path)
70+ require.True(t, os.IsNotExist(err))
71+}
72+
73+func TestTCPRelayBlackoutHoldsBytes(t *testing.T) {
74+ ctx, cancel := context.WithCancel(t.Context())
75+ defer cancel()
76+ r := &relay{ctx: ctx, cancel: cancel}
77+ _, err := r.apply(command{Op: "blackout", TCP: true, Active: true})
78+ require.NoError(t, err)
79+ source, writer := net.Pipe()
80+ reader, destination := net.Pipe()
81+ defer source.Close()
82+ defer writer.Close()
83+ defer reader.Close()
84+ defer destination.Close()
85+ done := make(chan struct{})
86+ go func() { defer close(done); r.copy(destination, source) }()
87+ payload := []byte("held")
88+ wrote := make(chan error, 1)
89+ go func() { _, err := writer.Write(payload); wrote <- err }()
90+ select {
91+ case err := <-wrote:
92+ t.Fatalf("blackout read the source: %v", err)
93+ case <-time.After(50 * time.Millisecond):
94+ }
95+ require.Zero(t, r.bytes.Load())
96+ _, err = r.apply(command{Op: "blackout", TCP: true, Active: false})
97+ require.NoError(t, err)
98+ reader.SetReadDeadline(time.Now().Add(time.Second))
99+ got := make([]byte, len(payload))
100+ _, err = io.ReadFull(reader, got)
101+ require.NoError(t, err)
102+ require.Equal(t, payload, got)
103+ require.NoError(t, <-wrote)
104+ cancel()
105+ writer.Close()
106+ <-done
107+}
108+
109+// TestRelayUDPProxyInheritsActiveBlackout opens a UDP route while a blackout
110+// is active and proves it drops datagrams until the blackout ends.
111+func TestRelayUDPProxyInheritsActiveBlackout(t *testing.T) {
112+ ctx, cancel := context.WithCancel(t.Context())
113+ defer cancel()
114+ r := &relay{ctx: ctx, cancel: cancel, remote: "127.0.0.1"}
115+ defer r.close()
116+ upstream, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
117+ require.NoError(t, err)
118+ defer upstream.Close()
119+ _, err = r.apply(command{Op: "blackout", UDP: true, Active: true})
120+ require.NoError(t, err)
121+ port, err := r.apply(command{Op: "udp", Port: upstream.LocalAddr().(*net.UDPAddr).Port})
122+ require.NoError(t, err)
123+ client, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port.(int)})
124+ require.NoError(t, err)
125+ defer client.Close()
126+ _, err = client.Write([]byte("blocked"))
127+ require.NoError(t, err)
128+ require.Eventually(t, func() bool { return r.proxies[0].Stats().ToServer.BlackoutDrops == 1 }, time.Second, time.Millisecond)
129+ _, err = r.apply(command{Op: "blackout", UDP: true, Active: false})
130+ require.NoError(t, err)
131+ _, err = client.Write([]byte("open"))
132+ require.NoError(t, err)
133+ require.NoError(t, upstream.SetReadDeadline(time.Now().Add(time.Second)))
134+ data := make([]byte, 16)
135+ n, _, err := upstream.ReadFromUDP(data)
136+ require.NoError(t, err)
137+ require.Equal(t, "open", string(data[:n]))
138+}
139+
140+func TestRelayDisconnectClosesConnections(t *testing.T) {
141+ ctx, cancel := context.WithCancel(t.Context())
142+ defer cancel()
143+ r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})}
144+ local, peer := net.Pipe()
145+ defer peer.Close()
146+ r.connections[local] = struct{}{}
147+ _, err := r.apply(command{Op: "disconnect"})
148+ require.NoError(t, err)
149+ peer.SetReadDeadline(time.Now().Add(time.Second))
150+ _, err = peer.Read(make([]byte, 1))
151+ require.ErrorIs(t, err, io.EOF)
152+}
153+
154+func TestRelayControlRoundTrip(t *testing.T) {
155+ ctx, cancel := context.WithCancel(t.Context())
156+ defer cancel()
157+ r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})}
158+ path := controlSocketPath(t)
159+ done := make(chan error, 1)
160+ go func() { done <- r.serve("127.0.0.1:0", path) }()
161+ require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond)
162+ send := func(c command) map[string]any {
163+ conn, err := net.Dial("unix", path)
164+ require.NoError(t, err)
165+ defer conn.Close()
166+ require.NoError(t, json.NewEncoder(conn).Encode(c))
167+ line, err := bufio.NewReader(conn).ReadBytes('\n')
168+ require.NoError(t, err)
169+ var reply map[string]any
170+ require.NoError(t, json.Unmarshal(line, &reply))
171+ return reply
172+ }
173+ reply := send(command{ID: 7, Op: "blackout", TCP: true, Active: true})
174+ require.EqualValues(t, 7, reply["id"])
175+ require.NotContains(t, reply, "error")
176+ require.True(t, r.blackout.Load())
177+ reply = send(command{ID: 8, Op: "unknown"})
178+ require.Equal(t, "unknown command", reply["error"])
179+ reply = send(command{ID: 9, Op: "stop"})
180+ require.NotContains(t, reply, "error")
181+ select {
182+ case err := <-done:
183+ require.NoError(t, err)
184+ case <-time.After(time.Second):
185+ t.Fatal("stop command did not shut the relay down")
186+ }
187+}
188+
189+func TestRelayRejectsInvalidControl(t *testing.T) {
190+ ctx, cancel := context.WithCancel(t.Context())
191+ defer cancel()
192+ r := &relay{ctx: ctx, cancel: cancel}
193+ for _, c := range []command{{Op: "unknown"}, {Op: "udp", Port: 0}, {Op: "udp", Port: 65536}} {
194+ _, err := r.apply(c)
195+ require.Error(t, err)
196+ }
197+ _, err := r.apply(command{Op: "stop"})
198+ require.NoError(t, err)
199+ require.ErrorIs(t, ctx.Err(), context.Canceled)
200+}
@@ -0,0 +1,200 @@
1+package main
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestTCPRelayPreservesBytesAndStops(t *testing.T) {
18+ ctx, cancel := context.WithCancel(t.Context())
19+ defer cancel()
20+ r := &relay{ctx: ctx, cancel: cancel}
21+ source, writer := net.Pipe()
22+ reader, destination := net.Pipe()
23+ defer source.Close()
24+ defer writer.Close()
25+ defer reader.Close()
26+ defer destination.Close()
27+ done := make(chan struct{})
28+ go func() { defer close(done); r.copy(destination, source) }()
29+ payload := []byte("fixture input in order")
30+ go func() { writer.Write(payload); writer.Close() }()
31+ reader.SetReadDeadline(time.Now().Add(time.Second))
32+ got := make([]byte, len(payload))
33+ _, err := io.ReadFull(reader, got)
34+ require.NoError(t, err)
35+ require.Equal(t, payload, got)
36+ select {
37+ case <-done:
38+ case <-time.After(time.Second):
39+ t.Fatal("relay did not stop after EOF")
40+ }
41+ require.Equal(t, uint64(len(payload)), r.bytes.Load())
42+}
43+
44+// controlSocketPath returns a socket path in a short private directory: the
45+// macOS t.TempDir path exceeds the 104-byte AF_UNIX limit.
46+func controlSocketPath(t *testing.T) string {
47+ t.Helper()
48+ directory, err := os.MkdirTemp("/tmp", "relay")
49+ require.NoError(t, err)
50+ t.Cleanup(func() { _ = os.RemoveAll(directory) })
51+ require.NoError(t, os.Chmod(directory, 0700))
52+ return filepath.Join(directory, "control.sock")
53+}
54+
55+func TestRelayShutdownClosesListeners(t *testing.T) {
56+ ctx, cancel := context.WithCancel(t.Context())
57+ r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})}
58+ path := controlSocketPath(t)
59+ done := make(chan error, 1)
60+ go func() { done <- r.serve("127.0.0.1:0", path) }()
61+ require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond)
62+ cancel()
63+ select {
64+ case err := <-done:
65+ require.NoError(t, err)
66+ case <-time.After(time.Second):
67+ t.Fatal("relay shutdown hung")
68+ }
69+ _, err := os.Stat(path)
70+ require.True(t, os.IsNotExist(err))
71+}
72+
73+func TestTCPRelayBlackoutHoldsBytes(t *testing.T) {
74+ ctx, cancel := context.WithCancel(t.Context())
75+ defer cancel()
76+ r := &relay{ctx: ctx, cancel: cancel}
77+ _, err := r.apply(command{Op: "blackout", TCP: true, Active: true})
78+ require.NoError(t, err)
79+ source, writer := net.Pipe()
80+ reader, destination := net.Pipe()
81+ defer source.Close()
82+ defer writer.Close()
83+ defer reader.Close()
84+ defer destination.Close()
85+ done := make(chan struct{})
86+ go func() { defer close(done); r.copy(destination, source) }()
87+ payload := []byte("held")
88+ wrote := make(chan error, 1)
89+ go func() { _, err := writer.Write(payload); wrote <- err }()
90+ select {
91+ case err := <-wrote:
92+ t.Fatalf("blackout read the source: %v", err)
93+ case <-time.After(50 * time.Millisecond):
94+ }
95+ require.Zero(t, r.bytes.Load())
96+ _, err = r.apply(command{Op: "blackout", TCP: true, Active: false})
97+ require.NoError(t, err)
98+ reader.SetReadDeadline(time.Now().Add(time.Second))
99+ got := make([]byte, len(payload))
100+ _, err = io.ReadFull(reader, got)
101+ require.NoError(t, err)
102+ require.Equal(t, payload, got)
103+ require.NoError(t, <-wrote)
104+ cancel()
105+ writer.Close()
106+ <-done
107+}
108+
109+// TestRelayUDPProxyInheritsActiveBlackout opens a UDP route while a blackout
110+// is active and proves it drops datagrams until the blackout ends.
111+func TestRelayUDPProxyInheritsActiveBlackout(t *testing.T) {
112+ ctx, cancel := context.WithCancel(t.Context())
113+ defer cancel()
114+ r := &relay{ctx: ctx, cancel: cancel, remote: "127.0.0.1"}
115+ defer r.close()
116+ upstream, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
117+ require.NoError(t, err)
118+ defer upstream.Close()
119+ _, err = r.apply(command{Op: "blackout", UDP: true, Active: true})
120+ require.NoError(t, err)
121+ port, err := r.apply(command{Op: "udp", Port: upstream.LocalAddr().(*net.UDPAddr).Port})
122+ require.NoError(t, err)
123+ client, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port.(int)})
124+ require.NoError(t, err)
125+ defer client.Close()
126+ _, err = client.Write([]byte("blocked"))
127+ require.NoError(t, err)
128+ require.Eventually(t, func() bool { return r.proxies[0].Stats().ToServer.BlackoutDrops == 1 }, time.Second, time.Millisecond)
129+ _, err = r.apply(command{Op: "blackout", UDP: true, Active: false})
130+ require.NoError(t, err)
131+ _, err = client.Write([]byte("open"))
132+ require.NoError(t, err)
133+ require.NoError(t, upstream.SetReadDeadline(time.Now().Add(time.Second)))
134+ data := make([]byte, 16)
135+ n, _, err := upstream.ReadFromUDP(data)
136+ require.NoError(t, err)
137+ require.Equal(t, "open", string(data[:n]))
138+}
139+
140+func TestRelayDisconnectClosesConnections(t *testing.T) {
141+ ctx, cancel := context.WithCancel(t.Context())
142+ defer cancel()
143+ r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})}
144+ local, peer := net.Pipe()
145+ defer peer.Close()
146+ r.connections[local] = struct{}{}
147+ _, err := r.apply(command{Op: "disconnect"})
148+ require.NoError(t, err)
149+ peer.SetReadDeadline(time.Now().Add(time.Second))
150+ _, err = peer.Read(make([]byte, 1))
151+ require.ErrorIs(t, err, io.EOF)
152+}
153+
154+func TestRelayControlRoundTrip(t *testing.T) {
155+ ctx, cancel := context.WithCancel(t.Context())
156+ defer cancel()
157+ r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})}
158+ path := controlSocketPath(t)
159+ done := make(chan error, 1)
160+ go func() { done <- r.serve("127.0.0.1:0", path) }()
161+ require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond)
162+ send := func(c command) map[string]any {
163+ conn, err := net.Dial("unix", path)
164+ require.NoError(t, err)
165+ defer conn.Close()
166+ require.NoError(t, json.NewEncoder(conn).Encode(c))
167+ line, err := bufio.NewReader(conn).ReadBytes('\n')
168+ require.NoError(t, err)
169+ var reply map[string]any
170+ require.NoError(t, json.Unmarshal(line, &reply))
171+ return reply
172+ }
173+ reply := send(command{ID: 7, Op: "blackout", TCP: true, Active: true})
174+ require.EqualValues(t, 7, reply["id"])
175+ require.NotContains(t, reply, "error")
176+ require.True(t, r.blackout.Load())
177+ reply = send(command{ID: 8, Op: "unknown"})
178+ require.Equal(t, "unknown command", reply["error"])
179+ reply = send(command{ID: 9, Op: "stop"})
180+ require.NotContains(t, reply, "error")
181+ select {
182+ case err := <-done:
183+ require.NoError(t, err)
184+ case <-time.After(time.Second):
185+ t.Fatal("stop command did not shut the relay down")
186+ }
187+}
188+
189+func TestRelayRejectsInvalidControl(t *testing.T) {
190+ ctx, cancel := context.WithCancel(t.Context())
191+ defer cancel()
192+ r := &relay{ctx: ctx, cancel: cancel}
193+ for _, c := range []command{{Op: "unknown"}, {Op: "udp", Port: 0}, {Op: "udp", Port: 65536}} {
194+ _, err := r.apply(c)
195+ require.Error(t, err)
196+ }
197+ _, err := r.apply(command{Op: "stop"})
198+ require.NoError(t, err)
199+ require.ErrorIs(t, ctx.Err(), context.Canceled)
200+}
A scripts/remote-resilience-harness/ssh.py +58 -0
@@ -0,0 +1,58 @@
1+#!/usr/bin/env python3
2+"""Fixture-only SSH wrapper: pin SSH and rewrite only QUIC readiness port."""
3+import json
4+import os
5+import socket
6+import subprocess
7+import sys
8+
9+
10+def control(request):
11+ with socket.socket(socket.AF_UNIX) as conn:
12+ conn.settimeout(2)
13+ conn.connect(os.environ.get("RESILIENCE_CONTROL", "/home/demo/relay/control.sock"))
14+ conn.sendall(json.dumps(request).encode() + b"\n")
15+ reply = json.loads(conn.makefile("rb").readline(4096))
16+ if "error" in reply:
17+ raise RuntimeError(reply["error"])
18+ return reply["result"]
19+
20+
21+def main():
22+ args = sys.argv[1:]
23+ command = ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote"] + args
24+ if not any("_broker-mux-quic-bootstrap" in arg for arg in args):
25+ os.execv(command[0], command)
26+ process = subprocess.Popen(command, stdin=sys.stdin.buffer, stdout=subprocess.PIPE)
27+ try:
28+ line = process.stdout.readline(4097)
29+ if len(line) > 4096 or not line.endswith(b"\n"):
30+ raise RuntimeError("invalid bounded bootstrap readiness")
31+ readiness = json.loads(line)
32+ port = readiness.get("port")
33+ if not isinstance(port, int) or not 0 < port <= 65535:
34+ raise RuntimeError("invalid bootstrap port")
35+ readiness["port"] = control({"id": 1, "op": "udp", "port": port})
36+ sys.stdout.buffer.write(json.dumps(readiness, separators=(",", ":")).encode() + b"\n")
37+ sys.stdout.buffer.flush()
38+ # No readiness or encrypted payload is recorded in artifacts.
39+ while chunk := process.stdout.read1(32768):
40+ sys.stdout.buffer.write(chunk)
41+ sys.stdout.buffer.flush()
42+ return process.wait()
43+ finally:
44+ if process.poll() is None:
45+ process.terminate()
46+ try:
47+ process.wait(timeout=2)
48+ except subprocess.TimeoutExpired:
49+ process.kill()
50+ process.wait()
51+
52+
53+if __name__ == "__main__":
54+ try:
55+ sys.exit(main())
56+ except (OSError, ValueError, RuntimeError):
57+ print("resilience SSH fixture failed", file=sys.stderr)
58+ sys.exit(1)
@@ -0,0 +1,58 @@
1+#!/usr/bin/env python3
2+"""Fixture-only SSH wrapper: pin SSH and rewrite only QUIC readiness port."""
3+import json
4+import os
5+import socket
6+import subprocess
7+import sys
8+
9+
10+def control(request):
11+ with socket.socket(socket.AF_UNIX) as conn:
12+ conn.settimeout(2)
13+ conn.connect(os.environ.get("RESILIENCE_CONTROL", "/home/demo/relay/control.sock"))
14+ conn.sendall(json.dumps(request).encode() + b"\n")
15+ reply = json.loads(conn.makefile("rb").readline(4096))
16+ if "error" in reply:
17+ raise RuntimeError(reply["error"])
18+ return reply["result"]
19+
20+
21+def main():
22+ args = sys.argv[1:]
23+ command = ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote"] + args
24+ if not any("_broker-mux-quic-bootstrap" in arg for arg in args):
25+ os.execv(command[0], command)
26+ process = subprocess.Popen(command, stdin=sys.stdin.buffer, stdout=subprocess.PIPE)
27+ try:
28+ line = process.stdout.readline(4097)
29+ if len(line) > 4096 or not line.endswith(b"\n"):
30+ raise RuntimeError("invalid bounded bootstrap readiness")
31+ readiness = json.loads(line)
32+ port = readiness.get("port")
33+ if not isinstance(port, int) or not 0 < port <= 65535:
34+ raise RuntimeError("invalid bootstrap port")
35+ readiness["port"] = control({"id": 1, "op": "udp", "port": port})
36+ sys.stdout.buffer.write(json.dumps(readiness, separators=(",", ":")).encode() + b"\n")
37+ sys.stdout.buffer.flush()
38+ # No readiness or encrypted payload is recorded in artifacts.
39+ while chunk := process.stdout.read1(32768):
40+ sys.stdout.buffer.write(chunk)
41+ sys.stdout.buffer.flush()
42+ return process.wait()
43+ finally:
44+ if process.poll() is None:
45+ process.terminate()
46+ try:
47+ process.wait(timeout=2)
48+ except subprocess.TimeoutExpired:
49+ process.kill()
50+ process.wait()
51+
52+
53+if __name__ == "__main__":
54+ try:
55+ sys.exit(main())
56+ except (OSError, ValueError, RuntimeError):
57+ print("resilience SSH fixture failed", file=sys.stderr)
58+ sys.exit(1)
A scripts/remote-resilience-harness/ssh_test.py +67 -0
@@ -0,0 +1,67 @@
1+"""Fixture readiness relay tests; no network or production credentials."""
2+import importlib.util
3+from pathlib import Path
4+import unittest
5+from unittest.mock import patch, MagicMock
6+
7+spec = importlib.util.spec_from_file_location("fixture_ssh", Path(__file__).with_name("ssh.py"))
8+wrapper = importlib.util.module_from_spec(spec)
9+spec.loader.exec_module(wrapper)
10+
11+
12+class ControlTests(unittest.TestCase):
13+ def test_control_failure_is_not_a_port(self):
14+ conn = MagicMock()
15+ conn.__enter__.return_value = conn
16+ conn.makefile.return_value.readline.return_value = b'{"id":1,"error":"listener limit"}\n'
17+ with patch.object(wrapper.socket, "socket", return_value=conn):
18+ with self.assertRaises(RuntimeError):
19+ wrapper.control({"id": 1, "op": "udp", "port": 1234})
20+
21+ def test_control_returns_fixture_port(self):
22+ conn = MagicMock()
23+ conn.__enter__.return_value = conn
24+ conn.makefile.return_value.readline.return_value = b'{"id":1,"result":4321}\n'
25+ with patch.object(wrapper.socket, "socket", return_value=conn):
26+ self.assertEqual(wrapper.control({"id": 1, "op": "udp", "port": 1234}), 4321)
27+
28+
29+class MainTests(unittest.TestCase):
30+ BOOTSTRAP = ["-T", "--", "remote", "'vev' '_broker-mux-quic-bootstrap'"]
31+
32+ def run_main(self, readiness, rewritten=4321):
33+ process = MagicMock()
34+ process.stdout.readline.return_value = readiness
35+ process.stdout.read1.return_value = b""
36+ process.wait.return_value = 0
37+ process.poll.return_value = 0
38+ out = MagicMock()
39+ with patch.object(wrapper.sys, "argv", ["ssh"] + self.BOOTSTRAP), \
40+ patch.object(wrapper.subprocess, "Popen", return_value=process), \
41+ patch.object(wrapper, "control", return_value=rewritten) as control, \
42+ patch.object(wrapper.sys, "stdout", MagicMock(buffer=out)):
43+ code = wrapper.main()
44+ return code, control, out
45+
46+ def test_rewrites_only_the_port(self):
47+ code, control, out = self.run_main(b'{"port":1234,"fingerprint":"f"}\n')
48+ self.assertEqual(code, 0)
49+ control.assert_called_once_with({"id": 1, "op": "udp", "port": 1234})
50+ self.assertEqual(out.write.call_args_list[0].args[0], b'{"port":4321,"fingerprint":"f"}\n')
51+
52+ def test_rejects_invalid_readiness(self):
53+ for line in (b'{"port":1}', b"x" * 4097 + b"\n", b'{"port":0}\n', b'{"port":65536}\n', b'{"port":"22"}\n'):
54+ with self.subTest(line=line[:20]):
55+ with self.assertRaises(RuntimeError):
56+ self.run_main(line)
57+
58+ def test_other_commands_exec_ssh_unchanged(self):
59+ with patch.object(wrapper.sys, "argv", ["ssh", "remote", "true"]), \
60+ patch.object(wrapper.os, "execv", side_effect=SystemExit(0)) as execv:
61+ with self.assertRaises(SystemExit):
62+ wrapper.main()
63+ execv.assert_called_once_with("/usr/bin/ssh-real", ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote", "remote", "true"])
64+
65+
66+if __name__ == "__main__":
67+ unittest.main()
@@ -0,0 +1,67 @@
1+"""Fixture readiness relay tests; no network or production credentials."""
2+import importlib.util
3+from pathlib import Path
4+import unittest
5+from unittest.mock import patch, MagicMock
6+
7+spec = importlib.util.spec_from_file_location("fixture_ssh", Path(__file__).with_name("ssh.py"))
8+wrapper = importlib.util.module_from_spec(spec)
9+spec.loader.exec_module(wrapper)
10+
11+
12+class ControlTests(unittest.TestCase):
13+ def test_control_failure_is_not_a_port(self):
14+ conn = MagicMock()
15+ conn.__enter__.return_value = conn
16+ conn.makefile.return_value.readline.return_value = b'{"id":1,"error":"listener limit"}\n'
17+ with patch.object(wrapper.socket, "socket", return_value=conn):
18+ with self.assertRaises(RuntimeError):
19+ wrapper.control({"id": 1, "op": "udp", "port": 1234})
20+
21+ def test_control_returns_fixture_port(self):
22+ conn = MagicMock()
23+ conn.__enter__.return_value = conn
24+ conn.makefile.return_value.readline.return_value = b'{"id":1,"result":4321}\n'
25+ with patch.object(wrapper.socket, "socket", return_value=conn):
26+ self.assertEqual(wrapper.control({"id": 1, "op": "udp", "port": 1234}), 4321)
27+
28+
29+class MainTests(unittest.TestCase):
30+ BOOTSTRAP = ["-T", "--", "remote", "'vev' '_broker-mux-quic-bootstrap'"]
31+
32+ def run_main(self, readiness, rewritten=4321):
33+ process = MagicMock()
34+ process.stdout.readline.return_value = readiness
35+ process.stdout.read1.return_value = b""
36+ process.wait.return_value = 0
37+ process.poll.return_value = 0
38+ out = MagicMock()
39+ with patch.object(wrapper.sys, "argv", ["ssh"] + self.BOOTSTRAP), \
40+ patch.object(wrapper.subprocess, "Popen", return_value=process), \
41+ patch.object(wrapper, "control", return_value=rewritten) as control, \
42+ patch.object(wrapper.sys, "stdout", MagicMock(buffer=out)):
43+ code = wrapper.main()
44+ return code, control, out
45+
46+ def test_rewrites_only_the_port(self):
47+ code, control, out = self.run_main(b'{"port":1234,"fingerprint":"f"}\n')
48+ self.assertEqual(code, 0)
49+ control.assert_called_once_with({"id": 1, "op": "udp", "port": 1234})
50+ self.assertEqual(out.write.call_args_list[0].args[0], b'{"port":4321,"fingerprint":"f"}\n')
51+
52+ def test_rejects_invalid_readiness(self):
53+ for line in (b'{"port":1}', b"x" * 4097 + b"\n", b'{"port":0}\n', b'{"port":65536}\n', b'{"port":"22"}\n'):
54+ with self.subTest(line=line[:20]):
55+ with self.assertRaises(RuntimeError):
56+ self.run_main(line)
57+
58+ def test_other_commands_exec_ssh_unchanged(self):
59+ with patch.object(wrapper.sys, "argv", ["ssh", "remote", "true"]), \
60+ patch.object(wrapper.os, "execv", side_effect=SystemExit(0)) as execv:
61+ with self.assertRaises(SystemExit):
62+ wrapper.main()
63+ execv.assert_called_once_with("/usr/bin/ssh-real", ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote", "remote", "true"])
64+
65+
66+if __name__ == "__main__":
67+ unittest.main()
A scripts/remote-resilience-harness/visual.py +369 -0
@@ -0,0 +1,369 @@
1+#!/usr/bin/env python3
2+"""Disposable dual-transport fixture and physical Wayland input acceptance."""
3+import importlib.util
4+import json
5+import os
6+from pathlib import Path
7+import shutil
8+import signal
9+import subprocess
10+import tempfile
11+import time
12+import uuid
13+
14+ROOT = Path(__file__).resolve().parents[2]
15+
16+
17+def run(*args, **kwargs):
18+ return subprocess.run(args, check=True, timeout=kwargs.pop("timeout", 60), **kwargs)
19+
20+
21+def docker(*args, **kwargs):
22+ return run("docker", *args, **kwargs)
23+
24+
25+def main():
26+ name = "vev-resilience-" + uuid.uuid4().hex[:10]
27+ artifacts = Path(tempfile.mkdtemp(prefix="vev-resilience-artifacts-"))
28+ processes = []
29+ containers = []
30+ network = False
31+ def interrupt(signum, frame):
32+ raise KeyboardInterrupt("fixture interrupted")
33+ signal.signal(signal.SIGTERM, interrupt)
34+ with tempfile.TemporaryDirectory(prefix="vev-resilience-private-") as directory:
35+ private = Path(directory)
36+ runtime = private / "runtime"
37+ runtime.mkdir(mode=0o700)
38+ try:
39+ for tool in ("docker", "foot", "ssh-keygen", "go"):
40+ if shutil.which(tool) is None:
41+ raise RuntimeError("missing prerequisite: " + tool)
42+ run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT, timeout=600)
43+ for helper in ("relay", "pixels"):
44+ run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT, timeout=600)
45+ nefer = os.environ.get("NEFERWL_BIN")
46+ if not nefer:
47+ nefer = str(private / "neferwl")
48+ common = run("git", "rev-parse", "--path-format=absolute", "--git-common-dir", cwd=ROOT, capture_output=True, text=True).stdout.strip()
49+ source = Path(os.environ.get("NEFERWL_SOURCE", str(Path(common).parent.parent / "neferwl")))
50+ run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}, timeout=600)
51+ docker("build", "-f", str(ROOT / "scripts/demo/Dockerfile"), "-t", name, str(ROOT), timeout=600)
52+ docker("network", "create", name)
53+ network = True
54+ for role in ("client", "remote"):
55+ container = name + "-" + role
56+ docker("run", "-d", "--name", container, "--network", name, "--network-alias", "upstream" if role == "remote" else "remote", "--entrypoint", "sleep", name, "infinity")
57+ containers.append(container)
58+ client, remote = containers
59+ for key in ("client", "host"):
60+ run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "fixture", "-f", str(private / key))
61+ docker("cp", str(private / "host"), remote + ":/etc/ssh/ssh_host_ed25519_key")
62+ docker("cp", str(private / "host.pub"), remote + ":/etc/ssh/ssh_host_ed25519_key.pub")
63+ docker("cp", str(private / "client.pub"), remote + ":/home/demo/.ssh/authorized_keys")
64+ docker("exec", "--user", "root", remote, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*; chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; mkdir -p /run/sshd; /usr/sbin/sshd")
65+ docker("exec", "--user", "root", client, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*")
66+ docker("cp", str(private / "client"), client + ":/home/demo/.ssh/id_ed25519")
67+ public = (private / "host.pub").read_text().split()
68+ (private / "known_hosts").write_text("remote " + " ".join(public[:2]) + "\n")
69+ (private / "config").write_text("Host remote\n HostName remote\n Port 2222\n User demo\n IdentityFile ~/.ssh/id_ed25519\n IdentitiesOnly yes\n StrictHostKeyChecking yes\n HostKeyAlias remote\n UserKnownHostsFile ~/.ssh/known_hosts\n")
70+ for file in ("known_hosts", "config"):
71+ docker("cp", str(private / file), client + ":/home/demo/.ssh/" + file)
72+ docker("cp", str(ROOT / "build/resilience-relay"), client + ":/tmp/relay")
73+ docker("cp", str(ROOT / "scripts/remote-resilience-harness/ssh.py"), client + ":/tmp/ssh")
74+ docker("exec", "--user", "root", client, "sh", "-c", "chown demo:demo /home/demo/.ssh/*; chmod 600 /home/demo/.ssh/*; chmod 755 /tmp/ssh; install -d -m 700 -o demo -g demo /home/demo/relay; mkdir -p /tmp/bin; ln -s /tmp/ssh /tmp/bin/ssh; mv /usr/bin/ssh /usr/bin/ssh-real; ln -s /tmp/ssh /usr/bin/ssh")
75+ transport = os.environ.get("VEV_RESILIENCE_TRANSPORT", "quic")
76+ if transport not in ("quic", "ssh"):
77+ raise ValueError("VEV_RESILIENCE_TRANSPORT must be quic or ssh")
78+ relay_args = ["-degraded"] if os.environ.get("VEV_RESILIENCE_DEGRADED") == "1" else []
79+ relay = subprocess.Popen(["docker", "exec", client, "/tmp/relay", "-remote", "upstream", "-control", "/home/demo/relay/control.sock", *relay_args])
80+ processes.append(relay)
81+ control_id = 0
82+ def control(op, **fields):
83+ nonlocal control_id
84+ control_id += 1
85+ request = {"id": control_id, "op": op, **fields}
86+ code = "import socket,json; s=socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect('/home/demo/relay/control.sock'); s.sendall(" + repr(json.dumps(request).encode() + b"\n") + "); print(s.makefile().readline())"
87+ result = docker("exec", client, "python3", "-c", code, capture_output=True, text=True)
88+ reply = json.loads(result.stdout)
89+ assert reply["id"] == control_id and "error" not in reply, reply
90+ return reply["result"]
91+ # Fixture image must explicitly supply Python rather than using host tools.
92+ docker("exec", "--user", "root", client, "sh", "-c", "command -v python3", capture_output=True)
93+ deadline = time.monotonic() + 5
94+ while True:
95+ ready = subprocess.run(["docker", "exec", client, "test", "-S", "/home/demo/relay/control.sock"], capture_output=True, timeout=3)
96+ if ready.returncode == 0:
97+ break
98+ if time.monotonic() > deadline:
99+ raise RuntimeError("relay control socket did not become ready")
100+ time.sleep(0.05)
101+ probe = subprocess.run(["docker", "exec", client, "/usr/bin/ssh", "-v", "remote", "true"], capture_output=True, text=True, timeout=10)
102+ if probe.returncode:
103+ (artifacts / "ssh-probe.log").write_text(probe.stderr)
104+ raise RuntimeError("fixture SSH authentication failed")
105+ env_args = ["-e", "PATH=/tmp/bin:/usr/local/bin:/usr/bin", "-e", "VEV_LOG=debug", "-e", "SSH_AUTH_SOCK="]
106+ spec = importlib.util.spec_from_file_location("acceptance", ROOT / "scripts/remote-picker-harness/acceptance.py")
107+ acceptance = importlib.util.module_from_spec(spec)
108+ spec.loader.exec_module(acceptance)
109+ fixture = acceptance.Driver(remote, ["--session", "resilience"])
110+ fixture.close()
111+ docker("exec", *env_args, client, "vev", "host", "add", "--transport", transport, "remote")
112+ # Wait for the broker publication before resolving direct attach.
113+ deadline = time.monotonic() + 30
114+ while True:
115+ inventory = docker("exec", *env_args, client, "vev", "ls", "remote", capture_output=True, text=True)
116+ if "resilience" in inventory.stdout:
117+ break
118+ (artifacts / "inventory.txt").write_text(inventory.stdout + inventory.stderr)
119+ if time.monotonic() > deadline:
120+ raise RuntimeError("remote fixture was not published")
121+ time.sleep(0.2)
122+ shots = artifacts / "shots"
123+ shots.mkdir()
124+ docker_endpoint = run("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}", capture_output=True, text=True).stdout.strip()
125+ env = {**os.environ, "DOCKER_HOST": docker_endpoint, "XDG_RUNTIME_DIR": str(runtime), "XDG_CONFIG_HOME": str(private / "config-home"), "XDG_STATE_HOME": str(private / "state"), "HOME": str(private)}
126+ env["TERMINAL"] = "foot --config=/dev/null --override=cursor.blink=no --log-level=info --hold -- docker --host=" + docker_endpoint + " exec -it " + " ".join(env_args) + " " + client + " vev attach demo@remote:resilience --ui-observe --ui-socket /home/demo/visual/ui.sock"
127+ log = (artifacts / "compositor.log").open("w")
128+ compositor = subprocess.Popen([nefer, "--backend=headless", "--size=1280x720", "--input=-", "--screenshot", str(shots), "--timeout", "180s"], stdin=subprocess.PIPE, stdout=log, stderr=log, text=True, env=env)
129+ processes.append(compositor)
130+ deadline = time.monotonic() + 15
131+ while not list(runtime.glob("wayland-*")):
132+ if compositor.poll() is not None or time.monotonic() > deadline:
133+ raise RuntimeError("headless compositor did not create socket")
134+ time.sleep(0.05)
135+ sockets = [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
136+ env["WAYLAND_DISPLAY"] = str(sockets[0])
137+ env.pop("WAYLAND_SOCKET", None)
138+ env.pop("DISPLAY", None)
139+ def capture():
140+ bridge = subprocess.Popen(["docker", "exec", "-i", client, "vev", "--ui-driver", "--socket", "/home/demo/visual/ui.sock"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
141+ try:
142+ import select
143+ if not select.select([bridge.stdout], [], [], 1)[0]:
144+ raise RuntimeError("observation discovery timed out")
145+ discovery = json.loads(bridge.stdout.readline())
146+ request = {"version": 1, "id": 1, "op": "capture", "attachment": discovery["result"]["attachment"]}
147+ output, error = bridge.communicate(json.dumps(request) + "\n", timeout=1)
148+ envelopes = [json.loads(line) for line in output.splitlines()]
149+ if not envelopes or "error" in envelopes[-1]:
150+ raise RuntimeError("observation capture failed: " + str(envelopes) + error)
151+ return envelopes[-1]["result"]
152+ finally:
153+ if bridge.poll() is None:
154+ bridge.kill()
155+ bridge.communicate()
156+ compositor.stdin.write("sleep 1s\nkey Return\n")
157+ compositor.stdin.flush()
158+ deadline = time.monotonic() + 30
159+ while True:
160+ try:
161+ state = capture()
162+ if state.get("context", {}).get("status") == "attached" and state.get("context", {}).get("session", {}).get("session_name") == "resilience":
163+ break
164+ except (subprocess.SubprocessError, ValueError, RuntimeError) as error:
165+ (artifacts / "capture-error.txt").write_text(str(error) + "\n" + str(getattr(error, "stderr", "")))
166+ if time.monotonic() > deadline:
167+ diagnosis = subprocess.run(["docker", "exec", client, "sh", "-c", "ps -ef; ls -l /home/demo/visual/ui.sock"], capture_output=True, text=True, timeout=5)
168+ (artifacts / "client-processes.txt").write_text(diagnosis.stdout + diagnosis.stderr)
169+ (artifacts / "relay-stats.json").write_text(json.dumps(control("stats")))
170+ (artifacts / "last-capture.json").write_text(json.dumps(locals().get("state")))
171+ raise RuntimeError("visual client did not attach")
172+ time.sleep(0.1)
173+ (artifacts / "initial.json").write_text(json.dumps(state))
174+ def key(command):
175+ compositor.stdin.write(command + "\n")
176+ compositor.stdin.flush()
177+ def responsive(label):
178+ latest = shots / "latest.png"
179+ before = artifacts / (label + "-before.png")
180+ shutil.copyfile(latest, before)
181+ start = time.monotonic()
182+ stamp = latest.stat().st_mtime_ns
183+ key("key Alt+space")
184+ key("type SSP")
185+ key("key Return")
186+ while time.monotonic() - start < 2:
187+ if latest.stat().st_mtime_ns != stamp:
188+ after = artifacts / (label + "-after.png")
189+ shutil.copyfile(latest, after)
190+ changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True)
191+ observed = capture()
192+ (artifacts / (label + "-observed.json")).write_text(json.dumps(observed))
193+ if changed.returncode == 0 and "Sessions" in observed.get("text", "") and "Esc close" in observed.get("text", ""):
194+ key("key Escape")
195+ return {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout), "context": observed["context"]}
196+ time.sleep(0.05)
197+ raise RuntimeError(label + ": local picker failed 2s response gate")
198+ evidence = {"healthy": responsive("healthy")}
199+ time.sleep(0.5)
200+ key("key Alt+space")
201+ key("type SSP")
202+ key("key Return")
203+ deadline = time.monotonic() + 5
204+ while "Sessions" not in capture().get("text", ""):
205+ if time.monotonic() > deadline:
206+ raise RuntimeError("picker did not open before blackout")
207+ time.sleep(0.1)
208+ before = artifacts / "blackout-before.png"
209+ shutil.copyfile(shots / "latest.png", before)
210+ assert "Sessions" in capture().get("text", ""), "picker was not open before blackout"
211+ impairment = os.environ.get("VEV_RESILIENCE_LINK", "both")
212+ if impairment not in ("both", "udp", "tcp"):
213+ raise ValueError("VEV_RESILIENCE_LINK must be both, udp or tcp")
214+ control("blackout", udp=impairment != "tcp", tcp=impairment != "udp", active=True)
215+ start = time.monotonic()
216+ key("type /resilience")
217+ deadline = start + 2
218+ while time.monotonic() < deadline:
219+ after = artifacts / "blackout-after.png"
220+ shutil.copyfile(shots / "latest.png", after)
221+ changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True)
222+ observed = capture()
223+ if changed.returncode == 0 and "1 matches" in observed.get("text", ""):
224+ evidence["blackout"] = {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout)}
225+ break
226+ time.sleep(0.05)
227+ else:
228+ raise RuntimeError("open picker search froze during blackout")
229+ time.sleep(3)
230+ control("blackout", udp=True, tcp=True, active=False)
231+ key("key Escape")
232+ time.sleep(0.2)
233+ key("key Escape")
234+ deadline = time.monotonic() + 5
235+ while "Sessions" in capture().get("text", ""):
236+ if time.monotonic() > deadline:
237+ raise RuntimeError("picker did not close after restoration")
238+ time.sleep(0.1)
239+ key("key Ctrl+c")
240+ time.sleep(1)
241+ initial_session = state["context"]["session"]
242+ key("type printf 'RESILIENCE_RECOVERED\\n'")
243+ key("key Return")
244+ deadline = time.monotonic() + 10
245+ while time.monotonic() < deadline:
246+ recovered = capture()
247+ if "\nRESILIENCE_RECOVERED" in recovered.get("text", "") and recovered["context"]["session"] == initial_session:
248+ evidence["recovered"] = recovered["context"]
249+ break
250+ time.sleep(0.1)
251+ else:
252+ raise RuntimeError("session did not recover after blackout")
253+ # Negative control: compositor can still render while client is
254+ # stopped. No client observation is allowed to satisfy the gate.
255+ pid_result = docker("exec", client, "pgrep", "-f", "^vev attach", capture_output=True, text=True)
256+ pid = pid_result.stdout.strip().splitlines()[0]
257+ docker("exec", client, "kill", "-STOP", pid)
258+ frozen = artifacts / "frozen.png"
259+ shutil.copyfile(shots / "latest.png", frozen)
260+ try:
261+ frozen_at = (shots / "latest.png").stat().st_mtime_ns
262+ key("key Alt+space")
263+ # The compositor must keep capturing, or an unchanged frame
264+ # proves nothing about the stopped client.
265+ deadline = time.monotonic() + 3
266+ while (shots / "latest.png").stat().st_mtime_ns == frozen_at:
267+ if time.monotonic() > deadline:
268+ raise RuntimeError("compositor stopped capturing during the negative control")
269+ time.sleep(0.05)
270+ time.sleep(0.3)
271+ check = subprocess.run([str(ROOT / "build/resilience-pixels"), str(frozen), str(shots / "latest.png")], capture_output=True, text=True)
272+ assert check.returncode == 1, "visual gate accepted a stopped client"
273+ evidence["negative_control"] = "stopped client rejected"
274+ finally:
275+ docker("exec", client, "kill", "-CONT", pid)
276+ if os.environ.get("VEV_RESILIENCE_LONG") == "1":
277+ # The negative control's Alt+space was buffered while the
278+ # client was stopped and opens the palette once it resumes.
279+ # Close it on observed state only: a blind extra Escape would
280+ # reach the shell as a lone ESC, which readline keeps as a
281+ # Meta prefix and uses to eat the first replayed key.
282+ deadline = time.monotonic() + 5
283+ while "Commands" not in capture().get("text", ""):
284+ if time.monotonic() > deadline:
285+ raise RuntimeError("palette did not open after the stopped client resumed")
286+ time.sleep(0.1)
287+ key("key Escape")
288+ deadline = time.monotonic() + 5
289+ while "Commands" in capture().get("text", ""):
290+ if time.monotonic() > deadline:
291+ raise RuntimeError("palette did not close before the long outage")
292+ time.sleep(0.1)
293+ time.sleep(0.5)
294+ control("blackout", udp=True, tcp=True, active=True)
295+ if transport == "ssh":
296+ control("disconnect")
297+ deadline = time.monotonic() + 90
298+ while time.monotonic() < deadline:
299+ observed = capture()
300+ if observed.get("context", {}).get("status") == "connecting":
301+ break
302+ time.sleep(0.2)
303+ else:
304+ raise RuntimeError("long outage did not enter resume")
305+ if os.environ.get("VEV_RESILIENCE_RESUME") == "1":
306+ key("type echo HELD_ONE; echo HELD_TWO")
307+ key("key Return")
308+ # Keep the route down until the compositor has delivered
309+ # the complete physical input script to the resume owner.
310+ time.sleep(2)
311+ control("blackout", udp=True, tcp=True, active=False)
312+ deadline = time.monotonic() + 30
313+ while time.monotonic() < deadline:
314+ resumed = capture()
315+ text = resumed.get("text", "")
316+ if "\nHELD_ONE" in text and "\nHELD_TWO" in text and resumed.get("context", {}).get("session") == initial_session:
317+ assert text.index("\nHELD_ONE") < text.index("\nHELD_TWO"), "held input reordered"
318+ # The echoed command line proves the first held
319+ # byte was not swallowed before the shell saw it.
320+ assert "$ echo HELD_ONE; echo HELD_TWO" in text, "held input replayed incompletely"
321+ evidence["resumed"] = resumed["context"]
322+ break
323+ time.sleep(0.1)
324+ else:
325+ raise RuntimeError("long outage did not replay held input")
326+ else:
327+ start = time.monotonic()
328+ cancel_key = os.environ.get("VEV_RESILIENCE_CANCEL", "Ctrl+c")
329+ if cancel_key not in ("Ctrl+c", "Escape"):
330+ raise ValueError("cancel must be Ctrl+c or Escape")
331+ key("key " + cancel_key)
332+ deadline = start + 2
333+ while time.monotonic() < deadline:
334+ observed = capture()
335+ if observed.get("context", {}).get("status") == "picker":
336+ evidence["resume_cancel"] = {"elapsed": time.monotonic()-start}
337+ break
338+ time.sleep(0.05)
339+ else:
340+ raise RuntimeError(cancel_key + " did not cancel resume")
341+ control("blackout", udp=True, tcp=True, active=False)
342+ evidence["stats"] = control("stats")
343+ if transport == "quic":
344+ assert evidence["stats"]["udp"], "QUIC bypassed impairment relay"
345+ assert evidence["stats"]["tcp_bytes"] > 0, "SSH bypassed impairment relay"
346+ (artifacts / "evidence.json").write_text(json.dumps(evidence, indent=2))
347+ control("stop")
348+ finally:
349+ for process in reversed(processes):
350+ if process.poll() is None:
351+ process.terminate()
352+ try:
353+ process.wait(timeout=5)
354+ except subprocess.TimeoutExpired:
355+ process.kill()
356+ process.wait()
357+ for container in containers:
358+ role = container.rsplit("-", 1)[-1]
359+ for filename in ("vev-client.log", "vev-daemon.log", "broker/log/vev-broker.log"):
360+ subprocess.run(["docker", "cp", container + ":/home/demo/.local/state/vev/" + filename, str(artifacts / (role + "-" + filename.replace("/", "-")))], timeout=30, capture_output=True)
361+ subprocess.run(["docker", "rm", "-f", container], timeout=30, capture_output=True)
362+ if network:
363+ subprocess.run(["docker", "network", "rm", name], timeout=30, capture_output=True)
364+ subprocess.run(["docker", "image", "rm", name], timeout=30, capture_output=True)
365+ print("artifacts:", artifacts)
366+
367+
368+if __name__ == "__main__":
369+ main()
@@ -0,0 +1,369 @@
1+#!/usr/bin/env python3
2+"""Disposable dual-transport fixture and physical Wayland input acceptance."""
3+import importlib.util
4+import json
5+import os
6+from pathlib import Path
7+import shutil
8+import signal
9+import subprocess
10+import tempfile
11+import time
12+import uuid
13+
14+ROOT = Path(__file__).resolve().parents[2]
15+
16+
17+def run(*args, **kwargs):
18+ return subprocess.run(args, check=True, timeout=kwargs.pop("timeout", 60), **kwargs)
19+
20+
21+def docker(*args, **kwargs):
22+ return run("docker", *args, **kwargs)
23+
24+
25+def main():
26+ name = "vev-resilience-" + uuid.uuid4().hex[:10]
27+ artifacts = Path(tempfile.mkdtemp(prefix="vev-resilience-artifacts-"))
28+ processes = []
29+ containers = []
30+ network = False
31+ def interrupt(signum, frame):
32+ raise KeyboardInterrupt("fixture interrupted")
33+ signal.signal(signal.SIGTERM, interrupt)
34+ with tempfile.TemporaryDirectory(prefix="vev-resilience-private-") as directory:
35+ private = Path(directory)
36+ runtime = private / "runtime"
37+ runtime.mkdir(mode=0o700)
38+ try:
39+ for tool in ("docker", "foot", "ssh-keygen", "go"):
40+ if shutil.which(tool) is None:
41+ raise RuntimeError("missing prerequisite: " + tool)
42+ run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT, timeout=600)
43+ for helper in ("relay", "pixels"):
44+ run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT, timeout=600)
45+ nefer = os.environ.get("NEFERWL_BIN")
46+ if not nefer:
47+ nefer = str(private / "neferwl")
48+ common = run("git", "rev-parse", "--path-format=absolute", "--git-common-dir", cwd=ROOT, capture_output=True, text=True).stdout.strip()
49+ source = Path(os.environ.get("NEFERWL_SOURCE", str(Path(common).parent.parent / "neferwl")))
50+ run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}, timeout=600)
51+ docker("build", "-f", str(ROOT / "scripts/demo/Dockerfile"), "-t", name, str(ROOT), timeout=600)
52+ docker("network", "create", name)
53+ network = True
54+ for role in ("client", "remote"):
55+ container = name + "-" + role
56+ docker("run", "-d", "--name", container, "--network", name, "--network-alias", "upstream" if role == "remote" else "remote", "--entrypoint", "sleep", name, "infinity")
57+ containers.append(container)
58+ client, remote = containers
59+ for key in ("client", "host"):
60+ run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "fixture", "-f", str(private / key))
61+ docker("cp", str(private / "host"), remote + ":/etc/ssh/ssh_host_ed25519_key")
62+ docker("cp", str(private / "host.pub"), remote + ":/etc/ssh/ssh_host_ed25519_key.pub")
63+ docker("cp", str(private / "client.pub"), remote + ":/home/demo/.ssh/authorized_keys")
64+ docker("exec", "--user", "root", remote, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*; chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; mkdir -p /run/sshd; /usr/sbin/sshd")
65+ docker("exec", "--user", "root", client, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*")
66+ docker("cp", str(private / "client"), client + ":/home/demo/.ssh/id_ed25519")
67+ public = (private / "host.pub").read_text().split()
68+ (private / "known_hosts").write_text("remote " + " ".join(public[:2]) + "\n")
69+ (private / "config").write_text("Host remote\n HostName remote\n Port 2222\n User demo\n IdentityFile ~/.ssh/id_ed25519\n IdentitiesOnly yes\n StrictHostKeyChecking yes\n HostKeyAlias remote\n UserKnownHostsFile ~/.ssh/known_hosts\n")
70+ for file in ("known_hosts", "config"):
71+ docker("cp", str(private / file), client + ":/home/demo/.ssh/" + file)
72+ docker("cp", str(ROOT / "build/resilience-relay"), client + ":/tmp/relay")
73+ docker("cp", str(ROOT / "scripts/remote-resilience-harness/ssh.py"), client + ":/tmp/ssh")
74+ docker("exec", "--user", "root", client, "sh", "-c", "chown demo:demo /home/demo/.ssh/*; chmod 600 /home/demo/.ssh/*; chmod 755 /tmp/ssh; install -d -m 700 -o demo -g demo /home/demo/relay; mkdir -p /tmp/bin; ln -s /tmp/ssh /tmp/bin/ssh; mv /usr/bin/ssh /usr/bin/ssh-real; ln -s /tmp/ssh /usr/bin/ssh")
75+ transport = os.environ.get("VEV_RESILIENCE_TRANSPORT", "quic")
76+ if transport not in ("quic", "ssh"):
77+ raise ValueError("VEV_RESILIENCE_TRANSPORT must be quic or ssh")
78+ relay_args = ["-degraded"] if os.environ.get("VEV_RESILIENCE_DEGRADED") == "1" else []
79+ relay = subprocess.Popen(["docker", "exec", client, "/tmp/relay", "-remote", "upstream", "-control", "/home/demo/relay/control.sock", *relay_args])
80+ processes.append(relay)
81+ control_id = 0
82+ def control(op, **fields):
83+ nonlocal control_id
84+ control_id += 1
85+ request = {"id": control_id, "op": op, **fields}
86+ code = "import socket,json; s=socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect('/home/demo/relay/control.sock'); s.sendall(" + repr(json.dumps(request).encode() + b"\n") + "); print(s.makefile().readline())"
87+ result = docker("exec", client, "python3", "-c", code, capture_output=True, text=True)
88+ reply = json.loads(result.stdout)
89+ assert reply["id"] == control_id and "error" not in reply, reply
90+ return reply["result"]
91+ # Fixture image must explicitly supply Python rather than using host tools.
92+ docker("exec", "--user", "root", client, "sh", "-c", "command -v python3", capture_output=True)
93+ deadline = time.monotonic() + 5
94+ while True:
95+ ready = subprocess.run(["docker", "exec", client, "test", "-S", "/home/demo/relay/control.sock"], capture_output=True, timeout=3)
96+ if ready.returncode == 0:
97+ break
98+ if time.monotonic() > deadline:
99+ raise RuntimeError("relay control socket did not become ready")
100+ time.sleep(0.05)
101+ probe = subprocess.run(["docker", "exec", client, "/usr/bin/ssh", "-v", "remote", "true"], capture_output=True, text=True, timeout=10)
102+ if probe.returncode:
103+ (artifacts / "ssh-probe.log").write_text(probe.stderr)
104+ raise RuntimeError("fixture SSH authentication failed")
105+ env_args = ["-e", "PATH=/tmp/bin:/usr/local/bin:/usr/bin", "-e", "VEV_LOG=debug", "-e", "SSH_AUTH_SOCK="]
106+ spec = importlib.util.spec_from_file_location("acceptance", ROOT / "scripts/remote-picker-harness/acceptance.py")
107+ acceptance = importlib.util.module_from_spec(spec)
108+ spec.loader.exec_module(acceptance)
109+ fixture = acceptance.Driver(remote, ["--session", "resilience"])
110+ fixture.close()
111+ docker("exec", *env_args, client, "vev", "host", "add", "--transport", transport, "remote")
112+ # Wait for the broker publication before resolving direct attach.
113+ deadline = time.monotonic() + 30
114+ while True:
115+ inventory = docker("exec", *env_args, client, "vev", "ls", "remote", capture_output=True, text=True)
116+ if "resilience" in inventory.stdout:
117+ break
118+ (artifacts / "inventory.txt").write_text(inventory.stdout + inventory.stderr)
119+ if time.monotonic() > deadline:
120+ raise RuntimeError("remote fixture was not published")
121+ time.sleep(0.2)
122+ shots = artifacts / "shots"
123+ shots.mkdir()
124+ docker_endpoint = run("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}", capture_output=True, text=True).stdout.strip()
125+ env = {**os.environ, "DOCKER_HOST": docker_endpoint, "XDG_RUNTIME_DIR": str(runtime), "XDG_CONFIG_HOME": str(private / "config-home"), "XDG_STATE_HOME": str(private / "state"), "HOME": str(private)}
126+ env["TERMINAL"] = "foot --config=/dev/null --override=cursor.blink=no --log-level=info --hold -- docker --host=" + docker_endpoint + " exec -it " + " ".join(env_args) + " " + client + " vev attach demo@remote:resilience --ui-observe --ui-socket /home/demo/visual/ui.sock"
127+ log = (artifacts / "compositor.log").open("w")
128+ compositor = subprocess.Popen([nefer, "--backend=headless", "--size=1280x720", "--input=-", "--screenshot", str(shots), "--timeout", "180s"], stdin=subprocess.PIPE, stdout=log, stderr=log, text=True, env=env)
129+ processes.append(compositor)
130+ deadline = time.monotonic() + 15
131+ while not list(runtime.glob("wayland-*")):
132+ if compositor.poll() is not None or time.monotonic() > deadline:
133+ raise RuntimeError("headless compositor did not create socket")
134+ time.sleep(0.05)
135+ sockets = [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
136+ env["WAYLAND_DISPLAY"] = str(sockets[0])
137+ env.pop("WAYLAND_SOCKET", None)
138+ env.pop("DISPLAY", None)
139+ def capture():
140+ bridge = subprocess.Popen(["docker", "exec", "-i", client, "vev", "--ui-driver", "--socket", "/home/demo/visual/ui.sock"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
141+ try:
142+ import select
143+ if not select.select([bridge.stdout], [], [], 1)[0]:
144+ raise RuntimeError("observation discovery timed out")
145+ discovery = json.loads(bridge.stdout.readline())
146+ request = {"version": 1, "id": 1, "op": "capture", "attachment": discovery["result"]["attachment"]}
147+ output, error = bridge.communicate(json.dumps(request) + "\n", timeout=1)
148+ envelopes = [json.loads(line) for line in output.splitlines()]
149+ if not envelopes or "error" in envelopes[-1]:
150+ raise RuntimeError("observation capture failed: " + str(envelopes) + error)
151+ return envelopes[-1]["result"]
152+ finally:
153+ if bridge.poll() is None:
154+ bridge.kill()
155+ bridge.communicate()
156+ compositor.stdin.write("sleep 1s\nkey Return\n")
157+ compositor.stdin.flush()
158+ deadline = time.monotonic() + 30
159+ while True:
160+ try:
161+ state = capture()
162+ if state.get("context", {}).get("status") == "attached" and state.get("context", {}).get("session", {}).get("session_name") == "resilience":
163+ break
164+ except (subprocess.SubprocessError, ValueError, RuntimeError) as error:
165+ (artifacts / "capture-error.txt").write_text(str(error) + "\n" + str(getattr(error, "stderr", "")))
166+ if time.monotonic() > deadline:
167+ diagnosis = subprocess.run(["docker", "exec", client, "sh", "-c", "ps -ef; ls -l /home/demo/visual/ui.sock"], capture_output=True, text=True, timeout=5)
168+ (artifacts / "client-processes.txt").write_text(diagnosis.stdout + diagnosis.stderr)
169+ (artifacts / "relay-stats.json").write_text(json.dumps(control("stats")))
170+ (artifacts / "last-capture.json").write_text(json.dumps(locals().get("state")))
171+ raise RuntimeError("visual client did not attach")
172+ time.sleep(0.1)
173+ (artifacts / "initial.json").write_text(json.dumps(state))
174+ def key(command):
175+ compositor.stdin.write(command + "\n")
176+ compositor.stdin.flush()
177+ def responsive(label):
178+ latest = shots / "latest.png"
179+ before = artifacts / (label + "-before.png")
180+ shutil.copyfile(latest, before)
181+ start = time.monotonic()
182+ stamp = latest.stat().st_mtime_ns
183+ key("key Alt+space")
184+ key("type SSP")
185+ key("key Return")
186+ while time.monotonic() - start < 2:
187+ if latest.stat().st_mtime_ns != stamp:
188+ after = artifacts / (label + "-after.png")
189+ shutil.copyfile(latest, after)
190+ changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True)
191+ observed = capture()
192+ (artifacts / (label + "-observed.json")).write_text(json.dumps(observed))
193+ if changed.returncode == 0 and "Sessions" in observed.get("text", "") and "Esc close" in observed.get("text", ""):
194+ key("key Escape")
195+ return {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout), "context": observed["context"]}
196+ time.sleep(0.05)
197+ raise RuntimeError(label + ": local picker failed 2s response gate")
198+ evidence = {"healthy": responsive("healthy")}
199+ time.sleep(0.5)
200+ key("key Alt+space")
201+ key("type SSP")
202+ key("key Return")
203+ deadline = time.monotonic() + 5
204+ while "Sessions" not in capture().get("text", ""):
205+ if time.monotonic() > deadline:
206+ raise RuntimeError("picker did not open before blackout")
207+ time.sleep(0.1)
208+ before = artifacts / "blackout-before.png"
209+ shutil.copyfile(shots / "latest.png", before)
210+ assert "Sessions" in capture().get("text", ""), "picker was not open before blackout"
211+ impairment = os.environ.get("VEV_RESILIENCE_LINK", "both")
212+ if impairment not in ("both", "udp", "tcp"):
213+ raise ValueError("VEV_RESILIENCE_LINK must be both, udp or tcp")
214+ control("blackout", udp=impairment != "tcp", tcp=impairment != "udp", active=True)
215+ start = time.monotonic()
216+ key("type /resilience")
217+ deadline = start + 2
218+ while time.monotonic() < deadline:
219+ after = artifacts / "blackout-after.png"
220+ shutil.copyfile(shots / "latest.png", after)
221+ changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True)
222+ observed = capture()
223+ if changed.returncode == 0 and "1 matches" in observed.get("text", ""):
224+ evidence["blackout"] = {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout)}
225+ break
226+ time.sleep(0.05)
227+ else:
228+ raise RuntimeError("open picker search froze during blackout")
229+ time.sleep(3)
230+ control("blackout", udp=True, tcp=True, active=False)
231+ key("key Escape")
232+ time.sleep(0.2)
233+ key("key Escape")
234+ deadline = time.monotonic() + 5
235+ while "Sessions" in capture().get("text", ""):
236+ if time.monotonic() > deadline:
237+ raise RuntimeError("picker did not close after restoration")
238+ time.sleep(0.1)
239+ key("key Ctrl+c")
240+ time.sleep(1)
241+ initial_session = state["context"]["session"]
242+ key("type printf 'RESILIENCE_RECOVERED\\n'")
243+ key("key Return")
244+ deadline = time.monotonic() + 10
245+ while time.monotonic() < deadline:
246+ recovered = capture()
247+ if "\nRESILIENCE_RECOVERED" in recovered.get("text", "") and recovered["context"]["session"] == initial_session:
248+ evidence["recovered"] = recovered["context"]
249+ break
250+ time.sleep(0.1)
251+ else:
252+ raise RuntimeError("session did not recover after blackout")
253+ # Negative control: compositor can still render while client is
254+ # stopped. No client observation is allowed to satisfy the gate.
255+ pid_result = docker("exec", client, "pgrep", "-f", "^vev attach", capture_output=True, text=True)
256+ pid = pid_result.stdout.strip().splitlines()[0]
257+ docker("exec", client, "kill", "-STOP", pid)
258+ frozen = artifacts / "frozen.png"
259+ shutil.copyfile(shots / "latest.png", frozen)
260+ try:
261+ frozen_at = (shots / "latest.png").stat().st_mtime_ns
262+ key("key Alt+space")
263+ # The compositor must keep capturing, or an unchanged frame
264+ # proves nothing about the stopped client.
265+ deadline = time.monotonic() + 3
266+ while (shots / "latest.png").stat().st_mtime_ns == frozen_at:
267+ if time.monotonic() > deadline:
268+ raise RuntimeError("compositor stopped capturing during the negative control")
269+ time.sleep(0.05)
270+ time.sleep(0.3)
271+ check = subprocess.run([str(ROOT / "build/resilience-pixels"), str(frozen), str(shots / "latest.png")], capture_output=True, text=True)
272+ assert check.returncode == 1, "visual gate accepted a stopped client"
273+ evidence["negative_control"] = "stopped client rejected"
274+ finally:
275+ docker("exec", client, "kill", "-CONT", pid)
276+ if os.environ.get("VEV_RESILIENCE_LONG") == "1":
277+ # The negative control's Alt+space was buffered while the
278+ # client was stopped and opens the palette once it resumes.
279+ # Close it on observed state only: a blind extra Escape would
280+ # reach the shell as a lone ESC, which readline keeps as a
281+ # Meta prefix and uses to eat the first replayed key.
282+ deadline = time.monotonic() + 5
283+ while "Commands" not in capture().get("text", ""):
284+ if time.monotonic() > deadline:
285+ raise RuntimeError("palette did not open after the stopped client resumed")
286+ time.sleep(0.1)
287+ key("key Escape")
288+ deadline = time.monotonic() + 5
289+ while "Commands" in capture().get("text", ""):
290+ if time.monotonic() > deadline:
291+ raise RuntimeError("palette did not close before the long outage")
292+ time.sleep(0.1)
293+ time.sleep(0.5)
294+ control("blackout", udp=True, tcp=True, active=True)
295+ if transport == "ssh":
296+ control("disconnect")
297+ deadline = time.monotonic() + 90
298+ while time.monotonic() < deadline:
299+ observed = capture()
300+ if observed.get("context", {}).get("status") == "connecting":
301+ break
302+ time.sleep(0.2)
303+ else:
304+ raise RuntimeError("long outage did not enter resume")
305+ if os.environ.get("VEV_RESILIENCE_RESUME") == "1":
306+ key("type echo HELD_ONE; echo HELD_TWO")
307+ key("key Return")
308+ # Keep the route down until the compositor has delivered
309+ # the complete physical input script to the resume owner.
310+ time.sleep(2)
311+ control("blackout", udp=True, tcp=True, active=False)
312+ deadline = time.monotonic() + 30
313+ while time.monotonic() < deadline:
314+ resumed = capture()
315+ text = resumed.get("text", "")
316+ if "\nHELD_ONE" in text and "\nHELD_TWO" in text and resumed.get("context", {}).get("session") == initial_session:
317+ assert text.index("\nHELD_ONE") < text.index("\nHELD_TWO"), "held input reordered"
318+ # The echoed command line proves the first held
319+ # byte was not swallowed before the shell saw it.
320+ assert "$ echo HELD_ONE; echo HELD_TWO" in text, "held input replayed incompletely"
321+ evidence["resumed"] = resumed["context"]
322+ break
323+ time.sleep(0.1)
324+ else:
325+ raise RuntimeError("long outage did not replay held input")
326+ else:
327+ start = time.monotonic()
328+ cancel_key = os.environ.get("VEV_RESILIENCE_CANCEL", "Ctrl+c")
329+ if cancel_key not in ("Ctrl+c", "Escape"):
330+ raise ValueError("cancel must be Ctrl+c or Escape")
331+ key("key " + cancel_key)
332+ deadline = start + 2
333+ while time.monotonic() < deadline:
334+ observed = capture()
335+ if observed.get("context", {}).get("status") == "picker":
336+ evidence["resume_cancel"] = {"elapsed": time.monotonic()-start}
337+ break
338+ time.sleep(0.05)
339+ else:
340+ raise RuntimeError(cancel_key + " did not cancel resume")
341+ control("blackout", udp=True, tcp=True, active=False)
342+ evidence["stats"] = control("stats")
343+ if transport == "quic":
344+ assert evidence["stats"]["udp"], "QUIC bypassed impairment relay"
345+ assert evidence["stats"]["tcp_bytes"] > 0, "SSH bypassed impairment relay"
346+ (artifacts / "evidence.json").write_text(json.dumps(evidence, indent=2))
347+ control("stop")
348+ finally:
349+ for process in reversed(processes):
350+ if process.poll() is None:
351+ process.terminate()
352+ try:
353+ process.wait(timeout=5)
354+ except subprocess.TimeoutExpired:
355+ process.kill()
356+ process.wait()
357+ for container in containers:
358+ role = container.rsplit("-", 1)[-1]
359+ for filename in ("vev-client.log", "vev-daemon.log", "broker/log/vev-broker.log"):
360+ subprocess.run(["docker", "cp", container + ":/home/demo/.local/state/vev/" + filename, str(artifacts / (role + "-" + filename.replace("/", "-")))], timeout=30, capture_output=True)
361+ subprocess.run(["docker", "rm", "-f", container], timeout=30, capture_output=True)
362+ if network:
363+ subprocess.run(["docker", "network", "rm", name], timeout=30, capture_output=True)
364+ subprocess.run(["docker", "image", "rm", name], timeout=30, capture_output=True)
365+ print("artifacts:", artifacts)
366+
367+
368+if __name__ == "__main__":
369+ main()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close