detached 25169d5

feat(remote): keep recent attachments warm (#279)

* feat(remote): keep recent attachments warm

* refactor(remote): bound warm attachment retention

* fix(remote): close rejected cache handoff

* fix(remote): harden attachment lifecycle edges

* fix(remote): close timed out activation safely

* test(remote): verify warm reuse in containers

* fix(ci): tolerate loaded picker scheduling
commit
25169d525876c3f71a44133ec4ee41f6b726dfc4
author
bnema · · 2026-09-15 20:10 UTC
committer
GitHub · · 2026-09-15 20:10 UTC
parent
a92a4c3
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
50 files · +4670 -234
M .gitignore +2 -0
@@ -1,6 +1,8 @@
11 /vev
22 .dev/
33 *.test
4+__pycache__/
5+*.pyc
46 .superpowers/
57 docs/superpowers/
68 .agent-reviews/
M docs/configuration.md +39 -0
@@ -7,6 +7,13 @@ vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means d
77 web.listen = 127.0.0.1:8778
88 web.origin = http://127.0.0.1:8778
99
10+# Client startup setting: retain suspended remote attachments. Reuse is on by
11+# default; off disables it. Capacity bounds dormant destinations (LRU). An
12+# optional positive idle timeout expires dormant entries; off has no age bound.
13+remote.attachment-cache = on
14+remote.attachment-cache-capacity = 8
15+remote.attachment-cache-idle-timeout = off
16+
1017 # Theme: auto follows the client; dark/light use neutral built-in defaults.
1118 theme = auto
1219 # In auto mode with palette inheritance enabled, infer a terminal accent.
@@ -88,6 +95,38 @@ code.detach = DET
8895
8996 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
9097
98+## Remote attachment cache
99+
100+These keys are read when a terminal client starts, not hot reloaded.
101+`remote.attachment-cache` is `on` (default) or `off`. With reuse enabled the
102+runner retains at most `remote.attachment-cache-capacity` dormant destinations
103+(default `8`), evicting the least-recently used one when a new suspension would
104+exceed capacity. `remote.attachment-cache-idle-timeout` is `off` (default, no
105+age bound) or a positive Go duration such as `30s` or `5m`; a positive value
106+expires a dormant entry that long after it was suspended.
107+
108+Navigating away suspends the remote attachment. Returning before eviction or
109+expiry reuses its authenticated connection without another bootstrap or dial.
110+Dormant attachments have no terminal input, output, or geometry authority.
111+Expiry, remote closure, or rejected activation evicts the connection; selecting
112+that endpoint then uses normal reconnect/attach behavior. Independently of the
113+client policy, the daemon force-retires an attachment suspended for 24 hours as
114+a safety bound. Endpoint aliases remain distinct, even if they resolve to the
115+same server. Exiting the client closes its retained connections. Heartbeat or
116+liveness traffic never refreshes
117+activity: a successful use followed by suspension marks the entry most
118+recently used, and only user navigation changes recency.
119+
120+Retention is bounded by the LRU capacity and, when configured, the idle
121+timeout, so visiting many distinct endpoints cannot retain more than the
122+capacity. Each dormant entry retains a transport, a reader, and daemon
123+attachment state; SSH stdio can also retain an SSH process. The client inbox is
124+bounded and dormant output is discarded. Warm reactivation has a short
125+client-local budget: a stalled retained transport is logically relinquished at
126+that deadline and the client cold-attaches immediately while the old transport
127+retires in the background. Use `remote.attachment-cache = off` when no
128+retention is wanted.
129+
91130 ## Scrollback
92131
93132 `vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports
M internal/adapters/config/config.go +29 -0
@@ -37,6 +37,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3737 seenTabsKeys := make(map[string]bool)
3838 seenScrollbackKeys := make(map[string]bool)
3939 seenWebKeys := make(map[string]bool)
40+ seenRemoteKeys := make(map[string]bool)
4041
4142 scanner := bufio.NewScanner(r)
4243 lineNo := 0
@@ -64,6 +65,34 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6465 }
6566
6667 switch {
68+ case key == "remote.attachment-cache":
69+ warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo)
70+ on, ok := parseOnOff(value)
71+ if !ok {
72+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)})
73+ continue
74+ }
75+ cfg.AttachmentCache.Enabled = on
76+ case key == "remote.attachment-cache-capacity":
77+ warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo)
78+ capacity, err := strconv.Atoi(value)
79+ if err != nil || capacity <= 0 {
80+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)})
81+ continue
82+ }
83+ cfg.AttachmentCache.Capacity = capacity
84+ case key == "remote.attachment-cache-idle-timeout":
85+ warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo)
86+ if strings.EqualFold(value, "off") {
87+ cfg.AttachmentCache.IdleTimeout = 0
88+ continue
89+ }
90+ timeout, err := time.ParseDuration(value)
91+ if err != nil || timeout <= 0 {
92+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)})
93+ continue
94+ }
95+ cfg.AttachmentCache.IdleTimeout = timeout
6796 case key == "web.listen" || key == "web.origin":
6897 warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)
6998 // Keep raw values for fail-closed validation at gateway startup.
M internal/adapters/config/config_test.go +1 -0
@@ -359,6 +359,7 @@ func TestParse(t *testing.T) {
359359 tt.want.Palette = domain.Defaults().Palette
360360 }
361361 tt.want.Scrollback = domain.DefaultScrollbackConfig()
362+ tt.want.AttachmentCache = domain.DefaultAttachmentCacheConfig()
362363 got, warnings, err := Parse(strings.NewReader(tt.input))
363364 if err != nil {
364365 t.Fatalf("Parse() error = %v", err)
A internal/adapters/config/remote_cache_test.go +46 -0
@@ -0,0 +1,46 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestParseAttachmentCacheConfig(t *testing.T) {
13+ for _, tt := range []struct {
14+ name string
15+ input string
16+ want domain.AttachmentCacheConfig
17+ warnings int
18+ }{
19+ {"default", "", domain.DefaultAttachmentCacheConfig(), 0},
20+ {"enabled", "remote.attachment-cache = on", domain.AttachmentCacheConfig{Enabled: true, Capacity: 8}, 0},
21+ {"disabled", "remote.attachment-cache = OFF", domain.AttachmentCacheConfig{Enabled: false, Capacity: 8}, 0},
22+ {"capacity", "remote.attachment-cache-capacity = 4", domain.AttachmentCacheConfig{Enabled: true, Capacity: 4}, 0},
23+ {"idle timeout", "remote.attachment-cache-idle-timeout = 30s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 8, IdleTimeout: 30 * time.Second}, 0},
24+ {"idle timeout off", "remote.attachment-cache-idle-timeout = Off", domain.DefaultAttachmentCacheConfig(), 0},
25+ {"all keys", "remote.attachment-cache = on\nremote.attachment-cache-capacity = 2\nremote.attachment-cache-idle-timeout = 90s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 2, IdleTimeout: 90 * time.Second}, 0},
26+ {"malformed boolean", "remote.attachment-cache = sometimes", domain.DefaultAttachmentCacheConfig(), 1},
27+ {"malformed capacity", "remote.attachment-cache-capacity = eight", domain.DefaultAttachmentCacheConfig(), 1},
28+ {"zero capacity", "remote.attachment-cache-capacity = 0", domain.DefaultAttachmentCacheConfig(), 1},
29+ {"negative capacity", "remote.attachment-cache-capacity = -3", domain.DefaultAttachmentCacheConfig(), 1},
30+ {"malformed timeout", "remote.attachment-cache-idle-timeout = forever", domain.DefaultAttachmentCacheConfig(), 1},
31+ {"zero timeout", "remote.attachment-cache-idle-timeout = 0s", domain.DefaultAttachmentCacheConfig(), 1},
32+ {"negative timeout", "remote.attachment-cache-idle-timeout = -5s", domain.DefaultAttachmentCacheConfig(), 1},
33+ {"overflow timeout", "remote.attachment-cache-idle-timeout = 999999999999999h", domain.DefaultAttachmentCacheConfig(), 1},
34+ {"duplicate", "remote.attachment-cache-capacity = 2\nremote.attachment-cache-capacity = 3", domain.AttachmentCacheConfig{Enabled: true, Capacity: 3}, 1},
35+ {"invalid keeps previous", "remote.attachment-cache-capacity = 4\nremote.attachment-cache-capacity = broken", domain.AttachmentCacheConfig{Enabled: true, Capacity: 4}, 2},
36+ {"disabled with values", "remote.attachment-cache = off\nremote.attachment-cache-capacity = 2\nremote.attachment-cache-idle-timeout = 1m", domain.AttachmentCacheConfig{Enabled: false, Capacity: 2, IdleTimeout: time.Minute}, 0},
37+ } {
38+ t.Run(tt.name, func(t *testing.T) {
39+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
40+ require.NoError(t, err)
41+ require.Equal(t, tt.want, cfg.AttachmentCache)
42+ require.Len(t, warnings, tt.warnings)
43+ require.Empty(t, cfg.BindingEntries)
44+ })
45+ }
46+}
A internal/adapters/sessionwire/attachment_test.go +188 -0
@@ -0,0 +1,188 @@
1+package sessionwire
2+
3+import (
4+ "encoding/hex"
5+ "reflect"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/wire"
11+ "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/proto"
13+ "google.golang.org/protobuf/reflect/protoreflect"
14+)
15+
16+func TestAttachmentEnvelopes(t *testing.T) {
17+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
18+ for _, tt := range []struct {
19+ name string
20+ message any
21+ golden string
22+ }{
23+ {"suspend", protocol.SuspendAttachment{RequestID: 1}, "ea01020801"},
24+ {"suspended", protocol.AttachmentSuspended{RequestID: 1, Target: target}, "fa011e0801121a0a120a10010000000000000000000000000000001204776f726b"},
25+ {"activate", protocol.ActivateAttachment{RequestID: 2, Target: target, Size: domain.Size{Cols: 80, Rows: 24}}, "f201220802121a0a120a10010000000000000000000000000000001204776f726b18502018"},
26+ {"activated", protocol.AttachmentActivated{RequestID: 2, Identity: protocol.CommittedRouteIdentity{Target: target}, Epoch: 2, State: 1, ViewPublication: 3}, "8202260802121c0a1a0a120a10010000000000000000000000000000001204776f726b180220012803"},
27+ } {
28+ t.Run(tt.name, func(t *testing.T) {
29+ var raw []byte
30+ var err error
31+ var decode func([]byte) (any, error)
32+ var wrong func([]byte) (any, error)
33+ if m, ok := tt.message.(protocol.ClientMessage); ok {
34+ raw, err = EncodeClientMessage(m)
35+ decode = func(b []byte) (any, error) { return DecodeClientEnvelope(b) }
36+ wrong = func(b []byte) (any, error) { return DecodeServerEnvelope(b) }
37+ } else {
38+ raw, err = EncodeServerMessage(tt.message.(protocol.ServerMessage))
39+ decode = func(b []byte) (any, error) { return DecodeServerEnvelope(b) }
40+ wrong = func(b []byte) (any, error) { return DecodeClientEnvelope(b) }
41+ }
42+ require.NoError(t, err)
43+ require.Equal(t, tt.golden, hex.EncodeToString(raw))
44+ got, err := decode(raw)
45+ require.NoError(t, err)
46+ require.Equal(t, tt.message, got)
47+ _, err = wrong(raw)
48+ require.Error(t, err)
49+ for i := 0; i < len(raw); i++ {
50+ _, err = decode(raw[:i])
51+ require.Error(t, err, "prefix %d", i)
52+ }
53+ for _, tail := range [][]byte{{0}, {0x80}, raw, {0xf8, 0x07, 0x01}} {
54+ _, err = decode(append(append([]byte(nil), raw...), tail...))
55+ require.Error(t, err)
56+ }
57+ })
58+ }
59+}
60+
61+// Clearing any required field must fail on both conversion directions. Direct
62+// protobuf marshal deliberately bypasses the semantic encoder for hostile input.
63+func TestAttachmentRequiredFields(t *testing.T) {
64+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
65+ for _, message := range []any{
66+ protocol.SuspendAttachment{RequestID: 1},
67+ protocol.AttachmentSuspended{RequestID: 1, Target: target},
68+ protocol.ActivateAttachment{RequestID: 2, Target: target, Size: domain.Size{Cols: 80, Rows: 24}},
69+ protocol.AttachmentActivated{RequestID: 2, Identity: protocol.CommittedRouteIdentity{Target: target}, Epoch: 2, State: 1, ViewPublication: 3},
70+ } {
71+ t.Run(reflect.TypeOf(message).Name(), func(t *testing.T) {
72+ value := reflect.ValueOf(message)
73+ // Pointer forms share the same validated path; typed nil pointers fail.
74+ pointer := reflect.New(value.Type())
75+ pointer.Elem().Set(value)
76+ nilPointer := reflect.Zero(pointer.Type()).Interface()
77+ encode := func(v any) ([]byte, error) {
78+ if m, ok := v.(protocol.ClientMessage); ok {
79+ return EncodeClientMessage(m)
80+ }
81+ return EncodeServerMessage(v.(protocol.ServerMessage))
82+ }
83+ _, err := encode(pointer.Interface())
84+ require.NoError(t, err)
85+ _, err = encode(nilPointer)
86+ require.Error(t, err)
87+ for i := 0; i < value.NumField(); i++ {
88+ if value.Type().Field(i).Name == "PixelWidth" || value.Type().Field(i).Name == "PixelHeight" {
89+ continue
90+ }
91+ bad := reflect.New(value.Type()).Elem()
92+ bad.Set(value)
93+ bad.Field(i).SetZero()
94+ _, err = encode(bad.Interface())
95+ require.Error(t, err, value.Type().Field(i).Name)
96+ }
97+ var envelope proto.Message
98+ var decode func([]byte) (any, error)
99+ if m, ok := message.(protocol.ClientMessage); ok {
100+ envelope, err = encodeProtoClient(m)
101+ decode = func(b []byte) (any, error) { return DecodeClientEnvelope(b) }
102+ } else {
103+ envelope, err = encodeProtoServer(message.(protocol.ServerMessage))
104+ decode = func(b []byte) (any, error) { return DecodeServerEnvelope(b) }
105+ }
106+ require.NoError(t, err)
107+ descriptor := envelope.ProtoReflect().Descriptor().Oneofs().Get(0)
108+ variant := envelope.ProtoReflect().WhichOneof(descriptor)
109+ payload := envelope.ProtoReflect().Get(variant).Message()
110+ payload.Range(func(field protoreflect.FieldDescriptor, _ protoreflect.Value) bool {
111+ clone := proto.Clone(envelope)
112+ clone.ProtoReflect().Get(variant).Message().Clear(field)
113+ raw, err := proto.Marshal(clone)
114+ require.NoError(t, err)
115+ _, err = decode(raw)
116+ require.Error(t, err, string(field.Name()))
117+ return true
118+ })
119+ })
120+ }
121+}
122+
123+func TestAttachmentActivationRejectsMalformedGeometryAndIdentity(t *testing.T) {
124+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
125+ for _, mutate := range []func(*wire.ActivateAttachment){
126+ func(m *wire.ActivateAttachment) { m.Cols = 1 << 16 },
127+ func(m *wire.ActivateAttachment) { m.Rows = 1 << 16 },
128+ func(m *wire.ActivateAttachment) { m.PixelWidth = 1 },
129+ func(m *wire.ActivateAttachment) { m.PixelWidth = 1 << 16; m.PixelHeight = 1 },
130+ func(m *wire.ActivateAttachment) { m.Target.LifecycleId.Value = []byte{1} },
131+ func(m *wire.ActivateAttachment) { m.Target.SessionName = "" },
132+ } {
133+ m := &wire.ActivateAttachment{RequestId: 2, Target: exactTargetToWire(&target), Cols: 80, Rows: 24}
134+ mutate(m)
135+ raw, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ActivateAttachment{ActivateAttachment: m}})
136+ require.NoError(t, err)
137+ _, err = DecodeClientEnvelope(raw)
138+ require.Error(t, err)
139+ }
140+}
141+
142+// This is a typed connection contract test, not a daemon lifecycle test. The
143+// scripted peer supplies the barrier and full-publication ordering Phase 2
144+// must enforce in the real daemon.
145+func TestAttachmentTypedConnectionSequence(t *testing.T) {
146+ context := testUIOutputContext()
147+ target := context.Route.Target
148+ suspend := protocol.SuspendAttachment{RequestID: 10}
149+ suspended := protocol.AttachmentSuspended{RequestID: 10, Target: target}
150+ activate := protocol.ActivateAttachment{RequestID: 11, Target: target, Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
151+ full := protocol.Output{Epoch: 2, New: 1, Full: true, Size: activate.Size, Context: context, Data: []byte("full")}
152+ activated := protocol.AttachmentActivated{RequestID: 11, Identity: context.Route, Epoch: full.Epoch, State: full.New, ViewPublication: context.Publication}
153+ clientRaw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}}
154+ serverRaw := &scriptedTransport{recv: []wire.Envelope{mustPreambleRequest(t)}}
155+ client := NewClientConnection(clientRaw)
156+ server := NewServerConnection(serverRaw)
157+ t.Cleanup(func() { require.NoError(t, client.Close()); require.NoError(t, server.Close()) })
158+ sendClient := func(message protocol.ClientMessage) {
159+ require.NoError(t, client.SendClient(message))
160+ serverRaw.recv = append(serverRaw.recv, wire.Envelope{Payload: clientRaw.sentPayload(clientRaw.sentLen() - 1)})
161+ got, err := server.ReceiveClient()
162+ require.NoError(t, err)
163+ require.Equal(t, message, got)
164+ }
165+ sendServer := func(message protocol.ServerMessage) {
166+ require.NoError(t, server.SendServer(message))
167+ clientRaw.recv = append(clientRaw.recv, wire.Envelope{Payload: serverRaw.sentPayload(serverRaw.sentLen() - 1)})
168+ got, err := client.ReceiveServer()
169+ require.NoError(t, err)
170+ require.Equal(t, message, got)
171+ }
172+ sendClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, ExactTarget: &target, Size: activate.Size})
173+ sendServer(protocol.Welcome{SessionID: "session", SessionName: target.SessionName, CommittedIdentity: &context.Route})
174+ initial := full
175+ initial.Epoch = 1
176+ sendServer(initial)
177+ sendClient(suspend)
178+ sendServer(suspended)
179+ sendClient(protocol.Ping{})
180+ sendServer(protocol.Pong{})
181+ sendClient(activate)
182+ sendServer(full)
183+ sendServer(activated)
184+ require.NotEqual(t, initial.Epoch, activated.Epoch)
185+ require.Equal(t, activate.RequestID, activated.RequestID)
186+ require.Equal(t, activate.Target, activated.Identity.Target)
187+ sendClient(protocol.Input{InputSeq: 1, Data: []byte("x")})
188+}
A internal/adapters/sessionwire/proto_convert_attachment.go +71 -0
@@ -0,0 +1,71 @@
1+package sessionwire
2+
3+import (
4+ "github.com/bnema/vev/internal/protocol"
5+ "github.com/bnema/vev/internal/protocol/wire"
6+)
7+
8+func suspendAttachmentToWire(m protocol.SuspendAttachment) (*wire.SuspendAttachment, error) {
9+ if err := m.Validate(); err != nil {
10+ return nil, err
11+ }
12+ return &wire.SuspendAttachment{RequestId: m.RequestID}, nil
13+}
14+func suspendAttachmentFromWire(m *wire.SuspendAttachment) (protocol.SuspendAttachment, error) {
15+ out := protocol.SuspendAttachment{RequestID: m.GetRequestId()}
16+ return out, out.Validate()
17+}
18+func attachmentSuspendedToWire(m protocol.AttachmentSuspended) (*wire.AttachmentSuspended, error) {
19+ if err := m.Validate(); err != nil {
20+ return nil, err
21+ }
22+ return &wire.AttachmentSuspended{RequestId: m.RequestID, Target: exactTargetToWire(&m.Target)}, nil
23+}
24+func attachmentSuspendedFromWire(m *wire.AttachmentSuspended) (protocol.AttachmentSuspended, error) {
25+ target, err := exactTargetFromWire(m.GetTarget())
26+ if err != nil || target == nil {
27+ return protocol.AttachmentSuspended{}, protocol.ErrInvalidAttachment
28+ }
29+ out := protocol.AttachmentSuspended{RequestID: m.GetRequestId(), Target: *target}
30+ return out, out.Validate()
31+}
32+func activateAttachmentToWire(m protocol.ActivateAttachment) (*wire.ActivateAttachment, error) {
33+ if err := m.Validate(); err != nil {
34+ return nil, err
35+ }
36+ cols, rows, pw, ph, err := geometryToWire(m.Size, m.PixelWidth, m.PixelHeight)
37+ if err != nil {
38+ return nil, err
39+ }
40+ return &wire.ActivateAttachment{RequestId: m.RequestID, Target: exactTargetToWire(&m.Target), Cols: cols, Rows: rows, PixelWidth: pw, PixelHeight: ph}, nil
41+}
42+func activateAttachmentFromWire(m *wire.ActivateAttachment) (protocol.ActivateAttachment, error) {
43+ target, err := exactTargetFromWire(m.GetTarget())
44+ if err != nil || target == nil {
45+ return protocol.ActivateAttachment{}, protocol.ErrInvalidAttachment
46+ }
47+ geometry, err := resizeFromWire(&wire.Resize{Cols: m.GetCols(), Rows: m.GetRows(), PixelWidth: m.GetPixelWidth(), PixelHeight: m.GetPixelHeight()})
48+ if err != nil {
49+ return protocol.ActivateAttachment{}, err
50+ }
51+ out := protocol.ActivateAttachment{RequestID: m.GetRequestId(), Target: *target, Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}
52+ return out, out.Validate()
53+}
54+func attachmentActivatedToWire(m protocol.AttachmentActivated) (*wire.AttachmentActivated, error) {
55+ if err := m.Validate(); err != nil {
56+ return nil, err
57+ }
58+ identity, err := committedIdentityToWire(m.Identity)
59+ if err != nil {
60+ return nil, err
61+ }
62+ return &wire.AttachmentActivated{RequestId: m.RequestID, Identity: identity, Epoch: m.Epoch, State: m.State, ViewPublication: m.ViewPublication}, nil
63+}
64+func attachmentActivatedFromWire(m *wire.AttachmentActivated) (protocol.AttachmentActivated, error) {
65+ identity, err := committedIdentityFromWire(m.GetIdentity())
66+ if err != nil {
67+ return protocol.AttachmentActivated{}, protocol.ErrInvalidAttachment
68+ }
69+ out := protocol.AttachmentActivated{RequestID: m.GetRequestId(), Identity: identity, Epoch: m.GetEpoch(), State: m.GetState(), ViewPublication: m.GetViewPublication()}
70+ return out, out.Validate()
71+}
M internal/adapters/sessionwire/proto_envelope.go +52 -0
@@ -15,6 +15,28 @@ import (
1515
1616 func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, error) {
1717 switch m := message.(type) {
18+ case protocol.SuspendAttachment:
19+ converted, err := suspendAttachmentToWire(m)
20+ if err != nil {
21+ return nil, err
22+ }
23+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SuspendAttachment{SuspendAttachment: converted}}, nil
24+ case *protocol.SuspendAttachment:
25+ if m == nil {
26+ return nil, ErrInvalidMessage
27+ }
28+ return encodeProtoClient(*m)
29+ case protocol.ActivateAttachment:
30+ converted, err := activateAttachmentToWire(m)
31+ if err != nil {
32+ return nil, err
33+ }
34+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_ActivateAttachment{ActivateAttachment: converted}}, nil
35+ case *protocol.ActivateAttachment:
36+ if m == nil {
37+ return nil, ErrInvalidMessage
38+ }
39+ return encodeProtoClient(*m)
1840 case protocol.Hello:
1941 converted, err := helloToWire(m)
2042 if err != nil {
@@ -294,6 +316,28 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
294316
295317 func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, error) {
296318 switch m := message.(type) {
319+ case protocol.AttachmentSuspended:
320+ converted, err := attachmentSuspendedToWire(m)
321+ if err != nil {
322+ return nil, err
323+ }
324+ return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_AttachmentSuspended{AttachmentSuspended: converted}}, nil
325+ case *protocol.AttachmentSuspended:
326+ if m == nil {
327+ return nil, ErrInvalidMessage
328+ }
329+ return encodeProtoServer(*m)
330+ case protocol.AttachmentActivated:
331+ converted, err := attachmentActivatedToWire(m)
332+ if err != nil {
333+ return nil, err
334+ }
335+ return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_AttachmentActivated{AttachmentActivated: converted}}, nil
336+ case *protocol.AttachmentActivated:
337+ if m == nil {
338+ return nil, ErrInvalidMessage
339+ }
340+ return encodeProtoServer(*m)
297341 case protocol.Welcome:
298342 converted, err := welcomeToWire(m)
299343 if err != nil {
@@ -598,6 +642,10 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
598642 return nil, ErrInvalidMessage
599643 }
600644 switch payload := envelope.Payload.(type) {
645+ case *wire.ClientEnvelope_SuspendAttachment:
646+ return suspendAttachmentFromWire(payload.SuspendAttachment)
647+ case *wire.ClientEnvelope_ActivateAttachment:
648+ return activateAttachmentFromWire(payload.ActivateAttachment)
601649 case *wire.ClientEnvelope_Hello:
602650 return helloFromWire(payload.Hello)
603651 case *wire.ClientEnvelope_Input:
@@ -666,6 +714,10 @@ func decodeProtoServer(envelope *wire.ServerEnvelope) (protocol.ServerMessage, e
666714 return nil, ErrInvalidMessage
667715 }
668716 switch payload := envelope.Payload.(type) {
717+ case *wire.ServerEnvelope_AttachmentSuspended:
718+ return attachmentSuspendedFromWire(payload.AttachmentSuspended)
719+ case *wire.ServerEnvelope_AttachmentActivated:
720+ return attachmentActivatedFromWire(payload.AttachmentActivated)
669721 case *wire.ServerEnvelope_Welcome:
670722 return welcomeFromWire(payload.Welcome)
671723 case *wire.ServerEnvelope_Error:
A internal/app/remote_cache_config_test.go +51 -0
@@ -0,0 +1,51 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/usecase/client"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestRunAttachThreadsAttachmentCacheConfig(t *testing.T) {
19+ for _, remote := range []string{"", "remote.example"} {
20+ for _, tt := range []struct {
21+ name, text string
22+ want domain.AttachmentCacheConfig
23+ }{
24+ {"default", "", domain.DefaultAttachmentCacheConfig()},
25+ {"configured", "remote.attachment-cache-capacity = 3\nremote.attachment-cache-idle-timeout = 42s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 3, IdleTimeout: 42 * time.Second}},
26+ {"disabled", "remote.attachment-cache = off", domain.AttachmentCacheConfig{Enabled: false, Capacity: 8}},
27+ {"warning with nil logger", "remote.attachment-cache-capacity = 0", domain.DefaultAttachmentCacheConfig()},
28+ } {
29+ t.Run(remote+"/"+tt.name, func(t *testing.T) {
30+ path := filepath.Join(t.TempDir(), "config")
31+ require.NoError(t, os.WriteFile(path, []byte(tt.text), 0600))
32+ factory := newRemoteDialerFactoryMock(t)
33+ if remote != "" {
34+ factory.EXPECT().DialerForRemote(remote, "", remoteadapter.TransportQUIC, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
35+ }
36+ calls := 0
37+ err := runAttachWithDeps(context.Background(), protocol.IntentNew, "work", remote, "", nil, runAttachDeps{
38+ configPath: func() string { return path },
39+ remoteDialerFactory: factory.DialerForRemote,
40+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
41+ calls++
42+ require.Equal(t, tt.want, deps.AttachmentCache)
43+ return nil
44+ },
45+ })
46+ require.NoError(t, err)
47+ require.Equal(t, 1, calls)
48+ })
49+ }
50+ }
51+}
M internal/app/run.go +23 -6
@@ -560,6 +560,20 @@ func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
560560 }
561561 }
562562
563+func loadConfigOrDefaults(log *slog.Logger, path string) domain.Config {
564+ cfg, warnings, err := config.Load(path)
565+ if err != nil {
566+ if log != nil {
567+ log.Warn("loading config failed; using defaults", "path", path, "err", err)
568+ }
569+ cfg = domain.Defaults()
570+ }
571+ if log != nil {
572+ logConfigWarnings(log, warnings)
573+ }
574+ return cfg
575+}
576+
563577 func snapshotDir() string {
564578 return filepath.Join(platform.StateDir(), "snapshots")
565579 }
@@ -756,12 +770,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
756770 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
757771 }
758772 configPath := platform.ConfigPath()
759- cfg, warnings, err := config.Load(configPath)
760- if err != nil {
761- log.Warn("loading config failed; using defaults", "path", configPath, "err", err)
762- cfg = domain.Defaults()
763- }
764- logConfigWarnings(log, warnings)
773+ cfg := loadConfigOrDefaults(log, configPath)
765774 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
766775 daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
767776 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
@@ -890,6 +899,7 @@ func defaultRemoteDialerFactory() remoteDialerForTarget {
890899 type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error
891900
892901 type runAttachDeps struct {
902+ configPath func() string
893903 localDialer func() wire.Dialer
894904 remoteDialerFactory remoteDialerForTarget
895905 selectedRemoteTransport string
@@ -1022,6 +1032,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10221032 }
10231033 return policy
10241034 }
1035+ configPath := deps.configPath
1036+ if configPath == nil {
1037+ configPath = platform.ConfigPath
1038+ }
1039+ cfg := loadConfigOrDefaults(log, configPath())
10251040 // The launching client owns one host registry for the whole run: endpoint
10261041 // bindings are resolved once and reused by every later handoff, and the
10271042 // discovery loop it drives lives exactly as long as the runner.
@@ -1041,6 +1056,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10411056 return resolveErr
10421057 }
10431058 err = runClient(ctx, client.Dependencies{
1059+ AttachmentCache: cfg.AttachmentCache,
10441060 Dialer: binding.Dialer,
10451061 LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}),
10461062 Terminal: clientTerminal(deps),
@@ -1070,6 +1086,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10701086 log.Info("attaching to local session", "intent", intent, "name", name)
10711087 }
10721088 err = runClient(ctx, client.Dependencies{
1089+ AttachmentCache: cfg.AttachmentCache,
10731090 Dialer: sessionwire.NewClientDialer(localDialer()),
10741091 LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}),
10751092 Terminal: clientTerminal(deps),
M internal/domain/config.go +48 -18
@@ -104,25 +104,54 @@ func (c ScrollbackConfig) Valid() bool {
104104 return c.Megabytes <= MaxScrollbackMegabytes && c.Lines >= 0 && c.Lines <= MaxScrollbackLines
105105 }
106106
107+// AttachmentCacheConfig is the client-side retention policy for suspended
108+// remote attachments. Capacity bounds how many dormant destinations one
109+// runner retains; IdleTimeout, when positive, expires a dormant entry that
110+// long after suspension. A zero IdleTimeout retains dormant entries for the
111+// whole runner lifetime.
112+type AttachmentCacheConfig struct {
113+ Enabled bool
114+ Capacity int
115+ IdleTimeout time.Duration
116+}
117+
118+// DefaultAttachmentCacheCapacity bounds one runner's dormant destinations.
119+const DefaultAttachmentCacheCapacity = 8
120+
121+// DefaultAttachmentCacheConfig returns the approved retention policy: reuse
122+// enabled, eight dormant destinations, and no client age expiry.
123+func DefaultAttachmentCacheConfig() AttachmentCacheConfig {
124+ return AttachmentCacheConfig{Enabled: true, Capacity: DefaultAttachmentCacheCapacity}
125+}
126+
127+// Valid reports whether the policy is usable: a positive dormant capacity and
128+// a non-negative idle timeout.
129+func (c AttachmentCacheConfig) Valid() bool {
130+ return c.Capacity > 0 && c.IdleTimeout >= 0
131+}
132+
107133 // Config is the user-editable vev configuration after parsing. Unknown binding
108134 // keys are preserved here (in BindingEntries, in file order) so the usecase
109135 // layer can decide which actions it understands.
110136 type Config struct {
111- WebListen string
112- WebOrigin string
113- Theme ThemeMode
114- ThemePalette bool
115- ThemeAccent ThemeAccent
116- Bar BarConfig
117- BindingEntries []ConfigEntry
118- Codes map[string]string
119- Snapshot SnapshotConfig
120- Copy CopyConfig
121- Palette PaletteConfig
122- Floating FloatingConfig
123- Nav NavConfig
124- Tabs TabsConfig
125- Scrollback ScrollbackConfig
137+ // AttachmentCache is read by each launching terminal client, not hot
138+ // reloaded.
139+ AttachmentCache AttachmentCacheConfig
140+ WebListen string
141+ WebOrigin string
142+ Theme ThemeMode
143+ ThemePalette bool
144+ ThemeAccent ThemeAccent
145+ Bar BarConfig
146+ BindingEntries []ConfigEntry
147+ Codes map[string]string
148+ Snapshot SnapshotConfig
149+ Copy CopyConfig
150+ Palette PaletteConfig
151+ Floating FloatingConfig
152+ Nav NavConfig
153+ Tabs TabsConfig
154+ Scrollback ScrollbackConfig
126155 }
127156
128157 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -135,9 +164,10 @@ type Warning struct {
135164 // Defaults returns vev's default configuration.
136165 func Defaults() Config {
137166 return Config{
138- Theme: ThemeAuto,
139- ThemePalette: true,
140- ThemeAccent: ThemeAccent{Mode: ThemeAccentAuto},
167+ AttachmentCache: DefaultAttachmentCacheConfig(),
168+ Theme: ThemeAuto,
169+ ThemePalette: true,
170+ ThemeAccent: ThemeAccent{Mode: ThemeAccentAuto},
141171 Bar: BarConfig{
142172 Interval: 5 * time.Second,
143173 },
M internal/ports/session.go +4 -1
@@ -6,7 +6,10 @@ import (
66 "github.com/bnema/vev/internal/protocol"
77 )
88
9-// ClientConnection is one typed client-side session channel.
9+// ClientConnection is one typed client-side session channel. Close must be
10+// concurrent-safe, required to unblock ReceiveServer (and every send mode),
11+// and must not need a prior graceful exchange: a retained dormant attachment
12+// is closed by the client when it is evicted, replaced, or the runner exits.
1013 type ClientConnection interface {
1114 SendClient(protocol.ClientMessage) error
1215 ReceiveServer() (protocol.ServerMessage, error)
A internal/protocol/attachment.go +87 -0
@@ -0,0 +1,87 @@
1+package protocol
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+var ErrInvalidAttachment = errors.New("invalid attachment transition")
10+
11+// SuspendAttachment requests a publication barrier on the current attachment.
12+// RequestID is nonzero and unique among this connection's transitions. Until
13+// its matching ACK arrives the client must not transfer foreground ownership.
14+type SuspendAttachment struct{ RequestID uint64 }
15+
16+// AttachmentSuspended acknowledges that input, UI, rendering and geometry
17+// authority have been revoked and pending publications drained. After this ACK
18+// no pre-suspension output may become visible. Target is the retained lifecycle,
19+// not authority to select any other session. The authenticated transport stays open.
20+type AttachmentSuspended struct {
21+ RequestID uint64
22+ Target ExactSessionTarget
23+}
24+
25+// ActivateAttachment reclaims only the exact target retained by this suspended
26+// attachment, with the client's current geometry. It does not grant same-peer
27+// navigation authority. Input remains gated until the matching full publication
28+// and AttachmentActivated have both been received.
29+type ActivateAttachment struct {
30+ RequestID uint64
31+ Target ExactSessionTarget
32+ Size domain.Size
33+ PixelWidth int
34+ PixelHeight int
35+}
36+
37+func (m ActivateAttachment) Geometry() domain.Geometry {
38+ return domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}.NormalizePixels()
39+}
40+
41+// AttachmentActivated commits a successful activation after a full Output and
42+// changed RoutePosition on a fresh epoch. Identity and the
43+// epoch/state/view-publication fence must match that Output's context. The
44+// receiver checks freshness against its previous
45+// chain and correlation against the pending request; Validate checks only the
46+// self-contained message. Failure must not emit this success ACK (the existing
47+// error/close path permits eviction and reconnect).
48+type AttachmentActivated struct {
49+ RequestID uint64
50+ Identity CommittedRouteIdentity
51+ Epoch uint64
52+ State uint64
53+ ViewPublication uint64
54+}
55+
56+func (m SuspendAttachment) Validate() error {
57+ if m.RequestID == 0 {
58+ return ErrInvalidAttachment
59+ }
60+ return nil
61+}
62+func (m AttachmentSuspended) Validate() error {
63+ if m.RequestID == 0 || m.Target.Validate() != nil {
64+ return ErrInvalidAttachment
65+ }
66+ return nil
67+}
68+func (m ActivateAttachment) Validate() error {
69+ if m.RequestID == 0 || m.Target.Validate() != nil {
70+ return ErrInvalidAttachment
71+ }
72+ if ValidateGeometry(domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}) != nil {
73+ return ErrInvalidAttachment
74+ }
75+ return nil
76+}
77+func (m AttachmentActivated) Validate() error {
78+ if m.RequestID == 0 || m.Identity.Validate() != nil || m.Epoch == 0 || m.State == 0 || m.ViewPublication == 0 {
79+ return ErrInvalidAttachment
80+ }
81+ return nil
82+}
83+
84+func (SuspendAttachment) clientMessage() {}
85+func (ActivateAttachment) clientMessage() {}
86+func (AttachmentSuspended) serverMessage() {}
87+func (AttachmentActivated) serverMessage() {}
A internal/protocol/attachment_test.go +43 -0
@@ -0,0 +1,43 @@
1+package protocol
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestAttachmentValidation(t *testing.T) {
11+ target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
12+ identity := CommittedRouteIdentity{Target: target}
13+ for _, tt := range []struct {
14+ name string
15+ message interface{ Validate() error }
16+ valid bool
17+ }{
18+ {"suspend", SuspendAttachment{RequestID: 1}, true},
19+ {"suspend zero", SuspendAttachment{}, false},
20+ {"suspended", AttachmentSuspended{RequestID: 1, Target: target}, true},
21+ {"suspended zero", AttachmentSuspended{Target: target}, false},
22+ {"suspended target", AttachmentSuspended{RequestID: 1}, false},
23+ {"activate", ActivateAttachment{RequestID: 2, Target: target, Size: domain.Size{Cols: 80, Rows: 24}}, true},
24+ {"activate zero", ActivateAttachment{Target: target, Size: domain.Size{Cols: 80, Rows: 24}}, false},
25+ {"activate target", ActivateAttachment{RequestID: 2, Size: domain.Size{Cols: 80, Rows: 24}}, false},
26+ {"activate geometry", ActivateAttachment{RequestID: 2, Target: target}, false},
27+ {"activate pixels", ActivateAttachment{RequestID: 2, Target: target, Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 1}, false},
28+ {"activated", AttachmentActivated{RequestID: 2, Identity: identity, Epoch: 2, State: 1, ViewPublication: 3}, true},
29+ {"activated zero", AttachmentActivated{Identity: identity, Epoch: 2, State: 1, ViewPublication: 3}, false},
30+ {"activated identity", AttachmentActivated{RequestID: 2, Epoch: 2, State: 1, ViewPublication: 3}, false},
31+ {"activated epoch", AttachmentActivated{RequestID: 2, Identity: identity, State: 1, ViewPublication: 3}, false},
32+ {"activated state", AttachmentActivated{RequestID: 2, Identity: identity, Epoch: 2, ViewPublication: 3}, false},
33+ {"activated publication", AttachmentActivated{RequestID: 2, Identity: identity, Epoch: 2, State: 1}, false},
34+ } {
35+ t.Run(tt.name, func(t *testing.T) {
36+ if tt.valid {
37+ require.NoError(t, tt.message.Validate())
38+ } else {
39+ require.Error(t, tt.message.Validate())
40+ }
41+ })
42+ }
43+}
M internal/protocol/session.go +4 -2
@@ -13,10 +13,12 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 51
16+const Version uint16 = 52
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
19-// first committed publication.
19+// first committed publication. It excludes the preceding client-local
20+// warm-reuse probe, which has its own short budget: a cold dial that follows
21+// a warm miss still receives the full handshake budget.
2022 const HandshakeTimeout = 15 * time.Second
2123
2224 // MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(51), Version)
13+ require.Equal(t, uint16(52), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
M internal/protocol/wire/envelope.pb.go +145 -65 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +4 -2
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 51 {
19- t.Fatalf("protocol.Version = %d, want 51", protocol.Version)
18+ if protocol.Version != 52 {
19+ t.Fatalf("protocol.Version = %d, want 52", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -30,6 +30,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
3030
3131 // Closed semantic unions, in wire-variant spelling.
3232 clientSemantic := map[string]bool{
33+ "suspend_attachment": true, "activate_attachment": true,
3334 "hello": true, "input": true, "resize": true, "detach": true, "ping": true,
3435 "list": true, "kill": true, "theme": true, "ack": true, "image_push": true,
3536 "client_notice": true, "command_request": true, "output_reset_request": true,
@@ -42,6 +43,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
4243 "picker_preview_request": true, "picker_control_request": true,
4344 }
4445 serverSemantic := map[string]bool{
46+ "attachment_suspended": true, "attachment_activated": true,
4547 "welcome": true, "error": true, "output": true, "detached": true, "pong": true,
4648 "sessions": true, "command_result": true, "attach_target": true,
4749 "remote_preview": true, "committed_route_identity": true,
M internal/protocol/wire/schema/envelope.proto +7 -0
@@ -96,12 +96,17 @@ message ClientEnvelope {
9696 PickerSelection picker_selection = 26;
9797 PickerPreviewRequest picker_preview_request = 27;
9898 PickerControlRequest picker_control_request = 28;
99+ SuspendAttachment suspend_attachment = 29;
100+ ActivateAttachment activate_attachment = 30;
99101 }
100102 }
101103
102104 // ServerEnvelope is the closed server-to-client union. Exactly one variant
103105 // must be set per envelope.
104106 message ServerEnvelope {
107+ // 29 and 30 mirror the client-direction attachment request tags.
108+ reserved 29, 30;
109+
105110 oneof payload {
106111 Welcome welcome = 1;
107112 ErrorMsg error = 2;
@@ -131,5 +136,7 @@ message ServerEnvelope {
131136 PickerFailure picker_failure = 26;
132137 PickerPreview picker_preview = 27;
133138 PickerControlResponse picker_control_response = 28;
139+ AttachmentSuspended attachment_suspended = 31;
140+ AttachmentActivated attachment_activated = 32;
134141 }
135142 }
M internal/protocol/wire/schema/session.proto +32 -0
@@ -211,3 +211,35 @@ message SamePeerSwitchFailure {
211211 // Closed code taxonomy: 1 = stale target, 2 = unavailable.
212212 uint32 code = 2;
213213 }
214+
215+// SuspendAttachment requests an acknowledged passive publication barrier.
216+message SuspendAttachment {
217+ uint64 request_id = 1;
218+}
219+
220+// AttachmentSuspended is sent only after authority revocation and output drain.
221+message AttachmentSuspended {
222+ uint64 request_id = 1;
223+ ExactTarget target = 2;
224+}
225+
226+// ActivateAttachment targets only the suspended attachment's retained lifecycle.
227+message ActivateAttachment {
228+ uint64 request_id = 1;
229+ ExactTarget target = 2;
230+ uint32 cols = 3;
231+ uint32 rows = 4;
232+ uint32 pixel_width = 5;
233+ uint32 pixel_height = 6;
234+}
235+
236+// AttachmentActivated follows a full Output and changed RoutePosition on a
237+// fresh epoch. The identity and epoch/state/publication fence must match that
238+// Output before input resumes.
239+message AttachmentActivated {
240+ uint64 request_id = 1;
241+ CommittedRouteIdentity identity = 2;
242+ uint64 epoch = 3;
243+ uint64 state = 4;
244+ uint64 view_publication = 5;
245+}
M internal/protocol/wire/session.pb.go +312 -20 generated file: collapsed
A internal/usecase/client/attachment_cache.go +551 -0
@@ -0,0 +1,551 @@
1+package client
2+
3+import (
4+ "container/list"
5+ "context"
6+ "errors"
7+ "log/slog"
8+ "slices"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+// warmActivationTimeout bounds the client-local reuse probe that precedes a
18+// cold dial. It is deliberately separate from protocol.HandshakeTimeout: when
19+// a retained transport stalls, the client logically revokes its ownership at
20+// this deadline and hands the following cold dial a fresh, full handshake
21+// budget. Physical retirement of the stalled transport continues in the
22+// background and is joined at runner shutdown.
23+const warmActivationTimeout = 2 * time.Second
24+
25+// attachmentRetirementJoinTimeout bounds the shutdown join of detached
26+// transports. A transport that violates the ClientConnection.Close contract
27+// must not pin runner teardown.
28+const attachmentRetirementJoinTimeout = 5 * time.Second
29+
30+// attachmentCoordinator owns dormant transports, not endpoint resolution or
31+// route history. Dormant workers may evict their own entry but own no terminal
32+// or route state. One coordinator exists per Runner.Run lifetime and ends in a
33+// terminal closed state.
34+type attachmentCoordinator struct {
35+ mu sync.Mutex
36+ clock ports.Clock
37+ log *slog.Logger
38+ cfg domain.AttachmentCacheConfig
39+ entries map[string]*cachedAttachment
40+ lru *list.List // front = most recently suspended; values are *cachedAttachment
41+ retiring map[*cachedAttachment]struct{}
42+ closed bool
43+}
44+
45+// cachedAttachment is one dormant transport. Each entry owns one LRU element
46+// and at most one dormant worker and one retirement goroutine.
47+type cachedAttachment struct {
48+ transport ports.ClientConnection
49+ inbox chan recvResult
50+ done chan struct{}
51+ readerDone chan struct{}
52+ retireDone chan struct{}
53+ readerStarted bool
54+ retireOnce sync.Once
55+ once sync.Once
56+ key string
57+ element *list.Element
58+ request AttachRequest
59+ identity protocol.CommittedRouteIdentity
60+ resumeToken uint64
61+ epoch uint64
62+ sequence uint64
63+ expires time.Time
64+ timer ports.Timer
65+ stopDormant chan struct{}
66+ dormantDone chan struct{}
67+}
68+
69+func newAttachmentCoordinator(clock ports.Clock, cfg domain.AttachmentCacheConfig, loggers ...*slog.Logger) *attachmentCoordinator {
70+ if clock == nil {
71+ clock = systemClock{}
72+ }
73+ log := slog.Default()
74+ if len(loggers) != 0 && loggers[0] != nil {
75+ log = loggers[0]
76+ }
77+ return &attachmentCoordinator{
78+ clock: clock,
79+ log: log,
80+ cfg: cfg,
81+ entries: make(map[string]*cachedAttachment),
82+ lru: list.New(),
83+ retiring: make(map[*cachedAttachment]struct{}),
84+ }
85+}
86+
87+// enabled reports whether this coordinator retains dormant transports at all.
88+func (c *attachmentCoordinator) enabled() bool {
89+ return c.cfg.Enabled && c.cfg.Valid()
90+}
91+
92+func newCachedAttachment(transport ports.ClientConnection) *cachedAttachment {
93+ return &cachedAttachment{
94+ transport: transport,
95+ inbox: make(chan recvResult, 1),
96+ done: make(chan struct{}),
97+ readerDone: make(chan struct{}),
98+ retireDone: make(chan struct{}),
99+ }
100+}
101+
102+func (e *cachedAttachment) startReader() {
103+ e.readerStarted = true
104+ go func() {
105+ defer close(e.readerDone)
106+ for {
107+ message, err := e.transport.ReceiveServer()
108+ var failure *protocol.DecodeFailure
109+ if errors.As(err, &failure) && (failure.Category == protocol.DecodeUnknownType || failure.Category == protocol.DecodeWrongDirection) {
110+ continue
111+ }
112+ select {
113+ case e.inbox <- recvResult{message: message, err: err}:
114+ case <-e.done:
115+ return
116+ }
117+ if err != nil {
118+ return
119+ }
120+ }
121+ }()
122+}
123+
124+// close signals the port to unblock, exactly once. It never waits.
125+func (e *cachedAttachment) close() {
126+ e.once.Do(func() { close(e.done); _ = e.transport.Close() })
127+}
128+
129+func (e *cachedAttachment) receive(ctx context.Context) (protocol.ServerMessage, error) {
130+ select {
131+ case <-ctx.Done():
132+ return nil, ctx.Err()
133+ case <-e.done:
134+ return nil, errors.New("vev: cached attachment closed")
135+ case r := <-e.inbox:
136+ return r.message, r.err
137+ }
138+}
139+
140+// transition runs under the shared handshake budget, including blocked sends.
141+func (c *attachmentCoordinator) transition(ctx context.Context, e *cachedAttachment, operation func(context.Context) error) error {
142+ bounded, _, finish := newHandshakeContext(ctx, c.clock)
143+ stop := watchHandshakeTransport(bounded, e.transport)
144+ defer finish()
145+ defer stop()
146+ if err := bounded.Err(); err != nil {
147+ e.close()
148+ return err
149+ }
150+ err := operation(bounded)
151+ if err == nil {
152+ err = bounded.Err()
153+ }
154+ if err != nil {
155+ e.close()
156+ }
157+ return err
158+}
159+
160+// warmActivation runs under the client-local warm budget while pumping the
161+// transition toast. At the deadline the caller revokes ownership and the
162+// stalled transport is retired asynchronously: neither a blocked send, a
163+// blocked receive, nor a slow Close delays the cold dial that follows.
164+func (c *attachmentCoordinator) warmActivation(ctx context.Context, e *cachedAttachment, transition *transitionUI, operation func(context.Context) error) error {
165+ bounded, _, finish := newBoundedContext(ctx, c.clock, warmActivationTimeout)
166+ defer finish()
167+ if err := bounded.Err(); err != nil {
168+ return err
169+ }
170+ completed := make(chan error, 1)
171+ go func() { completed <- operation(bounded) }()
172+ for {
173+ select {
174+ case err := <-completed:
175+ if ctxErr := bounded.Err(); ctxErr != nil {
176+ return ctxErr
177+ }
178+ return err
179+ case <-transition.tickC():
180+ if err := transition.advance(); err != nil {
181+ return err
182+ }
183+ case <-bounded.Done():
184+ return bounded.Err()
185+ }
186+ }
187+}
188+
189+// suspend performs the daemon handshake and, on success, installs the entry as
190+// dormant. A false result leaves the entry owned by the caller.
191+func (c *attachmentCoordinator) suspend(ctx context.Context, e *cachedAttachment, request AttachRequest, result attachResult) bool {
192+ if !c.enabled() || !request.Remote || request.OriginKey == "" || result.committedIdentity == nil {
193+ return false
194+ }
195+ e.sequence++
196+ e.identity = *result.committedIdentity
197+ e.request = cloneAttachRequest(request)
198+ e.resumeToken = result.resumeToken
199+ err := c.transition(ctx, e, func(ctx context.Context) error {
200+ if err := e.transport.SendClient(protocol.SuspendAttachment{RequestID: e.sequence}); err != nil {
201+ return err
202+ }
203+ for {
204+ message, err := e.receive(ctx)
205+ if err != nil {
206+ return err
207+ }
208+ switch m := message.(type) {
209+ case protocol.Output:
210+ if m.Epoch > e.epoch {
211+ e.epoch = m.Epoch
212+ }
213+ case protocol.AttachmentSuspended:
214+ if m.Validate() != nil || m.RequestID != e.sequence || m.Target != e.identity.Target {
215+ return protocol.ErrInvalidAttachment
216+ }
217+ return nil
218+ case protocol.ErrorMsg, protocol.Detached:
219+ return protocol.ErrInvalidAttachment
220+ }
221+ }
222+ })
223+ if err != nil {
224+ return false
225+ }
226+ e.key = request.OriginKey
227+ e.expires = c.clock.Now().Add(c.cfg.IdleTimeout)
228+ if c.cfg.IdleTimeout > 0 {
229+ e.timer = c.clock.NewTimer(c.cfg.IdleTimeout)
230+ }
231+ e.stopDormant = make(chan struct{})
232+ e.dormantDone = make(chan struct{})
233+ victims, installed := c.install(e)
234+ if !installed {
235+ close(e.dormantDone)
236+ return false
237+ }
238+ go c.dormantLoop(e)
239+ for _, victim := range victims {
240+ c.retire(victim)
241+ }
242+ c.log.Debug("remote attachment suspended", "origin", e.key, "session", e.identity.Target.SessionName, "lifecycle", e.identity.Target.LifecycleID)
243+ return true
244+}
245+
246+// install links the entry into the exact-key map and the LRU list under the
247+// coordinator lock, then detaches any replaced entry and the least-recently
248+// used dormant entries beyond capacity. Victims are returned for retirement
249+// outside the lock. It reports false once the coordinator is terminal.
250+func (c *attachmentCoordinator) install(e *cachedAttachment) ([]*cachedAttachment, bool) {
251+ c.mu.Lock()
252+ defer c.mu.Unlock()
253+ if c.closed {
254+ return nil, false
255+ }
256+ var victims []*cachedAttachment
257+ if old := c.entries[e.key]; old != nil {
258+ c.removeLocked(old)
259+ victims = append(victims, old)
260+ }
261+ e.element = c.lru.PushFront(e)
262+ c.entries[e.key] = e
263+ for len(c.entries) > c.cfg.Capacity {
264+ back := c.lru.Back()
265+ if back == nil {
266+ break
267+ }
268+ victim := back.Value.(*cachedAttachment)
269+ c.removeLocked(victim)
270+ victims = append(victims, victim)
271+ }
272+ return victims, true
273+}
274+
275+// removeLocked unlinks one entry from both the map and the LRU list.
276+func (c *attachmentCoordinator) removeLocked(e *cachedAttachment) {
277+ if c.entries[e.key] == e {
278+ delete(c.entries, e.key)
279+ }
280+ if e.element != nil {
281+ c.lru.Remove(e.element)
282+ e.element = nil
283+ }
284+}
285+
286+// detach unlinks an entry the dormant worker observed dying or expiring.
287+func (c *attachmentCoordinator) detach(e *cachedAttachment) {
288+ c.mu.Lock()
289+ c.removeLocked(e)
290+ c.mu.Unlock()
291+}
292+
293+// dormantLoop owns the entry between suspension and activation. It drains
294+// output, observes transport death, and, only when an idle timeout is
295+// configured, arms one expiry timer. No timer or extra goroutine exists for
296+// the default no-expiry policy.
297+func (c *attachmentCoordinator) dormantLoop(e *cachedAttachment) {
298+ defer close(e.dormantDone)
299+ if e.timer != nil {
300+ defer e.timer.Stop()
301+ }
302+ var idle <-chan time.Time
303+ if e.timer != nil {
304+ idle = e.timer.C()
305+ }
306+ for {
307+ select {
308+ case <-e.done:
309+ c.detach(e)
310+ return
311+ case <-e.stopDormant:
312+ return
313+ case <-idle:
314+ e.close()
315+ c.detach(e)
316+ return
317+ case r := <-e.inbox:
318+ if r.err != nil {
319+ e.close()
320+ c.detach(e)
321+ return
322+ }
323+ switch r.message.(type) {
324+ case protocol.Detached, protocol.ErrorMsg:
325+ e.close()
326+ c.detach(e)
327+ return
328+ }
329+ }
330+ }
331+}
332+
333+func (e *cachedAttachment) waitDormant() {
334+ if e.dormantDone != nil {
335+ <-e.dormantDone
336+ }
337+}
338+
339+// activate attempts one warm reuse. It detaches the entry under the lock so
340+// no concurrent path can observe it as dormant, then validates eligibility
341+// before sending ActivateAttachment. Every failure retires the transport
342+// outside the lock and reports a miss; the caller proceeds with a cold dial.
343+func (c *attachmentCoordinator) activate(ctx context.Context, request AttachRequest, geometry domain.Geometry, transition *transitionUI) (*cachedAttachment, *protocol.AttachmentActivated, *protocol.Output, *protocol.RoutePosition) {
344+ c.mu.Lock()
345+ e := c.entries[request.OriginKey]
346+ if e != nil {
347+ c.removeLocked(e)
348+ }
349+ closed := c.closed
350+ c.mu.Unlock()
351+ if e == nil {
352+ return nil, nil, nil, nil
353+ }
354+ if closed {
355+ c.retire(e)
356+ return nil, nil, nil, nil
357+ }
358+ // A retained entry means a real reuse probe is starting: show its progress
359+ // exactly like a daemon-authored transition, without entering raw mode
360+ // early (the toast only paints once raw is already active).
361+ transition.start(warmActivationTarget(request))
362+ close(e.stopDormant)
363+ e.waitDormant()
364+ fail := func(reason string, err error) (*cachedAttachment, *protocol.AttachmentActivated, *protocol.Output, *protocol.RoutePosition) {
365+ args := []any{"origin", e.key, "session", e.identity.Target.SessionName, "reason", reason}
366+ if err != nil {
367+ args = append(args, "err", err)
368+ }
369+ c.log.Debug("warm remote activation failed", args...)
370+ c.retire(e)
371+ return nil, nil, nil, nil
372+ }
373+ select {
374+ case <-e.done:
375+ return fail("transport closed", nil)
376+ default:
377+ }
378+ if (c.cfg.IdleTimeout > 0 && !c.clock.Now().Before(e.expires)) || !request.Remote || request.Intent == protocol.IntentNew || request.SessionName != e.identity.Target.SessionName || request.EnvironmentPolicy != e.request.EnvironmentPolicy || !slices.Equal(request.Environment, e.request.Environment) {
379+ return fail("request ineligible", nil)
380+ }
381+ // Activation retains the attachment cursor; unlike cold Hello it cannot
382+ // apply a different preferred tab (including a disappeared tab's fallback).
383+ if request.PreferredTabID != "" && request.PreferredTabID != e.request.PreferredTabID {
384+ return fail("preferred tab changed", nil)
385+ }
386+ if request.ExactTarget != nil && *request.ExactTarget != e.identity.Target {
387+ return fail("exact target changed", nil)
388+ }
389+ if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != e.identity.Target.LifecycleID || request.RemoteTarget.SessionName != e.identity.Target.SessionName) {
390+ return fail("remote target changed", nil)
391+ }
392+ e.sequence++
393+ var full *protocol.Output
394+ var activated *protocol.AttachmentActivated
395+ var position *protocol.RoutePosition
396+ err := c.warmActivation(ctx, e, transition, func(ctx context.Context) error {
397+ // The dormant worker has relinquished inbox ownership. Drain messages
398+ // already observed before sending activation; ordinary output after
399+ // suspension is invalid, but must never become an activation response.
400+ // Once Activate is sent, publication validation remains strict.
401+ if err := e.drainDormant(); err != nil {
402+ return err
403+ }
404+ if err := e.transport.SendClient(protocol.ActivateAttachment{RequestID: e.sequence, Target: e.identity.Target, Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}); err != nil {
405+ return err
406+ }
407+ for {
408+ message, err := e.receive(ctx)
409+ if err != nil {
410+ return err
411+ }
412+ switch m := message.(type) {
413+ case protocol.Output:
414+ if full != nil || !m.Full || m.Base != 0 || m.Epoch <= e.epoch || m.New == 0 || m.Context == nil || m.Context.Validate() != nil || m.Context.Route.Target != e.identity.Target {
415+ return protocol.ErrInvalidAttachment
416+ }
417+ full = &m
418+ case protocol.RoutePosition:
419+ if full == nil || position != nil || m.Validate() != nil || m.Target != e.identity.Target {
420+ return protocol.ErrInvalidAttachment
421+ }
422+ position = &m
423+ case protocol.AttachmentActivated:
424+ if position == nil || m.Validate() != nil || m.RequestID != e.sequence || m.Identity.Target != e.identity.Target || full == nil || m.Epoch != full.Epoch || m.State != full.New || m.ViewPublication != full.Context.Publication || m.Identity != full.Context.Route {
425+ return protocol.ErrInvalidAttachment
426+ }
427+ activated = &m
428+ return nil
429+ case protocol.ErrorMsg, protocol.Detached:
430+ return protocol.ErrInvalidAttachment
431+ }
432+ }
433+ })
434+ if err != nil {
435+ return fail("transition failed", err)
436+ }
437+ e.epoch = full.Epoch
438+ c.log.Debug("warm remote attachment activated", "origin", e.key, "session", e.identity.Target.SessionName, "lifecycle", e.identity.Target.LifecycleID, "epoch", e.epoch)
439+ return e, activated, full, position
440+}
441+
442+func (e *cachedAttachment) drainDormant() error {
443+ for {
444+ select {
445+ case r := <-e.inbox:
446+ if r.err != nil {
447+ return r.err
448+ }
449+ switch r.message.(type) {
450+ case protocol.Output:
451+ // Discard only pre-activation ordinary output.
452+ default:
453+ return protocol.ErrInvalidAttachment
454+ }
455+ default:
456+ return nil
457+ }
458+ }
459+}
460+
461+// retire detaches a transport from coordinator bookkeeping and physically
462+// closes it outside the lock. The join runs in one tracked goroutine per
463+// entry, so eviction, replacement, and navigation never serialize a slow
464+// physical close and shutdown can bound the wait.
465+func (c *attachmentCoordinator) retire(e *cachedAttachment) {
466+ if e == nil {
467+ return
468+ }
469+ e.retireOnce.Do(func() {
470+ c.mu.Lock()
471+ c.retiring[e] = struct{}{}
472+ c.mu.Unlock()
473+ go func() {
474+ e.close()
475+ e.waitDormant()
476+ if e.readerStarted {
477+ <-e.readerDone
478+ }
479+ close(e.retireDone)
480+ c.mu.Lock()
481+ delete(c.retiring, e)
482+ c.mu.Unlock()
483+ }()
484+ })
485+}
486+
487+// close is terminal: it rejects later suspension or insertion, detaches every
488+// dormant entry and in-flight retirement, signals all physical closes before
489+// joining any of them, and then performs one clock-bounded join.
490+func (c *attachmentCoordinator) close() {
491+ c.mu.Lock()
492+ if c.closed {
493+ c.mu.Unlock()
494+ return
495+ }
496+ c.closed = true
497+ dormant := make([]*cachedAttachment, 0, len(c.entries))
498+ for _, e := range c.entries {
499+ // Clear the LRU link so a dormant worker that observes the close never
500+ // unlinks a stale element from the freshly reset list.
501+ e.element = nil
502+ dormant = append(dormant, e)
503+ }
504+ c.entries = make(map[string]*cachedAttachment)
505+ c.lru.Init()
506+ c.mu.Unlock()
507+ for _, e := range dormant {
508+ c.retire(e)
509+ }
510+ c.joinRetirements()
511+}
512+
513+// joinRetirements waits for every tracked retirement within one bound. It
514+// allocates no timer when nothing is retiring, so a quiet shutdown produces no
515+// clock traffic. It never holds the coordinator lock while waiting.
516+func (c *attachmentCoordinator) joinRetirements() {
517+ c.mu.Lock()
518+ pending := c.snapshotRetiringLocked()
519+ c.mu.Unlock()
520+ if len(pending) == 0 {
521+ return
522+ }
523+ timer := c.clock.NewTimer(attachmentRetirementJoinTimeout)
524+ if timer == nil {
525+ timer = systemClock{}.NewTimer(attachmentRetirementJoinTimeout)
526+ }
527+ defer timer.Stop()
528+ for {
529+ for _, e := range pending {
530+ select {
531+ case <-e.retireDone:
532+ case <-timer.C():
533+ return
534+ }
535+ }
536+ c.mu.Lock()
537+ pending = c.snapshotRetiringLocked()
538+ c.mu.Unlock()
539+ if len(pending) == 0 {
540+ return
541+ }
542+ }
543+}
544+
545+func (c *attachmentCoordinator) snapshotRetiringLocked() []*cachedAttachment {
546+ pending := make([]*cachedAttachment, 0, len(c.retiring))
547+ for e := range c.retiring {
548+ pending = append(pending, e)
549+ }
550+ return pending
551+}
A internal/usecase/client/attachment_cache_test.go +612 -0
@@ -0,0 +1,612 @@
1+package client
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func releaseCachedAttachment(e *cachedAttachment) {
18+ e.close()
19+ e.waitDormant()
20+ if e.readerStarted {
21+ <-e.readerDone
22+ }
23+}
24+
25+func attachmentCacheHas(c *attachmentCoordinator, endpoint string) bool {
26+ c.mu.Lock()
27+ defer c.mu.Unlock()
28+ return c.entries[endpoint] != nil
29+}
30+
31+// The peer uses typed messages so the tests exercise publication correlation,
32+// reader ownership and terminal isolation independently of carriage encoding.
33+type cacheTestPeer struct {
34+ ports.ClientConnection
35+ messages chan protocol.ServerMessage
36+ sent chan protocol.ClientMessage
37+ closed chan struct{}
38+ once sync.Once
39+ identity protocol.CommittedRouteIdentity
40+ reject bool
41+ activation func(protocol.ActivateAttachment)
42+ // beforeSend, beforeRecv, and onClose let lifecycle tests stall one
43+ // direction at a time. onClose runs after closed is signaled, modelling a
44+ // transport whose Close unblocks readers but returns slowly.
45+ beforeSend func(protocol.ClientMessage)
46+ beforeRecv func()
47+ afterClose func()
48+ onClose func()
49+}
50+
51+func testCacheConfig(idle time.Duration) domain.AttachmentCacheConfig {
52+ return domain.AttachmentCacheConfig{Enabled: true, Capacity: domain.DefaultAttachmentCacheCapacity, IdleTimeout: idle}
53+}
54+
55+func newCacheTestPeer() *cacheTestPeer {
56+ return &cacheTestPeer{messages: make(chan protocol.ServerMessage, 16), sent: make(chan protocol.ClientMessage, 16), closed: make(chan struct{}), identity: testOutputView(1).Route}
57+}
58+func (p *cacheTestPeer) Close() error {
59+ p.once.Do(func() {
60+ close(p.closed)
61+ if p.onClose != nil {
62+ p.onClose()
63+ }
64+ })
65+ return nil
66+}
67+func (p *cacheTestPeer) ReceiveServer() (protocol.ServerMessage, error) {
68+ if p.beforeRecv != nil {
69+ p.beforeRecv()
70+ }
71+ select {
72+ case m := <-p.messages:
73+ return m, nil
74+ case <-p.closed:
75+ if p.afterClose != nil {
76+ p.afterClose()
77+ }
78+ return nil, io.EOF
79+ }
80+}
81+func (p *cacheTestPeer) SendClient(message protocol.ClientMessage) error {
82+ if p.beforeSend != nil {
83+ p.beforeSend(message)
84+ }
85+ p.sent <- message
86+ switch m := message.(type) {
87+ case protocol.SuspendAttachment:
88+ p.messages <- protocol.AttachmentSuspended{RequestID: m.RequestID, Target: p.identity.Target}
89+ case protocol.ActivateAttachment:
90+ if p.activation != nil {
91+ p.activation(m)
92+ return nil
93+ }
94+ if p.reject {
95+ p.messages <- protocol.ErrorMsg{}
96+ return nil
97+ }
98+ view := testOutputView(2)
99+ p.messages <- protocol.Output{Epoch: 2, New: 1, Full: true, Context: &view, Data: []byte("full")}
100+ p.messages <- protocol.RoutePosition{Target: view.Route.Target, ActiveTabID: "tab-2"}
101+ p.messages <- protocol.AttachmentActivated{RequestID: m.RequestID, Identity: view.Route, Epoch: 2, State: 1, ViewPublication: 2}
102+ }
103+ return nil
104+}
105+func cacheTestRequest(endpoint string) AttachRequest {
106+ return AttachRequest{Remote: true, OriginKey: endpoint, Intent: protocol.IntentAttach, SessionName: "work"}
107+}
108+
109+// peerClosed reports whether Close has been called on a test peer.
110+func peerClosed(p *cacheTestPeer) bool {
111+ select {
112+ case <-p.closed:
113+ return true
114+ default:
115+ return false
116+ }
117+}
118+func parkTestPeer(t *testing.T, c *attachmentCoordinator, endpoint string) (*cachedAttachment, *cacheTestPeer) {
119+ t.Helper()
120+ p := newCacheTestPeer()
121+ e := newCachedAttachment(p)
122+ e.startReader()
123+ e.epoch = 1
124+ require.True(t, c.suspend(context.Background(), e, cacheTestRequest(endpoint), attachResult{committedIdentity: &p.identity, resumeToken: 7}))
125+ return e, p
126+}
127+func TestAttachmentCacheMultipleEndpointsAndFullActivation(t *testing.T) {
128+ c := newAttachmentCoordinator(newInventoryTestClock(), testCacheConfig(time.Minute))
129+ defer c.close()
130+ a, pa := parkTestPeer(t, c, "a")
131+ b, pb := parkTestPeer(t, c, "b")
132+ for _, tt := range []struct {
133+ key string
134+ entry *cachedAttachment
135+ peer *cacheTestPeer
136+ }{{"a", a, pa}, {"b", b, pb}} {
137+ t.Run(tt.key, func(t *testing.T) {
138+ geometry := domain.Geometry{Size: domain.Size{Cols: 101, Rows: 37}}
139+ got, ack, full, position := c.activate(context.Background(), cacheTestRequest(tt.key), geometry, nil)
140+ require.Same(t, tt.entry, got)
141+ require.NotNil(t, ack)
142+ require.Equal(t, protocol.RoutePosition{Target: ack.Identity.Target, ActiveTabID: "tab-2"}, *position)
143+ require.Equal(t, []byte("full"), full.Data)
144+ require.IsType(t, protocol.SuspendAttachment{}, <-tt.peer.sent)
145+ activation := (<-tt.peer.sent).(protocol.ActivateAttachment)
146+ require.Equal(t, geometry.Size, activation.Size)
147+ got.close()
148+ <-got.readerDone
149+ })
150+ }
151+}
152+func TestAttachmentCacheEviction(t *testing.T) {
153+ for _, scenario := range []string{"expiry", "death", "stale lifecycle", "activation failure", "cancellation"} {
154+ t.Run(scenario, func(t *testing.T) {
155+ clock := newInventoryTestClock()
156+ c := newAttachmentCoordinator(clock, testCacheConfig(time.Minute))
157+ defer c.close()
158+ e, p := parkTestPeer(t, c, "a")
159+ request := cacheTestRequest("a")
160+ ctx := context.Background()
161+ switch scenario {
162+ case "expiry":
163+ e.timer.(*inventoryTestTimer).ch <- clock.Now()
164+ <-e.dormantDone
165+ case "death":
166+ _ = p.Close()
167+ <-e.dormantDone
168+ case "stale lifecycle":
169+ target := p.identity.Target
170+ target.LifecycleID[0]++
171+ request.ExactTarget = &target
172+ case "activation failure":
173+ p.reject = true
174+ case "cancellation":
175+ var cancel context.CancelFunc
176+ ctx, cancel = context.WithCancel(ctx)
177+ cancel()
178+ }
179+ got, _, _, _ := c.activate(ctx, request, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil)
180+ require.Nil(t, got)
181+ <-p.closed
182+ <-e.readerDone
183+ require.Empty(t, c.entries)
184+ })
185+ }
186+}
187+func TestAttachmentCacheDormantDrainAndExit(t *testing.T) {
188+ c := newAttachmentCoordinator(newInventoryTestClock(), testCacheConfig(time.Minute))
189+ e, p := parkTestPeer(t, c, "a")
190+ for i := 0; i < 100; i++ {
191+ p.messages <- protocol.Output{Data: []byte("must never reach terminal")}
192+ }
193+ c.close()
194+ <-p.closed
195+ <-e.readerDone
196+ <-e.dormantDone
197+ require.Empty(t, c.entries)
198+ require.Len(t, p.sent, 1, "dormant entry emits no input, resize or output ACK")
199+}
200+
201+func TestAttachmentCacheRejectsUncommittedPublication(t *testing.T) {
202+ for _, scenario := range []string{"ack before full", "wrong request", "wrong epoch", "wrong publication", "wrong lifecycle", "old output chain", "ordinary output after activation", "missing position", "invalid position", "wrong position target", "position before full", "duplicate position"} {
203+ t.Run(scenario, func(t *testing.T) {
204+ c := newAttachmentCoordinator(newInventoryTestClock(), testCacheConfig(time.Minute))
205+ defer c.close()
206+ e, p := parkTestPeer(t, c, "a")
207+ p.activation = func(request protocol.ActivateAttachment) {
208+ view := testOutputView(2)
209+ full := protocol.Output{Epoch: 2, New: 1, Full: true, Context: &view}
210+ ack := protocol.AttachmentActivated{RequestID: request.RequestID, Identity: view.Route, Epoch: 2, State: 1, ViewPublication: 2}
211+ switch scenario {
212+ case "ack before full":
213+ p.messages <- ack
214+ return
215+ case "wrong request":
216+ ack.RequestID++
217+ case "wrong epoch":
218+ ack.Epoch++
219+ case "wrong publication":
220+ ack.ViewPublication++
221+ case "wrong lifecycle":
222+ ack.Identity.Target.LifecycleID[0]++
223+ case "ordinary output after activation":
224+ full.Full = false
225+ case "old output chain":
226+ full.Epoch = 1
227+ }
228+ position := protocol.RoutePosition{Target: view.Route.Target, ActiveTabID: "tab-2"}
229+ switch scenario {
230+ case "invalid position":
231+ position.ActiveTabID = ""
232+ case "wrong position target":
233+ position.Target.LifecycleID[0]++
234+ case "position before full":
235+ p.messages <- position
236+ }
237+ p.messages <- full
238+ if scenario != "missing position" {
239+ p.messages <- position
240+ }
241+ if scenario == "duplicate position" {
242+ p.messages <- position
243+ }
244+ p.messages <- ack
245+ }
246+ got, _, _, _ := c.activate(context.Background(), cacheTestRequest("a"), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil)
247+ require.Nil(t, got)
248+ <-e.readerDone
249+ require.False(t, attachmentCacheHas(c, "a"))
250+ })
251+ }
252+}
253+
254+func TestAttachmentCachePreferredTabEligibility(t *testing.T) {
255+ for _, tt := range []struct {
256+ name string
257+ preferred domain.TabStableID
258+ warm bool
259+ }{
260+ {"no preference", "", true},
261+ {"matching committed tab", "tab-2", true},
262+ {"different tab", "tab-1", false},
263+ {"disappeared tab", "missing", false},
264+ } {
265+ t.Run(tt.name, func(t *testing.T) {
266+ c := newAttachmentCoordinator(newInventoryTestClock(), testCacheConfig(time.Minute))
267+ defer c.close()
268+ e, p := parkTestPeer(t, c, "a")
269+ e.request.PreferredTabID = "tab-2"
270+ request := cacheTestRequest("a")
271+ request.PreferredTabID = tt.preferred
272+ got, _, _, _ := c.activate(context.Background(), request, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil)
273+ if tt.warm {
274+ require.Same(t, e, got)
275+ releaseCachedAttachment(got)
276+ } else {
277+ require.Nil(t, got)
278+ require.Len(t, p.sent, 1, "mismatched preference must fall back before activation")
279+ }
280+ })
281+ }
282+}
283+
284+func TestAttachmentCacheActivationDrainsQueuedDormantMessages(t *testing.T) {
285+ for _, tt := range []struct {
286+ name string
287+ queued recvResult
288+ warm bool
289+ }{
290+ {"ordinary output", recvResult{message: protocol.Output{Data: []byte("discard")}}, true},
291+ {"receive error", recvResult{err: io.EOF}, false},
292+ {"detached", recvResult{message: protocol.Detached{}}, false},
293+ {"server error", recvResult{message: protocol.ErrorMsg{}}, false},
294+ {"premature activation", recvResult{message: protocol.AttachmentActivated{}}, false},
295+ } {
296+ t.Run(tt.name, func(t *testing.T) {
297+ c := newAttachmentCoordinator(newInventoryTestClock(), testCacheConfig(time.Minute))
298+ defer c.close()
299+ e, p := parkTestPeer(t, c, "a")
300+ // Freeze dormant consumption so the message is deterministically
301+ // queued at ownership transfer, rather than racing the worker.
302+ close(e.stopDormant)
303+ e.waitDormant()
304+ e.stopDormant = make(chan struct{})
305+ e.inbox <- tt.queued
306+ got, _, full, _ := c.activate(context.Background(), cacheTestRequest("a"), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil)
307+ if tt.warm {
308+ require.Same(t, e, got)
309+ require.Equal(t, []byte("full"), full.Data)
310+ releaseCachedAttachment(got)
311+ } else {
312+ require.Nil(t, got)
313+ require.Len(t, p.sent, 1)
314+ }
315+ })
316+ }
317+}
318+
319+// attachmentClock records every armed timer with its duration so lifecycle
320+// tests can fire exactly the timer they mean instead of keying on a duration
321+// inside the clock itself.
322+type attachmentClock struct {
323+ mu sync.Mutex
324+ now time.Time
325+ timers []*attachmentTimer
326+}
327+
328+type attachmentTimer struct {
329+ ch chan time.Time
330+ d time.Duration
331+}
332+
333+func newAttachmentClock() *attachmentClock {
334+ return &attachmentClock{now: time.Unix(1000, 0)}
335+}
336+
337+func (c *attachmentClock) Now() time.Time {
338+ c.mu.Lock()
339+ defer c.mu.Unlock()
340+ return c.now
341+}
342+
343+// armedFor reports whether a timer of duration d was ever armed.
344+func (c *attachmentClock) armedFor(d time.Duration) bool {
345+ c.mu.Lock()
346+ defer c.mu.Unlock()
347+ for _, timer := range c.timers {
348+ if timer.d == d {
349+ return true
350+ }
351+ }
352+ return false
353+}
354+
355+func (c *attachmentClock) timerCount() int {
356+ c.mu.Lock()
357+ defer c.mu.Unlock()
358+ return len(c.timers)
359+}
360+
361+func (c *attachmentClock) NewTimer(d time.Duration) ports.Timer {
362+ timer := &attachmentTimer{ch: make(chan time.Time, 1), d: d}
363+ c.mu.Lock()
364+ c.timers = append(c.timers, timer)
365+ c.mu.Unlock()
366+ return timer
367+}
368+
369+func (t *attachmentTimer) C() <-chan time.Time { return t.ch }
370+func (*attachmentTimer) Reset(time.Duration) bool { return false }
371+func (*attachmentTimer) Stop() bool { return true }
372+
373+// fire delivers the most recently armed timer for d once one is registered.
374+func (c *attachmentClock) fire(t *testing.T, d time.Duration) {
375+ t.Helper()
376+ deadline := time.Now().Add(2 * time.Second)
377+ for time.Now().Before(deadline) {
378+ c.mu.Lock()
379+ var match *attachmentTimer
380+ for _, timer := range c.timers {
381+ if timer.d == d {
382+ match = timer
383+ }
384+ }
385+ c.mu.Unlock()
386+ if match != nil {
387+ match.ch <- time.Now()
388+ return
389+ }
390+ time.Sleep(time.Millisecond)
391+ }
392+ t.Fatalf("no timer armed for %s", d)
393+}
394+
395+func TestAttachmentCacheLRUCapacityAndRecency(t *testing.T) {
396+ t.Run("capacity one evicts the replaced destination", func(t *testing.T) {
397+ c := newAttachmentCoordinator(newAttachmentClock(), domain.AttachmentCacheConfig{Enabled: true, Capacity: 1})
398+ defer c.close()
399+ _, pa := parkTestPeer(t, c, "a")
400+ _, pb := parkTestPeer(t, c, "b")
401+ require.False(t, attachmentCacheHas(c, "a"))
402+ require.True(t, attachmentCacheHas(c, "b"))
403+ <-pa.closed
404+ require.False(t, peerClosed(pb), "the retained destination stays open")
405+ })
406+ t.Run("nine destinations retain the newest eight", func(t *testing.T) {
407+ c := newAttachmentCoordinator(newAttachmentClock(), testCacheConfig(0))
408+ defer c.close()
409+ peers := make(map[string]*cacheTestPeer)
410+ for i := 0; i < 9; i++ {
411+ key := fmt.Sprintf("e%d", i)
412+ _, p := parkTestPeer(t, c, key)
413+ peers[key] = p
414+ }
415+ require.False(t, attachmentCacheHas(c, "e0"), "oldest dormant destination is evicted")
416+ <-peers["e0"].closed
417+ for i := 1; i < 9; i++ {
418+ require.True(t, attachmentCacheHas(c, fmt.Sprintf("e%d", i)))
419+ }
420+ })
421+ t.Run("least recently used dormant entry is evicted first", func(t *testing.T) {
422+ c := newAttachmentCoordinator(newAttachmentClock(), domain.AttachmentCacheConfig{Enabled: true, Capacity: 2})
423+ defer c.close()
424+ _, pa := parkTestPeer(t, c, "a")
425+ _, pb := parkTestPeer(t, c, "b")
426+ _, pc := parkTestPeer(t, c, "c")
427+ require.False(t, attachmentCacheHas(c, "a"))
428+ require.True(t, attachmentCacheHas(c, "b"))
429+ require.True(t, attachmentCacheHas(c, "c"))
430+ <-pa.closed
431+ require.False(t, peerClosed(pb))
432+ require.False(t, peerClosed(pc))
433+ })
434+ t.Run("re-suspension makes the entry most recently used", func(t *testing.T) {
435+ c := newAttachmentCoordinator(newAttachmentClock(), domain.AttachmentCacheConfig{Enabled: true, Capacity: 2})
436+ defer c.close()
437+ e, pa := parkTestPeer(t, c, "a")
438+ _, pb := parkTestPeer(t, c, "b")
439+ got, _, _, _ := c.activate(context.Background(), cacheTestRequest("a"), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil)
440+ require.Same(t, e, got)
441+ require.True(t, c.suspend(context.Background(), e, cacheTestRequest("a"), attachResult{committedIdentity: &pa.identity, resumeToken: 7}))
442+ _, pc := parkTestPeer(t, c, "c")
443+ require.True(t, attachmentCacheHas(c, "a"), "recently used entry survives")
444+ require.False(t, attachmentCacheHas(c, "b"), "older entry is evicted")
445+ require.True(t, attachmentCacheHas(c, "c"))
446+ <-pb.closed
447+ require.False(t, peerClosed(pa), "the re-suspended entry stays retained")
448+ require.False(t, peerClosed(pc))
449+ })
450+ t.Run("aliases stay distinct", func(t *testing.T) {
451+ c := newAttachmentCoordinator(newAttachmentClock(), domain.AttachmentCacheConfig{Enabled: true, Capacity: 1})
452+ defer c.close()
453+ _, _ = parkTestPeer(t, c, "alias-one")
454+ _, _ = parkTestPeer(t, c, "alias-two")
455+ require.True(t, attachmentCacheHas(c, "alias-two"))
456+ require.False(t, attachmentCacheHas(c, "alias-one"))
457+ })
458+}
459+
460+func TestAttachmentCacheDisabledPolicyRetainsNothing(t *testing.T) {
461+ clock := newAttachmentClock()
462+ c := newAttachmentCoordinator(clock, domain.AttachmentCacheConfig{Enabled: false})
463+ p := newCacheTestPeer()
464+ e := newCachedAttachment(p)
465+ e.startReader()
466+ require.False(t, c.suspend(context.Background(), e, cacheTestRequest("a"), attachResult{committedIdentity: &p.identity, resumeToken: 1}))
467+ require.Empty(t, c.entries)
468+ require.Zero(t, clock.timerCount())
469+ require.False(t, c.enabled())
470+ releaseCachedAttachment(e)
471+}
472+
473+func TestAttachmentCacheWithoutIdleTimeoutArmsNoTimer(t *testing.T) {
474+ clock := newAttachmentClock()
475+ c := newAttachmentCoordinator(clock, testCacheConfig(0))
476+ defer c.close()
477+ e, _ := parkTestPeer(t, c, "a")
478+ require.Nil(t, e.timer, "no client age expiry allocates no idle timer")
479+ require.False(t, clock.armedFor(time.Minute), "only the shared handshake timer may exist")
480+ require.True(t, attachmentCacheHas(c, "a"), "an entry without a timeout is retained")
481+}
482+
483+func TestAttachmentCacheShutdownInitiatesEveryCloseBeforeJoining(t *testing.T) {
484+ const peers = 4
485+ c := newAttachmentCoordinator(newAttachmentClock(), testCacheConfig(0))
486+ var closed sync.WaitGroup
487+ closed.Add(peers)
488+ entries := make([]*cachedAttachment, 0, peers)
489+ for i := 0; i < peers; i++ {
490+ p := newCacheTestPeer()
491+ // The reader only completes after every peer received Close, so a
492+ // serialized close-then-join shutdown can never make progress.
493+ p.onClose = closed.Done
494+ p.afterClose = closed.Wait
495+ e := newCachedAttachment(p)
496+ e.startReader()
497+ require.True(t, c.suspend(context.Background(), e, cacheTestRequest(fmt.Sprintf("e%d", i)), attachResult{committedIdentity: &p.identity, resumeToken: 1}))
498+ entries = append(entries, e)
499+ }
500+ done := make(chan struct{})
501+ go func() {
502+ c.close()
503+ close(done)
504+ }()
505+ select {
506+ case <-done:
507+ case <-time.After(5 * time.Second):
508+ t.Fatal("shutdown serialized transport closes instead of initiating all of them first")
509+ }
510+ for _, e := range entries {
511+ _, err := e.receive(context.Background())
512+ require.Error(t, err)
513+ }
514+}
515+
516+func TestAttachmentCacheShutdownIsBoundedWhenCloseStalls(t *testing.T) {
517+ clock := newAttachmentClock()
518+ c := newAttachmentCoordinator(clock, testCacheConfig(0))
519+ release := make(chan struct{})
520+ p := newCacheTestPeer()
521+ p.afterClose = func() { <-release }
522+ e := newCachedAttachment(p)
523+ e.startReader()
524+ require.True(t, c.suspend(context.Background(), e, cacheTestRequest("a"), attachResult{committedIdentity: &p.identity, resumeToken: 1}))
525+ done := make(chan struct{})
526+ go func() {
527+ c.close()
528+ close(done)
529+ }()
530+ <-p.closed
531+ clock.fire(t, attachmentRetirementJoinTimeout)
532+ select {
533+ case <-done:
534+ case <-time.After(2 * time.Second):
535+ t.Fatal("shutdown did not honor its clock bound")
536+ }
537+ close(release)
538+}
539+
540+func TestAttachmentCacheShutdownRacesLifecycle(t *testing.T) {
541+ for i := 0; i < 25; i++ {
542+ clock := newAttachmentClock()
543+ c := newAttachmentCoordinator(clock, testCacheConfig(time.Minute))
544+ e, p := parkTestPeer(t, c, "a")
545+ start := make(chan struct{})
546+ var wg sync.WaitGroup
547+ wg.Add(3)
548+ go func() { defer wg.Done(); <-start; c.close() }()
549+ go func() { defer wg.Done(); <-start; _ = p.Close() }()
550+ go func() {
551+ defer wg.Done()
552+ <-start
553+ c.activate(context.Background(), cacheTestRequest("a"), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil)
554+ }()
555+ close(start)
556+ wg.Wait()
557+ releaseCachedAttachment(e)
558+ require.False(t, attachmentCacheHas(c, "a"))
559+ }
560+}
561+
562+func TestAttachmentCacheWarmActivationDeadlineFallsBack(t *testing.T) {
563+ ctx := context.Background()
564+ geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
565+ for _, tt := range []struct {
566+ name string
567+ stall func(p *cacheTestPeer, release <-chan struct{})
568+ }{
569+ {"blocked receive", func(p *cacheTestPeer, release <-chan struct{}) {
570+ p.activation = func(protocol.ActivateAttachment) { <-release }
571+ }},
572+ {"blocked send", func(p *cacheTestPeer, release <-chan struct{}) {
573+ p.beforeSend = func(m protocol.ClientMessage) {
574+ if _, ok := m.(protocol.ActivateAttachment); ok {
575+ <-release
576+ }
577+ }
578+ }},
579+ {"slow close after revocation", func(p *cacheTestPeer, release <-chan struct{}) {
580+ p.activation = func(protocol.ActivateAttachment) { <-release }
581+ p.onClose = func() { <-release }
582+ }},
583+ } {
584+ t.Run(tt.name, func(t *testing.T) {
585+ clock := newAttachmentClock()
586+ c := newAttachmentCoordinator(clock, testCacheConfig(0))
587+ release := make(chan struct{})
588+ p := newCacheTestPeer()
589+ tt.stall(p, release)
590+ e := newCachedAttachment(p)
591+ e.startReader()
592+ require.True(t, c.suspend(ctx, e, cacheTestRequest("a"), attachResult{committedIdentity: &p.identity, resumeToken: 1}))
593+ activated := make(chan *cachedAttachment, 1)
594+ go func() {
595+ got, _, _, _ := c.activate(ctx, cacheTestRequest("a"), geometry, nil)
596+ activated <- got
597+ }()
598+ clock.fire(t, warmActivationTimeout)
599+ select {
600+ case got := <-activated:
601+ require.Nil(t, got, "the deadline revokes ownership instead of reusing a stalled transport")
602+ case <-time.After(2 * time.Second):
603+ t.Fatal("warm activation did not relinquish ownership at its deadline")
604+ }
605+ require.False(t, attachmentCacheHas(c, "a"))
606+ // Release the deliberate stall so physical retirement can finish
607+ // before the deferred shutdown joins it.
608+ close(release)
609+ c.close()
610+ })
611+ }
612+}
M internal/usecase/client/client.go +168 -22
@@ -151,6 +151,9 @@ type Dependencies struct {
151151 Dialer ports.ClientDialer
152152 Terminal ports.Terminal
153153 Clock ports.Clock
154+ // AttachmentCache is the retention policy for suspended remote
155+ // attachments. Its zero value disables reuse.
156+ AttachmentCache domain.AttachmentCacheConfig
154157 // DisableCapabilityProbe skips active outer-terminal discovery for headless
155158 // embedders and deterministic tests. Interactive clients leave it false.
156159 DisableCapabilityProbe bool
@@ -232,6 +235,17 @@ func (r *Runner) resolveHandoff(ctx context.Context, target protocol.AttachTarge
232235 }, nil
233236 }
234237
238+// warmActivationTarget renders the transition toast for a retained transport's
239+// reactivation. It carries no authority: the cache validates the real request
240+// and exact target before sending ActivateAttachment.
241+func warmActivationTarget(request AttachRequest) protocol.AttachTarget {
242+ return protocol.AttachTarget{
243+ Endpoint: request.OriginKey,
244+ Session: request.SessionName,
245+ RemoteTarget: request.RemoteTarget,
246+ }
247+}
248+
235249 // handoffEnvironmentPolicy mirrors the composition root's picker rule: a route
236250 // chosen from a locally launched client has no explicit remote selection, so
237251 // its environment belongs to the daemon; every other target keeps the policy
@@ -299,6 +313,7 @@ type Runner struct {
299313 logger *slog.Logger
300314 runtimeObserver ports.SerializedRuntimeObserver
301315 hostRegistry ports.ClientHostRegistry
316+ attachmentCache domain.AttachmentCacheConfig
302317 // launchedRemote records whether this runner was launched as a direct
303318 // remote attach. It decides the handoff environment policy exactly like the
304319 // composition root used to: a picker route from a locally launched client
@@ -340,6 +355,7 @@ func NewRunner(deps Dependencies) *Runner {
340355 logger: log,
341356 runtimeObserver: deps.RuntimeObserver,
342357 hostRegistry: deps.HostRegistry,
358+ attachmentCache: deps.AttachmentCache,
343359 launchedRemote: deps.Remote,
344360 remote: deps.Remote,
345361 localControlDialer: deps.LocalControlDialer,
@@ -569,6 +585,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
569585 return nil
570586 }
571587
588+ cache := newAttachmentCoordinator(r.clock, r.attachmentCache, r.logger)
589+ defer cache.close()
572590 resumeToken := uint64(0)
573591 attemptRequest := request
574592 dialer := r.dialer
@@ -666,8 +684,26 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
666684 if err := validateAttachRequest(attemptRequest); err != nil {
667685 return err
668686 }
687+ var entry *cachedAttachment
688+ var activated *protocol.AttachmentActivated
689+ var activationOutput *protocol.Output
690+ var activationPosition *protocol.RoutePosition
691+ if attemptRequest.Remote && attemptRequest.OriginKey != "" {
692+ if geometry, geometryErr := r.term.Geometry(); geometryErr == nil {
693+ // One activation attempt replaces the old lookup-then-activate
694+ // pair: a miss returns nil and falls through to a cold dial
695+ // without starting warm-probe transition feedback.
696+ entry, activated, activationOutput, activationPosition = cache.activate(ctx, attemptRequest, geometry.NormalizePixels(), transition)
697+ }
698+ }
669699 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
670- transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)
700+ var transport ports.ClientConnection
701+ var err error
702+ if entry != nil {
703+ transport = entry.transport
704+ } else {
705+ transport, err = boundedDialWithTransition(handshakeCtx, dialer, transition)
706+ }
671707 if err != nil {
672708 if r.ui != nil {
673709 r.ui.failHandoff()
@@ -752,7 +788,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
752788 if err != nil {
753789 stopHandshakeTransport()
754790 finishHandshake()
755- _ = transport.Close()
791+ if entry != nil {
792+ cache.retire(entry)
793+ } else {
794+ _ = transport.Close()
795+ }
756796 if navTransition.pendingCreation() {
757797 r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: protocol.RouteFailureUnavailable}
758798 route, ok := navTransition.restore()
@@ -773,6 +813,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
773813 return err
774814 }
775815
816+ if entry == nil && attemptRequest.Remote && attemptRequest.OriginKey != "" && cache.enabled() {
817+ entry = newCachedAttachment(transport)
818+ }
776819 var linkEvents <-chan ports.LinkEvent
777820 if connection.Connection().Capabilities().LinkState {
778821 linkEvents = connection.Connection().LinkEvents()
@@ -781,6 +824,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
781824 runner: r,
782825 dialer: dialer,
783826 connection: connection,
827+ entry: entry,
828+ activated: activated,
829+ activationOutput: activationOutput,
830+ activationPosition: activationPosition,
784831 remote: remote,
785832 handshakeCtx: handshakeCtx,
786833 handshakeTimedOut: timedOut,
@@ -817,8 +864,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
817864 if result.welcomed {
818865 backoff = defaultReconnectBackoff.initial
819866 }
820- if !result.transportClosed {
821- _ = connection.Close()
867+ navigating := result.err == nil && (result.target != nil || result.handoff != nil || result.routeAction != nil || result.routeCreateAction != nil)
868+ if entry == nil {
869+ if !result.transportClosed {
870+ _ = connection.Close()
871+ }
822872 }
823873 // Apply metadata from every processed frame before dispatching its
824874 // navigation action. A committed identity can arrive immediately before
@@ -845,6 +895,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
845895 attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
846896 attemptRequest.RemoteTarget = nil
847897 }
898+ // Cache the last committed route cursor, not the original attach hint.
899+ if entry != nil && (!navigating || !cache.suspend(ctx, entry, attemptRequest, result)) {
900+ cache.retire(entry)
901+ }
848902 if result.err == nil && result.welcomed && navTransition.pendingCreation() {
849903 navTransition.settleSuccess()
850904 }
@@ -1266,6 +1320,10 @@ type attachAttempt struct {
12661320 runner *Runner
12671321 dialer ports.ClientDialer
12681322 connection *SessionConnection
1323+ entry *cachedAttachment
1324+ activated *protocol.AttachmentActivated
1325+ activationOutput *protocol.Output
1326+ activationPosition *protocol.RoutePosition
12691327 remote bool
12701328 transport ports.ClientConnection
12711329 handshakeCtx context.Context
@@ -1422,6 +1480,34 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14221480 }
14231481 return nil
14241482 }
1483+ if a.activated != nil && a.activationOutput != nil {
1484+ // Activation is already protocol-committed; publish its full frame before
1485+ // route history or a new input generation may observe the destination.
1486+ if err := enterRaw(); err != nil {
1487+ return attachResult{err: err}
1488+ }
1489+ output := a.activationOutput
1490+ uiOutput, _ := term.(ports.UIOutputTransaction)
1491+ if uiOutput != nil {
1492+ state, _ := (outputApplyState{}).next(*output)
1493+ uiOutput.BeginOutput(state.uiContext(ports.UIContext{}, ports.UIStatusTransitioning))
1494+ }
1495+ _, err := term.Out().Write(output.Data)
1496+ if err == nil {
1497+ err = term.Flush()
1498+ }
1499+ if uiOutput != nil {
1500+ uiOutput.EndOutput(err == nil)
1501+ }
1502+ if err != nil {
1503+ return attachResult{err: fmt.Errorf("vev: publishing activation: %w", err)}
1504+ }
1505+ // The ordinary output path still commits ACK/UI bookkeeping, without
1506+ // writing the already-published cells a second time.
1507+ copyOutput := *output
1508+ copyOutput.Data = nil
1509+ a.activationOutput = &copyOutput
1510+ }
14251511 // Actively probe the direct outer terminal before Hello. The probe uses the
14261512 // lifecycle input pump; unsupported and silent terminals fail closed within
14271513 // its bounded deadline.
@@ -1475,18 +1561,27 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14751561 NavigationCapabilities: request.NavigationCapabilities,
14761562 Remote: remote,
14771563 }
1478- if err := sendHandshake(func() error {
1479- return transport.SendClient(hello)
1480- }); err != nil {
1481- return handshakeFailure("sending hello", err)
1564+ if a.activated == nil {
1565+ if err := sendHandshake(func() error { return transport.SendClient(hello) }); err != nil {
1566+ return handshakeFailure("sending hello", err)
1567+ }
1568+ if a.entry != nil {
1569+ a.entry.startReader()
1570+ }
14821571 }
14831572 ms.helloSent = true
14841573
14851574 // 2. Await Welcome or a typed rejection.
14861575 var reply protocol.ServerMessage
1487- if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error {
1576+ if a.activated != nil {
1577+ reply = protocol.Welcome{ResumeToken: a.entry.resumeToken, SessionName: a.activated.Identity.Target.SessionName, CommittedIdentity: &a.activated.Identity}
1578+ } else if err := boundedHandshakeOperationWithTransition(handshakeCtx, transport, func() error {
14881579 var receiveErr error
1489- reply, receiveErr = transport.ReceiveServer()
1580+ if a.entry != nil {
1581+ reply, receiveErr = a.entry.receive(handshakeCtx)
1582+ } else {
1583+ reply, receiveErr = transport.ReceiveServer()
1584+ }
14901585 return receiveErr
14911586 }, transition); err != nil {
14921587 return handshakeFailure("awaiting welcome", err)
@@ -1520,6 +1615,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15201615 return handshakeFailure("validating welcome", err)
15211616 }
15221617 welcomedResult := func(err error) attachResult { return result(true, err) }
1618+ rememberRoutePosition := func(position protocol.RoutePosition) error {
1619+ if a.runner.ledger == nil {
1620+ return errors.New("vev: route ledger unavailable")
1621+ }
1622+ if err := a.runner.ledger.updateRoutePosition(position); err != nil {
1623+ return fmt.Errorf("vev: remembering route position: %w", err)
1624+ }
1625+ routePosition = cloneRoutePosition(&position)
1626+ return nil
1627+ }
15231628 if committedIdentity != nil && a.runner.ledger != nil {
15241629 if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target {
15251630 return welcomedResult(errRouteTargetChanged)
@@ -1977,7 +2082,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
19772082 a.onInventoryCommitted()
19782083 }
19792084 senderStarted = true
1980- go runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log)
2085+ senderDone := make(chan struct{})
2086+ go func() {
2087+ defer close(senderDone)
2088+ runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log)
2089+ }()
2090+ defer func() {
2091+ cancel()
2092+ if a.entry != nil {
2093+ // Bound sender retirement before suspension takes over its write
2094+ // ownership. A wedged send cannot pin teardown: exceeding the
2095+ // shared budget closes the transport, which the port contract
2096+ // requires to unblock the send.
2097+ retireSender(ctx, clk, a.entry.transport, senderDone)
2098+ }
2099+ }()
19812100
19822101 type foregroundRuntime struct {
19832102 cancel context.CancelFunc
@@ -2215,11 +2334,27 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
22152334 abortPickerLease(true)
22162335 }
22172336 }
2218- startForeground()
2337+ if a.activationOutput == nil {
2338+ startForeground()
2339+ }
22192340 defer stopForeground()
22202341
2221- recvCh := make(chan recvResult, 1)
2222- go runRecv(loopCtx, transport, recvCh, transportFailed, log)
2342+ var recvCh <-chan recvResult
2343+ if a.entry != nil {
2344+ recvCh = a.entry.inbox
2345+ } else {
2346+ ch := make(chan recvResult, 1)
2347+ recvCh = ch
2348+ go runRecv(loopCtx, transport, ch, transportFailed, log)
2349+ }
2350+ // A validated activation frame is processed by the ordinary terminal transaction
2351+ // before input ownership is granted. No dormant frame enters this path.
2352+ var activationCh chan recvResult
2353+ if a.activationOutput != nil {
2354+ activationCh = make(chan recvResult, 1)
2355+ activationCh <- recvResult{message: *a.activationOutput}
2356+ recvCh = activationCh
2357+ }
22232358 armRouteObservation()
22242359
22252360 requestReconnectReset := func() error {
@@ -2707,7 +2842,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
27072842 }
27082843 continue
27092844 }
2710- if ui != nil && o.Full && foreground == nil && samePeerSwitch == nil {
2845+ if a.activationOutput == nil && ui != nil && o.Full && foreground == nil && samePeerSwitch == nil {
27112846 startForeground()
27122847 }
27132848 if uiOutput != nil {
@@ -2753,6 +2888,22 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
27532888 uiOutput.EndOutput(true)
27542889 }
27552890 outputState = nextState
2891+ if a.entry != nil && o.Epoch > a.entry.epoch {
2892+ a.entry.epoch = o.Epoch
2893+ }
2894+ if activationCh != nil {
2895+ // Publish the repaired cursor only after the warm full frame commits,
2896+ // through the same ledger/result path as foreground RoutePosition.
2897+ if a.activationPosition != nil {
2898+ if err := rememberRoutePosition(*a.activationPosition); err != nil {
2899+ return welcomedResult(err)
2900+ }
2901+ }
2902+ activationCh = nil
2903+ recvCh = a.entry.inbox
2904+ a.activationOutput = nil
2905+ startForeground()
2906+ }
27562907 if ui != nil && o.New != 0 {
27572908 ui.published(uiGeneration)
27582909 if o.Full {
@@ -3168,14 +3319,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
31683319 }
31693320 }
31703321 case protocol.RoutePosition:
3171- position := message
3172- if a.runner.ledger == nil {
3173- return welcomedResult(errors.New("vev: route ledger unavailable"))
3174- }
3175- if derr := a.runner.ledger.updateRoutePosition(position); derr != nil {
3176- return welcomedResult(fmt.Errorf("vev: remembering route position: %w", derr))
3322+ if err := rememberRoutePosition(message); err != nil {
3323+ return welcomedResult(err)
31773324 }
3178- routePosition = cloneRoutePosition(&position)
31793325 case protocol.RouteRetired:
31803326 if a.runner.ledger != nil && a.runner.ledger.retireRoute(message, a.runner.ledger.attentionSubscriptionFor(request)) {
31813327 if err := publishRouteSnapshot(); err != nil {
M internal/usecase/client/client_test.go +1 -0
@@ -317,6 +317,7 @@ func runTestClient(ctx context.Context, deps client.Dependencies, request client
317317
318318 func testDependencies(dialer ports.ClientDialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {
319319 return client.Dependencies{
320+ AttachmentCache: domain.DefaultAttachmentCacheConfig(),
320321 Dialer: dialer,
321322 Terminal: terminal,
322323 Clock: clock,
M internal/usecase/client/handshake.go +30 -2
@@ -26,6 +26,12 @@ func (t systemTimer) Stop() bool { return t.Timer.Stop() }
2626 // newHandshakeContext owns one deadline for the complete outbound handshake.
2727 // The caller must stop it before entering the long-lived connection loop.
2828 func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Context, <-chan struct{}, func()) {
29+ return newBoundedContext(parent, clock, protocol.HandshakeTimeout)
30+}
31+
32+// newBoundedContext owns one deadline of the requested length. The caller must
33+// invoke finish when the bounded operation ends.
34+func newBoundedContext(parent context.Context, clock ports.Clock, timeout time.Duration) (context.Context, <-chan struct{}, func()) {
2935 if parent == nil {
3036 parent = context.Background()
3137 }
@@ -34,9 +40,9 @@ func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Con
3440 if clock == nil {
3541 clock = systemClock{}
3642 }
37- timer := clock.NewTimer(protocol.HandshakeTimeout)
43+ timer := clock.NewTimer(timeout)
3844 if timer == nil {
39- timer = systemClock{}.NewTimer(protocol.HandshakeTimeout)
45+ timer = systemClock{}.NewTimer(timeout)
4046 }
4147 stop := make(chan struct{})
4248 done := make(chan struct{})
@@ -116,6 +122,28 @@ func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientCo
116122 return boundedDialWithTransition(ctx, dialer, nil)
117123 }
118124
125+// retireSender bounds the wait for an attach loop's sender goroutine after its
126+// context is canceled. A retained transport must not keep a wedged sender when
127+// the cache takes over write ownership, so exceeding the shared handshake
128+// budget closes the transport, which the ClientConnection contract requires to
129+// unblock the send. The helper never waits past that budget.
130+func retireSender(ctx context.Context, clock ports.Clock, transport ports.ClientConnection, done <-chan struct{}) {
131+ select {
132+ case <-done:
133+ return
134+ default:
135+ }
136+ bounded, _, finish := newHandshakeContext(ctx, clock)
137+ defer finish()
138+ select {
139+ case <-done:
140+ case <-bounded.Done():
141+ if transport != nil {
142+ _ = transport.Close()
143+ }
144+ }
145+}
146+
119147 func boundedDialWithTransition(ctx context.Context, dialer ports.ClientDialer, transition *transitionUI) (ports.ClientConnection, error) {
120148 // The dial context only bounds startup. A successful carriage outlives the
121149 // handshake context; canceling that context after Welcome must not kill SSH.
M internal/usecase/client/hybrid_picker_route_test.go +172 -8
@@ -32,6 +32,7 @@ import (
3232 type hybridPickerTransport struct {
3333 mu sync.Mutex
3434 queue []wire.Envelope
35+ receiving bool
3536 sends []wire.Envelope
3637 signal chan struct{}
3738 done chan struct{}
@@ -54,11 +55,13 @@ func (t *hybridPickerTransport) Recv() (wire.Envelope, error) {
5455 for {
5556 t.mu.Lock()
5657 if len(t.queue) > 0 {
58+ t.receiving = false
5759 frame := t.queue[0]
5860 t.queue = t.queue[1:]
5961 t.mu.Unlock()
6062 return frame, nil
6163 }
64+ t.receiving = true
6265 t.mu.Unlock()
6366 select {
6467 case <-t.signal:
@@ -77,6 +80,7 @@ func (t *hybridPickerTransport) Close() error {
7780 func (t *hybridPickerTransport) push(frame wire.Envelope) {
7881 t.mu.Lock()
7982 t.queue = append(t.queue, frame)
83+ t.receiving = false
8084 t.mu.Unlock()
8185 select {
8286 case t.signal <- struct{}{}:
@@ -84,6 +88,18 @@ func (t *hybridPickerTransport) push(frame wire.Envelope) {
8488 }
8589 }
8690
91+// awaitReceived waits until the reader has delivered every queued frame to its
92+// inbox and entered the next Recv. Merely pushing invalid dormant output does
93+// not establish that it arrived before the activation boundary.
94+func (t *hybridPickerTransport) awaitReceived(tb *testing.T) {
95+ tb.Helper()
96+ require.Eventually(tb, func() bool {
97+ t.mu.Lock()
98+ defer t.mu.Unlock()
99+ return len(t.queue) == 0 && t.receiving
100+ }, time.Second, time.Millisecond)
101+}
102+
87103 func (t *hybridPickerTransport) sent() []wire.Envelope {
88104 t.mu.Lock()
89105 defer t.mu.Unlock()
@@ -401,6 +417,9 @@ func TestHybridPickerDifferentHostDialsTheTargetEndpoint(t *testing.T) {
401417 CauseActionID: selection.CauseActionID,
402418 }))
403419
420+ suspend := mustAwaitSend(t, remote, "SuspendAttachment").(protocol.SuspendAttachment)
421+ remote.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: suspend.RequestID, Target: protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: source.SessionName}}))
422+
404423 targetTransport.push(hybridPickerWelcome("target", targetHost.LifecycleID))
405424 targetTransport.push(frameOfMessage(protocol.Detached{Reason: protocol.ReasonDetach}))
406425
@@ -447,7 +466,6 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) {
447466 }
448467 local := newHybridPickerTransport()
449468 remote := newHybridPickerTransport()
450- remoteReturn := newHybridPickerTransport()
451469 other := newHybridPickerTransport()
452470 resolutions := make(chan string, 8)
453471
@@ -457,7 +475,7 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) {
457475 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
458476 }))
459477
460- remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, remoteReturn}}
478+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}}
461479 otherDialer := &sequenceDialer{trs: []wire.Transport{other}}
462480 localDialer := &sequenceDialer{trs: []wire.Transport{local}}
463481 dialers := map[string]ports.ClientDialer{"remote": remoteDialer, "target-host": otherDialer}
@@ -493,14 +511,23 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) {
493511 RemoteTarget: &otherHost, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
494512 CauseActionID: selection.CauseActionID,
495513 }))
514+ suspend := mustAwaitSend(t, remote, "SuspendAttachment").(protocol.SuspendAttachment)
515+ remote.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: suspend.RequestID, Target: protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: source.SessionName}}))
496516 other.push(hybridPickerWelcome("other", otherHost.LifecycleID))
497517 // The new peer offers the return route to the first host.
498518 other.push(frameOfMessage(protocol.AttachTarget{
499519 Endpoint: "remote", Session: source.SessionName, Intent: protocol.IntentAttach,
500520 RemoteTarget: &source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
501521 }))
502- remoteReturn.push(hybridPickerWelcome("source", source.LifecycleID))
503- remoteReturn.push(frameOfMessage(protocol.Detached{Reason: protocol.ReasonDetach}))
522+ otherSuspend := mustAwaitSend(t, other, "SuspendAttachment").(protocol.SuspendAttachment)
523+ other.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: otherSuspend.RequestID, Target: protocol.ExactSessionTarget{LifecycleID: otherHost.LifecycleID, SessionName: otherHost.SessionName}}))
524+ activation := mustAwaitSend(t, remote, "ActivateAttachment").(protocol.ActivateAttachment)
525+ require.Equal(t, source.LifecycleID, activation.Target.LifecycleID)
526+ hybridPickerPaint(t, remote, 2, activation.Target, "warm return")
527+ remote.push(frameOfMessage(protocol.RoutePosition{Target: activation.Target, ActiveTabID: "tab-2"}))
528+ remote.push(frameOfMessage(protocol.AttachmentActivated{RequestID: activation.RequestID, Identity: protocol.CommittedRouteIdentity{Target: activation.Target}, Epoch: 2, State: 1, ViewPublication: 2}))
529+ term.awaitDisplay(t, "warm return")
530+ remote.push(frameOfMessage(protocol.Detached{Reason: protocol.ReasonDetach}))
504531
505532 select {
506533 case err := <-done:
@@ -508,10 +535,9 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) {
508535 case <-time.After(5 * time.Second):
509536 t.Fatal("client did not finish the cross-peer picker route")
510537 }
511- // Returning to the first peer resolves that endpoint again and dials a fresh
512- // connection through it: the registry owns binding reuse, and a route
513- // crossing serving peers never invalidates the client's own resolution.
514- require.Equal(t, int32(2), remoteDialer.calls.Load())
538+ // Resolution remains registry-owned, but activation reuses the authenticated
539+ // attachment and emits no second Hello or dial.
540+ require.Equal(t, int32(1), remoteDialer.calls.Load())
515541 require.Equal(t, int32(1), otherDialer.calls.Load())
516542 require.Equal(t, []string{"remote", "target-host", "remote"}, drainStrings(resolutions))
517543 }
@@ -523,3 +549,141 @@ func mustAwaitSend(t *testing.T, transport *hybridPickerTransport, name string)
523549 require.True(t, ok, "client message %s never crossed the wire", name)
524550 return message
525551 }
552+
553+// The route ledger chooses local/home; the cache only transfers transport
554+// ownership. The activation frame must be displayed before queued input resumes.
555+func TestWarmAttachmentRemoteLocalRemoteReusesDial(t *testing.T) {
556+ term := newHybridPickerTerminal()
557+ defer term.reader.unblock()
558+ local1, local2, remote := newHybridPickerTransport(), newHybridPickerTransport(), newHybridPickerTransport()
559+ localTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}
560+ remoteTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "work"}
561+ local1.push(hybridPickerWelcome("local", localTarget.LifecycleID))
562+ local1.push(frameOfMessage(protocol.AttachTarget{Endpoint: "remote", Session: "work", Intent: protocol.IntentAttach, ExactTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}))
563+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
564+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}}
565+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
566+ deps.HostRegistry = stubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) {
567+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
568+ }}
569+ ctx, cancel := context.WithCancel(context.Background())
570+ defer cancel()
571+ done := make(chan error, 1)
572+ go func() {
573+ done <- runTestClient(ctx, deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local"})
574+ }()
575+ remote.push(hybridPickerWelcome("work", remoteTarget.LifecycleID))
576+ hybridPickerPaint(t, remote, 1, remoteTarget, "remote first")
577+ term.awaitDisplay(t, "remote first")
578+ // The committed cursor differs from the original (empty) attach hint.
579+ // Both the route ledger and retained cache request must remember it.
580+ remote.push(frameOfMessage(protocol.RoutePosition{Target: remoteTarget, ActiveTabID: "tab-2"}))
581+ remote.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))
582+ suspension := mustAwaitSend(t, remote, "SuspendAttachment").(protocol.SuspendAttachment)
583+ remote.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: suspension.RequestID, Target: remoteTarget}))
584+ local2.push(hybridPickerWelcome("local", localTarget.LifecycleID))
585+ hybridPickerPaint(t, local2, 1, localTarget, "local return")
586+ term.awaitDisplay(t, "local return")
587+ // Ordinary dormant output cannot overwrite the local foreground.
588+ hybridPickerPaint(t, remote, 1, remoteTarget, "forbidden dormant output")
589+ remote.awaitReceived(t)
590+ local2.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}))
591+ activation := mustAwaitSend(t, remote, "ActivateAttachment").(protocol.ActivateAttachment)
592+ hybridPickerPaint(t, remote, 2, remoteTarget, "reactivated full")
593+ remote.push(frameOfMessage(protocol.RoutePosition{Target: activation.Target, ActiveTabID: "tab-2"}))
594+ remote.push(frameOfMessage(protocol.AttachmentActivated{RequestID: activation.RequestID, Identity: protocol.CommittedRouteIdentity{Target: remoteTarget}, Epoch: 2, State: 1, ViewPublication: 2}))
595+ term.awaitDisplay(t, "reactivated full")
596+ require.NotContains(t, term.screen(), "forbidden dormant output")
597+ require.Equal(t, int32(1), remoteDialer.calls.Load())
598+ cancel()
599+ select {
600+ case <-done:
601+ case <-time.After(5 * time.Second):
602+ t.Fatal("runner failed to close cached transports")
603+ }
604+ require.Positive(t, remote.closed.Load())
605+}
606+
607+func TestWarmAttachmentRepairsDisappearedTab(t *testing.T) {
608+ for _, scenario := range []string{"reconnect", "route return"} {
609+ t.Run(scenario, func(t *testing.T) {
610+ term := newHybridPickerTerminal()
611+ defer term.reader.unblock()
612+ local1, local2, remote := newHybridPickerTransport(), newHybridPickerTransport(), newHybridPickerTransport()
613+ local3, remote2 := newHybridPickerTransport(), newHybridPickerTransport()
614+ localTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}
615+ remoteTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "work"}
616+ local1.push(hybridPickerWelcome("local", localTarget.LifecycleID))
617+ local1.push(frameOfMessage(protocol.AttachTarget{Endpoint: "remote", Session: "work", Intent: protocol.IntentAttach, ExactTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}))
618+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2, local3}}
619+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, remote2}}
620+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
621+ deps.HostRegistry = stubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) {
622+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
623+ }}
624+ ctx, cancel := context.WithCancel(context.Background())
625+ defer cancel()
626+ done := make(chan error, 1)
627+ go func() {
628+ done <- runTestClient(ctx, deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local"})
629+ }()
630+ remote.push(hybridPickerWelcome("work", remoteTarget.LifecycleID))
631+ hybridPickerPaint(t, remote, 1, remoteTarget, "remote first")
632+ term.awaitDisplay(t, "remote first")
633+ // The committed cursor differs from the original (empty) attach hint.
634+ // Both the route ledger and retained cache request must remember it.
635+ remote.push(frameOfMessage(protocol.RoutePosition{Target: remoteTarget, ActiveTabID: "tab-2"}))
636+ remote.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))
637+ suspension := mustAwaitSend(t, remote, "SuspendAttachment").(protocol.SuspendAttachment)
638+ remote.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: suspension.RequestID, Target: remoteTarget}))
639+ local2.push(hybridPickerWelcome("local", localTarget.LifecycleID))
640+ hybridPickerPaint(t, local2, 1, localTarget, "local return")
641+ term.awaitDisplay(t, "local return")
642+ // Ordinary dormant output cannot overwrite the local foreground.
643+ hybridPickerPaint(t, remote, 1, remoteTarget, "forbidden dormant output")
644+ remote.awaitReceived(t)
645+ if scenario == "reconnect" {
646+ local2.push(frameOfMessage(protocol.AttachTarget{Endpoint: "remote", Session: "work", Intent: protocol.IntentAttach, ExactTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}))
647+ } else {
648+ local2.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}))
649+ }
650+ activation := mustAwaitSend(t, remote, "ActivateAttachment").(protocol.ActivateAttachment)
651+ hybridPickerPaint(t, remote, 2, remoteTarget, "reactivated full")
652+ remote.push(frameOfMessage(protocol.RoutePosition{Target: activation.Target, ActiveTabID: "tab-1"}))
653+ remote.push(frameOfMessage(protocol.AttachmentActivated{RequestID: activation.RequestID, Identity: protocol.CommittedRouteIdentity{Target: remoteTarget}, Epoch: 2, State: 1, ViewPublication: 2}))
654+ term.awaitDisplay(t, "reactivated full")
655+ require.NotContains(t, term.screen(), "forbidden dormant output")
656+ require.Equal(t, int32(1), remoteDialer.calls.Load())
657+ // While suspended tab-2 disappeared; the daemon repaired its retained
658+ // cursor to tab-1 in the activation publication above, with no later update.
659+ if scenario == "route return" {
660+ remote.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: 1, Generation: 3}))
661+ suspend := mustAwaitSend(t, remote, "SuspendAttachment").(protocol.SuspendAttachment)
662+ remote.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: suspend.RequestID, Target: remoteTarget}))
663+ local3.push(hybridPickerWelcome("local", localTarget.LifecycleID))
664+ hybridPickerPaint(t, local3, 1, localTarget, "local third")
665+ term.awaitDisplay(t, "local third")
666+ // Evict the parked connection so the next Hello exposes the ledger cursor.
667+ remote.push(frameOfMessage(protocol.Detached{Reason: protocol.ReasonDetach}))
668+ require.Eventually(t, func() bool { return remote.closed.Load() > 0 }, time.Second, time.Millisecond)
669+ local3.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 5, Key: 2, Generation: 4}))
670+ } else {
671+ // A foreground transport failure exposes the attempt request cursor.
672+ remote.closeOnce.Do(func() { close(remote.done) })
673+ }
674+ hello := mustAwaitSend(t, remote2, "Hello").(protocol.Hello)
675+ require.Equal(t, &remoteTarget, hello.ExactTarget)
676+ require.Equal(t, domain.TabStableID("tab-1"), hello.PreferredTabID)
677+ remote2.push(hybridPickerWelcome("work", remoteTarget.LifecycleID))
678+ hybridPickerPaint(t, remote2, 1, remoteTarget, "repaired return")
679+ term.awaitDisplay(t, "repaired return")
680+ cancel()
681+ select {
682+ case <-done:
683+ case <-time.After(5 * time.Second):
684+ t.Fatal("runner failed to close cached transports")
685+ }
686+ require.Positive(t, remote.closed.Load())
687+ })
688+ }
689+}
M internal/usecase/client/inventory_recovery_test.go +3 -0
@@ -75,6 +75,9 @@ func TestInventoryFailureRestoresLatestRemoteIdentityAfterResumeRejection(t *tes
7575 return conn, nil
7676 }).Twice()
7777 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
78+ // This journey exercises cold resume rejection, not warm suspension. Its
79+ // scripted peer deliberately has no suspension/activation responses.
80+ deps.AttachmentCache = domain.AttachmentCacheConfig{Enabled: false}
7881 deps.LocalControlDialer = control
7982 deps.HostRegistry = dialerForEndpoints(map[string]ports.ClientDialer{"remote": remoteDialer})
8083 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: local.SessionName, Origin: protocol.RouteOriginLocal, OriginKey: "local"}))
M internal/usecase/client/picker_input_test.go +1 -1
@@ -296,7 +296,7 @@ func TestPickerReleaseDropsInputWithoutReplay(t *testing.T) {
296296
297297 writeTerminal(t, writer, "ok")
298298 var delivered []byte
299- deadline := time.After(2 * time.Second)
299+ deadline := time.After(5 * time.Second)
300300 for len(delivered) < len("ok") {
301301 select {
302302 case frame := <-transport.inputCh:
M internal/usecase/client/picker_retire_test.go +1 -1
@@ -142,7 +142,7 @@ func TestPickerStaleRevisionClosesInteraction(t *testing.T) {
142142 writeTerminal(t, writer, "ok")
143143 select {
144144 case <-transport.inputCh:
145- case <-time.After(2 * time.Second):
145+ case <-time.After(5 * time.Second):
146146 t.Fatal("session input did not resume after the release paint")
147147 }
148148 }
A internal/usecase/client/remote_cache_acceptance_test.go +237 -0
@@ -0,0 +1,237 @@
1+package client_test
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/wire"
13+ "github.com/bnema/vev/internal/usecase/client"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// acceptanceTimerClock hands out registered manual timers for every request.
18+// Ordinary protocol and toast budgets simply never fire, keeping the ownership
19+// transfer deterministic; the journey fires exactly the cache idle timer it
20+// configured, so the fake never keys on a hardcoded duration.
21+type acceptanceTimerClock struct {
22+ realClock
23+ mu sync.Mutex
24+ timers []*acceptanceManualTimer
25+}
26+
27+type acceptanceManualTimer struct {
28+ ch chan time.Time
29+ d time.Duration
30+}
31+
32+func (t *acceptanceManualTimer) C() <-chan time.Time { return t.ch }
33+func (*acceptanceManualTimer) Stop() bool { return true }
34+func (*acceptanceManualTimer) Reset(time.Duration) bool { return false }
35+
36+func (c *acceptanceTimerClock) NewTimer(d time.Duration) ports.Timer {
37+ timer := &acceptanceManualTimer{ch: make(chan time.Time, 1), d: d}
38+ c.mu.Lock()
39+ c.timers = append(c.timers, timer)
40+ c.mu.Unlock()
41+ return timer
42+}
43+
44+// fire delivers the timer most recently armed for d, waiting for it to be
45+// registered first.
46+func (c *acceptanceTimerClock) fire(t *testing.T, d time.Duration) {
47+ t.Helper()
48+ deadline := time.Now().Add(2 * time.Second)
49+ for time.Now().Before(deadline) {
50+ c.mu.Lock()
51+ var match *acceptanceManualTimer
52+ for _, timer := range c.timers {
53+ if timer.d == d {
54+ match = timer
55+ }
56+ }
57+ c.mu.Unlock()
58+ if match != nil {
59+ match.ch <- time.Now()
60+ return
61+ }
62+ time.Sleep(time.Millisecond)
63+ }
64+ t.Fatalf("no timer armed for %s", d)
65+}
66+
67+func TestRunnerRemoteCacheFallbackAndOwnership(t *testing.T) {
68+ for _, scenario := range []string{"expiry", "dormant death", "stale lifecycle", "activation rejected"} {
69+ t.Run(scenario, func(t *testing.T) {
70+ term := newHybridPickerTerminal()
71+ defer term.reader.unblock()
72+ local1, local2 := newHybridPickerTransport(), newHybridPickerTransport()
73+ remote1, remote2 := newHybridPickerTransport(), newHybridPickerTransport()
74+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
75+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}}
76+ clk := &acceptanceTimerClock{}
77+ deps := testDependencies(localDialer, term, clk, nil, nil)
78+ deps.AttachmentCache = domain.AttachmentCacheConfig{Enabled: true, Capacity: domain.DefaultAttachmentCacheCapacity, IdleTimeout: 7 * time.Minute}
79+ deps.HostRegistry = stubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) {
80+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
81+ }}
82+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "source"}
83+ toRemote := protocol.AttachTarget{Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, ExactTarget: &target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
84+ local1.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1}))
85+ local1.push(frameOfMessage(toRemote))
86+ remote1.push(hybridPickerWelcome("source", target.LifecycleID))
87+ hybridPickerPaint(t, remote1, 1, target, "remote foreground")
88+ ctx, cancel := context.WithCancel(context.Background())
89+ done := make(chan error, 1)
90+ go func() {
91+ done <- runTestClient(ctx, deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local"})
92+ }()
93+ defer cancel()
94+ term.awaitDisplay(t, "remote foreground")
95+ remote1.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))
96+ suspend := mustAwaitSend(t, remote1, "SuspendAttachment").(protocol.SuspendAttachment)
97+ remote1.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: suspend.RequestID, Target: target}))
98+ local2.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1}))
99+ hybridPickerPaint(t, local2, 1, protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}, "local foreground")
100+ term.awaitDisplay(t, "local foreground")
101+ require.Equal(t, int32(1), remoteDialer.calls.Load(), "no background redial")
102+ // Late paints and route updates from a suspended generation must not
103+ // affect the terminal or redirect input/resize away from local.
104+ hybridPickerPaint(t, remote1, 9, target, "FORBIDDEN dormant paint")
105+ remote1.push(frameOfMessage(protocol.CommittedRouteIdentity{Target: target}))
106+ remote1.awaitReceived(t)
107+ term.reader.pressEnter()
108+ mustAwaitSend(t, local2, "Input")
109+ geometry := domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}
110+ select {
111+ case term.resize <- geometry:
112+ case <-time.After(time.Second):
113+ t.Fatal("resize not consumed")
114+ }
115+ mustAwaitSend(t, local2, "Resize")
116+ require.False(t, remote1.sentType(t, "Input"))
117+ require.False(t, remote1.sentType(t, "Resize"))
118+ switch scenario {
119+ case "expiry":
120+ clk.fire(t, deps.AttachmentCache.IdleTimeout)
121+ require.Eventually(t, func() bool { return remote1.closed.Load() == 1 }, time.Second, time.Millisecond)
122+ case "dormant death":
123+ // A remote Detached is terminal server traffic, not a client Close call.
124+ remote1.push(frameOfMessage(protocol.Detached{Reason: protocol.ReasonDetach}))
125+ require.Eventually(t, func() bool { return remote1.closed.Load() == 1 }, time.Second, time.Millisecond)
126+ case "stale lifecycle":
127+ replacement := target
128+ replacement.LifecycleID[0]++
129+ toRemote.ExactTarget = &replacement
130+ }
131+ require.Equal(t, int32(1), remoteDialer.calls.Load(), "eviction must not reconnect in the background")
132+ local2.push(frameOfMessage(toRemote))
133+ if scenario == "activation rejected" {
134+ activation := mustAwaitSend(t, remote1, "ActivateAttachment").(protocol.ActivateAttachment)
135+ require.Equal(t, target, activation.Target)
136+ remote1.push(frameOfMessage(protocol.ErrorMsg{Text: "stale lifecycle"}))
137+ }
138+ hello := mustAwaitSend(t, remote2, "Hello").(protocol.Hello)
139+ require.Equal(t, toRemote.ExactTarget, hello.ExactTarget, "fallback must preserve authoritative exact lifecycle")
140+ require.Equal(t, toRemote.PreferredTabID, hello.PreferredTabID, "cold attach must apply the preferred tab or its normal missing-tab fallback")
141+ require.Equal(t, int32(2), remoteDialer.calls.Load())
142+ remote2.push(hybridPickerWelcome("source", toRemote.ExactTarget.LifecycleID))
143+ hybridPickerPaint(t, remote2, 1, *toRemote.ExactTarget, "fallback foreground")
144+ term.awaitDisplay(t, "fallback foreground")
145+ if scenario == "stale lifecycle" {
146+ require.False(t, remote1.sentType(t, "ActivateAttachment"))
147+ }
148+ remote2.push(frameOfMessage(protocol.Detached{Reason: protocol.ReasonDetach}))
149+ select {
150+ case err := <-done:
151+ require.NoError(t, err)
152+ case <-time.After(5 * time.Second):
153+ t.Fatal("runner did not exit")
154+ }
155+ require.NotContains(t, term.screen(), "FORBIDDEN dormant paint")
156+ for _, transport := range []*hybridPickerTransport{local1, local2, remote1, remote2} {
157+ require.Equal(t, int32(1), transport.closed.Load(), "each owned connection closes once")
158+ }
159+ })
160+ }
161+}
162+
163+// slowCloseTransport unblocks its reader immediately on Close and then stalls,
164+// modelling an SSH process whose reaping outlives the logical revocation.
165+type slowCloseTransport struct {
166+ *hybridPickerTransport
167+ release <-chan struct{}
168+}
169+
170+func (t *slowCloseTransport) Close() error {
171+ _ = t.hybridPickerTransport.Close()
172+ <-t.release
173+ return nil
174+}
175+
176+// TestWarmActivationDeadlineColdDialsWhileCloseStalls proves the client starts
177+// the cold dial at the warm deadline even when the retained transport's
178+// physical close has not returned.
179+func TestWarmActivationDeadlineColdDialsWhileCloseStalls(t *testing.T) {
180+ term := newHybridPickerTerminal()
181+ defer term.reader.unblock()
182+ local1, local2 := newHybridPickerTransport(), newHybridPickerTransport()
183+ owner := newHybridPickerTransport()
184+ release := make(chan struct{})
185+ remote2 := newHybridPickerTransport()
186+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
187+ remoteDialer := &sequenceDialer{trs: []wire.Transport{&slowCloseTransport{hybridPickerTransport: owner, release: release}, remote2}}
188+ clk := &acceptanceTimerClock{}
189+ deps := testDependencies(localDialer, term, clk, nil, nil)
190+ // No idle timeout: the warm deadline is the only client-local budget here.
191+ deps.AttachmentCache = domain.DefaultAttachmentCacheConfig()
192+ deps.HostRegistry = stubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) {
193+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
194+ }}
195+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "source"}
196+ toRemote := protocol.AttachTarget{Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, ExactTarget: &target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
197+ local1.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1}))
198+ local1.push(frameOfMessage(toRemote))
199+ owner.push(hybridPickerWelcome("source", target.LifecycleID))
200+ hybridPickerPaint(t, owner, 1, target, "owner foreground")
201+ ctx, cancel := context.WithCancel(context.Background())
202+ done := make(chan error, 1)
203+ go func() {
204+ done <- runTestClient(ctx, deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local"})
205+ }()
206+ term.awaitDisplay(t, "owner foreground")
207+ owner.push(frameOfMessage(protocol.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))
208+ suspend := mustAwaitSend(t, owner, "SuspendAttachment").(protocol.SuspendAttachment)
209+ owner.push(frameOfMessage(protocol.AttachmentSuspended{RequestID: suspend.RequestID, Target: target}))
210+ local2.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1}))
211+ hybridPickerPaint(t, local2, 1, protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}, "local foreground")
212+ term.awaitDisplay(t, "local foreground")
213+ // Returning reuses the retained transport, which stalls without answering.
214+ local2.push(frameOfMessage(toRemote))
215+ mustAwaitSend(t, owner, "ActivateAttachment")
216+ clk.fire(t, 2*time.Second)
217+ hello := mustAwaitSend(t, remote2, "Hello").(protocol.Hello)
218+ require.Equal(t, toRemote.ExactTarget, hello.ExactTarget)
219+ require.Equal(t, int32(2), remoteDialer.calls.Load(), "a stalled retained transport must not delay the cold dial")
220+ require.Equal(t, int32(1), owner.closed.Load(), "the stalled transport is closed before its reaping finishes")
221+ select {
222+ case <-release:
223+ t.Fatal("the physical close returned before the journey asserted the cold dial")
224+ default:
225+ }
226+ remote2.push(hybridPickerWelcome("source", toRemote.ExactTarget.LifecycleID))
227+ hybridPickerPaint(t, remote2, 1, *toRemote.ExactTarget, "cold foreground")
228+ term.awaitDisplay(t, "cold foreground")
229+ close(release)
230+ cancel()
231+ select {
232+ case err := <-done:
233+ require.NoError(t, err)
234+ case <-time.After(5 * time.Second):
235+ t.Fatal("runner did not exit")
236+ }
237+}
M internal/usecase/client/wire_external_test_helpers_test.go +4 -0
@@ -60,6 +60,10 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string {
6060 return ""
6161 }
6262 switch message.(type) {
63+ case protocol.SuspendAttachment:
64+ return "SuspendAttachment"
65+ case protocol.ActivateAttachment:
66+ return "ActivateAttachment"
6367 case protocol.Hello:
6468 return "Hello"
6569 case protocol.Input:
M internal/usecase/daemon/attachment_effect_gate.go +37 -3
@@ -16,11 +16,21 @@ const (
1616 attachmentEffectsFrozen
1717 )
1818
19+type attachmentActivity uint8
20+
21+const (
22+ attachmentActive attachmentActivity = iota
23+ attachmentSuspended
24+ attachmentActivating
25+)
26+
1927 // attachmentLifecycle owns the immutable committed capability and the
2028 // linearization gate for every attachment-bound observable effect. Transitions
21-// freeze and drain it before changing membership, generation, Transport, or
22-// render lease identity.
29+// freeze and drain it before changing membership, generation, transport,
30+// activity, or render lease identity.
2331 type attachmentLifecycle struct {
32+ activity attachmentActivity // protected by mu; independent of transition freeze ownership
33+ retained protocol.ExactSessionTarget
2434 generation atomic.Uint64
2535 mu sync.Mutex
2636 cond *sync.Cond
@@ -35,6 +45,12 @@ type attachmentLifecycle struct {
3545
3646 var nextAttachmentEffectGateOrder atomic.Uint64
3747
48+func (ac *attachedClient) attachmentActivity() attachmentActivity {
49+ ac.lifecycle.mu.Lock()
50+ defer ac.lifecycle.mu.Unlock()
51+ return ac.lifecycle.activity
52+}
53+
3854 func (g *attachmentLifecycle) generationValue() uint64 {
3955 if g == nil {
4056 return 0
@@ -236,7 +252,7 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentCapability) (*at
236252 g := &ac.lifecycle
237253 g.mu.Lock()
238254 g.initLocked()
239- if g.phase != attachmentEffectsStable || !g.capability.sameIdentity(token) {
255+ if g.phase != attachmentEffectsStable || g.activity != attachmentActive || !g.capability.sameIdentity(token) {
240256 g.mu.Unlock()
241257 return nil, false
242258 }
@@ -350,3 +366,21 @@ func (ac *attachedClient) invalidateFrozenAttachmentCapability() {
350366 g.failedTransport = transportSnapshot{}
351367 g.mu.Unlock()
352368 }
369+
370+// detachFrozenAttachmentLocked atomically unregisters ac from sess and
371+// invalidates its frozen capability. Callers must already hold ac's transition
372+// freeze and sess.mu (plus routing locks where routing discipline applies); it
373+// never freezes, drains, waits, or parks, so terminal teardown and the
374+// daemon-owned suspension expiry can share one exact detach commit. A false
375+// result means another path already detached ac. Only the winner performs
376+// geometry reconciliation and transport/resource cleanup, outside locks.
377+func detachFrozenAttachmentLocked(sess *session, ac *attachedClient) bool {
378+ if sess == nil || ac == nil || ac.currentAttachmentSession() != sess || !attachmentRegisteredLocked(sess, ac) {
379+ return false
380+ }
381+ sess.unregisterAttachmentLocked(ac)
382+ ac.setSession(nil)
383+ ac.invalidateFrozenAttachmentCapability()
384+ cancelPickerPreviewWorker(ac)
385+ return true
386+}
A internal/usecase/daemon/attachment_suspension.go +370 -0
@@ -0,0 +1,370 @@
1+package daemon
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+// Suspension retains membership for transport ownership and session teardown,
11+// but removes the render lease and geometry claim. The lifecycle gate, rather
12+// than membership alone, admits all ordinary attachment effects.
13+func (d *Daemon) suspendAttachment(token attachmentCapability, request protocol.SuspendAttachment) error {
14+ if request.Validate() != nil || token.ac == nil {
15+ return errAttachmentTransition
16+ }
17+ ac, sess := token.ac, token.sess
18+ deadline := newAttachmentEffectDrainDeadline(d.clock)
19+ defer deadline.stop()
20+ refreshObservations := false
21+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: deadline.Done, afterFrozen: func(ac *attachedClient) {
22+ if d.afterAttachmentEffectGateFrozen != nil {
23+ d.afterAttachmentEffectGateFrozen("suspend", ac)
24+ }
25+ }}, ac)
26+ defer func() {
27+ frozen.unfreeze()
28+ if refreshObservations {
29+ d.reconcileAllRouteHistories()
30+ }
31+ }()
32+ if !frozen.acquired || !frozen.drained || !d.sourceAttachmentCapabilityCurrentFrozen(token) || ac.attachmentActivity() != attachmentActive {
33+ return errAttachmentTransition
34+ }
35+ d.mu.Lock()
36+ sess.mu.Lock()
37+ if d.closing || d.sessions[sess.id] != sess || !token.currentInSessionLocked(sess) {
38+ sess.mu.Unlock()
39+ d.mu.Unlock()
40+ return errAttachmentTransition
41+ }
42+ target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
43+ var cleanup renderLifecycleCleanup
44+ if rc := sess.coordinator.Load(); rc != nil {
45+ cleanup = rc.beginDetach(ac)
46+ }
47+ sess.geometry.removeAttachmentLocked(sess.core(), ac)
48+ token.lease = nil
49+ ac.publishFrozenAttachmentCapability(token)
50+ ac.lifecycle.mu.Lock()
51+ ac.lifecycle.activity = attachmentSuspended
52+ ac.lifecycle.retained = target
53+ ac.lifecycle.mu.Unlock()
54+ sess.mu.Unlock()
55+ d.mu.Unlock()
56+ cleanup.finish()
57+ ac.clearSamePeerOffer()
58+ d.retirePicker(ac)
59+ d.closePalette(ac)
60+ sess.geometry.reconcileAndInvalidate(d, sess, nil, "attachment_suspension.go")
61+ // Ordered SendServer is a transport publication barrier after drained renders.
62+ if err := d.boundedSendErr(ac, protocol.AttachmentSuspended{RequestID: request.RequestID, Target: target}); err != nil {
63+ return err
64+ }
65+ // Arm the daemon-owned safety expiry only after the ACK is on the wire and
66+ // while the transition is still frozen, so a racing activation must either
67+ // observe the record or already own this gate.
68+ d.armSuspendedExpiry(ac, sess)
69+ refreshObservations = true
70+ return nil
71+}
72+
73+func (d *Daemon) activateAttachment(ac *attachedClient, expected transportSnapshot, request protocol.ActivateAttachment) error {
74+ if ac == nil || request.Validate() != nil {
75+ return errAttachmentTransition
76+ }
77+ deadline := newAttachmentEffectDrainDeadline(d.clock)
78+ defer deadline.stop()
79+ refreshObservations := false
80+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: deadline.Done}, ac)
81+ defer func() {
82+ frozen.unfreeze()
83+ if refreshObservations {
84+ d.reconcileAllRouteHistories()
85+ }
86+ }()
87+ if !frozen.acquired || !frozen.drained {
88+ return errAttachmentTransition
89+ }
90+ sess := ac.currentAttachmentSession()
91+ if sess == nil {
92+ return errAttachmentTransition
93+ }
94+ d.mu.Lock()
95+ sess.mu.Lock()
96+ ac.lifecycle.mu.Lock()
97+ valid := ac.lifecycle.activity == attachmentSuspended && ac.lifecycle.retained == request.Target
98+ ac.lifecycle.mu.Unlock()
99+ if !valid || d.closing || d.sessions[sess.id] != sess || sess.incarnation != request.Target.LifecycleID || sess.name != request.Target.SessionName || !attachmentRegisteredLocked(sess, ac) || !ac.transportSnapshotCurrent(expected) {
100+ sess.mu.Unlock()
101+ d.mu.Unlock()
102+ return errAttachmentTransition
103+ }
104+ geometry := request.Geometry()
105+ ac.setGeometry(geometry)
106+ rc := d.ensureRenderCoordinatorPrelocked(sess)
107+ rc.mu.Lock()
108+ lease := rc.attachWithReadinessLocked(ac, true)
109+ rc.mu.Unlock()
110+ sess.geometry.claimLocked(sess.core(), ac, geometry)
111+ // A committed activation ends the suspended lifetime: cancel the daemon-owned
112+ // safety expiry before the new capability generation is published.
113+ d.clearSuspendedExpiryLocked(ac)
114+ token := ac.publishFrozenAttachmentCapability(attachmentCapability{sess: sess, ac: ac, transport: expected, lease: lease})
115+ // Reserve the sole activation effect under the still-frozen gate. No ordinary
116+ // effect can enter until the full output and correlated success both commit.
117+ ac.lifecycle.mu.Lock()
118+ ac.lifecycle.activity = attachmentActivating
119+ ac.lifecycle.inFlight++
120+ ac.lifecycle.mu.Unlock()
121+ effect := &attachmentEffect{attachmentCapability: token, lifecycle: &ac.lifecycle}
122+ defer effect.End()
123+ sess.mu.Unlock()
124+ d.mu.Unlock()
125+ sess.geometry.reconcileAndInvalidate(d, sess, nil, "attachment_suspension.go")
126+ ac.sendMu.Lock()
127+ view := ac.viewSnapshot()
128+ view.windowRows = request.Size.Rows
129+ view.windowSet = true
130+ view.windowTop = 0
131+ view.revision++
132+ ac.publishView(view)
133+ ac.rebaseOutput()
134+ ac.pipelineCache = composeCacheInput{}
135+ ac.pipelineScratch = composeCacheInput{}
136+ ac.captureFrames = nil
137+ ac.sendMu.Unlock()
138+ paintDone := make(chan struct{})
139+ paintTimer := d.clock.NewTimer(detachNotifyTimeout)
140+ go func() {
141+ select {
142+ case <-paintTimer.C():
143+ select {
144+ case <-paintDone:
145+ return
146+ default:
147+ }
148+ d.log.Warn("activation publication timed out; force closing client transport")
149+ _ = ac.closeCapturedTransport(expected.transport)
150+ case <-paintDone:
151+ paintTimer.Stop()
152+ }
153+ }()
154+ paintResult := d.paintWithActivationEffect(sess, ac, true, lease, effect)
155+ close(paintDone)
156+ if paintResult != paintEmitted || !effect.current() {
157+ return errAttachmentTransition
158+ }
159+ ac.sendMu.Lock()
160+ ac.output.lockView()
161+ output := ac.output
162+ ack := protocol.AttachmentActivated{RequestID: request.RequestID, Identity: output.lastViewContext.Route, Epoch: output.epoch, State: output.next, ViewPublication: output.lastViewContext.Publication}
163+ ac.output.unlockView()
164+ ac.sendMu.Unlock()
165+ if ack.Validate() != nil {
166+ return errAttachmentTransition
167+ }
168+ if err := d.boundedSendErr(ac, ack); err != nil {
169+ return err
170+ }
171+ ac.lifecycle.mu.Lock()
172+ ac.lifecycle.activity = attachmentActive
173+ ac.lifecycle.retained = protocol.ExactSessionTarget{}
174+ ac.lifecycle.mu.Unlock()
175+ refreshObservations = true
176+ return nil
177+}
178+
179+// suspendedAttachmentRetention is the daemon-owned safety expiry for exactly one
180+// suspended attachment incarnation. generation and transport pin the suspension
181+// that armed it, so a stale record can never retire a later activation, a
182+// rebound transport, or a different session lifetime.
183+type suspendedAttachmentRetention struct {
184+ ac *attachedClient
185+ sess *session
186+ generation uint64
187+ transport transportSnapshot
188+ timer ports.Timer
189+ done chan struct{}
190+ doneOnce sync.Once
191+}
192+
193+// stop ends the record exactly once: the timer is cancelled and any watcher is
194+// released. It is safe to call from the watcher itself.
195+func (r *suspendedAttachmentRetention) stop() {
196+ if r == nil {
197+ return
198+ }
199+ if r.timer != nil {
200+ r.timer.Stop()
201+ }
202+ r.closeDone()
203+}
204+
205+func (r *suspendedAttachmentRetention) closeDone() {
206+ if r == nil || r.done == nil {
207+ return
208+ }
209+ r.doneOnce.Do(func() { close(r.done) })
210+}
211+
212+// armSuspendedExpiry publishes the daemon-owned safety expiry for a suspension
213+// that has already committed and acknowledged. The caller must still hold ac's
214+// effect-gate freeze, so a racing activation is serialized behind the same gate
215+// and cannot miss the record. The watcher is admitted from the connection
216+// handler, so daemon shutdown joins it only after connWg drains: no Add can race
217+// the terminal Wait.
218+func (d *Daemon) armSuspendedExpiry(ac *attachedClient, sess *session) {
219+ if d == nil || ac == nil || sess == nil || d.suspendedSafetyExpiry <= 0 {
220+ return
221+ }
222+ transport := ac.transportSnapshot()
223+ if transport.transport == nil {
224+ return
225+ }
226+ record := &suspendedAttachmentRetention{
227+ ac: ac,
228+ sess: sess,
229+ generation: ac.lifecycle.generationValue(),
230+ transport: transport,
231+ timer: d.clock.NewTimer(d.suspendedSafetyExpiry),
232+ done: make(chan struct{}),
233+ }
234+ d.mu.Lock()
235+ if d.closing {
236+ d.mu.Unlock()
237+ record.stop()
238+ return
239+ }
240+ // Re-suspension replaces any prior record for this attachment; the stale
241+ // watcher must not outlive the suspension that armed it.
242+ if previous := d.suspended[ac]; previous != nil {
243+ previous.stop()
244+ }
245+ d.suspended[ac] = record
246+ d.attachmentCleanupWg.Add(1)
247+ d.mu.Unlock()
248+ go func() {
249+ defer d.attachmentCleanupWg.Done()
250+ select {
251+ case <-record.timer.C():
252+ d.expireSuspendedAttachment(record)
253+ case <-record.done:
254+ }
255+ }()
256+}
257+
258+// clearSuspendedExpiryLocked cancels ac's safety expiry. Caller holds d.mu.
259+func (d *Daemon) clearSuspendedExpiryLocked(ac *attachedClient) {
260+ if d == nil || ac == nil {
261+ return
262+ }
263+ record := d.suspended[ac]
264+ if record == nil {
265+ return
266+ }
267+ delete(d.suspended, ac)
268+ record.stop()
269+}
270+
271+// clearSuspendedExpiry cancels ac's safety expiry. Callers must not hold daemon,
272+// session, or routing locks.
273+func (d *Daemon) clearSuspendedExpiry(ac *attachedClient) {
274+ if d == nil || ac == nil {
275+ return
276+ }
277+ d.mu.Lock()
278+ d.clearSuspendedExpiryLocked(ac)
279+ d.mu.Unlock()
280+}
281+
282+// purgeSuspendedForSessionLocked cancels every safety expiry owned by sess.
283+// Caller holds d.mu.
284+func (d *Daemon) purgeSuspendedForSessionLocked(sess *session) {
285+ if d == nil || sess == nil {
286+ return
287+ }
288+ for ac, record := range d.suspended {
289+ if record.sess != sess {
290+ continue
291+ }
292+ delete(d.suspended, ac)
293+ record.stop()
294+ }
295+}
296+
297+// purgeAllSuspendedLocked cancels every safety expiry. Caller holds d.mu.
298+func (d *Daemon) purgeAllSuspendedLocked() {
299+ if d == nil {
300+ return
301+ }
302+ for ac, record := range d.suspended {
303+ delete(d.suspended, ac)
304+ record.stop()
305+ }
306+}
307+
308+// expireSuspendedAttachment force-retires one suspended attachment whose
309+// daemon-owned safety timer fired. It freezes and drains the exact gate before
310+// taking any architecture lock, then revalidates the record, lifecycle
311+// generation, transport incarnation, suspended activity, and session
312+// registration under that same freeze. The retained session name is
313+// deliberately not compared: a rename keeps the incarnation and must not
314+// postpone terminal eviction. Expiry is a no-notice terminal eviction with no
315+// resume credential, and it leaves the session to existing headless lifetime
316+// policy.
317+func (d *Daemon) expireSuspendedAttachment(record *suspendedAttachmentRetention) {
318+ if d == nil || record == nil || record.ac == nil {
319+ return
320+ }
321+ ac := record.ac
322+ if d.beforeSuspendedExpiryFreeze != nil {
323+ d.beforeSuspendedExpiryFreeze(ac)
324+ }
325+ // Freeze and drain without a bound: terminal eviction acts on a suspended
326+ // attachment whose gate has no admitted effects, and a gate owner always
327+ // releases, so waiting here can never strand the expiry the way an expired
328+ // acquisition budget would.
329+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{afterFrozen: func(ac *attachedClient) {
330+ if d.afterAttachmentEffectGateFrozen != nil {
331+ d.afterAttachmentEffectGateFrozen("expire", ac)
332+ }
333+ }}, ac)
334+ if !frozen.acquired || !frozen.drained {
335+ frozen.unfreeze()
336+ return
337+ }
338+ d.mu.Lock()
339+ sess := record.sess
340+ detached := false
341+ if d.suspended[ac] == record && !d.closing && sess != nil && d.sessions[sess.id] == sess && ac.currentAttachmentSession() == sess {
342+ sess.mu.Lock()
343+ ac.lifecycle.mu.Lock()
344+ current := ac.lifecycle.activity == attachmentSuspended &&
345+ ac.lifecycle.generationValue() == record.generation &&
346+ ac.transportSnapshotCurrent(record.transport) &&
347+ attachmentRegisteredLocked(sess, ac)
348+ ac.lifecycle.mu.Unlock()
349+ if current {
350+ detached = detachFrozenAttachmentLocked(sess, ac)
351+ }
352+ sess.mu.Unlock()
353+ if detached {
354+ delete(d.suspended, ac)
355+ }
356+ }
357+ d.mu.Unlock()
358+ frozen.unfreeze()
359+ if !detached {
360+ return
361+ }
362+ // The expiry owns the record now; release a watcher that may still be
363+ // selecting on the fired timer channel.
364+ record.stop()
365+ d.log.Warn("suspended attachment expired", "session", sess.nameSnapshot(), "expiry", d.suspendedSafetyExpiry)
366+ // Shared terminal eviction: geometry reconciliation, coordinator detach,
367+ // picker/palette/output cleanup, and the physical transport close all run
368+ // outside every architecture lock. Expiry never parks a resume credential.
369+ d.finishClientGone(sess, ac, record.transport.transport, true, false)
370+}
A internal/usecase/daemon/attachment_suspension_expiry_test.go +459 -0
@@ -0,0 +1,459 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+const testSuspendedExpiry = 7 * time.Second
18+
19+// expiryAckGateTransport blocks the first suspension ACK until released, so a
20+// test can observe the exact window between commit and record publication.
21+type expiryAckGateTransport struct {
22+ closeTrackingTransport
23+ entered chan struct{}
24+ release chan struct{}
25+ block atomic.Bool
26+ once sync.Once
27+}
28+
29+func (t *expiryAckGateTransport) SendServer(m protocol.ServerMessage) error {
30+ if t.block.Load() {
31+ t.once.Do(func() { close(t.entered) })
32+ <-t.release
33+ }
34+ return t.closeTrackingTransport.SendServer(m)
35+}
36+
37+type expiryAckFailureTransport struct{ closeTrackingTransport }
38+
39+func (*expiryAckFailureTransport) SendServer(protocol.ServerMessage) error {
40+ return errors.New("suspension ack failed")
41+}
42+
43+// expiryCloseSignalTransport reports exactly when the daemon physically closes
44+// the retained transport, which happens after eviction cleanup runs.
45+type expiryCloseSignalTransport struct {
46+ closeTrackingTransport
47+ closed chan struct{}
48+ once sync.Once
49+}
50+
51+func newExpiryCloseSignalTransport() *expiryCloseSignalTransport {
52+ return &expiryCloseSignalTransport{closed: make(chan struct{})}
53+}
54+
55+func (t *expiryCloseSignalTransport) Close() error {
56+ t.once.Do(func() { close(t.closed) })
57+ return t.closeTrackingTransport.Close()
58+}
59+
60+// newSuspendedExpiryFixture creates one manual session, suspends its sole
61+// attachment, and returns the armed fake-clock safety timer plus a transport
62+// whose Close call can be observed.
63+func newSuspendedExpiryFixture(t *testing.T) (*Daemon, *session, *attachedClient, *signalTimer, *expiryCloseSignalTransport) {
64+ t.Helper()
65+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
66+ WithSuspendedSafetyExpiry(testSuspendedExpiry)(d)
67+ d.attachCoordinator(sess, nil, ac, true)
68+ tr := newExpiryCloseSignalTransport()
69+ ac.replaceTransport(tr)
70+ ac.resumeCapable, ac.resumeToken = true, 123
71+ token := sess.captureAttachmentCapability(ac, tr)
72+ ac.installTestAttachmentCapability(token)
73+ clock := &signalClock{timers: make(chan *signalTimer, 64)}
74+ d.clock = clock
75+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
76+ timer := awaitTimerWithDuration(t, clock.timers, testSuspendedExpiry)
77+ return d, sess, ac, timer, tr
78+}
79+
80+// awaitTimerWithDuration drains unrelated watchdog timers (the suspension ACK
81+// has its own bounded-send deadline) until the requested one is armed.
82+func awaitTimerWithDuration(t *testing.T, timers <-chan *signalTimer, want time.Duration) *signalTimer {
83+ t.Helper()
84+ for range 8 {
85+ timer := awaitTestValue(t, timers, "expected timer was never armed")
86+ if timer.duration == want {
87+ return timer
88+ }
89+ }
90+ t.Fatalf("no timer with duration %s was armed", want)
91+ return nil
92+}
93+
94+func waitForAttachmentCleanup(t *testing.T, d *Daemon) {
95+ t.Helper()
96+ done := make(chan struct{})
97+ go func() {
98+ d.attachmentCleanupWg.Wait()
99+ close(done)
100+ }()
101+ awaitTestCompletion(t, done, "suspension watcher was never joined")
102+}
103+
104+func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
105+ d, sess, ac, timer, tr := newSuspendedExpiryFixture(t)
106+
107+ evicted := make(chan struct{})
108+ var once sync.Once
109+ d.afterClientGoneDetach = func() { once.Do(func() { close(evicted) }) }
110+
111+ // Suspended-only presence is not observable, but membership and transport
112+ // ownership are retained until expiry.
113+ require.False(t, sess.snapshotView(viewOptions{}).attached, "suspended-only session must not be observably attached")
114+ require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains session membership")
115+ d.mu.Lock()
116+ require.NotNil(t, d.suspended[ac], "suspension arms the daemon-owned safety expiry")
117+ d.mu.Unlock()
118+
119+ timer.ch <- time.Now()
120+ awaitTestCompletion(t, evicted, "safety expiry never evicted the suspended attachment")
121+ awaitTestCompletion(t, tr.closed, "expiry never closed the retained transport")
122+
123+ require.Nil(t, ac.currentAttachmentSession())
124+ sess.mu.Lock()
125+ _, registered := sess.attachments[ac]
126+ sess.mu.Unlock()
127+ require.False(t, registered, "expiry releases session membership")
128+ require.True(t, tr.Closed(), "expiry closes the retained transport")
129+ d.mu.Lock()
130+ _, retained := d.suspended[ac]
131+ parked := d.parked[123]
132+ d.mu.Unlock()
133+ require.False(t, retained)
134+ require.Nil(t, parked, "expiry must not park a resume credential")
135+ require.False(t, ac.parked)
136+ require.False(t, sess.snapshotView(viewOptions{}).attached)
137+ require.Same(t, sess, firstSession(d), "expiry leaves the session to headless lifetime policy")
138+ waitForAttachmentCleanup(t, d)
139+}
140+
141+func TestSuspendedAttachmentExpiryLeavesEphemeralSessionRegistered(t *testing.T) {
142+ d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
143+ sess.mu.Lock()
144+ sess.ephemeral = true
145+ sess.mu.Unlock()
146+ evicted := make(chan struct{})
147+ var once sync.Once
148+ d.afterClientGoneDetach = func() { once.Do(func() { close(evicted) }) }
149+ timer.ch <- time.Now()
150+ awaitTestCompletion(t, evicted, "safety expiry never evicted the ephemeral attachment")
151+ require.Same(t, sess, firstSession(d), "ephemeral sessions follow existing lifetime policy")
152+ require.Nil(t, ac.currentAttachmentSession())
153+ waitForAttachmentCleanup(t, d)
154+}
155+
156+func TestSuspendedAttachmentExpiryCancelledByActivation(t *testing.T) {
157+ d, sess, ac, _, tr := newSuspendedExpiryFixture(t)
158+ require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{
159+ RequestID: 2,
160+ Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name},
161+ Size: ac.sizeSnapshot(),
162+ }))
163+ d.mu.Lock()
164+ _, retained := d.suspended[ac]
165+ d.mu.Unlock()
166+ require.False(t, retained, "activation cancels the safety expiry")
167+ require.Equal(t, attachmentActive, ac.attachmentActivity())
168+ require.True(t, sess.snapshotView(viewOptions{}).attached)
169+ require.False(t, tr.Closed())
170+ waitForAttachmentCleanup(t, d)
171+}
172+
173+func TestSuspendedAttachmentExpiryIsRenameSafe(t *testing.T) {
174+ d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
175+ // A rename keeps the daemon incarnation; expiry must not require the current
176+ // name to equal the retained suspension target.
177+ sess.mu.Lock()
178+ sess.name = "renamed"
179+ sess.mu.Unlock()
180+ evicted := make(chan struct{})
181+ var once sync.Once
182+ d.afterClientGoneDetach = func() { once.Do(func() { close(evicted) }) }
183+ timer.ch <- time.Now()
184+ awaitTestCompletion(t, evicted, "rename must not postpone terminal eviction")
185+ require.Nil(t, ac.currentAttachmentSession())
186+ waitForAttachmentCleanup(t, d)
187+}
188+
189+func TestSuspendedAttachmentExpiryLosesToDisconnect(t *testing.T) {
190+ d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
191+ var detaches atomic.Int64
192+ d.afterClientGoneDetach = func() { detaches.Add(1) }
193+ // Run the competing disconnect while the expiry watcher is paused before it
194+ // freezes the gate.
195+ d.beforeSuspendedExpiryFreeze = func(a *attachedClient) {
196+ d.clientGone(sess, a, a.transport(), false)
197+ }
198+ timer.ch <- time.Now()
199+ waitForAttachmentCleanup(t, d)
200+ require.Nil(t, ac.currentAttachmentSession())
201+ require.Equal(t, int64(1), detaches.Load(), "the losing expiry must not evict twice")
202+ d.mu.Lock()
203+ _, retained := d.suspended[ac]
204+ d.mu.Unlock()
205+ require.False(t, retained)
206+}
207+
208+func TestSuspendedAttachmentExpiryLosesToActivation(t *testing.T) {
209+ d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
210+ var detaches atomic.Int64
211+ d.afterClientGoneDetach = func() { detaches.Add(1) }
212+ activationErr := make(chan error, 1)
213+ d.beforeSuspendedExpiryFreeze = func(a *attachedClient) {
214+ activationErr <- d.activateAttachment(a, a.transportSnapshot(), protocol.ActivateAttachment{
215+ RequestID: 2,
216+ Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name},
217+ Size: a.sizeSnapshot(),
218+ })
219+ }
220+ timer.ch <- time.Now()
221+ require.NoError(t, awaitTestValue(t, activationErr, "activation never ran"))
222+ waitForAttachmentCleanup(t, d)
223+ require.Equal(t, attachmentActive, ac.attachmentActivity())
224+ require.True(t, sess.attachmentRegistered(ac))
225+ require.Equal(t, int64(0), detaches.Load(), "the losing expiry must not evict an activated attachment")
226+}
227+
228+func TestSuspendedAttachmentExpiryRequiresExactRecord(t *testing.T) {
229+ tests := []struct {
230+ name string
231+ prepare func(t *testing.T, d *Daemon, ac *attachedClient, record *suspendedAttachmentRetention) *suspendedAttachmentRetention
232+ }{
233+ {
234+ name: "stale generation",
235+ prepare: func(t *testing.T, d *Daemon, ac *attachedClient, record *suspendedAttachmentRetention) *suspendedAttachmentRetention {
236+ stale := &suspendedAttachmentRetention{
237+ ac: record.ac, sess: record.sess, transport: record.transport,
238+ generation: record.generation + 1, timer: record.timer, done: record.done,
239+ }
240+ d.mu.Lock()
241+ d.suspended[ac] = stale
242+ d.mu.Unlock()
243+ return stale
244+ },
245+ },
246+ {
247+ name: "stale transport incarnation",
248+ prepare: func(t *testing.T, d *Daemon, ac *attachedClient, record *suspendedAttachmentRetention) *suspendedAttachmentRetention {
249+ stale := &suspendedAttachmentRetention{
250+ ac: record.ac, sess: record.sess, generation: record.generation,
251+ transport: transportSnapshot{transport: record.transport.transport, incarnation: record.transport.incarnation + 1},
252+ timer: record.timer, done: record.done,
253+ }
254+ d.mu.Lock()
255+ d.suspended[ac] = stale
256+ d.mu.Unlock()
257+ return stale
258+ },
259+ },
260+ {
261+ name: "foreign record identity",
262+ prepare: func(t *testing.T, d *Daemon, ac *attachedClient, record *suspendedAttachmentRetention) *suspendedAttachmentRetention {
263+ return &suspendedAttachmentRetention{
264+ ac: record.ac, sess: record.sess, generation: record.generation,
265+ transport: record.transport, timer: record.timer, done: record.done,
266+ }
267+ },
268+ },
269+ {
270+ name: "activity no longer suspended",
271+ prepare: func(t *testing.T, d *Daemon, ac *attachedClient, record *suspendedAttachmentRetention) *suspendedAttachmentRetention {
272+ ac.lifecycle.mu.Lock()
273+ ac.lifecycle.activity = attachmentActive
274+ ac.lifecycle.mu.Unlock()
275+ return record
276+ },
277+ },
278+ }
279+ for _, tt := range tests {
280+ t.Run(tt.name, func(t *testing.T) {
281+ d, sess, ac, _, _ := newSuspendedExpiryFixture(t)
282+ d.mu.Lock()
283+ record := d.suspended[ac]
284+ d.mu.Unlock()
285+ require.NotNil(t, record)
286+ expiring := tt.prepare(t, d, ac, record)
287+ d.expireSuspendedAttachment(expiring)
288+ require.Same(t, sess, ac.currentAttachmentSession(), "a stale record must not detach the live suspension")
289+ require.True(t, sess.attachmentRegistered(ac))
290+ d.mu.Lock()
291+ live := d.suspended[ac]
292+ d.mu.Unlock()
293+ require.NotNil(t, live, "the live safety expiry must survive a stale expiry")
294+ // Release the live record's watcher without triggering eviction.
295+ d.clearSuspendedExpiry(ac)
296+ waitForAttachmentCleanup(t, d)
297+ })
298+ }
299+}
300+
301+func TestSuspendedAttachmentExpiryArmsOnlyAfterAck(t *testing.T) {
302+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
303+ WithSuspendedSafetyExpiry(testSuspendedExpiry)(d)
304+ d.attachCoordinator(sess, nil, ac, true)
305+ tr := &expiryAckGateTransport{entered: make(chan struct{}), release: make(chan struct{})}
306+ tr.block.Store(true)
307+ ac.replaceTransport(tr)
308+ token := sess.captureAttachmentCapability(ac, tr)
309+ ac.installTestAttachmentCapability(token)
310+
311+ done := make(chan error, 1)
312+ go func() { done <- d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}) }()
313+ awaitTestCompletion(t, tr.entered, "suspension never attempted the ACK")
314+ d.mu.Lock()
315+ armed := d.suspended[ac] != nil
316+ d.mu.Unlock()
317+ require.False(t, armed, "the safety expiry must not be armed before the ACK is delivered")
318+
319+ close(tr.release)
320+ require.NoError(t, awaitTestValue(t, done, "suspension never completed"))
321+ d.mu.Lock()
322+ armed = d.suspended[ac] != nil
323+ d.mu.Unlock()
324+ require.True(t, armed, "the safety expiry must be armed once the ACK is delivered")
325+ d.clearSuspendedExpiry(ac)
326+}
327+
328+func TestSuspendedAttachmentExpiryAckFailureArmsNothing(t *testing.T) {
329+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
330+ WithSuspendedSafetyExpiry(testSuspendedExpiry)(d)
331+ d.attachCoordinator(sess, nil, ac, true)
332+ tr := &expiryAckFailureTransport{}
333+ ac.replaceTransport(tr)
334+ token := sess.captureAttachmentCapability(ac, tr)
335+ ac.installTestAttachmentCapability(token)
336+
337+ require.Error(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
338+ d.mu.Lock()
339+ _, armed := d.suspended[ac]
340+ d.mu.Unlock()
341+ require.False(t, armed, "a failed ACK must not arm a safety expiry")
342+}
343+
344+func TestSuspendedAttachmentExpiryRearmsOnResuspension(t *testing.T) {
345+ d, sess, ac, _, _ := newSuspendedExpiryFixture(t)
346+ d.mu.Lock()
347+ first := d.suspended[ac]
348+ d.mu.Unlock()
349+ require.NotNil(t, first)
350+ require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{
351+ RequestID: 2,
352+ Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name},
353+ Size: ac.sizeSnapshot(),
354+ }))
355+ token := sess.captureAttachmentCapability(ac, ac.transport())
356+ ac.installTestAttachmentCapability(token)
357+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 3}))
358+ d.mu.Lock()
359+ second := d.suspended[ac]
360+ d.mu.Unlock()
361+ require.NotNil(t, second)
362+ require.NotSame(t, first, second, "re-suspension replaces the retained safety expiry")
363+ select {
364+ case <-first.done:
365+ default:
366+ t.Fatal("the superseded safety expiry was not released")
367+ }
368+ d.clearSuspendedExpiry(ac)
369+ waitForAttachmentCleanup(t, d)
370+}
371+
372+func TestSuspendedAttachmentShutdownJoinsWatcher(t *testing.T) {
373+ d, sess, ac, _, _ := newSuspendedExpiryFixture(t)
374+ require.False(t, d.shutdownAll(protocol.ReasonServerShutdown))
375+ waitForAttachmentCleanup(t, d)
376+ d.mu.Lock()
377+ require.Empty(t, d.suspended)
378+ d.mu.Unlock()
379+ require.Nil(t, ac.currentAttachmentSession())
380+ sess.mu.Lock()
381+ _, registered := sess.attachments[ac]
382+ sess.mu.Unlock()
383+ require.False(t, registered)
384+}
385+
386+func TestWithSuspendedSafetyExpiryOption(t *testing.T) {
387+ log := slog.New(slog.NewTextHandler(io.Discard, nil))
388+ configured := New(nil, stubClock{}, log, WithSuspendedSafetyExpiry(37*time.Second))
389+ require.Equal(t, 37*time.Second, configured.suspendedSafetyExpiry)
390+ unchanged := New(nil, stubClock{}, log, WithSuspendedSafetyExpiry(0))
391+ require.Equal(t, defaultSuspendedSafetyExpiry, unchanged.suspendedSafetyExpiry)
392+}
393+
394+func TestSessionViewAttachedRequiresInteractivePresence(t *testing.T) {
395+ active := &attachedClient{}
396+ activating := &attachedClient{}
397+ activating.lifecycle.activity = attachmentActivating
398+ suspended := &attachedClient{}
399+ suspended.lifecycle.activity = attachmentSuspended
400+
401+ tests := []struct {
402+ name string
403+ attachments []*attachedClient
404+ want bool
405+ }{
406+ {name: "active only", attachments: []*attachedClient{active}, want: true},
407+ {name: "activating only", attachments: []*attachedClient{activating}, want: true},
408+ {name: "suspended only", attachments: []*attachedClient{suspended}, want: false},
409+ {name: "mixed active and suspended", attachments: []*attachedClient{suspended, active}, want: true},
410+ {name: "mixed activating and suspended", attachments: []*attachedClient{suspended, activating}, want: true},
411+ {name: "no attachments", attachments: nil, want: false},
412+ }
413+ for _, tt := range tests {
414+ t.Run(tt.name, func(t *testing.T) {
415+ s := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{}}}
416+ for _, ac := range tt.attachments {
417+ s.attachments[ac] = struct{}{}
418+ }
419+ require.Equal(t, tt.want, s.snapshotView(viewOptions{}).attached)
420+ s.mu.Lock()
421+ require.Equal(t, tt.want, sessionInteractivelyAttachedLocked(s))
422+ s.mu.Unlock()
423+ // Membership is retained for every registered participant, suspended
424+ // entries included, and remains activatable.
425+ require.Len(t, s.attachments, len(tt.attachments))
426+ })
427+ }
428+}
429+
430+func TestHandleListAttachedExcludesSuspendedOnly(t *testing.T) {
431+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
432+ d.attachCoordinator(sess, nil, ac, true)
433+ require.True(t, listedAttached(t, d, sess.name), "an active attachment is observably attached")
434+
435+ token := sess.captureAttachmentCapability(ac, ac.transport())
436+ ac.installTestAttachmentCapability(token)
437+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
438+ require.False(t, listedAttached(t, d, sess.name), "a suspended-only session is not attached in the listing")
439+ require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains membership for activation")
440+
441+ require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{
442+ RequestID: 2,
443+ Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name},
444+ Size: ac.sizeSnapshot(),
445+ }))
446+ require.True(t, listedAttached(t, d, sess.name), "activation restores observably attached presence")
447+}
448+
449+func listedAttached(t *testing.T, d *Daemon, name string) bool {
450+ t.Helper()
451+ for _, info := range listSessions(t, d).Sessions {
452+ if info.Name == name {
453+ require.Equal(t, protocol.SessionUp, info.State)
454+ return info.Attached
455+ }
456+ }
457+ t.Fatalf("session %q missing from the listing", name)
458+ return false
459+}
A internal/usecase/daemon/attachment_suspension_test.go +219 -0
@@ -0,0 +1,219 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/bnema/vev/internal/protocol/wire"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestAttachmentSuspensionRevokesEffectsAndGeometry(t *testing.T) {
15+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
16+ rc := d.attachCoordinator(sess, nil, ac, true)
17+ token := sess.captureAttachmentCapability(ac, ac.transport())
18+ ac.installTestAttachmentCapability(token)
19+ _, ok := sess.geometry.claimAttachment(sess, ac)
20+ require.True(t, ok)
21+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
22+ require.Nil(t, sess.geometry.latest.Load())
23+ require.Nil(t, rc.attachmentLease(ac))
24+ current := sess.captureAttachmentCapability(ac, ac.transport())
25+ _, admitted := ac.beginAttachmentEffect(current)
26+ require.False(t, admitted)
27+ require.Equal(t, paintRejected, d.paint(sess, ac, true, nil))
28+ require.True(t, ac.currentTransportIs(token.transport.transport))
29+}
30+
31+func TestAttachmentActivationExactLifecycle(t *testing.T) {
32+ for _, stale := range []bool{false, true} {
33+ t.Run(map[bool]string{false: "retained", true: "stale"}[stale], func(t *testing.T) {
34+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
35+ d.attachCoordinator(sess, nil, ac, true)
36+ token := sess.captureAttachmentCapability(ac, ac.transport())
37+ ac.installTestAttachmentCapability(token)
38+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
39+ target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
40+ if stale {
41+ target.LifecycleID[0] ^= 1
42+ }
43+ err := d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{RequestID: 2, Target: target, Size: domain.Size{Cols: 100, Rows: 30}})
44+ if stale {
45+ require.Error(t, err)
46+ require.Nil(t, sess.geometry.latest.Load())
47+ return
48+ }
49+ require.NoError(t, err)
50+ require.Equal(t, ac, sess.geometry.latest.Load().attachment)
51+ require.Equal(t, 100, ac.geometrySnapshot().Cols)
52+ current := sess.captureAttachmentCapability(ac, ac.transport())
53+ effect, admitted := ac.beginAttachmentEffect(current)
54+ require.True(t, admitted)
55+ effect.End()
56+ })
57+ }
58+}
59+
60+func TestAttachmentSuspensionBarrierDrainsAdmittedEffects(t *testing.T) {
61+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
62+ d.attachCoordinator(sess, nil, ac, true)
63+ token := sess.captureAttachmentCapability(ac, ac.transport())
64+ ac.installTestAttachmentCapability(token)
65+ effect, admitted := ac.beginAttachmentEffect(token)
66+ require.True(t, admitted)
67+ frozen := make(chan struct{})
68+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
69+ if action == "suspend" {
70+ close(frozen)
71+ }
72+ }
73+ result := make(chan error, 1)
74+ go func() { result <- d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 7}) }()
75+ <-frozen
76+ select {
77+ case <-sends:
78+ t.Fatal("ACK before effect drained")
79+ default:
80+ }
81+ _, admitted = ac.beginAttachmentEffect(token)
82+ require.False(t, admitted)
83+ require.NoError(t, effect.sendControl(protocol.Pong{}))
84+ effect.End()
85+ require.NoError(t, <-result)
86+ require.IsType(t, protocol.Pong{}, decodeServerMessage(t, <-sends))
87+ ack := decodeServerMessage(t, <-sends).(protocol.AttachmentSuspended)
88+ require.Equal(t, uint64(7), ack.RequestID)
89+}
90+
91+func TestAttachmentActivationPublicationPrecedesSuccess(t *testing.T) {
92+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
93+ d.attachCoordinator(sess, nil, ac, true)
94+ token := sess.captureAttachmentCapability(ac, ac.transport())
95+ ac.installTestAttachmentCapability(token)
96+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
97+ suspended := decodeServerMessage(t, <-sends).(protocol.AttachmentSuspended)
98+ require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{RequestID: 2, Target: suspended.Target, Size: ac.sizeSnapshot()}))
99+ var full *protocol.Output
100+ var position *protocol.RoutePosition
101+ for len(sends) != 0 {
102+ switch m := decodeServerMessage(t, <-sends).(type) {
103+ case protocol.Output:
104+ require.Nil(t, full)
105+ require.True(t, m.Full)
106+ require.Zero(t, m.Base)
107+ require.NotNil(t, m.Context)
108+ full = &m
109+ case protocol.RoutePosition:
110+ require.NotNil(t, full, "route position must follow full publication")
111+ require.Nil(t, position)
112+ position = &m
113+ case protocol.AttachmentActivated:
114+ require.NotNil(t, full, "activation success must follow full publication")
115+ require.NotNil(t, position, "activation success must follow route position")
116+ require.Equal(t, full.Epoch, m.Epoch)
117+ require.Equal(t, full.New, m.State)
118+ require.Equal(t, full.Context.Publication, m.ViewPublication)
119+ require.Equal(t, full.Context.Route, m.Identity)
120+ return
121+ }
122+ }
123+ t.Fatal("missing activation success")
124+}
125+
126+func TestAttachmentSuspensionCloseDoesNotPark(t *testing.T) {
127+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
128+ ac.resumeCapable, ac.resumeToken = true, 123
129+ d.attachCoordinator(sess, nil, ac, true)
130+ token := sess.captureAttachmentCapability(ac, ac.transport())
131+ ac.installTestAttachmentCapability(token)
132+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
133+ d.clientGone(sess, ac, token.transport.transport, false)
134+ require.Nil(t, ac.currentAttachmentSession())
135+ require.Empty(t, d.parked)
136+ require.False(t, ac.parked)
137+}
138+
139+func TestAttachmentSuspensionPeerGeometryFallbackAndReclaim(t *testing.T) {
140+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
141+ peerTransport, _ := newCapturingTransport(t)
142+ peer, err := d.attachClient(sess, peerTransport, domain.Size{Cols: 60, Rows: 20}, attachClientOptions{})
143+ require.NoError(t, err)
144+ d.attachCoordinator(sess, nil, ac, true)
145+ token := sess.captureAttachmentCapability(ac, ac.transport())
146+ ac.installTestAttachmentCapability(token)
147+ _, ok := sess.geometry.claimAttachment(sess, ac)
148+ require.True(t, ok)
149+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
150+ require.Equal(t, peer, sess.geometry.latest.Load().attachment)
151+ peerToken := sess.captureAttachmentCapability(peer, peer.transport())
152+ effect, admitted := peer.beginAttachmentEffect(peerToken)
153+ require.True(t, admitted)
154+ effect.End()
155+ require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{RequestID: 2, Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}, Size: ac.sizeSnapshot()}))
156+ require.Equal(t, ac, sess.geometry.latest.Load().attachment)
157+}
158+
159+func TestAttachmentSuspensionReaderKeepsControlAlive(t *testing.T) {
160+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
161+ tr := newMockServerConnection(t)
162+ incoming := make(chan protocol.ClientMessage, 4)
163+ incoming <- protocol.SuspendAttachment{RequestID: 1}
164+ incoming <- protocol.Input{Data: []byte("ignored"), InputSeq: 1}
165+ incoming <- protocol.Ping{}
166+ close(incoming)
167+ tr.EXPECT().Recv().RunAndReturn(func() (wire.Envelope, error) {
168+ m, ok := <-incoming
169+ if !ok {
170+ return wire.Envelope{}, io.EOF
171+ }
172+ return mustClientEnvelope(m), nil
173+ })
174+ var messages []protocol.ServerMessage
175+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f wire.Envelope) error {
176+ messages = append(messages, decodeServerMessage(t, f))
177+ return nil
178+ }).Maybe()
179+ tr.EXPECT().Close().Return(nil).Maybe()
180+ ac.replaceTransport(tr)
181+ ac.resumeCapable, ac.resumeToken = true, 123
182+ d.attachCoordinator(sess, nil, ac, true)
183+ token := sess.captureAttachmentCapability(ac, tr)
184+ ac.installTestAttachmentCapability(token)
185+ d.runConnLoop(ac)
186+ require.IsType(t, protocol.AttachmentSuspended{}, messages[0])
187+ require.IsType(t, protocol.Pong{}, messages[1])
188+ require.Zero(t, ac.echoAck.Load())
189+ require.Nil(t, ac.currentAttachmentSession())
190+ require.Empty(t, d.parked)
191+}
192+
193+func TestAttachmentActivationCloseDuringFullPublication(t *testing.T) {
194+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
195+ tr := newMockServerConnection(t)
196+ var activated bool
197+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f wire.Envelope) error {
198+ switch decodeServerMessage(t, f).(type) {
199+ case protocol.Output:
200+ return io.ErrClosedPipe
201+ case protocol.AttachmentActivated:
202+ activated = true
203+ }
204+ return nil
205+ }).Maybe()
206+ tr.EXPECT().Close().Return(nil).Maybe()
207+ ac.replaceTransport(tr)
208+ ac.resumeCapable, ac.resumeToken = true, 123
209+ d.attachCoordinator(sess, nil, ac, true)
210+ token := sess.captureAttachmentCapability(ac, tr)
211+ ac.installTestAttachmentCapability(token)
212+ require.False(t, d.handleAttachmentClientMessage(token, protocol.SuspendAttachment{RequestID: 1}))
213+ token = sess.captureAttachmentCapability(ac, tr)
214+ require.True(t, d.handleAttachmentClientMessage(token, protocol.ActivateAttachment{RequestID: 2, Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}, Size: ac.sizeSnapshot()}))
215+ d.attachmentCleanupWg.Wait()
216+ require.False(t, activated)
217+ require.Nil(t, ac.currentAttachmentSession())
218+ require.Empty(t, d.parked)
219+}
M internal/usecase/daemon/attachment_transition_publish.go +1 -1
@@ -120,7 +120,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
120120 sourceCore := source.core()
121121 targetCore := req.target.core()
122122 if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||
123- req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
123+ req.next == nil || req.next.attachmentActivity() != attachmentActive || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
124124 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
125125 req.preserveAttachment && req.sourceCapability == nil {
126126 return nil, errAttachmentTransition
M internal/usecase/daemon/client.go +6 -0
@@ -3,6 +3,12 @@
33 // Lock ordering: never acquire sendMu or session/daemon locks while holding a
44 // pane lock. Coordinator callbacks and timer methods run without coordinator.mu;
55 // see the lock-specific comments on attachedClient and renderCoordinator.
6+// Attachment lifecycle gates order after daemon, session, routing, coordinator,
7+// and pane locks: a daemon or session owner may briefly take one
8+// lifecycle.mu to observe committed activity (see
9+// sessionInteractivelyAttachedLocked), but must never freeze, drain, or wait on
10+// a gate while holding any architecture lock. Freeze/drain always runs with
11+// every architecture lock released.
612 package daemon
713
814 import (
M internal/usecase/daemon/client_frame_routing.go +30 -0
@@ -94,6 +94,36 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
9494 if d.afterAttachmentFrameDispatch != nil {
9595 d.afterAttachmentFrameDispatch(capability)
9696 }
97+ var transitionErr error
98+ switch request := message.(type) {
99+ case protocol.SuspendAttachment:
100+ transitionErr = d.suspendAttachment(capability, request)
101+ case protocol.ActivateAttachment:
102+ transitionErr = d.activateAttachment(capability.ac, capability.transport, request)
103+ default:
104+ if capability.ac.attachmentActivity() != attachmentActive {
105+ if _, ping := message.(protocol.Ping); ping {
106+ if d.boundedSendErr(capability.ac, protocol.Pong{}) != nil {
107+ d.clientGoneWithoutNotice(capability.sess, capability.ac, capability.transport.transport, true)
108+ return true
109+ }
110+ }
111+ if _, detach := message.(protocol.Detach); detach {
112+ d.clientGoneWithoutNotice(capability.sess, capability.ac, capability.transport.transport, true)
113+ return true
114+ }
115+ return false
116+ }
117+ return d.handleActiveAttachmentClientMessage(capability, message)
118+ }
119+ if transitionErr != nil {
120+ d.clientGoneWithoutNotice(capability.sess, capability.ac, capability.transport.transport, true)
121+ return true
122+ }
123+ return false
124+}
125+
126+func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapability, message protocol.ClientMessage) bool {
97127 effect, admitted := capability.ac.beginAttachmentEffect(capability)
98128 if !admitted {
99129 return false
M internal/usecase/daemon/client_transport_errors.go +7 -1
@@ -26,6 +26,12 @@ func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed
2626 return // stale connection loop; a newer transport owns this client
2727 }
2828 }
29+ // A warm attachment already retains its live session state. Closure evicts
30+ // it rather than creating a second, parked lifetime.
31+ if ac.attachmentActivity() != attachmentActive {
32+ explicit = true
33+ notice = false
34+ }
2935 // Advertise parking before detach so IntentResume never observes both the
3036 // live seat and parking/parked registries empty for a still-valid token.
3137 var parkingToken uint64
@@ -184,7 +190,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
184190 rc.noteDetach(ac)
185191 }
186192 sess.geometry.reconcileAndInvalidate(d, sess, nil, "client_transport_errors.go")
187- if d.parkAttachment(sess, ac) {
193+ if ac.attachmentActivity() == attachmentActive && d.parkAttachment(sess, ac) {
188194 _ = ac.closeCapturedTransport(failed)
189195 d.log.Warn("parked client after send error", "session", name)
190196 return
M internal/usecase/daemon/daemon.go +47 -13
@@ -67,6 +67,13 @@ func normalizeOutputWindow(window uint8) uint8 {
6767
6868 const defaultResumeParkGrace = 15 * time.Minute
6969
70+// defaultSuspendedSafetyExpiry bounds how long the daemon retains a suspended
71+// attachment. Suspension keeps transport ownership and session membership but
72+// removes interactive authority, so a client that never returns must not keep
73+// the underlying process alive indefinitely. Expiry is a terminal, no-notice
74+// eviction that never mints a resume credential.
75+const defaultSuspendedSafetyExpiry = 24 * time.Hour
76+
7077 // defaultSize is retained for headless layout helpers that have no client
7178 // window; Hello routing rejects invalid dimensions instead of using it.
7279 var defaultSize = domain.Size{Cols: 80, Rows: 24}
@@ -110,6 +117,12 @@ type Daemon struct {
110117 // with Wait.
111118 notifies []chan struct{}
112119 parked map[uint64]*parkedAttachment
120+ // suspended retains one daemon-owned safety expiry per suspended
121+ // attachment. Expiry is exact: the record carries the suspension's
122+ // lifecycle generation and transport incarnation, so a later activation,
123+ // re-suspension, detach, or session teardown can never be retired by a
124+ // stale timer. Guarded by mu.
125+ suspended map[*attachedClient]*suspendedAttachmentRetention
113126 // graphicsNamespaces reserves deterministic, attachment/session-scoped Kitty
114127 // ID blocks. Once a block may have reached an outer terminal it remains in
115128 // this bounded table for the daemon lifetime: side-effect Output frames have
@@ -232,18 +245,22 @@ type Daemon struct {
232245 // after the render ticket ends and before exact lifecycle validation.
233246 beforeAttachmentSendErrorCleanup func(attachmentCapability)
234247 afterAttachmentSendErrorCleanup func()
235- ptys ports.PTYFactory
236- clock ports.Clock
237- log *slog.Logger
238- runtimeObserver ports.RuntimeObserver
239- baseEnv []string
240- shell string
241- shellArgs []string
242- shellOverride bool
243- persistEnabled bool
244- catalogue ports.Catalogue
245- catalogueRecords []domain.CatalogueRecord
246- catalogueRecordsProvided bool
248+ // beforeSuspendedExpiryFreeze pauses the safety expiry after its timer fired
249+ // and before it freezes the attachment effect gate, so tests can win
250+ // activation, disconnect, or replacement first. It is a test-only seam.
251+ beforeSuspendedExpiryFreeze func(*attachedClient)
252+ ptys ports.PTYFactory
253+ clock ports.Clock
254+ log *slog.Logger
255+ runtimeObserver ports.RuntimeObserver
256+ baseEnv []string
257+ shell string
258+ shellArgs []string
259+ shellOverride bool
260+ persistEnabled bool
261+ catalogue ports.Catalogue
262+ catalogueRecords []domain.CatalogueRecord
263+ catalogueRecordsProvided bool
247264 // snapshotRepository is the sole checkpoint storage contract.
248265 snapshotRepository ports.SnapshotRepository
249266 recovery *recoveryusecase.Coordinator
@@ -304,6 +321,7 @@ type Daemon struct {
304321 barScripts *barScriptState
305322 notices *noticeCenter
306323 resumeParkGrace time.Duration
324+ suspendedSafetyExpiry time.Duration
307325 remotePreview remotePreviewState
308326 remotePreviewClient ports.RemotePreviewClient
309327 // remoteDirectory is the snapshot-only projection served by the remote
@@ -595,6 +613,17 @@ func WithResumeParkGrace(grace time.Duration) Option {
595613 }
596614 }
597615
616+// WithSuspendedSafetyExpiry overrides how long the daemon retains a suspended
617+// attachment before terminal eviction. Non-positive durations keep the default.
618+// Fake-clock tests set a short duration so the real timer path stays under test.
619+func WithSuspendedSafetyExpiry(expiry time.Duration) Option {
620+ return func(d *Daemon) {
621+ if expiry > 0 {
622+ d.suspendedSafetyExpiry = expiry
623+ }
624+ }
625+}
626+
598627 // WithConfig applies the initial user configuration.
599628 func WithConfig(cfg domain.Config) Option {
600629 return func(d *Daemon) {
@@ -673,6 +702,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
673702 inactive: make(map[string]inactiveSession),
674703 creating: make(map[string]struct{}),
675704 parked: make(map[uint64]*parkedAttachment),
705+ suspended: make(map[*attachedClient]*suspendedAttachmentRetention),
676706 graphicsNamespaces: make(map[uint64]struct{}),
677707 graphicsNamespaceFences: make(map[uint64]uint64),
678708 graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
@@ -694,6 +724,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
694724 snapshotWake: make(chan struct{}, 1),
695725 notices: newNoticeCenter(),
696726 resumeParkGrace: defaultResumeParkGrace,
727+ suspendedSafetyExpiry: defaultSuspendedSafetyExpiry,
697728 barScripts: &barScriptState{
698729 cfg: barConfigFromDomain(domain.Defaults().Bar),
699730 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -929,6 +960,7 @@ func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, dead
929960 d.closing = true
930961 d.purgeAllParkingLocked()
931962 parkedRetirements := d.purgeAllParkedLocked()
963+ d.purgeAllSuspendedLocked()
932964 snapshot := d.sessionsSnapshotLocked()
933965 stoppedNames := make([]string, 0, len(d.inactive))
934966 if purge {
@@ -1234,7 +1266,9 @@ func (d *Daemon) handleList(tr ports.ServerConnection) {
12341266 State: protocol.SessionUp,
12351267 Ephemeral: s.ephemeral,
12361268 Tabs: uint16(len(s.tabs)),
1237- Attached: len(s.attachments) != 0,
1269+ // Attached means registered interactive presence, not mere transport
1270+ // membership: a suspended-only session is headless to observers.
1271+ Attached: sessionInteractivelyAttachedLocked(s),
12381272 }
12391273 liveNames[s.name] = struct{}{}
12401274 s.mu.Unlock()
M internal/usecase/daemon/move_transaction.go +1 -0
@@ -316,6 +316,7 @@ func (t *moveTransaction) publishLocked() error {
316316 t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)
317317 t.source.tabs = nil
318318 d.purgeParkingForSessionLocked(t.source)
319+ d.purgeSuspendedForSessionLocked(t.source)
319320 t.retiredParked = d.purgeParkedForSessionLocked(t.source)
320321 }
321322 t.sourceName = t.source.name
M internal/usecase/daemon/render.go +12 -1
@@ -310,13 +310,24 @@ const (
310310 )
311311
312312 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
313+ return d.paintWithActivationEffect(entry, ac, reset, lease, nil)
314+}
315+
316+// activation owns the drained transition gate; only its explicit ticket may
317+// publish while ordinary input and render admission remain disabled.
318+func (d *Daemon) paintWithActivationEffect(entry *session, ac *attachedClient, reset bool, lease *attachmentLease, activation *attachmentEffect) paintResult {
313319 // Session-owned PTY preparation remains local; attachment rendering is
314320 // captured through session.captureRenderState.
315321 sess := entry
316322 local := sess != nil
317323 marks := d.newRuntimeMarkBatch()
318324 var paintEffect *attachmentEffect
319- if lease != nil {
325+ if activation != nil {
326+ paintEffect = activation
327+ marks.attachmentEffect = activation
328+ } else if ac.attachmentActivity() != attachmentActive {
329+ return paintRejected
330+ } else if lease != nil {
320331 token := captureAttachmentCapability(entry, ac, ac.transport())
321332 token.lease = lease
322333 ticket, admitted := ac.beginAttachmentEffect(token)
M internal/usecase/daemon/resume.go +2 -2
@@ -194,7 +194,7 @@ func (d *Daemon) waitParkingInFlight(h protocol.Hello) bool {
194194 }
195195
196196 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
197- if sess == nil || ac == nil || !ac.resumeCapable {
197+ if sess == nil || ac == nil || !ac.resumeCapable || ac.attachmentActivity() != attachmentActive {
198198 return false
199199 }
200200 d.mu.Lock()
@@ -497,7 +497,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
497497 if sess != nil {
498498 sess.mu.Lock()
499499 for candidate := range sess.attachments {
500- if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
500+ if candidate.attachmentActivity() != attachmentActive || !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
501501 continue
502502 }
503503 ac = candidate
M internal/usecase/daemon/route_position_test.go +46 -0
@@ -107,3 +107,49 @@ func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) {
107107 }, positions)
108108 d.clientGone(sess, ac, tr, false)
109109 }
110+
111+func TestWarmAndColdAttachmentPreferredTab(t *testing.T) {
112+ for _, disappeared := range []bool{false, true} {
113+ name := "matching retained tab"
114+ if disappeared {
115+ name = "retained tab disappeared"
116+ }
117+ t.Run(name, func(t *testing.T) {
118+ d := newTestDaemon(t, nil, stubClock{})
119+ sess := addControlSession(d, "work", "tab-1", "pane-1")
120+ sess.incarnation = domain.SessionLifecycleID{1}
121+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
122+ sess.mu.Lock()
123+ sess.tabs = append(sess.tabs, second)
124+ sess.mu.Unlock()
125+ target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
126+ hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: sess.name, Size: defaultSize, ExactTarget: &target, PreferredTabID: "tab-2", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
127+ warmTransport := &closeTrackingTransport{}
128+ _, warm, err := d.routeWithContext(context.Background(), hello, warmTransport)
129+ require.NoError(t, err)
130+ sess.repairAttachmentView(warm)
131+ require.Equal(t, domain.TabStableID("tab-2"), warm.viewSnapshot().tabID)
132+ rc := sess.renderCoordinator()
133+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(warm)))
134+ require.NoError(t, d.suspendAttachment(sess.captureAttachmentCapability(warm, warmTransport), protocol.SuspendAttachment{RequestID: 1}))
135+ if disappeared {
136+ sess.mu.Lock()
137+ sess.tabs = sess.tabs[:1]
138+ sess.mu.Unlock()
139+ }
140+ require.NoError(t, d.activateAttachment(warm, warm.transportSnapshot(), protocol.ActivateAttachment{RequestID: 2, Target: target, Size: defaultSize}))
141+ coldTransport := &closeTrackingTransport{}
142+ _, cold, err := d.routeWithContext(context.Background(), hello, coldTransport)
143+ require.NoError(t, err)
144+ sess.repairAttachmentView(cold)
145+ want := domain.TabStableID("tab-2")
146+ if disappeared {
147+ want = "tab-1"
148+ }
149+ require.Equal(t, want, cold.viewSnapshot().tabID)
150+ require.Equal(t, cold.viewSnapshot().tabID, warm.viewSnapshot().tabID)
151+ d.clientGone(sess, warm, warmTransport, false)
152+ d.clientGone(sess, cold, coldTransport, false)
153+ })
154+ }
155+}
M internal/usecase/daemon/session.go +13 -8
@@ -886,13 +886,15 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
886886 current = false
887887 }
888888 if current {
889- sess.unregisterAttachmentLocked(ac)
890- ac.setSession(nil)
891- ac.invalidateFrozenAttachmentCapability()
892- cancelPickerPreviewWorker(ac)
889+ current = detachFrozenAttachmentLocked(sess, ac)
893890 }
894891 sess.mu.Unlock()
895892 d.notices.routingMu.Unlock()
893+ if current {
894+ // Terminal detach ends any daemon-owned suspension expiry. The clear runs
895+ // outside routingMu so it never inverts the daemon -> routing lock order.
896+ d.clearSuspendedExpiry(ac)
897+ }
896898 return current
897899 }
898900
@@ -935,10 +937,7 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done
935937 }
936938 current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator)
937939 if current {
938- unregisterAttachmentSessionLocked(token.sess, token.ac)
939- token.ac.setSession(nil)
940- token.ac.invalidateFrozenAttachmentCapability()
941- cancelPickerPreviewWorker(token.ac)
940+ current = detachFrozenAttachmentLocked(token.sess, token.ac)
942941 }
943942 if coordinator != nil {
944943 coordinator.mu.Unlock()
@@ -946,6 +945,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done
946945 core.mu.Unlock()
947946 d.notices.routingMu.Unlock()
948947 d.mu.Unlock()
948+ if current {
949+ // The suspension expiry is daemon-scoped; clear it only after every other
950+ // architecture lock is released.
951+ d.clearSuspendedExpiry(token.ac)
952+ }
949953 return current
950954 }
951955
@@ -1782,6 +1786,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
17821786 capture(attached)
17831787 }
17841788 clear(sess.attachments)
1789+ d.purgeSuspendedForSessionLocked(sess)
17851790 stoppedName := sess.name
17861791 stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
17871792 ephemeral := sess.ephemeral
M internal/usecase/daemon/session_view.go +24 -2
@@ -40,6 +40,25 @@ type sessionView struct {
4040 tabs []tabView
4141 }
4242
43+// sessionInteractivelyAttachedLocked reports whether sess has registered
44+// interactive presence: at least one attachment that is active or has an
45+// activation in progress. Suspended attachments keep transport ownership and
46+// session membership but hold no interactive authority, so they do not count.
47+// Caller holds s.mu; each activity read briefly takes only that attachment's
48+// lifecycle.mu and never freezes, drains, or waits on a gate.
49+func sessionInteractivelyAttachedLocked(s *session) bool {
50+ if s == nil {
51+ return false
52+ }
53+ for ac := range s.attachments {
54+ switch ac.attachmentActivity() {
55+ case attachmentActive, attachmentActivating:
56+ return true
57+ }
58+ }
59+ return false
60+}
61+
4362 // snapshotView reads session fields under s.mu and samples the independently
4463 // atomic mruAt while that lock is held. tabCount and hasAttention are always
4564 // filled; tabs is allocated only when opts.tabDetails is true (non-nil empty
@@ -60,8 +79,11 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
6079 createdAt: s.createdAt,
6180 defaultTab: 0,
6281 mruAt: s.mruAt.Load(),
63- attached: len(s.attachments) != 0,
64- tabCount: len(s.tabs),
82+ // attached reports interactive presence only; a suspended-only session
83+ // remains registered for teardown and possible activation but is not
84+ // observably attached.
85+ attached: sessionInteractivelyAttachedLocked(s),
86+ tabCount: len(s.tabs),
6587 }
6688 if opts.tabDetails {
6789 view.tabs = make([]tabView, 0, len(s.tabs))
M scripts/remote-picker-harness/README.md +46 -15
@@ -16,26 +16,57 @@ lifecycle at return, and a successfully committed navigation action.
1616 `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs
1717 the table-driven baseline matrix. Every scenario asserts committed outcomes
1818 (action IDs, exact lifecycle/session/focus context, observed published
19-output); sending a key is never success. Transport selection passes through
20-the fixture process environment (`VEV_REMOTE_TRANSPORT=stdio` or unset for
21-QUIC). Direct-remote scenarios use `--remote` with no local daemon and never
22-create a local home session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct`
23-selects which daemon serves the client-picker scenario: `local` keeps the
24-attachment and the picker rows in the client container, `direct` serves both
25-from the remote container. The client needs a pinned `remote`
26-SSH alias and `vev host add remote`. Scripts create uniquely named sessions
27-and detach their clients; the container owner handles session cleanup.
19+output); sending a key is never success. Transport selection is forwarded
20+into the client container (`docker exec -e VEV_REMOTE_TRANSPORT=stdio`, or
21+unset for QUIC); passing it to the `docker` CLI process alone never reached
22+the driver. Direct-remote scenarios use `--remote remote --session NAME`
23+against a named fixture session, so the attached target is a named exact
24+lifecycle rather than an ephemeral session, and never create a local home
25+session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon
26+serves the client-picker scenario: `local` keeps the attachment and the
27+picker rows in the client container, `direct` serves both from the remote
28+container. The client needs a pinned `remote` SSH alias and
29+`vev host add remote`. Scripts create uniquely named sessions and detach
30+their clients; the container owner handles session cleanup.
31+
32+Client and daemon debug logging is on (`VEV_LOG=debug`); the remote accepts
33+`VEV_LOG` over SSH so `_stdio`/`_quic-proxy` and the daemon they spawn log at
34+debug too. After every run, sanitized per-run logs and scenario evidence are
35+copied out of the containers to `/tmp/vev-acceptance-artifacts-*` (override
36+with `VEV_ACCEPTANCE_ARTIFACTS_DIR`). Only structured vev state logs and
37+committed contexts are captured: no host state, sockets, or key material.
2838
2939 | Scenario | Modes |
3040 |---|---|
3141 | `local-palette-cycle` | M1 local IPC: palette open/action/close, committed output, stable lifecycle |
32-| `direct-remote-ephemeral@quic` | M2 direct remote QUIC: fresh attach, committed output, stable lifecycle |
33-| `direct-remote-ephemeral@stdio` | M3 direct remote explicit stdio: same semantics, no QUIC listener |
42+| `direct-remote-named@quic` | M2 direct named remote QUIC: exact fixture lifecycle, committed output, stable lifecycle |
43+| `direct-remote-named@stdio` | M3 direct named remote explicit stdio: same semantics over SSH stdio |
44+| `warm-reuse@quic` | Warm hybrid QUIC: local→named remote→local→same remote in one Runner, same remote lifecycle, retained and new committed output, exactly one suspend/activate and one remote attach |
45+| `warm-reuse@stdio` | Warm hybrid stdio: same shape across the retained SSH process |
3446 | `hybrid-exact-return@quic` | M4 hybrid QUIC: local→remote→exact local return with committed action |
3547 | `hybrid-exact-return@stdio` | M5 hybrid stdio: same shape across close/dial |
36-| `client-picker-navigate@quic` (topology `local`) | M1: client-owned picker presented by the client, searched commit, cancel without a route change |
37-| `client-picker-navigate@quic` (topology `direct`) | M2: same over the remote serving daemon, no local daemon involved |
38-| `client-picker-navigate@stdio` (topology `direct`) | M3: same across stdio |
48+| `client-picker-navigate-local@quic` | M1: client-owned picker presented by the client, searched commit, cancel without a route change |
49+| `client-picker-navigate-direct@quic` | M2: same over the remote serving daemon, no local daemon involved |
50+| `client-picker-navigate-direct@stdio` | M3: same across stdio |
51+
52+Warm reuse asserts deterministic no-redial evidence rather than timing: the
53+client debug log must carry exactly one `remote attachment suspended` and one
54+`warm remote attachment activated` for the origin/session and zero
55+`warm remote activation failed`, and the remote daemon log must show exactly
56+one `client attached` for that session. A cold fallback or second bootstrap
57+(or, when the remote proxy log is available, a second proxy start) fails the
58+scenario. The remote committed lifecycle and the retained pane output before
59+and after the round trip are compared directly.
3960
4061 A failed assertion fails the command. This is a targeted regression, not an
41-exhaustive transport or geometry matrix.
62+exhaustive transport or geometry matrix. For a focused run, set
63+`VEV_ACCEPTANCE_SCENARIOS` to a space-separated subset of the scenario names
64+above (for example `warm-reuse@quic warm-reuse@stdio`); the containers, keys
65+and artifacts still come from the same setup.
66+
67+Known gap: `client-picker-navigate` fails independently of this matrix. After
68+entering the search and the exact target name, the client-owned picker commits
69+the current session instead of the searched target. It fails in isolation and
70+at any log level; it predates the named-direct refactor, which previously
71+aborted the full run before this scenario was reached. The named-direct, warm
72+reuse and hybrid scenarios above are green.
M scripts/remote-picker-harness/acceptance.py +303 -20
@@ -7,10 +7,17 @@ lifecycle/session/focus context, observed published output). Sending a key
77 or accepting a request is never success: every scenario ends on a
88 postcondition ``wait`` or a verified capture.
99
10-Transport selection passes through the fixture process environment:
11-``VEV_REMOTE_TRANSPORT`` unset means QUIC, ``stdio`` means SSH stdio.
12-Direct-remote scenarios use ``--remote`` in a fresh client container with no
13-local daemon; they never create a local home session as setup.
10+Transport selection passes through the client process environment:
11+``VEV_REMOTE_TRANSPORT`` unset means QUIC, ``stdio`` means SSH stdio. The
12+value is forwarded into the container with ``docker exec -e`` so it actually
13+reaches the driver process.
14+
15+Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a
16+named fixture session: a named exact remote target, never an ephemeral
17+attach. The warm-reuse scenario keeps one client Runner across a
18+local -> named remote -> local -> same named remote journey and pins the
19+retained attachment with client debug logs, the remote daemon's attach
20+count, and the committed remote lifecycle.
1421 """
1522
1623 import json
@@ -25,6 +32,17 @@ ROWS = "30"
2532 REQUEST_TIMEOUT_S = 15
2633 WAIT_TIMEOUT_MS = 15000
2734
35+STATE_LOG_DIR = "$HOME/.local/state/vev"
36+CLIENT_LOG = "vev-client.log"
37+DAEMON_LOG = "vev-daemon.log"
38+STDIO_LOG = "vev-stdio.log"
39+REMOTE_CACHE_FILE = "remote-catalog-cache.json"
40+INVENTORY_TIMEOUT_S = 45
41+ARTIFACTS_DIR = os.environ.get("VEV_ACCEPTANCE_ARTIFACTS_DIR", "").strip()
42+# The warm-reuse scenario requires client debug events. Every driver gets
43+# debug logging unless a scenario explicitly overrides it.
44+DRIVER_BASE_ENV = {"VEV_LOG": "debug"}
45+
2846
2947 class DriverError(Exception):
3048 pass
@@ -34,15 +52,18 @@ class Driver:
3452 """One headless client with bounded reads and reliable termination."""
3553
3654 def __init__(self, container, args, env=None):
37- cmd = ["docker", "exec", "-i", container, "vev", "--ui-driver",
38- "--cols", COLS, "--rows", ROWS] + args
39- process_env = None
55+ effective = dict(DRIVER_BASE_ENV)
4056 if env:
41- process_env = dict(os.environ)
42- process_env.update(env)
57+ effective.update(env)
58+ cmd = ["docker", "exec", "-i"]
59+ for key in sorted(effective):
60+ cmd += ["-e", f"{key}={effective[key]}"]
61+ cmd += [container, "vev", "--ui-driver",
62+ "--cols", COLS, "--rows", ROWS] + args
63+ # The environment must be forwarded into the container (-e); passing
64+ # it to the docker CLI process alone never reaches the driver.
4365 self.process = subprocess.Popen(
44- cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
45- text=True, env=process_env,
66+ cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
4667 )
4768 self.request_id = 0
4869 ready = self._read("discovery")
@@ -133,6 +154,135 @@ def check(condition, message, diagnostics):
133154 raise DriverError(f"{message}\n diagnostics: {json.dumps(diagnostics)[:2000]}")
134155
135156
157+def docker_capture(args, timeout=30):
158+ return subprocess.run(["docker", *args], capture_output=True, text=True,
159+ timeout=timeout)
160+
161+
162+def log_size(container, filename):
163+ """Byte length of one state log, so a run can read only its own events."""
164+ script = (f'f="{STATE_LOG_DIR}/{filename}"; '
165+ 'if [ -f "$f" ]; then wc -c < "$f"; else echo 0; fi')
166+ try:
167+ result = docker_capture(["exec", container, "sh", "-c", script])
168+ except subprocess.TimeoutExpired:
169+ return 0
170+ if result.returncode != 0:
171+ return 0
172+ try:
173+ return int(result.stdout.strip() or "0")
174+ except ValueError:
175+ return 0
176+
177+
178+def log_since(container, filename, offset):
179+ """Raw log bytes appended after offset; empty when the file is absent."""
180+ script = (f'f="{STATE_LOG_DIR}/{filename}"; '
181+ f'if [ -f "$f" ]; then tail -c +{offset + 1} "$f"; fi')
182+ try:
183+ result = docker_capture(["exec", container, "sh", "-c", script])
184+ except subprocess.TimeoutExpired:
185+ return ""
186+ return result.stdout if result.returncode == 0 else ""
187+
188+
189+def json_events(text):
190+ """Parse structured vev log lines, tolerating a truncated boundary line."""
191+ events = []
192+ for line in text.splitlines():
193+ line = line.strip()
194+ if not line:
195+ continue
196+ try:
197+ event = json.loads(line)
198+ except json.JSONDecodeError:
199+ continue
200+ if isinstance(event, dict) and "msg" in event:
201+ events.append(event)
202+ return events
203+
204+
205+def count_events(events, message, **fields):
206+ return sum(1 for event in events
207+ if event.get("msg") == message
208+ and all(event.get(key) == value for key, value in fields.items()))
209+
210+
211+def write_artifact(name, payload):
212+ """Persist scenario evidence under the configured artifacts directory.
213+
214+ The payload carries only committed contexts and structured log counts:
215+ no credentials, key material, or raw environment are recorded.
216+ """
217+ if not ARTIFACTS_DIR:
218+ return
219+ os.makedirs(ARTIFACTS_DIR, mode=0o700, exist_ok=True)
220+ path = os.path.join(ARTIFACTS_DIR, name)
221+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
222+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
223+ json.dump(payload, handle, indent=2, sort_keys=True)
224+ handle.write("\n")
225+
226+
227+def await_remote_inventory(container, session, timeout_s=INVENTORY_TIMEOUT_S, interval_s=0.5):
228+ """Wait until the local daemon publishes ``session`` for its remote host.
229+
230+ The remote monitor observes each host on a ~15s healthy cadence, so a
231+ freshly created remote session is invisible to the palette until the next
232+ observation. Wait on the daemon's persisted catalog cache (rewritten right
233+ after a successful observation) rather than guessing a timeout, so the
234+ following navigation is deterministic and near-immediate in isolation.
235+ """
236+ script = (f'f="{STATE_LOG_DIR}/{REMOTE_CACHE_FILE}"; '
237+ f'grep -qF "{session}" "$f" 2>/dev/null')
238+ deadline = time.monotonic() + timeout_s
239+ while True:
240+ try:
241+ if docker_capture(["exec", container, "sh", "-c", script]).returncode == 0:
242+ return
243+ except subprocess.TimeoutExpired:
244+ pass
245+ if time.monotonic() >= deadline:
246+ raise DriverError(
247+ f"remote session {session} never reached the local inventory "
248+ f"within {timeout_s}s")
249+ time.sleep(interval_s)
250+
251+
252+def switch_route(driver, name, origin, lifecycle_id=None, timeout_ms=WAIT_TIMEOUT_MS):
253+ """Switch by exact qualified palette label and prove the commit.
254+
255+ A named target with a known lifecycle is additionally pinned with a
256+ ``wait`` on that exact lifecycle, so a stale or freshly-created session
257+ can never satisfy the check.
258+ """
259+ driver.palette(name, f"Switch to session {name}@{origin}")
260+ committed = driver.call("keys", keys=["Enter"])
261+ check(committed.get("status") == "processed",
262+ f"route switch to {name}@{origin} not processed", committed)
263+ session = committed.get("context", {}).get("session", {})
264+ check(session.get("session_name") == name,
265+ f"route switch did not commit {name}@{origin}", committed)
266+ if lifecycle_id is not None:
267+ driver.call("wait", expect={"session": {
268+ "session_name": name, "lifecycle_id": lifecycle_id,
269+ }}, timeout_ms=timeout_ms)
270+ return committed["context"]
271+
272+
273+def commit_output(driver, command, sentinel):
274+ """Type a command, run it, and wait for output the command text lacks.
275+
276+ The sentinel must not appear contiguously in the typed command; callers
277+ build it with shell interpolation so an echoed command line can never
278+ satisfy the wait.
279+ """
280+ entered = driver.call("text", text=command)
281+ check(entered.get("status") == "processed", "command text not processed", entered)
282+ driver.call("keys", keys=["Enter"])
283+ driver.wait_text(sentinel)
284+
285+
136286 def scenario_local_palette_cycle(client_container):
137287 """M1: local IPC palette open/action/close with committed observation."""
138288 suffix = uuid.uuid4().hex[:8]
@@ -157,22 +307,151 @@ def scenario_local_palette_cycle(client_container):
157307 driver.close()
158308
159309
160-def scenario_direct_remote_ephemeral(client_container, remote_container, env):
161- """M2/M3: direct remote attach with no local daemon, committed output."""
310+def scenario_direct_remote_named(client_container, remote_container, env):
311+ """M2/M3: direct attach to a named exact remote target, committed output.
312+
313+ The remote daemon owns a pre-created named session. ``--remote remote
314+ --session NAME`` attaches to that exact lifecycle instead of creating an
315+ ephemeral one, so the daemon-owned target is pinned, not inferred.
316+ """
162317 mode = env.get("VEV_REMOTE_TRANSPORT", "quic")
163- driver = Driver(client_container, ["--remote", "remote"], env=env)
318+ suffix = uuid.uuid4().hex[:8]
319+ name = "direct" + suffix
320+ fixture = Driver(remote_container, ["--session", name])
321+ fixture_capture = fixture.call("capture")
322+ fixture_session = fixture_capture["context"]["session"]
323+ fixture.close()
324+ check(fixture_session["session_name"] == name,
325+ "remote fixture did not commit the named target", fixture_capture)
326+ driver = Driver(client_container, ["--remote", "remote", "--session", name], env=env)
327+ evidence = {"scenario": "direct-remote-named", "mode": mode,
328+ "remote_session": name, "fixture_session": fixture_session}
164329 try:
165330 capture = driver.call("capture")
166331 session = capture["context"]["session"]
167- entered = driver.call("text", text="printf 'DIRECT_OK'")
168- check(entered.get("status") == "processed", "remote text action not processed", entered)
169- driver.call("keys", keys=["Enter"])
170- driver.wait_text("DIRECT_OK")
332+ check(session["session_name"] == name,
333+ "named remote target not committed", capture)
334+ check(session == fixture_session,
335+ "direct attach did not reuse the exact named remote lifecycle", capture)
336+ # The sentinel is built by the shell so the echoed command line cannot
337+ # satisfy the wait before the command actually runs.
338+ commit_output(driver, "printf 'DIRECT_%s' OK", "DIRECT_OK")
171339 final = driver.call("capture")
172340 check(final["context"]["session"] == session, "remote lifecycle changed", final)
173- print(f"PASS direct-remote-ephemeral mode={mode} lifecycle={session['lifecycle_id'][:8]}")
341+ evidence["remote_session_context"] = session
342+ evidence["final_context"] = final["context"]
343+ print(f"PASS direct-remote-named mode={mode} session={name} "
344+ f"lifecycle={session['lifecycle_id'][:8]}")
345+ finally:
346+ driver.close()
347+ write_artifact(f"direct-remote-named-{mode}.json", evidence)
348+
349+
350+def scenario_warm_reuse(client_container, remote_container, env):
351+ """Warm remote reuse across one client Runner.
352+
353+ Journey: local -> named remote -> local -> same named remote. The remote
354+ attachment is expected to suspend on the way back to local and reactivate
355+ warm on the return: same committed remote lifecycle, retained pane output,
356+ and new committed output. No-redial evidence is deterministic: the client
357+ debug log records exactly one suspend and one warm activation (zero
358+ fallbacks), and the remote daemon records exactly one attach for the
359+ session, so a second bootstrap or dial would be visible. When the remote
360+ proxy debug log is available it must also show exactly one proxy start.
361+ """
362+ mode = env.get("VEV_REMOTE_TRANSPORT", "quic")
363+ suffix = uuid.uuid4().hex[:8]
364+ local_name = "locl" + suffix
365+ remote_name = "remt" + suffix
366+ fixture = Driver(remote_container, ["--session", remote_name])
367+ fixture_capture = fixture.call("capture")
368+ fixture_session = fixture_capture["context"]["session"]
369+ fixture.close()
370+ check(fixture_session["session_name"] == remote_name,
371+ "remote fixture did not commit the named target", fixture_capture)
372+ remote_lifecycle = fixture_session["lifecycle_id"]
373+ # Snapshot log positions after the fixture so only events from this client
374+ # journey are counted.
375+ client_offset = log_size(client_container, CLIENT_LOG)
376+ remote_offset = log_size(remote_container, DAEMON_LOG)
377+ stdio_offset = log_size(remote_container, STDIO_LOG)
378+ driver = Driver(client_container, ["--session", local_name], env=env)
379+ evidence = {"scenario": "warm-reuse", "mode": mode,
380+ "local_session": local_name, "remote_session": remote_name,
381+ "remote_lifecycle": remote_lifecycle,
382+ "fixture_session": fixture_session}
383+ try:
384+ initial = driver.call("capture")["context"]
385+ check(initial["session"]["session_name"] == local_name,
386+ "local session not committed", initial)
387+ local_lifecycle = initial["session"]["lifecycle_id"]
388+ # 1. local -> named exact remote.
389+ await_remote_inventory(client_container, remote_name)
390+ switch_route(driver, remote_name, "remote", remote_lifecycle)
391+ first_remote = driver.call("capture")["context"]
392+ check(first_remote["session"] == fixture_session,
393+ "first remote attach did not commit the exact named lifecycle", first_remote)
394+ evidence["first_remote"] = first_remote
395+ # Committed output while the remote pane is authoritative.
396+ commit_output(driver, "printf 'WARM_%s' PERSIST", "WARM_PERSIST")
397+ # 2. remote -> local: the remote attachment suspends and stays warm.
398+ switch_route(driver, local_name, "local", local_lifecycle)
399+ local_return = driver.call("capture")["context"]
400+ check(local_return["session"]["session_name"] == local_name,
401+ "return to local did not commit", local_return)
402+ evidence["local_return"] = local_return
403+ # 3. local -> same named remote: the retained transport reactivates.
404+ switch_route(driver, remote_name, "remote", remote_lifecycle)
405+ second_remote = driver.call("capture")["context"]
406+ check(second_remote["session"]["lifecycle_id"] == remote_lifecycle,
407+ "warm return changed the remote lifecycle", second_remote)
408+ check(second_remote["session"] == fixture_session,
409+ "warm return did not reuse the exact named target", second_remote)
410+ evidence["second_remote"] = second_remote
411+ # State and new committed output both survive the warm round trip.
412+ driver.wait_text("WARM_PERSIST")
413+ commit_output(driver, "printf 'WARM_%s' AFTER", "WARM_AFTER")
414+ after_output = driver.call("capture")["context"]
415+ check(after_output["session"]["lifecycle_id"] == remote_lifecycle,
416+ "lifecycle changed after warm committed output", after_output)
417+ evidence["after_warm_output"] = after_output
174418 finally:
175419 driver.close()
420+ client_events = json_events(log_since(client_container, CLIENT_LOG, client_offset))
421+ remote_events = json_events(log_since(remote_container, DAEMON_LOG, remote_offset))
422+ stdio_events = json_events(log_since(remote_container, STDIO_LOG, stdio_offset))
423+ evidence["log_counts"] = {
424+ "client_suspended": count_events(
425+ client_events, "remote attachment suspended",
426+ origin="remote", session=remote_name),
427+ "client_warm_activated": count_events(
428+ client_events, "warm remote attachment activated",
429+ origin="remote", session=remote_name, lifecycle=remote_lifecycle),
430+ "client_warm_failed": count_events(
431+ client_events, "warm remote activation failed",
432+ origin="remote", session=remote_name),
433+ "remote_client_attached": count_events(
434+ remote_events, "client attached", session=remote_name),
435+ "remote_proxy_started": (
436+ count_events(stdio_events, "stdio proxy starting") +
437+ count_events(stdio_events, "quic proxy starting")),
438+ }
439+ write_artifact(f"warm-reuse-{mode}.json", evidence)
440+ counts = evidence["log_counts"]
441+ check(counts["client_suspended"] == 1,
442+ "expected exactly one remote attachment suspension", counts)
443+ check(counts["client_warm_activated"] == 1,
444+ "expected exactly one warm remote activation", counts)
445+ check(counts["client_warm_failed"] == 0,
446+ "warm activation failed or fell back to a cold dial", counts)
447+ check(counts["remote_client_attached"] == 1,
448+ "remote daemon saw a second bootstrap/attach (redial)", counts)
449+ if counts["remote_proxy_started"]:
450+ check(counts["remote_proxy_started"] == 1,
451+ "remote proxy started more than once (redial)", counts)
452+ print(f"PASS warm-reuse mode={mode} local={local_name} remote={remote_name} "
453+ f"lifecycle={remote_lifecycle[:8]} "
454+ f"attaches={counts['remote_client_attached']}")
176455
177456
178457 def picker_topology(env):
@@ -298,6 +577,9 @@ def scenario_hybrid_exact_return(client_container, remote_container, env):
298577 switched = driver.call("keys", keys=["Enter"])
299578 check(switched["context"]["session"]["session_name"] == second,
300579 "local switch did not commit", switched)
580+ # The local daemon refreshes remote inventory on its own cadence; wait
581+ # for the fixture session to be published before searching the palette.
582+ await_remote_inventory(client_container, remote)
301583 driver.palette(remote, "Switch to session " + remote + "@remote")
302584 entered = driver.call("keys", keys=["Enter"])
303585 check(entered["context"]["session"]["session_name"] == remote,
@@ -315,7 +597,8 @@ def scenario_hybrid_exact_return(client_container, remote_container, env):
315597
316598 SCENARIOS = {
317599 "local-palette-cycle": lambda client, remote, env: scenario_local_palette_cycle(client),
318- "direct-remote-ephemeral": scenario_direct_remote_ephemeral,
600+ "direct-remote-named": scenario_direct_remote_named,
601+ "warm-reuse": scenario_warm_reuse,
319602 "hybrid-exact-return": scenario_hybrid_exact_return,
320603 "client-picker-navigate": scenario_client_picker_navigate,
321604 }
M scripts/remote-picker-harness/run.sh +78 -19
@@ -2,7 +2,7 @@
22 # Disposable navigation acceptance with the real client, daemons and UI driver.
33 set -euo pipefail
44 case "${1:-}" in
5- -h|--help) printf 'Usage: scripts/remote-picker-harness/run.sh\nRequires Docker, Python 3 and ssh-keygen.\n'; exit 0 ;;
5+ -h|--help) printf 'Usage: scripts/remote-picker-harness/run.sh\nRequires Docker, Python 3 and ssh-keygen.\nSet VEV_ACCEPTANCE_ARTIFACTS_DIR to keep logs elsewhere than /tmp.\nSet VEV_ACCEPTANCE_SCENARIOS to a space-separated subset, e.g.\n VEV_ACCEPTANCE_SCENARIOS="warm-reuse@quic warm-reuse@stdio"\nNames: navigation-repro local-palette-cycle direct-remote-named@quic|@stdio\n warm-reuse@quic|@stdio hybrid-exact-return@quic|@stdio\n client-picker-navigate-local@quic client-picker-navigate-direct@quic|@stdio\n'; exit 0 ;;
66 '') ;;
77 *) exit 2 ;;
88 esac
@@ -15,20 +15,40 @@ network="vev-acceptance-${run_id}"
1515 local_container="${network}-local"
1616 remote_container="${network}-remote"
1717 context_dir="$(mktemp -d)"
18+# Sanitized, per-run evidence outside the worktree. Container state logs carry
19+# structured vev events only: no host state, credentials, sockets or keys.
20+artifacts_dir="${VEV_ACCEPTANCE_ARTIFACTS_DIR:-$(mktemp -d "/tmp/vev-acceptance-artifacts-${run_id}.XXXXXX")}"
21+export VEV_ACCEPTANCE_ARTIFACTS_DIR="$artifacts_dir"
22+
23+collect_artifacts() {
24+ mkdir -p "$artifacts_dir"
25+ for role in local remote; do
26+ container="${network}-${role}"
27+ for file in vev-daemon.log vev-client.log vev-stdio.log \
28+ vev-daemon-crash.log vev-client-crash.log vev-stdio-crash.log; do
29+ docker cp "$container:/home/demo/.local/state/vev/$file" \
30+ "$artifacts_dir/${role}-${file}" >/dev/null 2>&1 || true
31+ done
32+ done
33+}
34+
1835 cleanup() {
1936 status=$?
2037 trap - EXIT INT TERM
2138 set +e
39+ collect_artifacts
2240 if [ "$status" -ne 0 ]; then
23- for container in "$local_container" "$remote_container"; do
24- docker logs "$container" >&2
25- docker exec "$container" sh -c 'tail -n 40 ~/.local/state/vev/vev-daemon.log' >&2
41+ for file in "$artifacts_dir"/*.log; do
42+ [ -f "$file" ] || continue
43+ printf '===== %s =====\n' "$file" >&2
44+ tail -n 60 "$file" >&2
2645 done
2746 fi
2847 docker rm -f "$local_container" "$remote_container" >/dev/null 2>&1
2948 docker network rm "$network" >/dev/null 2>&1
3049 docker image rm "$image" >/dev/null 2>&1
3150 rm -rf "$context_dir"
51+ printf 'acceptance artifacts: %s\n' "$artifacts_dir"
3252 exit "$status"
3353 }
3454 trap cleanup EXIT
@@ -37,9 +57,13 @@ trap 'exit 143' TERM
3757
3858 docker build -f "$repo_root/scripts/demo/Dockerfile" -t "$image" "$repo_root" >/dev/null
3959 docker network create "$network" >/dev/null
60+# Client and daemon debug logging is enabled container-wide; docker exec and
61+# the daemon child both inherit it. Remote proxy children receive it through
62+# ssh SendEnv/AcceptEnv below.
4063 for role in local remote; do
4164 docker run -d --name "${network}-${role}" --network "$network" \
42- --network-alias "$role" --entrypoint sleep "$image" infinity >/dev/null
65+ --network-alias "$role" -e VEV_LOG=debug \
66+ --entrypoint sleep "$image" infinity >/dev/null
4367 done
4468
4569 # Only generated fixture credentials enter these containers; no host HOME or
@@ -50,29 +74,64 @@ docker exec --user root "$remote_container" sh -c 'rm -f /etc/ssh/ssh_host_* /ho
5074 docker cp "$context_dir/host" "$remote_container:/etc/ssh/ssh_host_ed25519_key"
5175 docker cp "$context_dir/host.pub" "$remote_container:/etc/ssh/ssh_host_ed25519_key.pub"
5276 docker cp "$context_dir/client.pub" "$remote_container:/home/demo/.ssh/authorized_keys"
53-docker exec --user root "$remote_container" sh -c 'chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; /usr/sbin/sshd'
77+# Accept the debug level the client forwards so remote _stdio/_quic-proxy and
78+# the daemon they spawn log at debug too.
79+docker exec --user root "$remote_container" sh -c 'printf "\nAcceptEnv VEV_LOG\n" >> /etc/ssh/sshd_config; chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; /usr/sbin/sshd'
5480 docker cp "$context_dir/client" "$local_container:/home/demo/.ssh/id_ed25519"
5581 docker cp "$context_dir/client.pub" "$local_container:/home/demo/.ssh/id_ed25519.pub"
5682 { printf 'remote '; cut -d' ' -f1-2 "$context_dir/host.pub"; } > "$context_dir/known_hosts"
5783 docker cp "$context_dir/known_hosts" "$local_container:/home/demo/.ssh/known_hosts"
5884 docker exec --user root "$local_container" sh -c 'chown demo:demo /home/demo/.ssh/id_ed25519* /home/demo/.ssh/known_hosts; chmod 600 /home/demo/.ssh/id_ed25519 /home/demo/.ssh/known_hosts'
85+# Forward VEV_LOG on every remote SSH child (global option before the first
86+# Host block); the fixture ssh config itself is otherwise untouched.
87+docker exec "$local_container" sh -c 'p="$HOME/.ssh/config"; { printf "SendEnv VEV_LOG\n"; cat "$p"; } > "$p.new" && mv "$p.new" "$p" && chmod 600 "$p"'
5988 docker exec "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=5 remote true
6089 docker exec "$local_container" vev host add remote
61-# Baseline matrix: local IPC, direct remote (QUIC + explicit stdio, no local
62-# daemon), and hybrid exact-return (QUIC + stdio). Each scenario asserts
63-# committed outcomes, not sent keys. navigation_repro.py keeps the original
64-# hybrid transport regression alongside the table-driven runner.
65-python3 "$repo_root/scripts/remote-picker-harness/navigation_repro.py" "$local_container" "$remote_container"
66-python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" local-palette-cycle
67-python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" direct-remote-ephemeral@quic
68-python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" direct-remote-ephemeral@stdio
69-python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" hybrid-exact-return@quic
70-python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" hybrid-exact-return@stdio
90+# Baseline matrix: local IPC, direct named-remote (QUIC + explicit stdio, no
91+# local daemon), warm remote reuse (QUIC + stdio), and hybrid exact-return
92+# (QUIC + stdio). Each scenario asserts committed outcomes, not sent keys.
93+# navigation_repro.py keeps the original hybrid transport regression.
94+acceptance_py="$repo_root/scripts/remote-picker-harness/acceptance.py"
95+scenarios_env="${VEV_ACCEPTANCE_SCENARIOS:-}"
96+known_scenarios="navigation-repro local-palette-cycle direct-remote-named@quic direct-remote-named@stdio warm-reuse@quic warm-reuse@stdio hybrid-exact-return@quic hybrid-exact-return@stdio client-picker-navigate-local@quic client-picker-navigate-direct@quic client-picker-navigate-direct@stdio"
97+for requested in $scenarios_env; do
98+ case " $known_scenarios " in
99+ *" $requested "*) ;;
100+ *) printf 'unknown acceptance scenario: %s\n' "$requested" >&2; exit 2 ;;
101+ esac
102+done
103+if [ -n "$scenarios_env" ] && [ -z "${scenarios_env//[[:space:]]/}" ]; then
104+ printf 'VEV_ACCEPTANCE_SCENARIOS selects no scenarios\n' >&2
105+ exit 2
106+fi
107+wanted_scenario() {
108+ [ -z "$scenarios_env" ] && return 0
109+ for want in $scenarios_env; do [ "$want" = "$1" ] && return 0; done
110+ return 1
111+}
112+run_scenario() { # name spec [topology]
113+ wanted_scenario "$1" || return 0
114+ if [ -n "${3:-}" ]; then
115+ VEV_ACCEPTANCE_TOPOLOGY="$3" python3 "$acceptance_py" "$local_container" "$remote_container" "$2"
116+ else
117+ python3 "$acceptance_py" "$local_container" "$remote_container" "$2"
118+ fi
119+}
120+if wanted_scenario navigation-repro; then
121+ python3 "$repo_root/scripts/remote-picker-harness/navigation_repro.py" "$local_container" "$remote_container"
122+fi
123+run_scenario local-palette-cycle local-palette-cycle
124+run_scenario direct-remote-named@quic direct-remote-named@quic
125+run_scenario direct-remote-named@stdio direct-remote-named@stdio
126+run_scenario warm-reuse@quic warm-reuse@quic
127+run_scenario warm-reuse@stdio warm-reuse@stdio
128+run_scenario hybrid-exact-return@quic hybrid-exact-return@quic
129+run_scenario hybrid-exact-return@stdio hybrid-exact-return@stdio
71130 # Client-owned navigation picker where the serving attachment answers the
72131 # palette session-picker command itself: local IPC and direct remote (QUIC and
73132 # explicit stdio). A hybrid client advertises the home-picker capability, so
74133 # its session-picker command delegates to the home route overlay (an R1
75134 # behavior pinned by hybrid-exact-return) instead of this interaction.
76-VEV_ACCEPTANCE_TOPOLOGY=local python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" client-picker-navigate@quic
77-VEV_ACCEPTANCE_TOPOLOGY=direct python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" client-picker-navigate@quic
78-VEV_ACCEPTANCE_TOPOLOGY=direct python3 "$repo_root/scripts/remote-picker-harness/acceptance.py" "$local_container" "$remote_container" client-picker-navigate@stdio
135+run_scenario client-picker-navigate-local@quic client-picker-navigate@quic local
136+run_scenario client-picker-navigate-direct@quic client-picker-navigate@quic direct
137+run_scenario client-picker-navigate-direct@stdio client-picker-navigate@stdio direct

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