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