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 Unverified
B5690EEEBB952194The key is unknown
50 files · +4670 -234
M .gitignore +2 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | /vev | |
| 2 | 2 | .dev/ | |
| 3 | 3 | *.test | |
| 4 | + | __pycache__/ | |
| 5 | + | *.pyc | |
| 4 | 6 | .superpowers/ | |
| 5 | 7 | docs/superpowers/ | |
| 6 | 8 | .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 | |||
| 7 | 7 | web.listen = 127.0.0.1:8778 | |
| 8 | 8 | web.origin = http://127.0.0.1:8778 | |
| 9 | 9 | ||
| 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 | + | ||
| 10 | 17 | # Theme: auto follows the client; dark/light use neutral built-in defaults. | |
| 11 | 18 | theme = auto | |
| 12 | 19 | # In auto mode with palette inheritance enabled, infer a terminal accent. | |
| @@ -88,6 +95,38 @@ code.detach = DET | |||
| 88 | 95 | ||
| 89 | 96 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | |
| 90 | 97 | ||
| 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 | + | ||
| 91 | 130 | ## Scrollback | |
| 92 | 131 | ||
| 93 | 132 | `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) { | |||
| 37 | 37 | seenTabsKeys := make(map[string]bool) | |
| 38 | 38 | seenScrollbackKeys := make(map[string]bool) | |
| 39 | 39 | seenWebKeys := make(map[string]bool) | |
| 40 | + | seenRemoteKeys := make(map[string]bool) | |
| 40 | 41 | ||
| 41 | 42 | scanner := bufio.NewScanner(r) | |
| 42 | 43 | lineNo := 0 | |
| @@ -64,6 +65,34 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 64 | 65 | } | |
| 65 | 66 | ||
| 66 | 67 | 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 | |
| 67 | 96 | case key == "web.listen" || key == "web.origin": | |
| 68 | 97 | warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo) | |
| 69 | 98 | // 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) { | |||
| 359 | 359 | tt.want.Palette = domain.Defaults().Palette | |
| 360 | 360 | } | |
| 361 | 361 | tt.want.Scrollback = domain.DefaultScrollbackConfig() | |
| 362 | + | tt.want.AttachmentCache = domain.DefaultAttachmentCacheConfig() | |
| 362 | 363 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 363 | 364 | if err != nil { | |
| 364 | 365 | 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 ( | |||
| 15 | 15 | ||
| 16 | 16 | func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, error) { | |
| 17 | 17 | 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) | |
| 18 | 40 | case protocol.Hello: | |
| 19 | 41 | converted, err := helloToWire(m) | |
| 20 | 42 | if err != nil { | |
| @@ -294,6 +316,28 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||
| 294 | 316 | ||
| 295 | 317 | func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, error) { | |
| 296 | 318 | 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) | |
| 297 | 341 | case protocol.Welcome: | |
| 298 | 342 | converted, err := welcomeToWire(m) | |
| 299 | 343 | if err != nil { | |
| @@ -598,6 +642,10 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||
| 598 | 642 | return nil, ErrInvalidMessage | |
| 599 | 643 | } | |
| 600 | 644 | 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) | |
| 601 | 649 | case *wire.ClientEnvelope_Hello: | |
| 602 | 650 | return helloFromWire(payload.Hello) | |
| 603 | 651 | case *wire.ClientEnvelope_Input: | |
| @@ -666,6 +714,10 @@ func decodeProtoServer(envelope *wire.ServerEnvelope) (protocol.ServerMessage, e | |||
| 666 | 714 | return nil, ErrInvalidMessage | |
| 667 | 715 | } | |
| 668 | 716 | 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) | |
| 669 | 721 | case *wire.ServerEnvelope_Welcome: | |
| 670 | 722 | return welcomeFromWire(payload.Welcome) | |
| 671 | 723 | 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) { | |||
| 560 | 560 | } | |
| 561 | 561 | } | |
| 562 | 562 | ||
| 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 | + | ||
| 563 | 577 | func snapshotDir() string { | |
| 564 | 578 | return filepath.Join(platform.StateDir(), "snapshots") | |
| 565 | 579 | } | |
| @@ -756,12 +770,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 756 | 770 | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | |
| 757 | 771 | } | |
| 758 | 772 | 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) | |
| 765 | 774 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 766 | 775 | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |
| 767 | 776 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |
| @@ -890,6 +899,7 @@ func defaultRemoteDialerFactory() remoteDialerForTarget { | |||
| 890 | 899 | type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error | |
| 891 | 900 | ||
| 892 | 901 | type runAttachDeps struct { | |
| 902 | + | configPath func() string | |
| 893 | 903 | localDialer func() wire.Dialer | |
| 894 | 904 | remoteDialerFactory remoteDialerForTarget | |
| 895 | 905 | selectedRemoteTransport string | |
| @@ -1022,6 +1032,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1022 | 1032 | } | |
| 1023 | 1033 | return policy | |
| 1024 | 1034 | } | |
| 1035 | + | configPath := deps.configPath | |
| 1036 | + | if configPath == nil { | |
| 1037 | + | configPath = platform.ConfigPath | |
| 1038 | + | } | |
| 1039 | + | cfg := loadConfigOrDefaults(log, configPath()) | |
| 1025 | 1040 | // The launching client owns one host registry for the whole run: endpoint | |
| 1026 | 1041 | // bindings are resolved once and reused by every later handoff, and the | |
| 1027 | 1042 | // 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 | |||
| 1041 | 1056 | return resolveErr | |
| 1042 | 1057 | } | |
| 1043 | 1058 | err = runClient(ctx, client.Dependencies{ | |
| 1059 | + | AttachmentCache: cfg.AttachmentCache, | |
| 1044 | 1060 | Dialer: binding.Dialer, | |
| 1045 | 1061 | LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}), | |
| 1046 | 1062 | Terminal: clientTerminal(deps), | |
| @@ -1070,6 +1086,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1070 | 1086 | log.Info("attaching to local session", "intent", intent, "name", name) | |
| 1071 | 1087 | } | |
| 1072 | 1088 | err = runClient(ctx, client.Dependencies{ | |
| 1089 | + | AttachmentCache: cfg.AttachmentCache, | |
| 1073 | 1090 | Dialer: sessionwire.NewClientDialer(localDialer()), | |
| 1074 | 1091 | LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}), | |
| 1075 | 1092 | Terminal: clientTerminal(deps), | |
M internal/domain/config.go +48 -18
| @@ -104,25 +104,54 @@ func (c ScrollbackConfig) Valid() bool { | |||
| 104 | 104 | return c.Megabytes <= MaxScrollbackMegabytes && c.Lines >= 0 && c.Lines <= MaxScrollbackLines | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 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 | + | ||
| 107 | 133 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 108 | 134 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 109 | 135 | // layer can decide which actions it understands. | |
| 110 | 136 | 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 | |
| 126 | 155 | } | |
| 127 | 156 | ||
| 128 | 157 | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| @@ -135,9 +164,10 @@ type Warning struct { | |||
| 135 | 164 | // Defaults returns vev's default configuration. | |
| 136 | 165 | func Defaults() Config { | |
| 137 | 166 | 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}, | |
| 141 | 171 | Bar: BarConfig{ | |
| 142 | 172 | Interval: 5 * time.Second, | |
| 143 | 173 | }, | |
M internal/ports/session.go +4 -1
| @@ -6,7 +6,10 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/protocol" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 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. | |
| 10 | 13 | type ClientConnection interface { | |
| 11 | 14 | SendClient(protocol.ClientMessage) error | |
| 12 | 15 | 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 ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // Version is the negotiated vev session protocol version. | |
| 16 | - | const Version uint16 = 51 | |
| 16 | + | const Version uint16 = 52 | |
| 17 | 17 | ||
| 18 | 18 | // 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. | |
| 20 | 22 | const HandshakeTimeout = 15 * time.Second | |
| 21 | 23 | ||
| 22 | 24 | // MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest | |
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(51), Version) | |
| 13 | + | require.Equal(t, uint16(52), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
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 ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | 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) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -30,6 +30,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |||
| 30 | 30 | ||
| 31 | 31 | // Closed semantic unions, in wire-variant spelling. | |
| 32 | 32 | clientSemantic := map[string]bool{ | |
| 33 | + | "suspend_attachment": true, "activate_attachment": true, | |
| 33 | 34 | "hello": true, "input": true, "resize": true, "detach": true, "ping": true, | |
| 34 | 35 | "list": true, "kill": true, "theme": true, "ack": true, "image_push": true, | |
| 35 | 36 | "client_notice": true, "command_request": true, "output_reset_request": true, | |
| @@ -42,6 +43,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |||
| 42 | 43 | "picker_preview_request": true, "picker_control_request": true, | |
| 43 | 44 | } | |
| 44 | 45 | serverSemantic := map[string]bool{ | |
| 46 | + | "attachment_suspended": true, "attachment_activated": true, | |
| 45 | 47 | "welcome": true, "error": true, "output": true, "detached": true, "pong": true, | |
| 46 | 48 | "sessions": true, "command_result": true, "attach_target": true, | |
| 47 | 49 | "remote_preview": true, "committed_route_identity": true, | |
M internal/protocol/wire/schema/envelope.proto +7 -0
| @@ -96,12 +96,17 @@ message ClientEnvelope { | |||
| 96 | 96 | PickerSelection picker_selection = 26; | |
| 97 | 97 | PickerPreviewRequest picker_preview_request = 27; | |
| 98 | 98 | PickerControlRequest picker_control_request = 28; | |
| 99 | + | SuspendAttachment suspend_attachment = 29; | |
| 100 | + | ActivateAttachment activate_attachment = 30; | |
| 99 | 101 | } | |
| 100 | 102 | } | |
| 101 | 103 | ||
| 102 | 104 | // ServerEnvelope is the closed server-to-client union. Exactly one variant | |
| 103 | 105 | // must be set per envelope. | |
| 104 | 106 | message ServerEnvelope { | |
| 107 | + | // 29 and 30 mirror the client-direction attachment request tags. | |
| 108 | + | reserved 29, 30; | |
| 109 | + | ||
| 105 | 110 | oneof payload { | |
| 106 | 111 | Welcome welcome = 1; | |
| 107 | 112 | ErrorMsg error = 2; | |
| @@ -131,5 +136,7 @@ message ServerEnvelope { | |||
| 131 | 136 | PickerFailure picker_failure = 26; | |
| 132 | 137 | PickerPreview picker_preview = 27; | |
| 133 | 138 | PickerControlResponse picker_control_response = 28; | |
| 139 | + | AttachmentSuspended attachment_suspended = 31; | |
| 140 | + | AttachmentActivated attachment_activated = 32; | |
| 134 | 141 | } | |
| 135 | 142 | } | |
M internal/protocol/wire/schema/session.proto +32 -0
| @@ -211,3 +211,35 @@ message SamePeerSwitchFailure { | |||
| 211 | 211 | // Closed code taxonomy: 1 = stale target, 2 = unavailable. | |
| 212 | 212 | uint32 code = 2; | |
| 213 | 213 | } | |
| 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 { | |||
| 151 | 151 | Dialer ports.ClientDialer | |
| 152 | 152 | Terminal ports.Terminal | |
| 153 | 153 | Clock ports.Clock | |
| 154 | + | // AttachmentCache is the retention policy for suspended remote | |
| 155 | + | // attachments. Its zero value disables reuse. | |
| 156 | + | AttachmentCache domain.AttachmentCacheConfig | |
| 154 | 157 | // DisableCapabilityProbe skips active outer-terminal discovery for headless | |
| 155 | 158 | // embedders and deterministic tests. Interactive clients leave it false. | |
| 156 | 159 | DisableCapabilityProbe bool | |
| @@ -232,6 +235,17 @@ func (r *Runner) resolveHandoff(ctx context.Context, target protocol.AttachTarge | |||
| 232 | 235 | }, nil | |
| 233 | 236 | } | |
| 234 | 237 | ||
| 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 | + | ||
| 235 | 249 | // handoffEnvironmentPolicy mirrors the composition root's picker rule: a route | |
| 236 | 250 | // chosen from a locally launched client has no explicit remote selection, so | |
| 237 | 251 | // its environment belongs to the daemon; every other target keeps the policy | |
| @@ -299,6 +313,7 @@ type Runner struct { | |||
| 299 | 313 | logger *slog.Logger | |
| 300 | 314 | runtimeObserver ports.SerializedRuntimeObserver | |
| 301 | 315 | hostRegistry ports.ClientHostRegistry | |
| 316 | + | attachmentCache domain.AttachmentCacheConfig | |
| 302 | 317 | // launchedRemote records whether this runner was launched as a direct | |
| 303 | 318 | // remote attach. It decides the handoff environment policy exactly like the | |
| 304 | 319 | // composition root used to: a picker route from a locally launched client | |
| @@ -340,6 +355,7 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 340 | 355 | logger: log, | |
| 341 | 356 | runtimeObserver: deps.RuntimeObserver, | |
| 342 | 357 | hostRegistry: deps.HostRegistry, | |
| 358 | + | attachmentCache: deps.AttachmentCache, | |
| 343 | 359 | launchedRemote: deps.Remote, | |
| 344 | 360 | remote: deps.Remote, | |
| 345 | 361 | localControlDialer: deps.LocalControlDialer, | |
| @@ -569,6 +585,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 569 | 585 | return nil | |
| 570 | 586 | } | |
| 571 | 587 | ||
| 588 | + | cache := newAttachmentCoordinator(r.clock, r.attachmentCache, r.logger) | |
| 589 | + | defer cache.close() | |
| 572 | 590 | resumeToken := uint64(0) | |
| 573 | 591 | attemptRequest := request | |
| 574 | 592 | dialer := r.dialer | |
| @@ -666,8 +684,26 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 666 | 684 | if err := validateAttachRequest(attemptRequest); err != nil { | |
| 667 | 685 | return err | |
| 668 | 686 | } | |
| 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 | + | } | |
| 669 | 699 | 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 | + | } | |
| 671 | 707 | if err != nil { | |
| 672 | 708 | if r.ui != nil { | |
| 673 | 709 | r.ui.failHandoff() | |
| @@ -752,7 +788,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 752 | 788 | if err != nil { | |
| 753 | 789 | stopHandshakeTransport() | |
| 754 | 790 | finishHandshake() | |
| 755 | - | _ = transport.Close() | |
| 791 | + | if entry != nil { | |
| 792 | + | cache.retire(entry) | |
| 793 | + | } else { | |
| 794 | + | _ = transport.Close() | |
| 795 | + | } | |
| 756 | 796 | if navTransition.pendingCreation() { | |
| 757 | 797 | r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: protocol.RouteFailureUnavailable} | |
| 758 | 798 | route, ok := navTransition.restore() | |
| @@ -773,6 +813,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 773 | 813 | return err | |
| 774 | 814 | } | |
| 775 | 815 | ||
| 816 | + | if entry == nil && attemptRequest.Remote && attemptRequest.OriginKey != "" && cache.enabled() { | |
| 817 | + | entry = newCachedAttachment(transport) | |
| 818 | + | } | |
| 776 | 819 | var linkEvents <-chan ports.LinkEvent | |
| 777 | 820 | if connection.Connection().Capabilities().LinkState { | |
| 778 | 821 | linkEvents = connection.Connection().LinkEvents() | |
| @@ -781,6 +824,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 781 | 824 | runner: r, | |
| 782 | 825 | dialer: dialer, | |
| 783 | 826 | connection: connection, | |
| 827 | + | entry: entry, | |
| 828 | + | activated: activated, | |
| 829 | + | activationOutput: activationOutput, | |
| 830 | + | activationPosition: activationPosition, | |
| 784 | 831 | remote: remote, | |
| 785 | 832 | handshakeCtx: handshakeCtx, | |
| 786 | 833 | handshakeTimedOut: timedOut, | |
| @@ -817,8 +864,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 817 | 864 | if result.welcomed { | |
| 818 | 865 | backoff = defaultReconnectBackoff.initial | |
| 819 | 866 | } | |
| 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 | + | } | |
| 822 | 872 | } | |
| 823 | 873 | // Apply metadata from every processed frame before dispatching its | |
| 824 | 874 | // navigation action. A committed identity can arrive immediately before | |
| @@ -845,6 +895,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 845 | 895 | attemptRequest.PreferredTabID = result.routePosition.ActiveTabID | |
| 846 | 896 | attemptRequest.RemoteTarget = nil | |
| 847 | 897 | } | |
| 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 | + | } | |
| 848 | 902 | if result.err == nil && result.welcomed && navTransition.pendingCreation() { | |
| 849 | 903 | navTransition.settleSuccess() | |
| 850 | 904 | } | |
| @@ -1266,6 +1320,10 @@ type attachAttempt struct { | |||
| 1266 | 1320 | runner *Runner | |
| 1267 | 1321 | dialer ports.ClientDialer | |
| 1268 | 1322 | connection *SessionConnection | |
| 1323 | + | entry *cachedAttachment | |
| 1324 | + | activated *protocol.AttachmentActivated | |
| 1325 | + | activationOutput *protocol.Output | |
| 1326 | + | activationPosition *protocol.RoutePosition | |
| 1269 | 1327 | remote bool | |
| 1270 | 1328 | transport ports.ClientConnection | |
| 1271 | 1329 | handshakeCtx context.Context | |
| @@ -1422,6 +1480,34 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1422 | 1480 | } | |
| 1423 | 1481 | return nil | |
| 1424 | 1482 | } | |
| 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 = ©Output | |
| 1510 | + | } | |
| 1425 | 1511 | // Actively probe the direct outer terminal before Hello. The probe uses the | |
| 1426 | 1512 | // lifecycle input pump; unsupported and silent terminals fail closed within | |
| 1427 | 1513 | // its bounded deadline. | |
| @@ -1475,18 +1561,27 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1475 | 1561 | NavigationCapabilities: request.NavigationCapabilities, | |
| 1476 | 1562 | Remote: remote, | |
| 1477 | 1563 | } | |
| 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 | + | } | |
| 1482 | 1571 | } | |
| 1483 | 1572 | ms.helloSent = true | |
| 1484 | 1573 | ||
| 1485 | 1574 | // 2. Await Welcome or a typed rejection. | |
| 1486 | 1575 | 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 { | |
| 1488 | 1579 | 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 | + | } | |
| 1490 | 1585 | return receiveErr | |
| 1491 | 1586 | }, transition); err != nil { | |
| 1492 | 1587 | return handshakeFailure("awaiting welcome", err) | |
| @@ -1520,6 +1615,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1520 | 1615 | return handshakeFailure("validating welcome", err) | |
| 1521 | 1616 | } | |
| 1522 | 1617 | 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 | + | } | |
| 1523 | 1628 | if committedIdentity != nil && a.runner.ledger != nil { | |
| 1524 | 1629 | if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target { | |
| 1525 | 1630 | return welcomedResult(errRouteTargetChanged) | |
| @@ -1977,7 +2082,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1977 | 2082 | a.onInventoryCommitted() | |
| 1978 | 2083 | } | |
| 1979 | 2084 | 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 | + | }() | |
| 1981 | 2100 | ||
| 1982 | 2101 | type foregroundRuntime struct { | |
| 1983 | 2102 | cancel context.CancelFunc | |
| @@ -2215,11 +2334,27 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2215 | 2334 | abortPickerLease(true) | |
| 2216 | 2335 | } | |
| 2217 | 2336 | } | |
| 2218 | - | startForeground() | |
| 2337 | + | if a.activationOutput == nil { | |
| 2338 | + | startForeground() | |
| 2339 | + | } | |
| 2219 | 2340 | defer stopForeground() | |
| 2220 | 2341 | ||
| 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 | + | } | |
| 2223 | 2358 | armRouteObservation() | |
| 2224 | 2359 | ||
| 2225 | 2360 | requestReconnectReset := func() error { | |
| @@ -2707,7 +2842,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2707 | 2842 | } | |
| 2708 | 2843 | continue | |
| 2709 | 2844 | } | |
| 2710 | - | if ui != nil && o.Full && foreground == nil && samePeerSwitch == nil { | |
| 2845 | + | if a.activationOutput == nil && ui != nil && o.Full && foreground == nil && samePeerSwitch == nil { | |
| 2711 | 2846 | startForeground() | |
| 2712 | 2847 | } | |
| 2713 | 2848 | if uiOutput != nil { | |
| @@ -2753,6 +2888,22 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2753 | 2888 | uiOutput.EndOutput(true) | |
| 2754 | 2889 | } | |
| 2755 | 2890 | 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 | + | } | |
| 2756 | 2907 | if ui != nil && o.New != 0 { | |
| 2757 | 2908 | ui.published(uiGeneration) | |
| 2758 | 2909 | if o.Full { | |
| @@ -3168,14 +3319,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 3168 | 3319 | } | |
| 3169 | 3320 | } | |
| 3170 | 3321 | 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) | |
| 3177 | 3324 | } | |
| 3178 | - | routePosition = cloneRoutePosition(&position) | |
| 3179 | 3325 | case protocol.RouteRetired: | |
| 3180 | 3326 | if a.runner.ledger != nil && a.runner.ledger.retireRoute(message, a.runner.ledger.attentionSubscriptionFor(request)) { | |
| 3181 | 3327 | 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 | |||
| 317 | 317 | ||
| 318 | 318 | func testDependencies(dialer ports.ClientDialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies { | |
| 319 | 319 | return client.Dependencies{ | |
| 320 | + | AttachmentCache: domain.DefaultAttachmentCacheConfig(), | |
| 320 | 321 | Dialer: dialer, | |
| 321 | 322 | Terminal: terminal, | |
| 322 | 323 | Clock: clock, | |
M internal/usecase/client/handshake.go +30 -2
| @@ -26,6 +26,12 @@ func (t systemTimer) Stop() bool { return t.Timer.Stop() } | |||
| 26 | 26 | // newHandshakeContext owns one deadline for the complete outbound handshake. | |
| 27 | 27 | // The caller must stop it before entering the long-lived connection loop. | |
| 28 | 28 | 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()) { | |
| 29 | 35 | if parent == nil { | |
| 30 | 36 | parent = context.Background() | |
| 31 | 37 | } | |
| @@ -34,9 +40,9 @@ func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Con | |||
| 34 | 40 | if clock == nil { | |
| 35 | 41 | clock = systemClock{} | |
| 36 | 42 | } | |
| 37 | - | timer := clock.NewTimer(protocol.HandshakeTimeout) | |
| 43 | + | timer := clock.NewTimer(timeout) | |
| 38 | 44 | if timer == nil { | |
| 39 | - | timer = systemClock{}.NewTimer(protocol.HandshakeTimeout) | |
| 45 | + | timer = systemClock{}.NewTimer(timeout) | |
| 40 | 46 | } | |
| 41 | 47 | stop := make(chan struct{}) | |
| 42 | 48 | done := make(chan struct{}) | |
| @@ -116,6 +122,28 @@ func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientCo | |||
| 116 | 122 | return boundedDialWithTransition(ctx, dialer, nil) | |
| 117 | 123 | } | |
| 118 | 124 | ||
| 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 | + | ||
| 119 | 147 | func boundedDialWithTransition(ctx context.Context, dialer ports.ClientDialer, transition *transitionUI) (ports.ClientConnection, error) { | |
| 120 | 148 | // The dial context only bounds startup. A successful carriage outlives the | |
| 121 | 149 | // 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 ( | |||
| 32 | 32 | type hybridPickerTransport struct { | |
| 33 | 33 | mu sync.Mutex | |
| 34 | 34 | queue []wire.Envelope | |
| 35 | + | receiving bool | |
| 35 | 36 | sends []wire.Envelope | |
| 36 | 37 | signal chan struct{} | |
| 37 | 38 | done chan struct{} | |
| @@ -54,11 +55,13 @@ func (t *hybridPickerTransport) Recv() (wire.Envelope, error) { | |||
| 54 | 55 | for { | |
| 55 | 56 | t.mu.Lock() | |
| 56 | 57 | if len(t.queue) > 0 { | |
| 58 | + | t.receiving = false | |
| 57 | 59 | frame := t.queue[0] | |
| 58 | 60 | t.queue = t.queue[1:] | |
| 59 | 61 | t.mu.Unlock() | |
| 60 | 62 | return frame, nil | |
| 61 | 63 | } | |
| 64 | + | t.receiving = true | |
| 62 | 65 | t.mu.Unlock() | |
| 63 | 66 | select { | |
| 64 | 67 | case <-t.signal: | |
| @@ -77,6 +80,7 @@ func (t *hybridPickerTransport) Close() error { | |||
| 77 | 80 | func (t *hybridPickerTransport) push(frame wire.Envelope) { | |
| 78 | 81 | t.mu.Lock() | |
| 79 | 82 | t.queue = append(t.queue, frame) | |
| 83 | + | t.receiving = false | |
| 80 | 84 | t.mu.Unlock() | |
| 81 | 85 | select { | |
| 82 | 86 | case t.signal <- struct{}{}: | |
| @@ -84,6 +88,18 @@ func (t *hybridPickerTransport) push(frame wire.Envelope) { | |||
| 84 | 88 | } | |
| 85 | 89 | } | |
| 86 | 90 | ||
| 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 | + | ||
| 87 | 103 | func (t *hybridPickerTransport) sent() []wire.Envelope { | |
| 88 | 104 | t.mu.Lock() | |
| 89 | 105 | defer t.mu.Unlock() | |
| @@ -401,6 +417,9 @@ func TestHybridPickerDifferentHostDialsTheTargetEndpoint(t *testing.T) { | |||
| 401 | 417 | CauseActionID: selection.CauseActionID, | |
| 402 | 418 | })) | |
| 403 | 419 | ||
| 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 | + | ||
| 404 | 423 | targetTransport.push(hybridPickerWelcome("target", targetHost.LifecycleID)) | |
| 405 | 424 | targetTransport.push(frameOfMessage(protocol.Detached{Reason: protocol.ReasonDetach})) | |
| 406 | 425 | ||
| @@ -447,7 +466,6 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) { | |||
| 447 | 466 | } | |
| 448 | 467 | local := newHybridPickerTransport() | |
| 449 | 468 | remote := newHybridPickerTransport() | |
| 450 | - | remoteReturn := newHybridPickerTransport() | |
| 451 | 469 | other := newHybridPickerTransport() | |
| 452 | 470 | resolutions := make(chan string, 8) | |
| 453 | 471 | ||
| @@ -457,7 +475,7 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) { | |||
| 457 | 475 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 458 | 476 | })) | |
| 459 | 477 | ||
| 460 | - | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, remoteReturn}} | |
| 478 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}} | |
| 461 | 479 | otherDialer := &sequenceDialer{trs: []wire.Transport{other}} | |
| 462 | 480 | localDialer := &sequenceDialer{trs: []wire.Transport{local}} | |
| 463 | 481 | dialers := map[string]ports.ClientDialer{"remote": remoteDialer, "target-host": otherDialer} | |
| @@ -493,14 +511,23 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) { | |||
| 493 | 511 | RemoteTarget: &otherHost, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 494 | 512 | CauseActionID: selection.CauseActionID, | |
| 495 | 513 | })) | |
| 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}})) | |
| 496 | 516 | other.push(hybridPickerWelcome("other", otherHost.LifecycleID)) | |
| 497 | 517 | // The new peer offers the return route to the first host. | |
| 498 | 518 | other.push(frameOfMessage(protocol.AttachTarget{ | |
| 499 | 519 | Endpoint: "remote", Session: source.SessionName, Intent: protocol.IntentAttach, | |
| 500 | 520 | RemoteTarget: &source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 501 | 521 | })) | |
| 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})) | |
| 504 | 531 | ||
| 505 | 532 | select { | |
| 506 | 533 | case err := <-done: | |
| @@ -508,10 +535,9 @@ func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) { | |||
| 508 | 535 | case <-time.After(5 * time.Second): | |
| 509 | 536 | t.Fatal("client did not finish the cross-peer picker route") | |
| 510 | 537 | } | |
| 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()) | |
| 515 | 541 | require.Equal(t, int32(1), otherDialer.calls.Load()) | |
| 516 | 542 | require.Equal(t, []string{"remote", "target-host", "remote"}, drainStrings(resolutions)) | |
| 517 | 543 | } | |
| @@ -523,3 +549,141 @@ func mustAwaitSend(t *testing.T, transport *hybridPickerTransport, name string) | |||
| 523 | 549 | require.True(t, ok, "client message %s never crossed the wire", name) | |
| 524 | 550 | return message | |
| 525 | 551 | } | |
| 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 | |||
| 75 | 75 | return conn, nil | |
| 76 | 76 | }).Twice() | |
| 77 | 77 | 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} | |
| 78 | 81 | deps.LocalControlDialer = control | |
| 79 | 82 | deps.HostRegistry = dialerForEndpoints(map[string]ports.ClientDialer{"remote": remoteDialer}) | |
| 80 | 83 | 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) { | |||
| 296 | 296 | ||
| 297 | 297 | writeTerminal(t, writer, "ok") | |
| 298 | 298 | var delivered []byte | |
| 299 | - | deadline := time.After(2 * time.Second) | |
| 299 | + | deadline := time.After(5 * time.Second) | |
| 300 | 300 | for len(delivered) < len("ok") { | |
| 301 | 301 | select { | |
| 302 | 302 | case frame := <-transport.inputCh: | |
M internal/usecase/client/picker_retire_test.go +1 -1
| @@ -142,7 +142,7 @@ func TestPickerStaleRevisionClosesInteraction(t *testing.T) { | |||
| 142 | 142 | writeTerminal(t, writer, "ok") | |
| 143 | 143 | select { | |
| 144 | 144 | case <-transport.inputCh: | |
| 145 | - | case <-time.After(2 * time.Second): | |
| 145 | + | case <-time.After(5 * time.Second): | |
| 146 | 146 | t.Fatal("session input did not resume after the release paint") | |
| 147 | 147 | } | |
| 148 | 148 | } | |
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 { | |||
| 60 | 60 | return "" | |
| 61 | 61 | } | |
| 62 | 62 | switch message.(type) { | |
| 63 | + | case protocol.SuspendAttachment: | |
| 64 | + | return "SuspendAttachment" | |
| 65 | + | case protocol.ActivateAttachment: | |
| 66 | + | return "ActivateAttachment" | |
| 63 | 67 | case protocol.Hello: | |
| 64 | 68 | return "Hello" | |
| 65 | 69 | case protocol.Input: | |
M internal/usecase/daemon/attachment_effect_gate.go +37 -3
| @@ -16,11 +16,21 @@ const ( | |||
| 16 | 16 | attachmentEffectsFrozen | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | + | type attachmentActivity uint8 | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | attachmentActive attachmentActivity = iota | |
| 23 | + | attachmentSuspended | |
| 24 | + | attachmentActivating | |
| 25 | + | ) | |
| 26 | + | ||
| 19 | 27 | // attachmentLifecycle owns the immutable committed capability and the | |
| 20 | 28 | // 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. | |
| 23 | 31 | type attachmentLifecycle struct { | |
| 32 | + | activity attachmentActivity // protected by mu; independent of transition freeze ownership | |
| 33 | + | retained protocol.ExactSessionTarget | |
| 24 | 34 | generation atomic.Uint64 | |
| 25 | 35 | mu sync.Mutex | |
| 26 | 36 | cond *sync.Cond | |
| @@ -35,6 +45,12 @@ type attachmentLifecycle struct { | |||
| 35 | 45 | ||
| 36 | 46 | var nextAttachmentEffectGateOrder atomic.Uint64 | |
| 37 | 47 | ||
| 48 | + | func (ac *attachedClient) attachmentActivity() attachmentActivity { | |
| 49 | + | ac.lifecycle.mu.Lock() | |
| 50 | + | defer ac.lifecycle.mu.Unlock() | |
| 51 | + | return ac.lifecycle.activity | |
| 52 | + | } | |
| 53 | + | ||
| 38 | 54 | func (g *attachmentLifecycle) generationValue() uint64 { | |
| 39 | 55 | if g == nil { | |
| 40 | 56 | return 0 | |
| @@ -236,7 +252,7 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentCapability) (*at | |||
| 236 | 252 | g := &ac.lifecycle | |
| 237 | 253 | g.mu.Lock() | |
| 238 | 254 | g.initLocked() | |
| 239 | - | if g.phase != attachmentEffectsStable || !g.capability.sameIdentity(token) { | |
| 255 | + | if g.phase != attachmentEffectsStable || g.activity != attachmentActive || !g.capability.sameIdentity(token) { | |
| 240 | 256 | g.mu.Unlock() | |
| 241 | 257 | return nil, false | |
| 242 | 258 | } | |
| @@ -350,3 +366,21 @@ func (ac *attachedClient) invalidateFrozenAttachmentCapability() { | |||
| 350 | 366 | g.failedTransport = transportSnapshot{} | |
| 351 | 367 | g.mu.Unlock() | |
| 352 | 368 | } | |
| 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 | |||
| 120 | 120 | sourceCore := source.core() | |
| 121 | 121 | targetCore := req.target.core() | |
| 122 | 122 | 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 || | |
| 124 | 124 | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) || | |
| 125 | 125 | req.preserveAttachment && req.sourceCapability == nil { | |
| 126 | 126 | return nil, errAttachmentTransition | |
M internal/usecase/daemon/client.go +6 -0
| @@ -3,6 +3,12 @@ | |||
| 3 | 3 | // Lock ordering: never acquire sendMu or session/daemon locks while holding a | |
| 4 | 4 | // pane lock. Coordinator callbacks and timer methods run without coordinator.mu; | |
| 5 | 5 | // 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. | |
| 6 | 12 | package daemon | |
| 7 | 13 | ||
| 8 | 14 | import ( | |
M internal/usecase/daemon/client_frame_routing.go +30 -0
| @@ -94,6 +94,36 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability, | |||
| 94 | 94 | if d.afterAttachmentFrameDispatch != nil { | |
| 95 | 95 | d.afterAttachmentFrameDispatch(capability) | |
| 96 | 96 | } | |
| 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 { | |
| 97 | 127 | effect, admitted := capability.ac.beginAttachmentEffect(capability) | |
| 98 | 128 | if !admitted { | |
| 99 | 129 | 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 | |||
| 26 | 26 | return // stale connection loop; a newer transport owns this client | |
| 27 | 27 | } | |
| 28 | 28 | } | |
| 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 | + | } | |
| 29 | 35 | // Advertise parking before detach so IntentResume never observes both the | |
| 30 | 36 | // live seat and parking/parked registries empty for a still-valid token. | |
| 31 | 37 | var parkingToken uint64 | |
| @@ -184,7 +190,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||
| 184 | 190 | rc.noteDetach(ac) | |
| 185 | 191 | } | |
| 186 | 192 | 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) { | |
| 188 | 194 | _ = ac.closeCapturedTransport(failed) | |
| 189 | 195 | d.log.Warn("parked client after send error", "session", name) | |
| 190 | 196 | return | |
M internal/usecase/daemon/daemon.go +47 -13
| @@ -67,6 +67,13 @@ func normalizeOutputWindow(window uint8) uint8 { | |||
| 67 | 67 | ||
| 68 | 68 | const defaultResumeParkGrace = 15 * time.Minute | |
| 69 | 69 | ||
| 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 | + | ||
| 70 | 77 | // defaultSize is retained for headless layout helpers that have no client | |
| 71 | 78 | // window; Hello routing rejects invalid dimensions instead of using it. | |
| 72 | 79 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | |
| @@ -110,6 +117,12 @@ type Daemon struct { | |||
| 110 | 117 | // with Wait. | |
| 111 | 118 | notifies []chan struct{} | |
| 112 | 119 | 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 | |
| 113 | 126 | // graphicsNamespaces reserves deterministic, attachment/session-scoped Kitty | |
| 114 | 127 | // ID blocks. Once a block may have reached an outer terminal it remains in | |
| 115 | 128 | // this bounded table for the daemon lifetime: side-effect Output frames have | |
| @@ -232,18 +245,22 @@ type Daemon struct { | |||
| 232 | 245 | // after the render ticket ends and before exact lifecycle validation. | |
| 233 | 246 | beforeAttachmentSendErrorCleanup func(attachmentCapability) | |
| 234 | 247 | 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 | |
| 247 | 264 | // snapshotRepository is the sole checkpoint storage contract. | |
| 248 | 265 | snapshotRepository ports.SnapshotRepository | |
| 249 | 266 | recovery *recoveryusecase.Coordinator | |
| @@ -304,6 +321,7 @@ type Daemon struct { | |||
| 304 | 321 | barScripts *barScriptState | |
| 305 | 322 | notices *noticeCenter | |
| 306 | 323 | resumeParkGrace time.Duration | |
| 324 | + | suspendedSafetyExpiry time.Duration | |
| 307 | 325 | remotePreview remotePreviewState | |
| 308 | 326 | remotePreviewClient ports.RemotePreviewClient | |
| 309 | 327 | // remoteDirectory is the snapshot-only projection served by the remote | |
| @@ -595,6 +613,17 @@ func WithResumeParkGrace(grace time.Duration) Option { | |||
| 595 | 613 | } | |
| 596 | 614 | } | |
| 597 | 615 | ||
| 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 | + | ||
| 598 | 627 | // WithConfig applies the initial user configuration. | |
| 599 | 628 | func WithConfig(cfg domain.Config) Option { | |
| 600 | 629 | return func(d *Daemon) { | |
| @@ -673,6 +702,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 673 | 702 | inactive: make(map[string]inactiveSession), | |
| 674 | 703 | creating: make(map[string]struct{}), | |
| 675 | 704 | parked: make(map[uint64]*parkedAttachment), | |
| 705 | + | suspended: make(map[*attachedClient]*suspendedAttachmentRetention), | |
| 676 | 706 | graphicsNamespaces: make(map[uint64]struct{}), | |
| 677 | 707 | graphicsNamespaceFences: make(map[uint64]uint64), | |
| 678 | 708 | graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine), | |
| @@ -694,6 +724,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 694 | 724 | snapshotWake: make(chan struct{}, 1), | |
| 695 | 725 | notices: newNoticeCenter(), | |
| 696 | 726 | resumeParkGrace: defaultResumeParkGrace, | |
| 727 | + | suspendedSafetyExpiry: defaultSuspendedSafetyExpiry, | |
| 697 | 728 | barScripts: &barScriptState{ | |
| 698 | 729 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 699 | 730 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| @@ -929,6 +960,7 @@ func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, dead | |||
| 929 | 960 | d.closing = true | |
| 930 | 961 | d.purgeAllParkingLocked() | |
| 931 | 962 | parkedRetirements := d.purgeAllParkedLocked() | |
| 963 | + | d.purgeAllSuspendedLocked() | |
| 932 | 964 | snapshot := d.sessionsSnapshotLocked() | |
| 933 | 965 | stoppedNames := make([]string, 0, len(d.inactive)) | |
| 934 | 966 | if purge { | |
| @@ -1234,7 +1266,9 @@ func (d *Daemon) handleList(tr ports.ServerConnection) { | |||
| 1234 | 1266 | State: protocol.SessionUp, | |
| 1235 | 1267 | Ephemeral: s.ephemeral, | |
| 1236 | 1268 | 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), | |
| 1238 | 1272 | } | |
| 1239 | 1273 | liveNames[s.name] = struct{}{} | |
| 1240 | 1274 | s.mu.Unlock() | |
M internal/usecase/daemon/move_transaction.go +1 -0
| @@ -316,6 +316,7 @@ func (t *moveTransaction) publishLocked() error { | |||
| 316 | 316 | t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports) | |
| 317 | 317 | t.source.tabs = nil | |
| 318 | 318 | d.purgeParkingForSessionLocked(t.source) | |
| 319 | + | d.purgeSuspendedForSessionLocked(t.source) | |
| 319 | 320 | t.retiredParked = d.purgeParkedForSessionLocked(t.source) | |
| 320 | 321 | } | |
| 321 | 322 | t.sourceName = t.source.name | |
M internal/usecase/daemon/render.go +12 -1
| @@ -310,13 +310,24 @@ const ( | |||
| 310 | 310 | ) | |
| 311 | 311 | ||
| 312 | 312 | 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 { | |
| 313 | 319 | // Session-owned PTY preparation remains local; attachment rendering is | |
| 314 | 320 | // captured through session.captureRenderState. | |
| 315 | 321 | sess := entry | |
| 316 | 322 | local := sess != nil | |
| 317 | 323 | marks := d.newRuntimeMarkBatch() | |
| 318 | 324 | 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 { | |
| 320 | 331 | token := captureAttachmentCapability(entry, ac, ac.transport()) | |
| 321 | 332 | token.lease = lease | |
| 322 | 333 | 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 { | |||
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | 196 | 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 { | |
| 198 | 198 | return false | |
| 199 | 199 | } | |
| 200 | 200 | d.mu.Lock() | |
| @@ -497,7 +497,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio | |||
| 497 | 497 | if sess != nil { | |
| 498 | 498 | sess.mu.Lock() | |
| 499 | 499 | for candidate := range sess.attachments { | |
| 500 | - | if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken { | |
| 500 | + | if candidate.attachmentActivity() != attachmentActive || !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken { | |
| 501 | 501 | continue | |
| 502 | 502 | } | |
| 503 | 503 | ac = candidate | |
M internal/usecase/daemon/route_position_test.go +46 -0
| @@ -107,3 +107,49 @@ func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) { | |||
| 107 | 107 | }, positions) | |
| 108 | 108 | d.clientGone(sess, ac, tr, false) | |
| 109 | 109 | } | |
| 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 | |||
| 886 | 886 | current = false | |
| 887 | 887 | } | |
| 888 | 888 | if current { | |
| 889 | - | sess.unregisterAttachmentLocked(ac) | |
| 890 | - | ac.setSession(nil) | |
| 891 | - | ac.invalidateFrozenAttachmentCapability() | |
| 892 | - | cancelPickerPreviewWorker(ac) | |
| 889 | + | current = detachFrozenAttachmentLocked(sess, ac) | |
| 893 | 890 | } | |
| 894 | 891 | sess.mu.Unlock() | |
| 895 | 892 | 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 | + | } | |
| 896 | 898 | return current | |
| 897 | 899 | } | |
| 898 | 900 | ||
| @@ -935,10 +937,7 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done | |||
| 935 | 937 | } | |
| 936 | 938 | current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator) | |
| 937 | 939 | 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) | |
| 942 | 941 | } | |
| 943 | 942 | if coordinator != nil { | |
| 944 | 943 | coordinator.mu.Unlock() | |
| @@ -946,6 +945,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done | |||
| 946 | 945 | core.mu.Unlock() | |
| 947 | 946 | d.notices.routingMu.Unlock() | |
| 948 | 947 | 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 | + | } | |
| 949 | 953 | return current | |
| 950 | 954 | } | |
| 951 | 955 | ||
| @@ -1782,6 +1786,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas | |||
| 1782 | 1786 | capture(attached) | |
| 1783 | 1787 | } | |
| 1784 | 1788 | clear(sess.attachments) | |
| 1789 | + | d.purgeSuspendedForSessionLocked(sess) | |
| 1785 | 1790 | stoppedName := sess.name | |
| 1786 | 1791 | stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))) | |
| 1787 | 1792 | ephemeral := sess.ephemeral | |
M internal/usecase/daemon/session_view.go +24 -2
| @@ -40,6 +40,25 @@ type sessionView struct { | |||
| 40 | 40 | tabs []tabView | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 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 | + | ||
| 43 | 62 | // snapshotView reads session fields under s.mu and samples the independently | |
| 44 | 63 | // atomic mruAt while that lock is held. tabCount and hasAttention are always | |
| 45 | 64 | // filled; tabs is allocated only when opts.tabDetails is true (non-nil empty | |
| @@ -60,8 +79,11 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 60 | 79 | createdAt: s.createdAt, | |
| 61 | 80 | defaultTab: 0, | |
| 62 | 81 | 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), | |
| 65 | 87 | } | |
| 66 | 88 | if opts.tabDetails { | |
| 67 | 89 | 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. | |||
| 16 | 16 | `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs | |
| 17 | 17 | the table-driven baseline matrix. Every scenario asserts committed outcomes | |
| 18 | 18 | (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. | |
| 28 | 38 | ||
| 29 | 39 | | Scenario | Modes | | |
| 30 | 40 | |---|---| | |
| 31 | 41 | | `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 | | |
| 34 | 46 | | `hybrid-exact-return@quic` | M4 hybrid QUIC: local→remote→exact local return with committed action | | |
| 35 | 47 | | `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. | |
| 39 | 60 | ||
| 40 | 61 | 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 | |||
| 7 | 7 | or accepting a request is never success: every scenario ends on a | |
| 8 | 8 | postcondition ``wait`` or a verified capture. | |
| 9 | 9 | ||
| 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. | |
| 14 | 21 | """ | |
| 15 | 22 | ||
| 16 | 23 | import json | |
| @@ -25,6 +32,17 @@ ROWS = "30" | |||
| 25 | 32 | REQUEST_TIMEOUT_S = 15 | |
| 26 | 33 | WAIT_TIMEOUT_MS = 15000 | |
| 27 | 34 | ||
| 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 | + | ||
| 28 | 46 | ||
| 29 | 47 | class DriverError(Exception): | |
| 30 | 48 | pass | |
| @@ -34,15 +52,18 @@ class Driver: | |||
| 34 | 52 | """One headless client with bounded reads and reliable termination.""" | |
| 35 | 53 | ||
| 36 | 54 | 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) | |
| 40 | 56 | 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. | |
| 43 | 65 | 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, | |
| 46 | 67 | ) | |
| 47 | 68 | self.request_id = 0 | |
| 48 | 69 | ready = self._read("discovery") | |
| @@ -133,6 +154,135 @@ def check(condition, message, diagnostics): | |||
| 133 | 154 | raise DriverError(f"{message}\n diagnostics: {json.dumps(diagnostics)[:2000]}") | |
| 134 | 155 | ||
| 135 | 156 | ||
| 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 | + | ||
| 136 | 286 | def scenario_local_palette_cycle(client_container): | |
| 137 | 287 | """M1: local IPC palette open/action/close with committed observation.""" | |
| 138 | 288 | suffix = uuid.uuid4().hex[:8] | |
| @@ -157,22 +307,151 @@ def scenario_local_palette_cycle(client_container): | |||
| 157 | 307 | driver.close() | |
| 158 | 308 | ||
| 159 | 309 | ||
| 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 | + | """ | |
| 162 | 317 | 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} | |
| 164 | 329 | try: | |
| 165 | 330 | capture = driver.call("capture") | |
| 166 | 331 | 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") | |
| 171 | 339 | final = driver.call("capture") | |
| 172 | 340 | 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 | |
| 174 | 418 | finally: | |
| 175 | 419 | 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']}") | |
| 176 | 455 | ||
| 177 | 456 | ||
| 178 | 457 | def picker_topology(env): | |
| @@ -298,6 +577,9 @@ def scenario_hybrid_exact_return(client_container, remote_container, env): | |||
| 298 | 577 | switched = driver.call("keys", keys=["Enter"]) | |
| 299 | 578 | check(switched["context"]["session"]["session_name"] == second, | |
| 300 | 579 | "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) | |
| 301 | 583 | driver.palette(remote, "Switch to session " + remote + "@remote") | |
| 302 | 584 | entered = driver.call("keys", keys=["Enter"]) | |
| 303 | 585 | check(entered["context"]["session"]["session_name"] == remote, | |
| @@ -315,7 +597,8 @@ def scenario_hybrid_exact_return(client_container, remote_container, env): | |||
| 315 | 597 | ||
| 316 | 598 | SCENARIOS = { | |
| 317 | 599 | "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, | |
| 319 | 602 | "hybrid-exact-return": scenario_hybrid_exact_return, | |
| 320 | 603 | "client-picker-navigate": scenario_client_picker_navigate, | |
| 321 | 604 | } | |
M scripts/remote-picker-harness/run.sh +78 -19
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | # Disposable navigation acceptance with the real client, daemons and UI driver. | |
| 3 | 3 | set -euo pipefail | |
| 4 | 4 | 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 ;; | |
| 6 | 6 | '') ;; | |
| 7 | 7 | *) exit 2 ;; | |
| 8 | 8 | esac | |
| @@ -15,20 +15,40 @@ network="vev-acceptance-${run_id}" | |||
| 15 | 15 | local_container="${network}-local" | |
| 16 | 16 | remote_container="${network}-remote" | |
| 17 | 17 | 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 | + | ||
| 18 | 35 | cleanup() { | |
| 19 | 36 | status=$? | |
| 20 | 37 | trap - EXIT INT TERM | |
| 21 | 38 | set +e | |
| 39 | + | collect_artifacts | |
| 22 | 40 | 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 | |
| 26 | 45 | done | |
| 27 | 46 | fi | |
| 28 | 47 | docker rm -f "$local_container" "$remote_container" >/dev/null 2>&1 | |
| 29 | 48 | docker network rm "$network" >/dev/null 2>&1 | |
| 30 | 49 | docker image rm "$image" >/dev/null 2>&1 | |
| 31 | 50 | rm -rf "$context_dir" | |
| 51 | + | printf 'acceptance artifacts: %s\n' "$artifacts_dir" | |
| 32 | 52 | exit "$status" | |
| 33 | 53 | } | |
| 34 | 54 | trap cleanup EXIT | |
| @@ -37,9 +57,13 @@ trap 'exit 143' TERM | |||
| 37 | 57 | ||
| 38 | 58 | docker build -f "$repo_root/scripts/demo/Dockerfile" -t "$image" "$repo_root" >/dev/null | |
| 39 | 59 | 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. | |
| 40 | 63 | for role in local remote; do | |
| 41 | 64 | 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 | |
| 43 | 67 | done | |
| 44 | 68 | ||
| 45 | 69 | # 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 | |||
| 50 | 74 | docker cp "$context_dir/host" "$remote_container:/etc/ssh/ssh_host_ed25519_key" | |
| 51 | 75 | docker cp "$context_dir/host.pub" "$remote_container:/etc/ssh/ssh_host_ed25519_key.pub" | |
| 52 | 76 | 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' | |
| 54 | 80 | docker cp "$context_dir/client" "$local_container:/home/demo/.ssh/id_ed25519" | |
| 55 | 81 | docker cp "$context_dir/client.pub" "$local_container:/home/demo/.ssh/id_ed25519.pub" | |
| 56 | 82 | { printf 'remote '; cut -d' ' -f1-2 "$context_dir/host.pub"; } > "$context_dir/known_hosts" | |
| 57 | 83 | docker cp "$context_dir/known_hosts" "$local_container:/home/demo/.ssh/known_hosts" | |
| 58 | 84 | 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"' | |
| 59 | 88 | docker exec "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=5 remote true | |
| 60 | 89 | 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 | |
| 71 | 130 | # Client-owned navigation picker where the serving attachment answers the | |
| 72 | 131 | # palette session-picker command itself: local IPC and direct remote (QUIC and | |
| 73 | 132 | # explicit stdio). A hybrid client advertises the home-picker capability, so | |
| 74 | 133 | # its session-picker command delegates to the home route overlay (an R1 | |
| 75 | 134 | # 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 | |