feat(remote): implement picker session parity
- commit
- 2d2ac84a6087a384288315666ed2f21f0237c157
- author
- brice · · 2026-08-05 08:53 UTC
- parent
- 9344cd0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
54 files · +5140 -224
M Makefile +4 -1
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | .PHONY: install test test-installer lint mocks demo | 1 | + | .PHONY: install test test-installer lint mocks demo remote-acceptance |
| 2 | 2 | ||||
| 3 | install: | 3 | install: | ||
| 4 | go install . | 4 | go install . | ||
| @@ -25,6 +25,9 @@ mocks: | |||||
| 25 | fi | 25 | fi | ||
| 26 | mockery | 26 | mockery | ||
| 27 | 27 | ||||
| 28 | + | remote-acceptance: | |||
| 29 | + | scripts/remote-picker-harness/run.sh | |||
| 30 | + | ||||
| 28 | demo: | 31 | demo: | ||
| 29 | docker build -f scripts/demo/Dockerfile -t vev-demo . | 32 | docker build -f scripts/demo/Dockerfile -t vev-demo . | ||
| 30 | ./scripts/demo/run.sh demo.tape | 33 | ./scripts/demo/run.sh demo.tape | ||
M docs/remote-resilience.md +25 -0
| @@ -13,6 +13,31 @@ acknowledgements, and reconnect state. Reconnecting one attachment resumes that | |||||
| 13 | attachment without replacing its peers or changing their attachment-local | 13 | attachment without replacing its peers or changing their attachment-local | ||
| 14 | windows/views; its latest claim may still update shared PTY geometry. | 14 | windows/views; its latest claim may still update shared PTY geometry. | ||
| 15 | 15 | ||||
| 16 | + | ## Remote picker identity | |||
| 17 | + | ||||
| 18 | + | The session picker shows remote live, stopped, broken, stale, and unavailable | |||
| 19 | + | rows using the same expandable session/tab shape as local rows. A row carries a | |||
| 20 | + | lifecycle ID and stable tab selector separately from its display origin; labels | |||
| 21 | + | are never parsed back into routes. Cached and stale rows remain navigable, but | |||
| 22 | + | activation is gated until the host catalog and the exact lifecycle/tab identity | |||
| 23 | + | are current. A successful picker handoff sends the structured target to the | |||
| 24 | + | owning daemon without creating a local shadow session. Picker-selected remote | |||
| 25 | + | attachments use the daemon's environment and persisted session working | |||
| 26 | + | directory; direct CLI remote attaches retain their client-request semantics. | |||
| 27 | + | ||||
| 28 | + | Remote catalogues are bounded and versioned. Complete catalogues contain ordered | |||
| 29 | + | tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped | |||
| 30 | + | tab metadata is retained across daemon restart; legacy count-only catalogues are | |||
| 31 | + | read-only compatibility rows and cannot provide exact tab identity. | |||
| 32 | + | ||||
| 33 | + | Remote picker previews are bounded styled-cell snapshots fetched without | |||
| 34 | + | attachment or PTY mutation. They are held in a short-lived in-memory cache only; | |||
| 35 | + | viewport contents are not persisted or written to diagnostics. Live preview | |||
| 36 | + | requests use debounce, same-key single-flight, bounded concurrency, TTL | |||
| 37 | + | stale-while-revalidate, and failure cooldowns. Stopped sessions show a static | |||
| 38 | + | placeholder, and late or canceled preview responses are discarded when the | |||
| 39 | + | picker selection changes. | |||
| 40 | + | ||||
| 16 | ## Connection limits | 41 | ## Connection limits | ||
| 17 | 42 | ||||
| 18 | - A connection handshake has one 15-second deadline from connection start | 43 | - A connection handshake has one 15-second deadline from connection start | ||
M internal/adapters/remote/catalog.go +25 -10
| @@ -114,6 +114,7 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa | |||||
| 114 | 114 | ||||
| 115 | type catalogEnvelope struct { | 115 | type catalogEnvelope struct { | ||
| 116 | ProtocolVersion *uint16 `json:"protocol_version"` | 116 | ProtocolVersion *uint16 `json:"protocol_version"` | ||
| 117 | + | SchemaVersion *uint16 `json:"schema_version"` | |||
| 117 | Sessions *[]ports.RemoteCatalogSession `json:"sessions"` | 118 | Sessions *[]ports.RemoteCatalogSession `json:"sessions"` | ||
| 118 | } | 119 | } | ||
| 119 | 120 | ||||
| @@ -130,10 +131,22 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) { | |||||
| 130 | return ports.RemoteCatalog{}, err | 131 | return ports.RemoteCatalog{}, err | ||
| 131 | } | 132 | } | ||
| 132 | 133 | ||||
| 134 | + | schemaVersion := uint16(0) | |||
| 135 | + | if envelope.SchemaVersion != nil { | |||
| 136 | + | schemaVersion = *envelope.SchemaVersion | |||
| 137 | + | } | |||
| 133 | if *envelope.ProtocolVersion != ports.ProtocolVersion { | 138 | if *envelope.ProtocolVersion != ports.ProtocolVersion { | ||
| 134 | return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{ | 139 | return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{ | ||
| 135 | Got: *envelope.ProtocolVersion, | 140 | Got: *envelope.ProtocolVersion, | ||
| 136 | Want: ports.ProtocolVersion, | 141 | Want: ports.ProtocolVersion, | ||
| 142 | + | Kind: "protocol", | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | if schemaVersion != 0 && schemaVersion != ports.RemoteCatalogSchemaVersion { | |||
| 146 | + | return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{ | |||
| 147 | + | Got: schemaVersion, | |||
| 148 | + | Want: ports.RemoteCatalogSchemaVersion, | |||
| 149 | + | Kind: "catalog", | |||
| 137 | } | 150 | } | ||
| 138 | } | 151 | } | ||
| 139 | 152 | ||||
| @@ -141,19 +154,21 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) { | |||||
| 141 | if sessions == nil { | 154 | if sessions == nil { | ||
| 142 | sessions = []ports.RemoteCatalogSession{} | 155 | sessions = []ports.RemoteCatalogSession{} | ||
| 143 | } | 156 | } | ||
| 144 | - | for _, session := range sessions { | 157 | + | catalog := ports.RemoteCatalog{ |
| 145 | - | if err := domain.ValidateSessionName(session.Name); err != nil { | 158 | + | ProtocolVersion: *envelope.ProtocolVersion, |
| 159 | + | SchemaVersion: schemaVersion, | |||
| 160 | + | Sessions: sessions, | |||
| 161 | + | } | |||
| 162 | + | if err := ports.ValidateRemoteCatalog(catalog); err != nil { | |||
| 163 | + | if schemaVersion == 0 { | |||
| 164 | + | // Keep the old adapter error taxonomy for pre-parity peers and | |||
| 165 | + | // direct CLI compatibility. Current-schema payloads retain the | |||
| 166 | + | // typed validation error for callers that need to classify it. | |||
| 146 | return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err) | 167 | return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err) | ||
| 147 | } | 168 | } | ||
| 148 | - | if !validCatalogState(session.State) { | 169 | + | return ports.RemoteCatalog{}, err |
| 149 | - | return ports.RemoteCatalog{}, fmt.Errorf("%w: invalid state %q", errCatalogSession, session.State) | |||
| 150 | - | } | |||
| 151 | } | 170 | } | ||
| 152 | - | 171 | + | return catalog, nil | |
| 153 | - | return ports.RemoteCatalog{ | |||
| 154 | - | ProtocolVersion: *envelope.ProtocolVersion, | |||
| 155 | - | Sessions: sessions, | |||
| 156 | - | }, nil | |||
| 157 | } | 172 | } | ||
| 158 | 173 | ||||
| 159 | func rejectTrailingJSON(dec *json.Decoder) error { | 174 | func rejectTrailingJSON(dec *json.Decoder) error { | ||
M internal/adapters/remote/catalog_cache.go +60 -18
| @@ -35,11 +35,15 @@ type catalogCacheHost struct { | |||||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | type catalogCacheSession struct { | 37 | type catalogCacheSession struct { | ||
| 38 | - | Name *string `json:"name"` | 38 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` |
| 39 | - | State *string `json:"state"` | 39 | + | Name *string `json:"name"` |
| 40 | - | Ephemeral *bool `json:"ephemeral"` | 40 | + | State *string `json:"state"` |
| 41 | - | Tabs *uint16 `json:"tabs"` | 41 | + | Ephemeral *bool `json:"ephemeral"` |
| 42 | - | Attached *bool `json:"attached"` | 42 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` |
| 43 | + | Tabs *uint16 `json:"tabs,omitempty"` | |||
| 44 | + | TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"` | |||
| 45 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |||
| 46 | + | Attached *bool `json:"attached"` | |||
| 43 | } | 47 | } | ||
| 44 | 48 | ||||
| 45 | type fileCatalogCache struct { | 49 | type fileCatalogCache struct { | ||
| @@ -103,19 +107,33 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||||
| 103 | Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)), | 107 | Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)), | ||
| 104 | } | 108 | } | ||
| 105 | for _, session := range *host.Sessions { | 109 | for _, session := range *host.Sessions { | ||
| 106 | - | if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil { | 110 | + | if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil && session.TabList == nil { |
| 107 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | 111 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | ||
| 108 | } | 112 | } | ||
| 109 | if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) { | 113 | if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) { | ||
| 110 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | 114 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | ||
| 111 | } | 115 | } | ||
| 112 | - | entry.Sessions = append(entry.Sessions, ports.RemoteCatalogSession{ | 116 | + | decoded := ports.RemoteCatalogSession{ |
| 113 | Name: *session.Name, | 117 | Name: *session.Name, | ||
| 114 | State: *session.State, | 118 | State: *session.State, | ||
| 115 | Ephemeral: *session.Ephemeral, | 119 | Ephemeral: *session.Ephemeral, | ||
| 116 | - | Tabs: *session.Tabs, | |||
| 117 | Attached: *session.Attached, | 120 | Attached: *session.Attached, | ||
| 118 | - | }) | 121 | + | } |
| 122 | + | if session.LastUsedSeq != nil { | |||
| 123 | + | decoded.LastUsedSeq = *session.LastUsedSeq | |||
| 124 | + | } | |||
| 125 | + | if session.LifecycleID != nil { | |||
| 126 | + | decoded.LifecycleID = *session.LifecycleID | |||
| 127 | + | } | |||
| 128 | + | if session.ActiveTabID != nil { | |||
| 129 | + | decoded.ActiveTabID = *session.ActiveTabID | |||
| 130 | + | } | |||
| 131 | + | if session.TabList != nil { | |||
| 132 | + | decoded.Tabs = append([]ports.RemoteCatalogTab(nil), (*session.TabList)...) | |||
| 133 | + | } else { | |||
| 134 | + | decoded.Tabs = int(*session.Tabs) | |||
| 135 | + | } | |||
| 136 | + | entry.Sessions = append(entry.Sessions, decoded) | |||
| 119 | } | 137 | } | ||
| 120 | entries = append(entries, entry) | 138 | entries = append(entries, entry) | ||
| 121 | } | 139 | } | ||
| @@ -141,15 +159,28 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error | |||||
| 141 | name := session.Name | 159 | name := session.Name | ||
| 142 | state := session.State | 160 | state := session.State | ||
| 143 | ephemeral := session.Ephemeral | 161 | ephemeral := session.Ephemeral | ||
| 144 | - | tabs := session.Tabs | |||
| 145 | attached := session.Attached | 162 | attached := session.Attached | ||
| 146 | - | sessions = append(sessions, catalogCacheSession{ | 163 | + | row := catalogCacheSession{Name: &name, State: &state, Ephemeral: &ephemeral, Attached: &attached} |
| 147 | - | Name: &name, | 164 | + | if session.LastUsedSeq != 0 { |
| 148 | - | State: &state, | 165 | + | lastUsed := session.LastUsedSeq |
| 149 | - | Ephemeral: &ephemeral, | 166 | + | row.LastUsedSeq = &lastUsed |
| 150 | - | Tabs: &tabs, | 167 | + | } |
| 151 | - | Attached: &attached, | 168 | + | if session.LifecycleID != (domain.SessionLifecycleID{}) { |
| 152 | - | }) | 169 | + | id := session.LifecycleID |
| 170 | + | row.LifecycleID = &id | |||
| 171 | + | } | |||
| 172 | + | if session.ActiveTabID != "" { | |||
| 173 | + | active := session.ActiveTabID | |||
| 174 | + | row.ActiveTabID = &active | |||
| 175 | + | } | |||
| 176 | + | if tabs := ports.CatalogTabs(session); tabs != nil { | |||
| 177 | + | copyTabs := append([]ports.RemoteCatalogTab(nil), tabs...) | |||
| 178 | + | row.TabList = ©Tabs | |||
| 179 | + | } else { | |||
| 180 | + | count := uint16(ports.CatalogTabCount(session)) | |||
| 181 | + | row.Tabs = &count | |||
| 182 | + | } | |||
| 183 | + | sessions = append(sessions, row) | |||
| 153 | } | 184 | } | ||
| 154 | hosts = append(hosts, catalogCacheHost{ | 185 | hosts = append(hosts, catalogCacheHost{ | ||
| 155 | Target: &target, | 186 | Target: &target, | ||
| @@ -166,6 +197,9 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error | |||||
| 166 | } | 197 | } | ||
| 167 | 198 | ||||
| 168 | func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) { | 199 | func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) { | ||
| 200 | + | if err := ports.ValidateRemoteCatalogCacheEntries(entries); err != nil { | |||
| 201 | + | return nil, err | |||
| 202 | + | } | |||
| 169 | normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries)) | 203 | normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries)) | ||
| 170 | hosts := make(map[string]struct{}, len(entries)) | 204 | hosts := make(map[string]struct{}, len(entries)) | ||
| 171 | for _, entry := range entries { | 205 | for _, entry := range entries { | ||
| @@ -200,7 +234,15 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po | |||||
| 200 | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | 234 | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | ||
| 201 | } | 235 | } | ||
| 202 | sessions[session.Name] = struct{}{} | 236 | sessions[session.Name] = struct{}{} | ||
| 203 | - | copyEntry.Sessions = append(copyEntry.Sessions, session) | 237 | + | copySession := session |
| 238 | + | if tabs := ports.CatalogTabs(session); tabs != nil { | |||
| 239 | + | copyTabs := make([]ports.RemoteCatalogTab, len(tabs)) | |||
| 240 | + | for i, tab := range tabs { | |||
| 241 | + | copyTabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name} | |||
| 242 | + | } | |||
| 243 | + | copySession.Tabs = copyTabs | |||
| 244 | + | } | |||
| 245 | + | copyEntry.Sessions = append(copyEntry.Sessions, copySession) | |||
| 204 | } | 246 | } | ||
| 205 | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | 247 | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | ||
| 206 | return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name | 248 | return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name | ||
A internal/adapters/remote/preview.go +88 -0
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | remotePreviewCommandTimeout = 5 * time.Second | |||
| 18 | + | remotePreviewWaitDelay = 500 * time.Millisecond | |||
| 19 | + | remotePreviewMaxDiagnostic = 4 << 10 | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | var ( | |||
| 23 | + | errRemotePreviewSSH = errors.New("remote preview: ssh command failed") | |||
| 24 | + | errRemotePreviewTooLarge = errors.New("remote preview: output exceeds size limit") | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // PreviewClient invokes the hidden remote preview command over SSH. The | |||
| 28 | + | // command receives only a base64url request and stdout is decoded as one | |||
| 29 | + | // bounded binary response. | |||
| 30 | + | type PreviewClient struct { | |||
| 31 | + | command func(ctx context.Context, name string, args ...string) *exec.Cmd | |||
| 32 | + | timeout time.Duration | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | var _ ports.RemotePreviewClient = (*PreviewClient)(nil) | |||
| 36 | + | ||||
| 37 | + | func NewPreviewClient() ports.RemotePreviewClient { | |||
| 38 | + | return &PreviewClient{command: exec.CommandContext} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) { | |||
| 42 | + | request := ports.RemotePreviewRequest{Version: ports.RemotePreviewSchemaVersion, Target: target, Width: width, Height: height} | |||
| 43 | + | payload := ports.MarshalRemotePreviewRequest(request) | |||
| 44 | + | if payload == nil { | |||
| 45 | + | return ports.RemotePreview{}, ports.ErrInvalidRemotePreviewRequest | |||
| 46 | + | } | |||
| 47 | + | runCtx, cancel := context.WithTimeout(ctx, c.previewTimeout()) | |||
| 48 | + | defer cancel() | |||
| 49 | + | command := c.command | |||
| 50 | + | if command == nil { | |||
| 51 | + | command = exec.CommandContext | |||
| 52 | + | } | |||
| 53 | + | encoded := base64.RawURLEncoding.EncodeToString(payload) | |||
| 54 | + | spec := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", encoded) | |||
| 55 | + | cmd := command(runCtx, spec.Path, spec.Args...) | |||
| 56 | + | stdout := boundedBuffer{limit: ports.RemotePreviewMaxBytes} | |||
| 57 | + | stderr := boundedBuffer{limit: remotePreviewMaxDiagnostic} | |||
| 58 | + | cmd.Stdout = &stdout | |||
| 59 | + | cmd.Stderr = &stderr | |||
| 60 | + | cmd.WaitDelay = remotePreviewWaitDelay | |||
| 61 | + | if err := cmd.Run(); err != nil { | |||
| 62 | + | if ctxErr := runCtx.Err(); ctxErr != nil { | |||
| 63 | + | return ports.RemotePreview{}, ctxErr | |||
| 64 | + | } | |||
| 65 | + | if stdout.overflow || stderr.overflow { | |||
| 66 | + | return ports.RemotePreview{}, errRemotePreviewTooLarge | |||
| 67 | + | } | |||
| 68 | + | return ports.RemotePreview{}, fmt.Errorf("%w: %v", errRemotePreviewSSH, err) | |||
| 69 | + | } | |||
| 70 | + | if stdout.overflow || stderr.overflow { | |||
| 71 | + | return ports.RemotePreview{}, errRemotePreviewTooLarge | |||
| 72 | + | } | |||
| 73 | + | preview, err := ports.UnmarshalRemotePreview(stdout.Bytes()) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return ports.RemotePreview{}, err | |||
| 76 | + | } | |||
| 77 | + | if preview.Status != ports.RemotePreviewOK { | |||
| 78 | + | return ports.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status) | |||
| 79 | + | } | |||
| 80 | + | return preview, nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (c *PreviewClient) previewTimeout() time.Duration { | |||
| 84 | + | if c != nil && c.timeout > 0 { | |||
| 85 | + | return c.timeout | |||
| 86 | + | } | |||
| 87 | + | return remotePreviewCommandTimeout | |||
| 88 | + | } | |||
A internal/adapters/remote/preview_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T) { | |||
| 19 | + | var lifecycle domain.SessionLifecycleID | |||
| 20 | + | lifecycle[0] = 9 | |||
| 21 | + | target := domain.RemoteSessionTarget{ | |||
| 22 | + | Endpoint: "build@mule", DisplayOrigin: "mule", LifecycleID: lifecycle, | |||
| 23 | + | SessionName: "work", LiveTabID: "tab-1", | |||
| 24 | + | } | |||
| 25 | + | want := ports.RemotePreview{ | |||
| 26 | + | Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK, | |||
| 27 | + | LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1, | |||
| 28 | + | Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}}, | |||
| 29 | + | } | |||
| 30 | + | payload := ports.MarshalRemotePreview(want) | |||
| 31 | + | require.NotNil(t, payload) | |||
| 32 | + | ||||
| 33 | + | var gotPath string | |||
| 34 | + | var gotArgs []string | |||
| 35 | + | client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |||
| 36 | + | gotPath = name | |||
| 37 | + | gotArgs = append([]string(nil), args...) | |||
| 38 | + | if len(args) != 3 { | |||
| 39 | + | t.Fatalf("ssh args = %#v, want target and one remote command", args) | |||
| 40 | + | } | |||
| 41 | + | words := strings.Split(args[2], " ") | |||
| 42 | + | if len(words) != 3 { | |||
| 43 | + | t.Fatalf("remote command = %q, want three shell words", args[2]) | |||
| 44 | + | } | |||
| 45 | + | encoded := strings.Trim(words[2], "'") | |||
| 46 | + | requestPayload, err := base64.RawURLEncoding.DecodeString(encoded) | |||
| 47 | + | if err != nil { | |||
| 48 | + | t.Fatalf("decode request: %v", err) | |||
| 49 | + | } | |||
| 50 | + | request, err := ports.UnmarshalRemotePreviewRequest(requestPayload) | |||
| 51 | + | if err != nil { | |||
| 52 | + | t.Fatalf("decode request payload: %v", err) | |||
| 53 | + | } | |||
| 54 | + | if request.Target != target || request.Width != 1 || request.Height != 1 { | |||
| 55 | + | t.Fatalf("request = %#v, want target and 1x1 dimensions", request) | |||
| 56 | + | } | |||
| 57 | + | return stdoutCmd(ctx, string(payload)) | |||
| 58 | + | }} | |||
| 59 | + | ||||
| 60 | + | got, err := client.Preview(context.Background(), target, 1, 1) | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | require.Equal(t, want, got) | |||
| 63 | + | wantCommand := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", "request") | |||
| 64 | + | require.Equal(t, wantCommand.Path, gotPath) | |||
| 65 | + | require.Equal(t, target.Endpoint, gotArgs[1]) | |||
| 66 | + | require.Contains(t, gotArgs[2], "'_remote-preview'") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestRemotePreviewClientRejectsMalformedResponse(t *testing.T) { | |||
| 70 | + | client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |||
| 71 | + | return stdoutCmd(ctx, "not-a-preview") | |||
| 72 | + | }} | |||
| 73 | + | var lifecycle domain.SessionLifecycleID | |||
| 74 | + | lifecycle[0] = 1 | |||
| 75 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |||
| 76 | + | _, err := client.Preview(context.Background(), target, 1, 1) | |||
| 77 | + | require.Error(t, err) | |||
| 78 | + | } | |||
M internal/app/remote_hosts.go +1 -1
| @@ -201,7 +201,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [ | |||||
| 201 | info := ports.SessionInfo{ | 201 | info := ports.SessionInfo{ | ||
| 202 | Name: session.Name + "@" + host, | 202 | Name: session.Name + "@" + host, | ||
| 203 | Ephemeral: session.Ephemeral, | 203 | Ephemeral: session.Ephemeral, | ||
| 204 | - | Tabs: session.Tabs, | 204 | + | Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)), |
| 205 | Attached: session.Attached, | 205 | Attached: session.Attached, | ||
| 206 | } | 206 | } | ||
| 207 | switch session.State { | 207 | switch session.State { | ||
A internal/app/remote_preview.go +60 -0
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // runRemotePreview is the hidden SSH-side carriage. The request is encoded in | |||
| 14 | + | // one shell-safe argument, then the daemon returns one bounded binary response | |||
| 15 | + | // whose payload is written verbatim to stdout; no viewport bytes are logged. | |||
| 16 | + | func runRemotePreview(ctx context.Context, encoded string) error { | |||
| 17 | + | request, err := base64.RawURLEncoding.DecodeString(encoded) | |||
| 18 | + | if err != nil { | |||
| 19 | + | return fmt.Errorf("vev: invalid remote preview request: %w", err) | |||
| 20 | + | } | |||
| 21 | + | if _, err := ports.UnmarshalRemotePreviewRequest(request); err != nil { | |||
| 22 | + | return fmt.Errorf("vev: invalid remote preview request: %w", err) | |||
| 23 | + | } | |||
| 24 | + | transport, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |||
| 25 | + | if err != nil { | |||
| 26 | + | return err | |||
| 27 | + | } | |||
| 28 | + | defer func() { _ = transport.Close() }() | |||
| 29 | + | if err := transport.Send(ports.Frame{Type: ports.MsgRemotePreviewRequest, Payload: request}); err != nil { | |||
| 30 | + | return fmt.Errorf("vev: sending remote preview request: %w", err) | |||
| 31 | + | } | |||
| 32 | + | frameCh := make(chan struct { | |||
| 33 | + | frame ports.Frame | |||
| 34 | + | err error | |||
| 35 | + | }, 1) | |||
| 36 | + | go func() { | |||
| 37 | + | frame, err := transport.Recv() | |||
| 38 | + | frameCh <- struct { | |||
| 39 | + | frame ports.Frame | |||
| 40 | + | err error | |||
| 41 | + | }{frame: frame, err: err} | |||
| 42 | + | }() | |||
| 43 | + | select { | |||
| 44 | + | case <-ctx.Done(): | |||
| 45 | + | _ = transport.Close() | |||
| 46 | + | return ctx.Err() | |||
| 47 | + | case result := <-frameCh: | |||
| 48 | + | if result.err != nil { | |||
| 49 | + | return fmt.Errorf("vev: receiving remote preview: %w", result.err) | |||
| 50 | + | } | |||
| 51 | + | if result.frame.Type != ports.MsgRemotePreviewResponse { | |||
| 52 | + | return fmt.Errorf("vev: unexpected remote preview response %d", result.frame.Type) | |||
| 53 | + | } | |||
| 54 | + | if _, err := ports.UnmarshalRemotePreview(result.frame.Payload); err != nil { | |||
| 55 | + | return fmt.Errorf("vev: malformed remote preview response: %w", err) | |||
| 56 | + | } | |||
| 57 | + | _, err := os.Stdout.Write(result.frame.Payload) | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | } | |||
M internal/app/run.go +97 -18
| @@ -67,6 +67,7 @@ const ( | |||||
| 67 | kindStdio | 67 | kindStdio | ||
| 68 | kindUDPBootstrap | 68 | kindUDPBootstrap | ||
| 69 | kindUDPProxy | 69 | kindUDPProxy | ||
| 70 | + | kindRemotePreview | |||
| 70 | kindHelp | 71 | kindHelp | ||
| 71 | kindVersion | 72 | kindVersion | ||
| 72 | ) | 73 | ) | ||
| @@ -74,17 +75,18 @@ const ( | |||||
| 74 | // command is the parsed CLI invocation: what to do, plus the attach intent | 75 | // command is the parsed CLI invocation: what to do, plus the attach intent | ||
| 75 | // and session name where relevant. | 76 | // and session name where relevant. | ||
| 76 | type command struct { | 77 | type command struct { | ||
| 77 | - | kind cmdKind | 78 | + | kind cmdKind |
| 78 | - | intent uint8 | 79 | + | intent uint8 |
| 79 | - | name string | 80 | + | name string |
| 80 | - | remoteTarget string | 81 | + | remoteTarget string |
| 81 | - | listHost string | 82 | + | listHost string |
| 82 | - | listAll bool | 83 | + | listAll bool |
| 83 | - | hostAction string | 84 | + | hostAction string |
| 84 | - | hostTarget string | 85 | + | hostTarget string |
| 85 | - | killAll bool | 86 | + | killAll bool |
| 86 | - | killDaemon bool | 87 | + | killDaemon bool |
| 87 | - | cmd cmdInvocation | 88 | + | cmd cmdInvocation |
| 89 | + | remotePreviewPayload string | |||
| 88 | } | 90 | } | ||
| 89 | 91 | ||||
| 90 | // usageError is a user-facing argument error; the app prints it (with usage) | 92 | // usageError is a user-facing argument error; the app prints it (with usage) | ||
| @@ -175,6 +177,11 @@ func parseArgs(args []string) (command, error) { | |||||
| 175 | return command{}, usagef("`_udp-proxy` does not accept a session name") | 177 | return command{}, usagef("`_udp-proxy` does not accept a session name") | ||
| 176 | } | 178 | } | ||
| 177 | return command{kind: kindUDPProxy}, nil | 179 | return command{kind: kindUDPProxy}, nil | ||
| 180 | + | case "_remote-preview": | |||
| 181 | + | if len(args) != 2 || args[1] == "" { | |||
| 182 | + | return command{}, usagef("`_remote-preview` requires one encoded request") | |||
| 183 | + | } | |||
| 184 | + | return command{kind: kindRemotePreview, remotePreviewPayload: args[1]}, nil | |||
| 178 | case "new": | 185 | case "new": | ||
| 179 | if len(args) < 2 || args[1] == "" { | 186 | if len(args) < 2 || args[1] == "" { | ||
| 180 | return command{}, usagef("`new` requires a session name") | 187 | return command{}, usagef("`new` requires a session name") | ||
| @@ -269,6 +276,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 269 | return runUDPBootstrap(ctx) | 276 | return runUDPBootstrap(ctx) | ||
| 270 | case kindUDPProxy: | 277 | case kindUDPProxy: | ||
| 271 | return runUDPProxy(ctx, os.Stdout) | 278 | return runUDPProxy(ctx, os.Stdout) | ||
| 279 | + | case kindRemotePreview: | |||
| 280 | + | return runRemotePreview(ctx, cmd.remotePreviewPayload) | |||
| 272 | case kindList: | 281 | case kindList: | ||
| 273 | return runList(ctx, cmd) | 282 | return runList(ctx, cmd) | ||
| 274 | case kindHost: | 283 | case kindHost: | ||
| @@ -341,6 +350,7 @@ var ( | |||||
| 341 | newRemoteHostStore = remoteadapter.NewFileHostStore | 350 | newRemoteHostStore = remoteadapter.NewFileHostStore | ||
| 342 | newRemoteCatalogCache = remoteadapter.NewFileCatalogCache | 351 | newRemoteCatalogCache = remoteadapter.NewFileCatalogCache | ||
| 343 | newRemoteCatalogClient = remoteadapter.NewCatalogClient | 352 | newRemoteCatalogClient = remoteadapter.NewCatalogClient | ||
| 353 | + | newRemotePreviewClient = remoteadapter.NewPreviewClient | |||
| 344 | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | 354 | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | ||
| 345 | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | 355 | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | ||
| 346 | } | 356 | } | ||
| @@ -790,7 +800,18 @@ func validateRemoteAttachHandoff(target ports.AttachTarget) error { | |||||
| 790 | if err := domain.ValidateRemoteHostTarget(target.Endpoint); err != nil { | 800 | if err := domain.ValidateRemoteHostTarget(target.Endpoint); err != nil { | ||
| 791 | return err | 801 | return err | ||
| 792 | } | 802 | } | ||
| 793 | - | return domain.ValidateSessionName(target.Session) | 803 | + | if err := domain.ValidateSessionName(target.Session); err != nil { |
| 804 | + | return err | |||
| 805 | + | } | |||
| 806 | + | if target.RemoteTarget != nil { | |||
| 807 | + | if target.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned { | |||
| 808 | + | return errors.New("remote picker handoff must use daemon-owned environment") | |||
| 809 | + | } | |||
| 810 | + | if target.RemoteTarget.Endpoint != target.Endpoint || target.RemoteTarget.SessionName != target.Session { | |||
| 811 | + | return errors.New("remote picker handoff identity does not match route") | |||
| 812 | + | } | |||
| 813 | + | } | |||
| 814 | + | return nil | |||
| 794 | } | 815 | } | ||
| 795 | 816 | ||||
| 796 | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | 817 | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | ||
| @@ -800,19 +821,27 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti | |||||
| 800 | if err != nil { | 821 | if err != nil { | ||
| 801 | return nil, err | 822 | return nil, err | ||
| 802 | } | 823 | } | ||
| 803 | - | return daemon.WithRemoteDiscovery( | 824 | + | discovery := daemon.WithRemoteDiscovery( |
| 804 | newRemoteHostStore(remoteadapter.HostStorePath(stateDir)), | 825 | newRemoteHostStore(remoteadapter.HostStorePath(stateDir)), | ||
| 805 | newRemoteCatalogClient(), | 826 | newRemoteCatalogClient(), | ||
| 806 | newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)), | 827 | newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)), | ||
| 807 | newRemoteDialerFactoryWithRuntimeObserver(observer), | 828 | newRemoteDialerFactoryWithRuntimeObserver(observer), | ||
| 808 | mode, | 829 | mode, | ||
| 809 | - | ), nil | 830 | + | ) |
| 831 | + | preview := daemon.WithRemotePreview(newRemotePreviewClient()) | |||
| 832 | + | return func(d *daemon.Daemon) { | |||
| 833 | + | discovery(d) | |||
| 834 | + | preview(d) | |||
| 835 | + | }, nil | |||
| 810 | } | 836 | } | ||
| 811 | 837 | ||||
| 812 | type attachHandoffKey struct { | 838 | type attachHandoffKey struct { | ||
| 813 | - | endpoint string | 839 | + | endpoint string |
| 814 | - | session string | 840 | + | session string |
| 815 | - | intent uint8 | 841 | + | intent uint8 |
| 842 | + | lifecycle domain.SessionLifecycleID | |||
| 843 | + | liveTab domain.TabStableID | |||
| 844 | + | stopped domain.TabSelector | |||
| 816 | } | 845 | } | ||
| 817 | 846 | ||||
| 818 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | 847 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | ||
| @@ -833,6 +862,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 833 | } | 862 | } | ||
| 834 | mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport) | 863 | mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport) | ||
| 835 | factory := deps.remoteDialerFactory | 864 | factory := deps.remoteDialerFactory | ||
| 865 | + | var remoteSelection *domain.RemoteSessionTarget | |||
| 866 | + | var remoteEnvironmentPolicy ports.EnvironmentPolicy | |||
| 836 | seenHandoffs := make(map[attachHandoffKey]struct{}) | 867 | seenHandoffs := make(map[attachHandoffKey]struct{}) | ||
| 837 | if remoteTarget != "" { | 868 | if remoteTarget != "" { | ||
| 838 | if modeErr != nil { | 869 | if modeErr != nil { | ||
| @@ -843,6 +874,36 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 843 | } | 874 | } | ||
| 844 | seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{} | 875 | seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{} | ||
| 845 | } | 876 | } | ||
| 877 | + | var handoff client.AttachHandoffFunc | |||
| 878 | + | handoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) { | |||
| 879 | + | if err := validateRemoteAttachHandoff(target); err != nil { | |||
| 880 | + | return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err) | |||
| 881 | + | } | |||
| 882 | + | if modeErr != nil { | |||
| 883 | + | return nil, client.AttachRequest{}, modeErr | |||
| 884 | + | } | |||
| 885 | + | if factory == nil { | |||
| 886 | + | factory = defaultRemoteDialerFactory() | |||
| 887 | + | } | |||
| 888 | + | next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent} | |||
| 889 | + | var selection *domain.RemoteSessionTarget | |||
| 890 | + | if target.RemoteTarget != nil { | |||
| 891 | + | copyTarget := *target.RemoteTarget | |||
| 892 | + | selection = ©Target | |||
| 893 | + | next.lifecycle = copyTarget.LifecycleID | |||
| 894 | + | next.liveTab = copyTarget.LiveTabID | |||
| 895 | + | next.stopped = copyTarget.StoppedTab | |||
| 896 | + | } | |||
| 897 | + | if _, seen := seenHandoffs[next]; seen { | |||
| 898 | + | return nil, client.AttachRequest{}, fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session) | |||
| 899 | + | } | |||
| 900 | + | seenHandoffs[next] = struct{}{} | |||
| 901 | + | dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log) | |||
| 902 | + | if err != nil { | |||
| 903 | + | return nil, client.AttachRequest{}, err | |||
| 904 | + | } | |||
| 905 | + | return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: target.EnvironmentPolicy}, nil | |||
| 906 | + | } | |||
| 846 | 907 | ||||
| 847 | for { | 908 | for { | ||
| 848 | var err error | 909 | var err error | ||
| @@ -862,8 +923,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 862 | Logger: log, | 923 | Logger: log, | ||
| 863 | RuntimeObserver: deps.runtimeObserver, | 924 | RuntimeObserver: deps.runtimeObserver, | ||
| 864 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | 925 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | ||
| 926 | + | AttachHandoff: handoff, | |||
| 865 | Remote: true, | 927 | Remote: true, | ||
| 866 | - | }, client.AttachRequest{Intent: intent, SessionName: name}) | 928 | + | }, client.AttachRequest{ |
| 929 | + | Intent: intent, | |||
| 930 | + | SessionName: name, | |||
| 931 | + | RemoteTarget: remoteSelection, | |||
| 932 | + | EnvironmentPolicy: remoteEnvironmentPolicy, | |||
| 933 | + | }) | |||
| 867 | } else { | 934 | } else { | ||
| 868 | if log != nil { | 935 | if log != nil { | ||
| 869 | log.Info("attaching to local session", "intent", intent, "name", name) | 936 | log.Info("attaching to local session", "intent", intent, "name", name) | ||
| @@ -874,6 +941,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 874 | Clock: clock.New(), | 941 | Clock: clock.New(), | ||
| 875 | Logger: log, | 942 | Logger: log, | ||
| 876 | RuntimeObserver: deps.runtimeObserver, | 943 | RuntimeObserver: deps.runtimeObserver, | ||
| 944 | + | AttachHandoff: handoff, | |||
| 877 | Remote: false, | 945 | Remote: false, | ||
| 878 | }, client.AttachRequest{Intent: intent, SessionName: name}) | 946 | }, client.AttachRequest{Intent: intent, SessionName: name}) | ||
| 879 | } | 947 | } | ||
| @@ -889,6 +957,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 889 | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | 957 | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | ||
| 890 | } | 958 | } | ||
| 891 | next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent} | 959 | next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent} | ||
| 960 | + | if handoff.Target.RemoteTarget != nil { | |||
| 961 | + | next.lifecycle = handoff.Target.RemoteTarget.LifecycleID | |||
| 962 | + | next.liveTab = handoff.Target.RemoteTarget.LiveTabID | |||
| 963 | + | next.stopped = handoff.Target.RemoteTarget.StoppedTab | |||
| 964 | + | } | |||
| 892 | if _, seen := seenHandoffs[next]; seen { | 965 | if _, seen := seenHandoffs[next]; seen { | ||
| 893 | return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session) | 966 | return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session) | ||
| 894 | } | 967 | } | ||
| @@ -896,6 +969,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 896 | remoteTarget = handoff.Target.Endpoint | 969 | remoteTarget = handoff.Target.Endpoint | ||
| 897 | name = handoff.Target.Session | 970 | name = handoff.Target.Session | ||
| 898 | intent = handoff.Target.Intent | 971 | intent = handoff.Target.Intent | ||
| 972 | + | remoteSelection = nil | |||
| 973 | + | remoteEnvironmentPolicy = handoff.Target.EnvironmentPolicy | |||
| 974 | + | if handoff.Target.RemoteTarget != nil { | |||
| 975 | + | copyTarget := *handoff.Target.RemoteTarget | |||
| 976 | + | remoteSelection = ©Target | |||
| 977 | + | } | |||
| 899 | if modeErr != nil { | 978 | if modeErr != nil { | ||
| 900 | return modeErr | 979 | return modeErr | ||
| 901 | } | 980 | } | ||
A internal/domain/lifecycle.go +208 -0
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | "unicode" | |||
| 9 | + | "unicode/utf8" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // SessionLifecycleID is the opaque identity of one session lifecycle. | |||
| 13 | + | // | |||
| 14 | + | // IncarnationID predates the remote picker and is the durable implementation | |||
| 15 | + | // used by snapshots and the catalogue. This alias deliberately keeps one | |||
| 16 | + | // identity type rather than introducing a second, independently comparable | |||
| 17 | + | // UUID. A lifecycle ID changes only when a session is destroyed and recreated. | |||
| 18 | + | type SessionLifecycleID = IncarnationID | |||
| 19 | + | ||||
| 20 | + | // NewSessionLifecycleID allocates a non-zero lifecycle identity. | |||
| 21 | + | func NewSessionLifecycleID(r io.Reader) (SessionLifecycleID, error) { | |||
| 22 | + | return NewIncarnationID(r) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // TabSelectorKind identifies the exact selector form used for a stopped tab. | |||
| 26 | + | type TabSelectorKind uint8 | |||
| 27 | + | ||||
| 28 | + | const ( | |||
| 29 | + | TabSelectorByStableID TabSelectorKind = iota + 1 | |||
| 30 | + | TabSelectorByOrdinal | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // TabSelector is an exact tab identity handoff. Stable IDs are preferred for | |||
| 34 | + | // persisted tabs. The ordinal form is a compatibility selector for catalogue | |||
| 35 | + | // records that predate stable stopped-tab IDs; raw name and expected count are | |||
| 36 | + | // required so a reordered or replaced tab cannot be selected accidentally. | |||
| 37 | + | type TabSelector struct { | |||
| 38 | + | Kind TabSelectorKind | |||
| 39 | + | StableID TabStableID | |||
| 40 | + | Ordinal uint16 | |||
| 41 | + | RawName string | |||
| 42 | + | ExpectedCount uint16 | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // NewStableTabSelector creates an exact stable-ID selector. | |||
| 46 | + | func NewStableTabSelector(id TabStableID) TabSelector { | |||
| 47 | + | return TabSelector{Kind: TabSelectorByStableID, StableID: id} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // NewOrdinalTabSelector creates an exact legacy-compatible selector. | |||
| 51 | + | func NewOrdinalTabSelector(ordinal uint16, rawName string, expectedCount uint16) TabSelector { | |||
| 52 | + | return TabSelector{Kind: TabSelectorByOrdinal, Ordinal: ordinal, RawName: rawName, ExpectedCount: expectedCount} | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Validate rejects selectors that could silently resolve a different tab. | |||
| 56 | + | func (s TabSelector) Validate() error { | |||
| 57 | + | switch s.Kind { | |||
| 58 | + | case TabSelectorByStableID: | |||
| 59 | + | if s.Ordinal != 0 || s.RawName != "" || s.ExpectedCount != 0 || ValidateTabStableID(s.StableID) != nil { | |||
| 60 | + | return errors.New("invalid stable tab selector") | |||
| 61 | + | } | |||
| 62 | + | case TabSelectorByOrdinal: | |||
| 63 | + | if s.ExpectedCount == 0 || s.Ordinal >= s.ExpectedCount || s.StableID != "" { | |||
| 64 | + | return errors.New("invalid ordinal tab selector") | |||
| 65 | + | } | |||
| 66 | + | if err := validateTabSelectorName(s.RawName); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | default: | |||
| 70 | + | return errors.New("unknown tab selector kind") | |||
| 71 | + | } | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // TabSelectorTab is the immutable metadata needed to resolve a selector. | |||
| 76 | + | type TabSelectorTab struct { | |||
| 77 | + | ID TabStableID | |||
| 78 | + | Name string | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Resolve returns the selected tab index only when the complete selector still | |||
| 82 | + | // describes the supplied ordered tab metadata. | |||
| 83 | + | func (s TabSelector) Resolve(tabs []TabSelectorTab) (int, bool) { | |||
| 84 | + | if s.Validate() != nil || len(tabs) != int(s.ExpectedCount) && s.Kind == TabSelectorByOrdinal { | |||
| 85 | + | return 0, false | |||
| 86 | + | } | |||
| 87 | + | switch s.Kind { | |||
| 88 | + | case TabSelectorByStableID: | |||
| 89 | + | if len(tabs) == 0 { | |||
| 90 | + | return 0, false | |||
| 91 | + | } | |||
| 92 | + | found := -1 | |||
| 93 | + | for i, tab := range tabs { | |||
| 94 | + | if tab.ID != s.StableID { | |||
| 95 | + | continue | |||
| 96 | + | } | |||
| 97 | + | if found != -1 { | |||
| 98 | + | return 0, false | |||
| 99 | + | } | |||
| 100 | + | found = i | |||
| 101 | + | } | |||
| 102 | + | return found, found >= 0 | |||
| 103 | + | case TabSelectorByOrdinal: | |||
| 104 | + | index := int(s.Ordinal) | |||
| 105 | + | return index, index < len(tabs) && tabs[index].Name == s.RawName | |||
| 106 | + | default: | |||
| 107 | + | return 0, false | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // RemoteSessionTarget is the structured identity used by picker-based remote | |||
| 112 | + | // access. Endpoint and DisplayOrigin are intentionally separate values: the | |||
| 113 | + | // latter is presentation-only and is never parsed back into a route. | |||
| 114 | + | type RemoteSessionTarget struct { | |||
| 115 | + | Endpoint string | |||
| 116 | + | DisplayOrigin string | |||
| 117 | + | LifecycleID SessionLifecycleID | |||
| 118 | + | SessionName string | |||
| 119 | + | LiveTabID TabStableID | |||
| 120 | + | StoppedTab TabSelector | |||
| 121 | + | Stopped bool | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Validate validates route, presentation, lifecycle, and selector identity as | |||
| 125 | + | // independent contract fields. | |||
| 126 | + | func (t RemoteSessionTarget) Validate() error { | |||
| 127 | + | if err := ValidateRemoteHostTarget(t.Endpoint); err != nil { | |||
| 128 | + | return fmt.Errorf("remote route endpoint: %w", err) | |||
| 129 | + | } | |||
| 130 | + | if err := ValidateRemoteDisplayOrigin(t.DisplayOrigin); err != nil { | |||
| 131 | + | return fmt.Errorf("remote display origin: %w", err) | |||
| 132 | + | } | |||
| 133 | + | if t.LifecycleID == (SessionLifecycleID{}) { | |||
| 134 | + | return errors.New("remote lifecycle ID is zero") | |||
| 135 | + | } | |||
| 136 | + | if err := ValidateSessionName(t.SessionName); err != nil { | |||
| 137 | + | return err | |||
| 138 | + | } | |||
| 139 | + | if t.Stopped { | |||
| 140 | + | if t.LiveTabID != "" || t.StoppedTab.Validate() != nil { | |||
| 141 | + | return errors.New("stopped remote target requires an ordinal or stable tab selector") | |||
| 142 | + | } | |||
| 143 | + | return nil | |||
| 144 | + | } | |||
| 145 | + | if err := ValidateTabStableID(t.LiveTabID); err != nil { | |||
| 146 | + | return errors.New("running remote target requires a stable tab ID") | |||
| 147 | + | } | |||
| 148 | + | if t.StoppedTab != (TabSelector{}) { | |||
| 149 | + | return errors.New("running remote target cannot carry a stopped selector") | |||
| 150 | + | } | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // ValidateRemoteDisplayOrigin validates presentation data without imposing | |||
| 155 | + | // SSH route grammar. Aliases, users, ports, and bracketed IPv6 remain opaque. | |||
| 156 | + | func ValidateRemoteDisplayOrigin(origin string) error { | |||
| 157 | + | return validateRemoteText(origin, 256, "remote display origin") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // ValidateTabStableID validates the opaque stable tab identity used by | |||
| 161 | + | // picker and attachment handoffs. | |||
| 162 | + | func ValidateTabStableID(value TabStableID) error { | |||
| 163 | + | if value == "" || len(value) > 128 || !utf8.ValidString(string(value)) { | |||
| 164 | + | return errors.New("invalid stable tab ID") | |||
| 165 | + | } | |||
| 166 | + | for _, r := range value { | |||
| 167 | + | if unicode.IsControl(r) || unicode.IsSpace(r) { | |||
| 168 | + | return errors.New("invalid stable tab ID") | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func validateTabSelectorName(value string) error { | |||
| 175 | + | if len(value) > 256 { | |||
| 176 | + | return errors.New("tab name exceeds 256 bytes") | |||
| 177 | + | } | |||
| 178 | + | if !utf8.ValidString(value) { | |||
| 179 | + | return errors.New("tab name is not valid UTF-8") | |||
| 180 | + | } | |||
| 181 | + | for _, r := range value { | |||
| 182 | + | if unicode.IsControl(r) || r == '\n' || r == '\r' || r == '\x1b' { | |||
| 183 | + | return errors.New("tab name contains control characters") | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | return nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func validateRemoteText(value string, maxBytes int, label string) error { | |||
| 190 | + | if value == "" { | |||
| 191 | + | return fmt.Errorf("%s is empty", label) | |||
| 192 | + | } | |||
| 193 | + | if len(value) > maxBytes { | |||
| 194 | + | return fmt.Errorf("%s exceeds %d bytes", label, maxBytes) | |||
| 195 | + | } | |||
| 196 | + | if !utf8.ValidString(value) { | |||
| 197 | + | return fmt.Errorf("%s is not valid UTF-8", label) | |||
| 198 | + | } | |||
| 199 | + | if strings.TrimSpace(value) != value { | |||
| 200 | + | return fmt.Errorf("%s has surrounding whitespace", label) | |||
| 201 | + | } | |||
| 202 | + | for _, r := range value { | |||
| 203 | + | if unicode.IsControl(r) || r == '\n' || r == '\r' || r == '\x1b' { | |||
| 204 | + | return fmt.Errorf("%s contains control characters", label) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | return nil | |||
| 208 | + | } | |||
A internal/domain/lifecycle_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestOrdinalTabSelectorPreservesUnnamedTabs(t *testing.T) { | |||
| 6 | + | selector := NewOrdinalTabSelector(1, "", 3) | |||
| 7 | + | tabs := []TabSelectorTab{{Name: "first"}, {Name: ""}, {Name: "trailing"}} | |||
| 8 | + | index, ok := selector.Resolve(tabs) | |||
| 9 | + | if !ok || index != 1 { | |||
| 10 | + | t.Fatalf("Resolve() = %d, %t, want 1, true", index, ok) | |||
| 11 | + | } | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) { | |||
| 15 | + | for _, id := range []TabStableID{"", "tab id", "tab\n1"} { | |||
| 16 | + | if err := ValidateTabStableID(id); err == nil { | |||
| 17 | + | t.Fatalf("ValidateTabStableID(%q) = nil, want error", id) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | } | |||
M internal/domain/recovery.go +27 -8
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "slices" | 8 | "slices" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | + | "unicode/utf8" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // IncarnationID permanently distinguishes uses of the same session name. | 13 | // IncarnationID permanently distinguishes uses of the same session name. | ||
| @@ -18,15 +19,26 @@ type CheckpointRef struct { | |||||
| 18 | ManifestDigest [32]byte | 19 | ManifestDigest [32]byte | ||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 22 | + | // CatalogueTabRecord is the durable ordered metadata for one stopped tab. | |||
| 23 | + | // StableID is empty for records migrated from the legacy TabNames-only format. | |||
| 24 | + | type CatalogueTabRecord struct { | |||
| 25 | + | StableID TabStableID | |||
| 26 | + | Name string | |||
| 27 | + | } | |||
| 28 | + | ||||
| 21 | // CatalogueRecord is the durable source of truth for one named session. | 29 | // CatalogueRecord is the durable source of truth for one named session. | ||
| 22 | type CatalogueRecord struct { | 30 | type CatalogueRecord struct { | ||
| 23 | - | Name string | 31 | + | Name string |
| 24 | - | IncarnationID IncarnationID | 32 | + | IncarnationID IncarnationID |
| 25 | - | Cwd string | 33 | + | Cwd string |
| 26 | - | CreatedAt int64 | 34 | + | CreatedAt int64 |
| 27 | - | UpdatedAt int64 | 35 | + | UpdatedAt int64 |
| 28 | - | LastUsedSeq uint64 | 36 | + | LastUsedSeq uint64 |
| 37 | + | // TabNames remains the compatibility metadata consumed by older recovery | |||
| 38 | + | // paths. TabRecords is authoritative when present and retains trailing | |||
| 39 | + | // unnamed tabs instead of silently trimming them. | |||
| 29 | TabNames []string | 40 | TabNames []string | ||
| 41 | + | TabRecords []CatalogueTabRecord | |||
| 30 | Committed *CheckpointRef | 42 | Committed *CheckpointRef | ||
| 31 | DegradedReason string | 43 | DegradedReason string | ||
| 32 | } | 44 | } | ||
| @@ -44,7 +56,7 @@ func (r *CheckpointRef) Equal(other *CheckpointRef) bool { | |||||
| 44 | func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | 56 | func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | ||
| 45 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | 57 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | ||
| 46 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | 58 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | ||
| 47 | - | slices.Equal(r.TabNames, other.TabNames) && r.Committed.Equal(other.Committed) && | 59 | + | slices.Equal(r.TabNames, other.TabNames) && slices.Equal(r.TabRecords, other.TabRecords) && r.Committed.Equal(other.Committed) && |
| 48 | r.DegradedReason == other.DegradedReason | 60 | r.DegradedReason == other.DegradedReason | ||
| 49 | } | 61 | } | ||
| 50 | 62 | ||||
| @@ -57,6 +69,7 @@ type CatalogueMetadataUpdate struct { | |||||
| 57 | UpdatedAt *int64 | 69 | UpdatedAt *int64 | ||
| 58 | LastUsedSeq *uint64 | 70 | LastUsedSeq *uint64 | ||
| 59 | TabNames *[]string | 71 | TabNames *[]string | ||
| 72 | + | TabRecords *[]CatalogueTabRecord | |||
| 60 | } | 73 | } | ||
| 61 | 74 | ||||
| 62 | // MetadataUpdate returns a complete mutable-metadata update for r. Catalogue | 75 | // MetadataUpdate returns a complete mutable-metadata update for r. Catalogue | ||
| @@ -64,9 +77,10 @@ type CatalogueMetadataUpdate struct { | |||||
| 64 | func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate { | 77 | func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate { | ||
| 65 | cwd, updatedAt, lastUsedSeq := r.Cwd, r.UpdatedAt, r.LastUsedSeq | 78 | cwd, updatedAt, lastUsedSeq := r.Cwd, r.UpdatedAt, r.LastUsedSeq | ||
| 66 | tabNames := append([]string(nil), r.TabNames...) | 79 | tabNames := append([]string(nil), r.TabNames...) | ||
| 80 | + | tabRecords := append([]CatalogueTabRecord(nil), r.TabRecords...) | |||
| 67 | return CatalogueMetadataUpdate{ | 81 | return CatalogueMetadataUpdate{ | ||
| 68 | Name: r.Name, IncarnationID: r.IncarnationID, | 82 | Name: r.Name, IncarnationID: r.IncarnationID, | ||
| 69 | - | Cwd: &cwd, UpdatedAt: &updatedAt, LastUsedSeq: &lastUsedSeq, TabNames: &tabNames, | 83 | + | Cwd: &cwd, UpdatedAt: &updatedAt, LastUsedSeq: &lastUsedSeq, TabNames: &tabNames, TabRecords: &tabRecords, |
| 70 | } | 84 | } | ||
| 71 | } | 85 | } | ||
| 72 | 86 | ||||
| @@ -119,6 +133,11 @@ func (r CatalogueRecord) Validate() error { | |||||
| 119 | return errors.New("zero catalogue incarnation ID") | 133 | return errors.New("zero catalogue incarnation ID") | ||
| 120 | } | 134 | } | ||
| 121 | 135 | ||||
| 136 | + | for _, tab := range r.TabRecords { | |||
| 137 | + | if !utf8.ValidString(string(tab.StableID)) || !utf8.ValidString(tab.Name) { | |||
| 138 | + | return errors.New("invalid catalogue tab metadata") | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 122 | if r.DegradedReason != "" && r.Committed == nil { | 141 | if r.DegradedReason != "" && r.Committed == nil { | ||
| 123 | return errors.New("broken session has no committed checkpoint") | 142 | return errors.New("broken session has no committed checkpoint") | ||
| 124 | } | 143 | } | ||
M internal/domain/remote_session.go +18 -6
| @@ -8,8 +8,13 @@ import ( | |||||
| 8 | // RemoteSessionKey identifies a discovered remote session independently from | 8 | // RemoteSessionKey identifies a discovered remote session independently from | ||
| 9 | // its display label. | 9 | // its display label. | ||
| 10 | type RemoteSessionKey struct { | 10 | type RemoteSessionKey struct { | ||
| 11 | - | Host string | 11 | + | Host string |
| 12 | - | Name string | 12 | + | Name string |
| 13 | + | LifecycleID SessionLifecycleID | |||
| 14 | + | // DisplayOrigin is presentation-only. When empty, Display derives a | |||
| 15 | + | // conservative origin from Host for legacy callers; picker targets should | |||
| 16 | + | // populate it explicitly and never parse Display back into routing data. | |||
| 17 | + | DisplayOrigin string | |||
| 13 | } | 18 | } | ||
| 14 | 19 | ||||
| 15 | // Validate reports whether the host target and session name are both valid. | 20 | // Validate reports whether the host target and session name are both valid. | ||
| @@ -25,14 +30,21 @@ func (k RemoteSessionKey) Validate() error { | |||||
| 25 | func (k RemoteSessionKey) ID() SessionID { | 30 | func (k RemoteSessionKey) ID() SessionID { | ||
| 26 | host := base64.RawURLEncoding.EncodeToString([]byte(k.Host)) | 31 | host := base64.RawURLEncoding.EncodeToString([]byte(k.Host)) | ||
| 27 | name := base64.RawURLEncoding.EncodeToString([]byte(k.Name)) | 32 | name := base64.RawURLEncoding.EncodeToString([]byte(k.Name)) | ||
| 33 | + | if k.LifecycleID != (SessionLifecycleID{}) { | |||
| 34 | + | lifecycle := base64.RawURLEncoding.EncodeToString(k.LifecycleID[:]) | |||
| 35 | + | return SessionID("remote:" + host + "." + name + "." + lifecycle) | |||
| 36 | + | } | |||
| 28 | return SessionID("remote:" + host + "." + name) | 37 | return SessionID("remote:" + host + "." + name) | ||
| 29 | } | 38 | } | ||
| 30 | 39 | ||||
| 31 | // Display returns the presentation label for this remote session. | 40 | // Display returns the presentation label for this remote session. | ||
| 32 | func (k RemoteSessionKey) Display() string { | 41 | func (k RemoteSessionKey) Display() string { | ||
| 33 | - | host := k.Host | 42 | + | origin := k.DisplayOrigin |
| 34 | - | if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 { | 43 | + | if origin == "" { |
| 35 | - | host = host[i+1:] | 44 | + | origin = k.Host |
| 45 | + | if i := strings.LastIndexByte(origin, '@'); i > 0 && i < len(origin)-1 { | |||
| 46 | + | origin = origin[i+1:] | |||
| 47 | + | } | |||
| 36 | } | 48 | } | ||
| 37 | - | return k.Name + "@" + host | 49 | + | return k.Name + "@" + origin |
| 38 | } | 50 | } | ||
M internal/persist/persist.go +3 -0
| @@ -402,6 +402,9 @@ func (p *Persister) UpdateMetadata(update domain.CatalogueMetadataUpdate) error | |||||
| 402 | if update.TabNames != nil { | 402 | if update.TabNames != nil { | ||
| 403 | current.TabNames = append([]string(nil), (*update.TabNames)...) | 403 | current.TabNames = append([]string(nil), (*update.TabNames)...) | ||
| 404 | } | 404 | } | ||
| 405 | + | if update.TabRecords != nil { | |||
| 406 | + | current.TabRecords = append([]domain.CatalogueTabRecord(nil), (*update.TabRecords)...) | |||
| 407 | + | } | |||
| 405 | return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: ¤t}, false) | 408 | return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: ¤t}, false) | ||
| 406 | } | 409 | } | ||
| 407 | 410 | ||||
M internal/persist/record.go +43 -2
| @@ -9,7 +9,10 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | const catalogueRecordVersion uint16 = 3 | 12 | + | const ( |
| 13 | + | catalogueRecordVersion uint16 = 4 | |||
| 14 | + | legacyCatalogueRecordVersion uint16 = 3 | |||
| 15 | + | ) | |||
| 13 | 16 | ||||
| 14 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | 17 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | ||
| 15 | 18 | ||||
| @@ -44,6 +47,20 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) { | |||||
| 44 | return nil, err | 47 | return nil, err | ||
| 45 | } | 48 | } | ||
| 46 | } | 49 | } | ||
| 50 | + | if uint64(len(record.TabRecords)) > math.MaxUint32 { | |||
| 51 | + | return nil, errors.New("persist: too many tab records") | |||
| 52 | + | } | |||
| 53 | + | buf = binary.BigEndian.AppendUint32(buf, uint32(len(record.TabRecords))) | |||
| 54 | + | for _, tab := range record.TabRecords { | |||
| 55 | + | buf, err = appendCheckedString(buf, string(tab.StableID)) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | buf, err = appendCheckedString(buf, tab.Name) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return nil, err | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 47 | buf = appendRef(buf, record.Committed) | 64 | buf = appendRef(buf, record.Committed) | ||
| 48 | buf, err = appendCheckedString(buf, record.DegradedReason) | 65 | buf, err = appendCheckedString(buf, record.DegradedReason) | ||
| 49 | return buf, err | 66 | return buf, err | ||
| @@ -59,7 +76,7 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||||
| 59 | return domain.CatalogueRecord{}, errMalformedRecord | 76 | return domain.CatalogueRecord{}, errMalformedRecord | ||
| 60 | } | 77 | } | ||
| 61 | version, ok := reader.u16() | 78 | version, ok := reader.u16() | ||
| 62 | - | if !ok || version != catalogueRecordVersion { | 79 | + | if !ok || (version != catalogueRecordVersion && version != legacyCatalogueRecordVersion) { |
| 63 | return domain.CatalogueRecord{}, errMalformedRecord | 80 | return domain.CatalogueRecord{}, errMalformedRecord | ||
| 64 | } | 81 | } | ||
| 65 | id, ok := reader.take(16) | 82 | id, ok := reader.take(16) | ||
| @@ -99,6 +116,30 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||||
| 99 | } | 116 | } | ||
| 100 | record.TabNames = append(record.TabNames, tab) | 117 | record.TabNames = append(record.TabNames, tab) | ||
| 101 | } | 118 | } | ||
| 119 | + | if version == legacyCatalogueRecordVersion { | |||
| 120 | + | if len(record.TabNames) != 0 { | |||
| 121 | + | record.TabRecords = make([]domain.CatalogueTabRecord, len(record.TabNames)) | |||
| 122 | + | for i, name := range record.TabNames { | |||
| 123 | + | record.TabRecords[i] = domain.CatalogueTabRecord{Name: name} | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | } else { | |||
| 127 | + | tabRecordCount, ok := reader.u32() | |||
| 128 | + | if !ok || uint64(tabRecordCount) > uint64(reader.remaining())/8 { | |||
| 129 | + | return domain.CatalogueRecord{}, errMalformedRecord | |||
| 130 | + | } | |||
| 131 | + | if tabRecordCount > 0 { | |||
| 132 | + | record.TabRecords = make([]domain.CatalogueTabRecord, 0, int(tabRecordCount)) | |||
| 133 | + | } | |||
| 134 | + | for range tabRecordCount { | |||
| 135 | + | stableID, stableOK := reader.str() | |||
| 136 | + | name, nameOK := reader.str() | |||
| 137 | + | if !stableOK || !nameOK { | |||
| 138 | + | return domain.CatalogueRecord{}, errMalformedRecord | |||
| 139 | + | } | |||
| 140 | + | record.TabRecords = append(record.TabRecords, domain.CatalogueTabRecord{StableID: domain.TabStableID(stableID), Name: name}) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 102 | if record.Committed, ok = reader.ref(); !ok { | 143 | if record.Committed, ok = reader.ref(); !ok { | ||
| 103 | return domain.CatalogueRecord{}, errMalformedRecord | 144 | return domain.CatalogueRecord{}, errMalformedRecord | ||
| 104 | } | 145 | } | ||
M internal/ports/frame.go +28 -26
| @@ -7,7 +7,7 @@ import "time" | |||||
| 7 | const HandshakeTimeout = 15 * time.Second | 7 | const HandshakeTimeout = 15 * time.Second | ||
| 8 | 8 | ||||
| 9 | // ProtocolVersion is the current vev IPC wire protocol version. | 9 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 10 | - | const ProtocolVersion uint16 = 23 | 10 | + | const ProtocolVersion uint16 = 24 |
| 11 | 11 | ||||
| 12 | // MaxFrameLen is the largest permitted frame length, including the type byte | 12 | // MaxFrameLen is the largest permitted frame length, including the type byte | ||
| 13 | // and excluding the four-byte length prefix. | 13 | // and excluding the four-byte length prefix. | ||
| @@ -17,32 +17,34 @@ const MaxFrameLen = 16 << 20 | |||||
| 17 | type MsgType uint8 | 17 | type MsgType uint8 | ||
| 18 | 18 | ||||
| 19 | // Frame message types. Client-originated messages are numbered from 1 | 19 | // Frame message types. Client-originated messages are numbered from 1 | ||
| 20 | - | // through 15, server-originated messages from 16, leaving room for growth in | 20 | + | // through 15, server-originated messages from 16, with reserved values kept |
| 21 | - | // each band. | 21 | + | // for future extensions. |
| 22 | const ( | 22 | const ( | ||
| 23 | - | MsgHello MsgType = 1 | 23 | + | MsgHello MsgType = 1 |
| 24 | - | MsgInput MsgType = 2 | 24 | + | MsgInput MsgType = 2 |
| 25 | - | MsgResize MsgType = 3 | 25 | + | MsgResize MsgType = 3 |
| 26 | - | MsgDetach MsgType = 4 | 26 | + | MsgDetach MsgType = 4 |
| 27 | - | MsgPing MsgType = 5 | 27 | + | MsgPing MsgType = 5 |
| 28 | - | MsgList MsgType = 6 | 28 | + | MsgList MsgType = 6 |
| 29 | - | MsgKill MsgType = 7 | 29 | + | MsgKill MsgType = 7 |
| 30 | - | MsgTheme MsgType = 8 | 30 | + | MsgTheme MsgType = 8 |
| 31 | - | MsgAck MsgType = 9 | 31 | + | MsgAck MsgType = 9 |
| 32 | - | MsgImagePush MsgType = 10 | 32 | + | MsgImagePush MsgType = 10 |
| 33 | - | MsgClientNotice MsgType = 11 | 33 | + | MsgClientNotice MsgType = 11 |
| 34 | - | MsgCommand MsgType = 12 | 34 | + | MsgCommand MsgType = 12 |
| 35 | - | MsgOutputResetRequest MsgType = 13 | 35 | + | MsgOutputResetRequest MsgType = 13 |
| 36 | - | 36 | + | MsgRemotePreviewRequest MsgType = 14 | |
| 37 | - | MsgWelcome MsgType = 16 | 37 | + | |
| 38 | - | MsgError MsgType = 17 | 38 | + | MsgWelcome MsgType = 16 |
| 39 | - | MsgOutput MsgType = 18 | 39 | + | MsgError MsgType = 17 |
| 40 | - | MsgDetached MsgType = 19 | 40 | + | MsgOutput MsgType = 18 |
| 41 | - | MsgPong MsgType = 20 | 41 | + | MsgDetached MsgType = 19 |
| 42 | - | MsgSessions MsgType = 21 | 42 | + | MsgPong MsgType = 20 |
| 43 | - | MsgCommandResult MsgType = 22 | 43 | + | MsgSessions MsgType = 21 |
| 44 | - | MsgSessionMeta MsgType = 23 | 44 | + | MsgCommandResult MsgType = 22 |
| 45 | - | MsgAttachTarget MsgType = 25 | 45 | + | MsgSessionMeta MsgType = 23 |
| 46 | + | MsgRemotePreviewResponse MsgType = 24 | |||
| 47 | + | MsgAttachTarget MsgType = 25 | |||
| 46 | ) | 48 | ) | ||
| 47 | 49 | ||||
| 48 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 50 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/ports/frame_test.go +4 -4
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 23 { | 6 | + | if ProtocolVersion != 24 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -32,11 +32,11 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 32 | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, | 32 | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, | ||
| 33 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | 33 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | ||
| 34 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | 34 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | ||
| 35 | - | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, | 35 | + | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, |
| 36 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | 36 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | ||
| 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | ||
| 38 | {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta}, | 38 | {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta}, | ||
| 39 | - | {"MsgAttachTarget", MsgAttachTarget}, | 39 | + | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, |
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | seen := make(map[MsgType]string, len(tests)) | 42 | seen := make(map[MsgType]string, len(tests)) | ||
M internal/ports/mocks/remote_mock.go +106 -0
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "context" | 8 | "context" | ||
| 9 | "log/slog" | 9 | "log/slog" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 12 | mock "github.com/stretchr/testify/mock" | 13 | mock "github.com/stretchr/testify/mock" | ||
| 13 | ) | 14 | ) | ||
| @@ -637,6 +638,111 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C | |||||
| 637 | return _c | 638 | return _c | ||
| 638 | } | 639 | } | ||
| 639 | 640 | ||||
| 641 | + | // NewMockRemotePreviewClient creates a new instance of MockRemotePreviewClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 642 | + | // The first argument is typically a *testing.T value. | |||
| 643 | + | func NewMockRemotePreviewClient(t interface { | |||
| 644 | + | mock.TestingT | |||
| 645 | + | Cleanup(func()) | |||
| 646 | + | }) *MockRemotePreviewClient { | |||
| 647 | + | mock := &MockRemotePreviewClient{} | |||
| 648 | + | mock.Mock.Test(t) | |||
| 649 | + | ||||
| 650 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 651 | + | ||||
| 652 | + | return mock | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | // MockRemotePreviewClient is an autogenerated mock type for the RemotePreviewClient type | |||
| 656 | + | type MockRemotePreviewClient struct { | |||
| 657 | + | mock.Mock | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | type MockRemotePreviewClient_Expecter struct { | |||
| 661 | + | mock *mock.Mock | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | func (_m *MockRemotePreviewClient) EXPECT() *MockRemotePreviewClient_Expecter { | |||
| 665 | + | return &MockRemotePreviewClient_Expecter{mock: &_m.Mock} | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | // Preview provides a mock function for the type MockRemotePreviewClient | |||
| 669 | + | func (_mock *MockRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (ports.RemotePreview, error) { | |||
| 670 | + | ret := _mock.Called(ctx, target, width, height) | |||
| 671 | + | ||||
| 672 | + | if len(ret) == 0 { | |||
| 673 | + | panic("no return value specified for Preview") | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | var r0 ports.RemotePreview | |||
| 677 | + | var r1 error | |||
| 678 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) (ports.RemotePreview, error)); ok { | |||
| 679 | + | return returnFunc(ctx, target, width, height) | |||
| 680 | + | } | |||
| 681 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) ports.RemotePreview); ok { | |||
| 682 | + | r0 = returnFunc(ctx, target, width, height) | |||
| 683 | + | } else { | |||
| 684 | + | r0 = ret.Get(0).(ports.RemotePreview) | |||
| 685 | + | } | |||
| 686 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) error); ok { | |||
| 687 | + | r1 = returnFunc(ctx, target, width, height) | |||
| 688 | + | } else { | |||
| 689 | + | r1 = ret.Error(1) | |||
| 690 | + | } | |||
| 691 | + | return r0, r1 | |||
| 692 | + | } | |||
| 693 | + | ||||
| 694 | + | // MockRemotePreviewClient_Preview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Preview' | |||
| 695 | + | type MockRemotePreviewClient_Preview_Call struct { | |||
| 696 | + | *mock.Call | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | // Preview is a helper method to define mock.On call | |||
| 700 | + | // - ctx context.Context | |||
| 701 | + | // - target domain.RemoteSessionTarget | |||
| 702 | + | // - width uint16 | |||
| 703 | + | // - height uint16 | |||
| 704 | + | func (_e *MockRemotePreviewClient_Expecter) Preview(ctx any, target any, width any, height any) *MockRemotePreviewClient_Preview_Call { | |||
| 705 | + | return &MockRemotePreviewClient_Preview_Call{Call: _e.mock.On("Preview", ctx, target, width, height)} | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | func (_c *MockRemotePreviewClient_Preview_Call) Run(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16)) *MockRemotePreviewClient_Preview_Call { | |||
| 709 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 710 | + | var arg0 context.Context | |||
| 711 | + | if args[0] != nil { | |||
| 712 | + | arg0 = args[0].(context.Context) | |||
| 713 | + | } | |||
| 714 | + | var arg1 domain.RemoteSessionTarget | |||
| 715 | + | if args[1] != nil { | |||
| 716 | + | arg1 = args[1].(domain.RemoteSessionTarget) | |||
| 717 | + | } | |||
| 718 | + | var arg2 uint16 | |||
| 719 | + | if args[2] != nil { | |||
| 720 | + | arg2 = args[2].(uint16) | |||
| 721 | + | } | |||
| 722 | + | var arg3 uint16 | |||
| 723 | + | if args[3] != nil { | |||
| 724 | + | arg3 = args[3].(uint16) | |||
| 725 | + | } | |||
| 726 | + | run( | |||
| 727 | + | arg0, | |||
| 728 | + | arg1, | |||
| 729 | + | arg2, | |||
| 730 | + | arg3, | |||
| 731 | + | ) | |||
| 732 | + | }) | |||
| 733 | + | return _c | |||
| 734 | + | } | |||
| 735 | + | ||||
| 736 | + | func (_c *MockRemotePreviewClient_Preview_Call) Return(remotePreview ports.RemotePreview, err error) *MockRemotePreviewClient_Preview_Call { | |||
| 737 | + | _c.Call.Return(remotePreview, err) | |||
| 738 | + | return _c | |||
| 739 | + | } | |||
| 740 | + | ||||
| 741 | + | func (_c *MockRemotePreviewClient_Preview_Call) RunAndReturn(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (ports.RemotePreview, error)) *MockRemotePreviewClient_Preview_Call { | |||
| 742 | + | _c.Call.Return(run) | |||
| 743 | + | return _c | |||
| 744 | + | } | |||
| 745 | + | ||||
| 640 | // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 746 | // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 641 | // The first argument is typically a *testing.T value. | 747 | // The first argument is typically a *testing.T value. | ||
| 642 | func NewMockRemoteCatalogCache(t interface { | 748 | func NewMockRemoteCatalogCache(t interface { | ||
M internal/ports/remote.go +356 -13
| @@ -2,9 +2,17 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 5 | "fmt" | 7 | "fmt" | ||
| 6 | "log/slog" | 8 | "log/slog" | ||
| 9 | + | "math" | |||
| 10 | + | "slices" | |||
| 7 | "time" | 11 | "time" | ||
| 12 | + | "unicode" | |||
| 13 | + | "unicode/utf8" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | ) | 16 | ) | ||
| 9 | 17 | ||||
| 10 | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | 18 | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | ||
| @@ -47,14 +55,55 @@ type RemoteCatalogClient interface { | |||||
| 47 | List(ctx context.Context, target string) (RemoteCatalog, error) | 55 | List(ctx context.Context, target string) (RemoteCatalog, error) | ||
| 48 | } | 56 | } | ||
| 49 | 57 | ||||
| 58 | + | // RemotePreviewClient fetches one bounded, exact-target viewport without | |||
| 59 | + | // attaching to or mutating the remote session. | |||
| 60 | + | type RemotePreviewClient interface { | |||
| 61 | + | Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (RemotePreview, error) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | const ( | |||
| 65 | + | // RemoteCatalogSchemaVersion is independent from the IPC protocol. A | |||
| 66 | + | // catalogue can be rejected without changing the attachment wire layout. | |||
| 67 | + | RemoteCatalogSchemaVersion uint16 = 1 | |||
| 68 | + | ||||
| 69 | + | RemoteCatalogMaxHosts = 64 | |||
| 70 | + | RemoteCatalogMaxSessions = 256 | |||
| 71 | + | RemoteCatalogMaxTabsPerSess = 128 | |||
| 72 | + | RemoteCatalogMaxNameBytes = 256 | |||
| 73 | + | RemoteCatalogMaxDetailBytes = 512 | |||
| 74 | + | RemoteCatalogMaxIDBytes = 128 | |||
| 75 | + | RemoteCatalogMaxCatalogBytes = 1 << 20 | |||
| 76 | + | ) | |||
| 77 | + | ||||
| 50 | // RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for | 78 | // RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for | ||
| 51 | - | // immediate remote discovery at daemon startup. | 79 | + | // immediate remote discovery at daemon startup. The legacy-shaped Sessions |
| 80 | + | // field is retained as a source-compatible container, but cache writers strip | |||
| 81 | + | // dynamic Detail and Attention fields before persistence. | |||
| 52 | type RemoteCatalogCacheEntry struct { | 82 | type RemoteCatalogCacheEntry struct { | ||
| 53 | Host string | 83 | Host string | ||
| 54 | FetchedAt time.Time | 84 | FetchedAt time.Time | ||
| 55 | Sessions []RemoteCatalogSession | 85 | Sessions []RemoteCatalogSession | ||
| 56 | } | 86 | } | ||
| 57 | 87 | ||||
| 88 | + | // RemoteCatalogCacheTab is the durable subset of a tab catalogue entry. | |||
| 89 | + | type RemoteCatalogCacheTab struct { | |||
| 90 | + | ID string `json:"id,omitempty"` | |||
| 91 | + | Index uint16 `json:"index"` | |||
| 92 | + | Name string `json:"name"` | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // RemoteCatalogCacheSession is the durable subset of a remote session entry. | |||
| 96 | + | type RemoteCatalogCacheSession struct { | |||
| 97 | + | LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |||
| 98 | + | Name string `json:"name"` | |||
| 99 | + | State string `json:"state"` | |||
| 100 | + | Ephemeral bool `json:"ephemeral"` | |||
| 101 | + | Attached bool `json:"attached"` | |||
| 102 | + | LastUsedSeq uint64 `json:"last_used_seq,omitempty"` | |||
| 103 | + | ActiveTabID string `json:"active_tab_id,omitempty"` | |||
| 104 | + | Tabs []RemoteCatalogCacheTab `json:"tabs"` | |||
| 105 | + | } | |||
| 106 | + | ||||
| 58 | // RemoteCatalogCache persists complete remote discovery snapshots independently | 107 | // RemoteCatalogCache persists complete remote discovery snapshots independently | ||
| 59 | // from the remote host registry. | 108 | // from the remote host registry. | ||
| 60 | type RemoteCatalogCache interface { | 109 | type RemoteCatalogCache interface { | ||
| @@ -62,40 +111,334 @@ type RemoteCatalogCache interface { | |||||
| 62 | Store([]RemoteCatalogCacheEntry) error | 111 | Store([]RemoteCatalogCacheEntry) error | ||
| 63 | } | 112 | } | ||
| 64 | 113 | ||||
| 65 | - | // RemoteCatalogSession is one live session in the remote discovery catalog. | 114 | + | // RemoteCatalogTab is one bounded, untrusted tab presentation hint. Detail |
| 66 | - | // State is an explicit string contract (running|stopped|broken), not SessionState. | 115 | + | // and Attention are deliberately omitted from the durable cache. |
| 67 | - | type RemoteCatalogSession struct { | 116 | + | type RemoteCatalogTab struct { |
| 117 | + | ID string `json:"id"` | |||
| 118 | + | Index uint16 `json:"index"` | |||
| 68 | Name string `json:"name"` | 119 | Name string `json:"name"` | ||
| 69 | - | State string `json:"state"` | 120 | + | Detail string `json:"detail,omitempty"` |
| 70 | - | Ephemeral bool `json:"ephemeral"` | 121 | + | Attention bool `json:"attention,omitempty"` |
| 71 | - | Tabs uint16 `json:"tabs"` | 122 | + | } |
| 72 | - | Attached bool `json:"attached"` | 123 | + | |
| 124 | + | // RemoteCatalogSession is one session in the remote discovery catalog. State | |||
| 125 | + | // is an explicit string contract (running|stopped|broken), not SessionState. | |||
| 126 | + | // | |||
| 127 | + | // Tabs is interface-typed only to keep old v0.x callers that supplied a count | |||
| 128 | + | // source-compatible while the versioned schema carries []RemoteCatalogTab. | |||
| 129 | + | // New code must use CatalogTabs and never infer routing identity from a count. | |||
| 130 | + | type RemoteCatalogSession struct { | |||
| 131 | + | LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |||
| 132 | + | Name string `json:"name"` | |||
| 133 | + | State string `json:"state"` | |||
| 134 | + | Ephemeral bool `json:"ephemeral"` | |||
| 135 | + | Tabs any `json:"tabs"` | |||
| 136 | + | Attached bool `json:"attached"` | |||
| 137 | + | LastUsedSeq uint64 `json:"last_used_seq,omitempty"` | |||
| 138 | + | ActiveTabID string `json:"active_tab_id,omitempty"` | |||
| 139 | + | Reason string `json:"reason,omitempty"` | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues. | |||
| 143 | + | // Encoding/json does not apply omitempty to a zero array that implements | |||
| 144 | + | // encoding.TextMarshaler, so the pointer is made explicit here. | |||
| 145 | + | func (s RemoteCatalogSession) MarshalJSON() ([]byte, error) { | |||
| 146 | + | type envelope struct { | |||
| 147 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |||
| 148 | + | Name string `json:"name"` | |||
| 149 | + | State string `json:"state"` | |||
| 150 | + | Ephemeral bool `json:"ephemeral"` | |||
| 151 | + | Tabs any `json:"tabs"` | |||
| 152 | + | Attached bool `json:"attached"` | |||
| 153 | + | LastUsedSeq uint64 `json:"last_used_seq,omitempty"` | |||
| 154 | + | ActiveTabID string `json:"active_tab_id,omitempty"` | |||
| 155 | + | Reason string `json:"reason,omitempty"` | |||
| 156 | + | } | |||
| 157 | + | var lifecycle *domain.SessionLifecycleID | |||
| 158 | + | if s.LifecycleID != (domain.SessionLifecycleID{}) { | |||
| 159 | + | id := s.LifecycleID | |||
| 160 | + | lifecycle = &id | |||
| 161 | + | } | |||
| 162 | + | return json.Marshal(envelope{LifecycleID: lifecycle, Name: s.Name, State: s.State, Ephemeral: s.Ephemeral, Tabs: s.Tabs, Attached: s.Attached, LastUsedSeq: s.LastUsedSeq, ActiveTabID: s.ActiveTabID, Reason: s.Reason}) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // UnmarshalJSON preserves the legacy numeric tab-count representation while | |||
| 166 | + | // decoding the current array representation into concrete tab values. | |||
| 167 | + | func (s *RemoteCatalogSession) UnmarshalJSON(data []byte) error { | |||
| 168 | + | type envelope struct { | |||
| 169 | + | LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"` | |||
| 170 | + | Name string `json:"name"` | |||
| 171 | + | State string `json:"state"` | |||
| 172 | + | Ephemeral bool `json:"ephemeral"` | |||
| 173 | + | Tabs json.RawMessage `json:"tabs"` | |||
| 174 | + | Attached bool `json:"attached"` | |||
| 175 | + | LastUsedSeq uint64 `json:"last_used_seq"` | |||
| 176 | + | ActiveTabID string `json:"active_tab_id"` | |||
| 177 | + | Reason string `json:"reason"` | |||
| 178 | + | } | |||
| 179 | + | var raw envelope | |||
| 180 | + | if err := json.Unmarshal(data, &raw); err != nil { | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | *s = RemoteCatalogSession{LifecycleID: raw.LifecycleID, Name: raw.Name, State: raw.State, Ephemeral: raw.Ephemeral, Attached: raw.Attached, LastUsedSeq: raw.LastUsedSeq, ActiveTabID: raw.ActiveTabID, Reason: raw.Reason} | |||
| 184 | + | if len(raw.Tabs) == 0 || string(raw.Tabs) == "null" { | |||
| 185 | + | s.Tabs = 0 | |||
| 186 | + | return nil | |||
| 187 | + | } | |||
| 188 | + | var tabs []RemoteCatalogTab | |||
| 189 | + | if err := json.Unmarshal(raw.Tabs, &tabs); err == nil { | |||
| 190 | + | s.Tabs = tabs | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | var count uint16 | |||
| 194 | + | if err := json.Unmarshal(raw.Tabs, &count); err != nil { | |||
| 195 | + | return err | |||
| 196 | + | } | |||
| 197 | + | if count == math.MaxUint16 { | |||
| 198 | + | s.Tabs = count | |||
| 199 | + | } else { | |||
| 200 | + | s.Tabs = int(count) | |||
| 201 | + | } | |||
| 202 | + | return nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // CatalogTabs returns a copied typed tab list. Legacy count-only entries have | |||
| 206 | + | // no stable tab identity and therefore return nil. | |||
| 207 | + | func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab { | |||
| 208 | + | tabs, ok := session.Tabs.([]RemoteCatalogTab) | |||
| 209 | + | if !ok { | |||
| 210 | + | return nil | |||
| 211 | + | } | |||
| 212 | + | return slices.Clone(tabs) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // CatalogTabCount returns the count represented by either schema form. | |||
| 216 | + | func CatalogTabCount(session RemoteCatalogSession) int { | |||
| 217 | + | switch tabs := session.Tabs.(type) { | |||
| 218 | + | case []RemoteCatalogTab: | |||
| 219 | + | return len(tabs) | |||
| 220 | + | case uint16: | |||
| 221 | + | return int(tabs) | |||
| 222 | + | case uint32: | |||
| 223 | + | return int(tabs) | |||
| 224 | + | case int: | |||
| 225 | + | return tabs | |||
| 226 | + | case float64: | |||
| 227 | + | return int(tabs) | |||
| 228 | + | default: | |||
| 229 | + | return 0 | |||
| 230 | + | } | |||
| 73 | } | 231 | } | ||
| 74 | 232 | ||||
| 75 | - | // SaturateUint16 clamps n to the uint16 range. | 233 | + | // SaturateUint16 clamps n to the uint16 range for legacy command output only. |
| 76 | func SaturateUint16(n int) uint16 { | 234 | func SaturateUint16(n int) uint16 { | ||
| 77 | if n <= 0 { | 235 | if n <= 0 { | ||
| 78 | return 0 | 236 | return 0 | ||
| 79 | } | 237 | } | ||
| 80 | if n >= 1<<16 { | 238 | if n >= 1<<16 { | ||
| 81 | - | return ^uint16(0) | 239 | + | return math.MaxUint16 |
| 82 | } | 240 | } | ||
| 83 | return uint16(n) | 241 | return uint16(n) | ||
| 84 | } | 242 | } | ||
| 85 | 243 | ||||
| 86 | - | // RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json. | 244 | + | // RemoteCatalog is the independently versioned JSON envelope returned by |
| 245 | + | // remote-catalog --json. ProtocolVersion remains for legacy peers; new peers | |||
| 246 | + | // must also provide SchemaVersion. | |||
| 87 | type RemoteCatalog struct { | 247 | type RemoteCatalog struct { | ||
| 88 | ProtocolVersion uint16 `json:"protocol_version"` | 248 | ProtocolVersion uint16 `json:"protocol_version"` | ||
| 249 | + | SchemaVersion uint16 `json:"schema_version,omitempty"` | |||
| 89 | Sessions []RemoteCatalogSession `json:"sessions"` | 250 | Sessions []RemoteCatalogSession `json:"sessions"` | ||
| 90 | } | 251 | } | ||
| 91 | 252 | ||||
| 92 | // RemoteCatalogVersionMismatchError is returned when a remote catalog envelope | 253 | // RemoteCatalogVersionMismatchError is returned when a remote catalog envelope | ||
| 93 | - | // reports a protocol_version that is not equal to ProtocolVersion. | 254 | + | // reports an incompatible protocol or catalogue schema version. |
| 94 | type RemoteCatalogVersionMismatchError struct { | 255 | type RemoteCatalogVersionMismatchError struct { | ||
| 95 | Got uint16 | 256 | Got uint16 | ||
| 96 | Want uint16 | 257 | Want uint16 | ||
| 258 | + | Kind string | |||
| 97 | } | 259 | } | ||
| 98 | 260 | ||||
| 99 | func (e *RemoteCatalogVersionMismatchError) Error() string { | 261 | func (e *RemoteCatalogVersionMismatchError) Error() string { | ||
| 100 | - | return fmt.Sprintf("remote catalog: protocol version mismatch: got %d, want %d", e.Got, e.Want) | 262 | + | kind := e.Kind |
| 263 | + | if kind == "" { | |||
| 264 | + | kind = "protocol" | |||
| 265 | + | } | |||
| 266 | + | return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | var ( | |||
| 270 | + | ErrInvalidRemoteCatalog = errors.New("ports: invalid remote catalog") | |||
| 271 | + | ErrRemoteCatalogTooLarge = errors.New("ports: remote catalog exceeds size limit") | |||
| 272 | + | ErrRemoteCatalogUnknownState = errors.New("ports: remote catalog has unknown state") | |||
| 273 | + | ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason") | |||
| 274 | + | ) | |||
| 275 | + | ||||
| 276 | + | // ValidateRemoteCatalog applies all limits before a catalogue is cached or | |||
| 277 | + | // rendered. It is deliberately strict for the current schema; zero schema is | |||
| 278 | + | // the read-only compatibility form used by pre-parity v0.x peers. | |||
| 279 | + | func ValidateRemoteCatalog(c RemoteCatalog) error { | |||
| 280 | + | if c.ProtocolVersion != ProtocolVersion { | |||
| 281 | + | return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"} | |||
| 282 | + | } | |||
| 283 | + | if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion { | |||
| 284 | + | return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"} | |||
| 285 | + | } | |||
| 286 | + | if len(c.Sessions) > RemoteCatalogMaxSessions { | |||
| 287 | + | return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge) | |||
| 288 | + | } | |||
| 289 | + | seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions)) | |||
| 290 | + | bytes := 0 | |||
| 291 | + | for _, session := range c.Sessions { | |||
| 292 | + | if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) { | |||
| 293 | + | return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog) | |||
| 294 | + | } | |||
| 295 | + | if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes { | |||
| 296 | + | return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge) | |||
| 297 | + | } | |||
| 298 | + | if err := domain.ValidateSessionName(session.Name); err != nil { | |||
| 299 | + | return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err) | |||
| 300 | + | } | |||
| 301 | + | if !validRemoteCatalogState(session.State) { | |||
| 302 | + | return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State) | |||
| 303 | + | } | |||
| 304 | + | if session.ActiveTabID != "" { | |||
| 305 | + | if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil { | |||
| 306 | + | return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | if session.Reason != "" && !validRemoteCatalogReason(session.Reason) { | |||
| 310 | + | return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason) | |||
| 311 | + | } | |||
| 312 | + | if c.SchemaVersion != 0 { | |||
| 313 | + | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |||
| 314 | + | return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog) | |||
| 315 | + | } | |||
| 316 | + | if prior, exists := seen[session.LifecycleID]; exists { | |||
| 317 | + | return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name) | |||
| 318 | + | } | |||
| 319 | + | seen[session.LifecycleID] = session.Name | |||
| 320 | + | } | |||
| 321 | + | tabs := CatalogTabs(session) | |||
| 322 | + | if len(tabs) == 0 { | |||
| 323 | + | if c.SchemaVersion != 0 { | |||
| 324 | + | if session.ActiveTabID != "" { | |||
| 325 | + | return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog) | |||
| 326 | + | } | |||
| 327 | + | if session.State == "running" && session.Tabs == nil { | |||
| 328 | + | return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | } else { | |||
| 332 | + | if len(tabs) > RemoteCatalogMaxTabsPerSess { | |||
| 333 | + | return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge) | |||
| 334 | + | } | |||
| 335 | + | tabIDs := make(map[string]struct{}, len(tabs)) | |||
| 336 | + | for i, tab := range tabs { | |||
| 337 | + | if !validRemoteCatalogText(tab.ID) || !validRemoteCatalogText(tab.Name) || !validRemoteCatalogText(tab.Detail) { | |||
| 338 | + | return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog) | |||
| 339 | + | } | |||
| 340 | + | if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes { | |||
| 341 | + | return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge) | |||
| 342 | + | } | |||
| 343 | + | if tab.Index != uint16(i) { | |||
| 344 | + | return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog) | |||
| 345 | + | } | |||
| 346 | + | if session.State == "running" && tab.ID == "" { | |||
| 347 | + | return fmt.Errorf("%w: running tab has zero ID", ErrInvalidRemoteCatalog) | |||
| 348 | + | } | |||
| 349 | + | if tab.ID != "" { | |||
| 350 | + | if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil { | |||
| 351 | + | return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog) | |||
| 352 | + | } | |||
| 353 | + | if _, duplicate := tabIDs[tab.ID]; duplicate { | |||
| 354 | + | return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog) | |||
| 355 | + | } | |||
| 356 | + | tabIDs[tab.ID] = struct{}{} | |||
| 357 | + | } | |||
| 358 | + | bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail) | |||
| 359 | + | } | |||
| 360 | + | if session.ActiveTabID != "" { | |||
| 361 | + | if session.State != "running" { | |||
| 362 | + | return fmt.Errorf("%w: stopped or broken session has an active tab", ErrInvalidRemoteCatalog) | |||
| 363 | + | } | |||
| 364 | + | if _, ok := tabIDs[session.ActiveTabID]; !ok { | |||
| 365 | + | return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog) | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | bytes += len(session.Name) + len(session.State) + len(session.Reason) | |||
| 370 | + | } | |||
| 371 | + | if bytes > RemoteCatalogMaxCatalogBytes { | |||
| 372 | + | return ErrRemoteCatalogTooLarge | |||
| 373 | + | } | |||
| 374 | + | return nil | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | // ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it | |||
| 378 | + | // is loaded into picker state or written to disk. A cache may contain legacy | |||
| 379 | + | // count-only sessions, but any complete typed snapshot must carry lifecycle | |||
| 380 | + | // identity and the same tab invariants as a live catalog. | |||
| 381 | + | func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error { | |||
| 382 | + | if len(entries) > RemoteCatalogMaxHosts { | |||
| 383 | + | return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge) | |||
| 384 | + | } | |||
| 385 | + | seenHosts := make(map[string]struct{}, len(entries)) | |||
| 386 | + | for _, entry := range entries { | |||
| 387 | + | if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil || len(entry.Host) > RemoteCatalogMaxNameBytes { | |||
| 388 | + | return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog) | |||
| 389 | + | } | |||
| 390 | + | if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 { | |||
| 391 | + | return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog) | |||
| 392 | + | } | |||
| 393 | + | if _, exists := seenHosts[entry.Host]; exists { | |||
| 394 | + | return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog) | |||
| 395 | + | } | |||
| 396 | + | seenHosts[entry.Host] = struct{}{} | |||
| 397 | + | complete := false | |||
| 398 | + | for _, session := range entry.Sessions { | |||
| 399 | + | if session.LifecycleID != (domain.SessionLifecycleID{}) || CatalogTabs(session) != nil { | |||
| 400 | + | complete = true | |||
| 401 | + | break | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | schema := uint16(0) | |||
| 405 | + | if complete { | |||
| 406 | + | schema = RemoteCatalogSchemaVersion | |||
| 407 | + | } | |||
| 408 | + | catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: schema, Sessions: entry.Sessions} | |||
| 409 | + | if err := ValidateRemoteCatalog(catalog); err != nil { | |||
| 410 | + | return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err) | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | return nil | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func validRemoteCatalogText(value string) bool { | |||
| 417 | + | if !utf8.ValidString(value) { | |||
| 418 | + | return false | |||
| 419 | + | } | |||
| 420 | + | for _, r := range value { | |||
| 421 | + | if unicode.IsControl(r) { | |||
| 422 | + | return false | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | return true | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | func validRemoteCatalogState(state string) bool { | |||
| 429 | + | switch state { | |||
| 430 | + | case "running", "stopped", "broken": | |||
| 431 | + | return true | |||
| 432 | + | default: | |||
| 433 | + | return false | |||
| 434 | + | } | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func validRemoteCatalogReason(reason string) bool { | |||
| 438 | + | switch reason { | |||
| 439 | + | case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_stopped", "session_broken", "identity_changed", "not_found", "timeout", "malformed": | |||
| 440 | + | return true | |||
| 441 | + | default: | |||
| 442 | + | return false | |||
| 443 | + | } | |||
| 101 | } | 444 | } | ||
A internal/ports/remote_preview.go +395 -0
| @@ -0,0 +1,395 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "math" | |||
| 6 | + | "unicode/utf8" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // RemotePreviewSchemaVersion is independent from the attachment IPC version. | |||
| 13 | + | const RemotePreviewSchemaVersion uint16 = 1 | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | RemotePreviewMaxWidth = 256 | |||
| 17 | + | RemotePreviewMaxHeight = 128 | |||
| 18 | + | RemotePreviewMaxCells = RemotePreviewMaxWidth * RemotePreviewMaxHeight | |||
| 19 | + | RemotePreviewMaxBytes = 1 << 20 | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // RemotePreviewStatus is a closed response taxonomy. Terminal contents are | |||
| 23 | + | // never carried in an error response. | |||
| 24 | + | type RemotePreviewStatus uint8 | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | RemotePreviewOK RemotePreviewStatus = iota | |||
| 28 | + | RemotePreviewUnavailable | |||
| 29 | + | RemotePreviewNoSuchTarget | |||
| 30 | + | RemotePreviewStale | |||
| 31 | + | RemotePreviewMalformed | |||
| 32 | + | RemotePreviewTooLarge | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // RemotePreviewRequest asks the owning daemon for an in-memory live viewport. | |||
| 36 | + | type RemotePreviewRequest struct { | |||
| 37 | + | Version uint16 | |||
| 38 | + | Target domain.RemoteSessionTarget | |||
| 39 | + | Width uint16 | |||
| 40 | + | Height uint16 | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // RemotePreview is a bounded row-major styled-cell viewport. It is a | |||
| 44 | + | // process-memory DTO and is never persisted, logged, or traced. | |||
| 45 | + | type RemotePreview struct { | |||
| 46 | + | Version uint16 | |||
| 47 | + | Status RemotePreviewStatus | |||
| 48 | + | LifecycleID domain.SessionLifecycleID | |||
| 49 | + | TabID domain.TabStableID | |||
| 50 | + | Revision uint64 | |||
| 51 | + | Width uint16 | |||
| 52 | + | Height uint16 | |||
| 53 | + | Cells []renderer.Cell | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | var ( | |||
| 57 | + | ErrInvalidRemotePreviewRequest = errors.New("ports: invalid remote preview request") | |||
| 58 | + | ErrInvalidRemotePreview = errors.New("ports: invalid remote preview") | |||
| 59 | + | ErrRemotePreviewTooLarge = errors.New("ports: remote preview exceeds size limit") | |||
| 60 | + | ErrRemotePreviewUnsupportedStyle = errors.New("ports: remote preview has unsupported style") | |||
| 61 | + | ) | |||
| 62 | + | ||||
| 63 | + | func ValidateRemotePreviewRequest(request RemotePreviewRequest) error { | |||
| 64 | + | if request.Version != RemotePreviewSchemaVersion || request.Target.Stopped || request.Target.Validate() != nil { | |||
| 65 | + | return ErrInvalidRemotePreviewRequest | |||
| 66 | + | } | |||
| 67 | + | // These fields are encoded with uint16 byte lengths. Reject an oversized | |||
| 68 | + | // nested route before putString truncates it on the wire. | |||
| 69 | + | if len(request.Target.Endpoint) > math.MaxUint16 || | |||
| 70 | + | len(request.Target.DisplayOrigin) > math.MaxUint16 || | |||
| 71 | + | len(request.Target.SessionName) > math.MaxUint16 || | |||
| 72 | + | len(request.Target.LiveTabID) > math.MaxUint16 { | |||
| 73 | + | return ErrInvalidRemotePreviewRequest | |||
| 74 | + | } | |||
| 75 | + | if request.Width == 0 || request.Height == 0 || int(request.Width) > RemotePreviewMaxWidth || int(request.Height) > RemotePreviewMaxHeight { | |||
| 76 | + | return ErrInvalidRemotePreviewRequest | |||
| 77 | + | } | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func ValidateRemotePreview(preview RemotePreview) error { | |||
| 82 | + | if preview.Version != RemotePreviewSchemaVersion || preview.Status > RemotePreviewTooLarge { | |||
| 83 | + | return ErrInvalidRemotePreview | |||
| 84 | + | } | |||
| 85 | + | if preview.Status != RemotePreviewOK { | |||
| 86 | + | if preview.Width != 0 || preview.Height != 0 || len(preview.Cells) != 0 { | |||
| 87 | + | return ErrInvalidRemotePreview | |||
| 88 | + | } | |||
| 89 | + | return nil | |||
| 90 | + | } | |||
| 91 | + | if preview.LifecycleID == (domain.SessionLifecycleID{}) || domain.ValidateTabStableID(preview.TabID) != nil || preview.Revision == 0 { | |||
| 92 | + | return ErrInvalidRemotePreview | |||
| 93 | + | } | |||
| 94 | + | if preview.Width == 0 || preview.Height == 0 || int(preview.Width) > RemotePreviewMaxWidth || int(preview.Height) > RemotePreviewMaxHeight { | |||
| 95 | + | return ErrInvalidRemotePreview | |||
| 96 | + | } | |||
| 97 | + | want := int(preview.Width) * int(preview.Height) | |||
| 98 | + | if want > RemotePreviewMaxCells || len(preview.Cells) != want { | |||
| 99 | + | return ErrRemotePreviewTooLarge | |||
| 100 | + | } | |||
| 101 | + | for i, cell := range preview.Cells { | |||
| 102 | + | if !utf8.ValidRune(cell.Rune) || !validRemotePreviewStyle(cell.Style) { | |||
| 103 | + | return ErrInvalidRemotePreview | |||
| 104 | + | } | |||
| 105 | + | if cell.Continuation { | |||
| 106 | + | if cell.Rune != 0 || i == 0 || preview.Cells[i-1].Continuation || renderer.RuneWidth(preview.Cells[i-1].Rune) != 2 { | |||
| 107 | + | return ErrInvalidRemotePreview | |||
| 108 | + | } | |||
| 109 | + | } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= len(preview.Cells) || !preview.Cells[i+1].Continuation) { | |||
| 110 | + | return ErrInvalidRemotePreview | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func validRemotePreviewStyle(style renderer.Style) bool { | |||
| 117 | + | return style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) == 0 && | |||
| 118 | + | style.UnderlineStyle <= renderer.UnderlineDashed && | |||
| 119 | + | style.Foreground >= math.MinInt16 && style.Foreground <= math.MaxInt16 && | |||
| 120 | + | style.Background >= math.MinInt16 && style.Background <= math.MaxInt16 && | |||
| 121 | + | style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt16 | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) } | |||
| 125 | + | func getInt16(r *payloadReader) (int, error) { | |||
| 126 | + | n, err := r.getUint16() | |||
| 127 | + | return int(int16(n)), err | |||
| 128 | + | } | |||
| 129 | + | func putRGB(w *payloadWriter, c renderer.RGB) { w.putUint8(c.R); w.putUint8(c.G); w.putUint8(c.B) } | |||
| 130 | + | func getRGB(r *payloadReader) (renderer.RGB, error) { | |||
| 131 | + | red, err := r.getUint8() | |||
| 132 | + | if err != nil { | |||
| 133 | + | return renderer.RGB{}, err | |||
| 134 | + | } | |||
| 135 | + | green, err := r.getUint8() | |||
| 136 | + | if err != nil { | |||
| 137 | + | return renderer.RGB{}, err | |||
| 138 | + | } | |||
| 139 | + | blue, err := r.getUint8() | |||
| 140 | + | if err != nil { | |||
| 141 | + | return renderer.RGB{}, err | |||
| 142 | + | } | |||
| 143 | + | return renderer.RGB{R: red, G: green, B: blue}, nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func putPreviewStyle(w *payloadWriter, s renderer.Style) { | |||
| 147 | + | var flags uint8 | |||
| 148 | + | if s.Bold { | |||
| 149 | + | flags |= 1 << 0 | |||
| 150 | + | } | |||
| 151 | + | if s.Italic { | |||
| 152 | + | flags |= 1 << 1 | |||
| 153 | + | } | |||
| 154 | + | if s.Inverse { | |||
| 155 | + | flags |= 1 << 2 | |||
| 156 | + | } | |||
| 157 | + | if s.HasForegroundRGB { | |||
| 158 | + | flags |= 1 << 3 | |||
| 159 | + | } | |||
| 160 | + | if s.HasBackgroundRGB { | |||
| 161 | + | flags |= 1 << 4 | |||
| 162 | + | } | |||
| 163 | + | if s.HasUnderlineColor { | |||
| 164 | + | flags |= 1 << 5 | |||
| 165 | + | } | |||
| 166 | + | if s.HasUnderlineColorRGB { | |||
| 167 | + | flags |= 1 << 6 | |||
| 168 | + | } | |||
| 169 | + | w.putUint8(flags) | |||
| 170 | + | w.putUint16(uint16(s.Attrs)) | |||
| 171 | + | putInt16(w, s.Foreground) | |||
| 172 | + | putInt16(w, s.Background) | |||
| 173 | + | w.putUint8(uint8(s.UnderlineStyle)) | |||
| 174 | + | putInt16(w, s.UnderlineColor) | |||
| 175 | + | putRGB(w, s.ForegroundRGB) | |||
| 176 | + | putRGB(w, s.BackgroundRGB) | |||
| 177 | + | putRGB(w, s.UnderlineColorRGB) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func getPreviewStyle(r *payloadReader) (renderer.Style, error) { | |||
| 181 | + | flags, err := r.getUint8() | |||
| 182 | + | if err != nil { | |||
| 183 | + | return renderer.Style{}, err | |||
| 184 | + | } | |||
| 185 | + | if flags&0x80 != 0 { | |||
| 186 | + | return renderer.Style{}, ErrRemotePreviewUnsupportedStyle | |||
| 187 | + | } | |||
| 188 | + | attrs, err := r.getUint16() | |||
| 189 | + | if err != nil { | |||
| 190 | + | return renderer.Style{}, err | |||
| 191 | + | } | |||
| 192 | + | fg, err := getInt16(r) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return renderer.Style{}, err | |||
| 195 | + | } | |||
| 196 | + | bg, err := getInt16(r) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return renderer.Style{}, err | |||
| 199 | + | } | |||
| 200 | + | ul, err := r.getUint8() | |||
| 201 | + | if err != nil { | |||
| 202 | + | return renderer.Style{}, err | |||
| 203 | + | } | |||
| 204 | + | ulc, err := getInt16(r) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return renderer.Style{}, err | |||
| 207 | + | } | |||
| 208 | + | fgrgb, err := getRGB(r) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return renderer.Style{}, err | |||
| 211 | + | } | |||
| 212 | + | bgrgb, err := getRGB(r) | |||
| 213 | + | if err != nil { | |||
| 214 | + | return renderer.Style{}, err | |||
| 215 | + | } | |||
| 216 | + | ulrgb, err := getRGB(r) | |||
| 217 | + | if err != nil { | |||
| 218 | + | return renderer.Style{}, err | |||
| 219 | + | } | |||
| 220 | + | return renderer.Style{Bold: flags&1 != 0, Italic: flags&(1<<1) != 0, Inverse: flags&(1<<2) != 0, | |||
| 221 | + | HasForegroundRGB: flags&(1<<3) != 0, HasBackgroundRGB: flags&(1<<4) != 0, | |||
| 222 | + | HasUnderlineColor: flags&(1<<5) != 0, HasUnderlineColorRGB: flags&(1<<6) != 0, | |||
| 223 | + | Attrs: renderer.StyleAttrs(attrs), Foreground: fg, Background: bg, UnderlineStyle: renderer.UnderlineStyle(ul), UnderlineColor: ulc, | |||
| 224 | + | ForegroundRGB: fgrgb, BackgroundRGB: bgrgb, UnderlineColorRGB: ulrgb}, nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func MarshalRemotePreviewRequest(request RemotePreviewRequest) []byte { | |||
| 228 | + | if ValidateRemotePreviewRequest(request) != nil { | |||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 231 | + | w := payloadWriter{} | |||
| 232 | + | w.putUint16(request.Version) | |||
| 233 | + | w.putBytes(request.Target.LifecycleID[:]) | |||
| 234 | + | w.putString(request.Target.Endpoint) | |||
| 235 | + | w.putString(request.Target.DisplayOrigin) | |||
| 236 | + | w.putString(request.Target.SessionName) | |||
| 237 | + | w.putString(string(request.Target.LiveTabID)) | |||
| 238 | + | w.putUint16(request.Width) | |||
| 239 | + | w.putUint16(request.Height) | |||
| 240 | + | return w.b | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func UnmarshalRemotePreviewRequest(data []byte) (RemotePreviewRequest, error) { | |||
| 244 | + | if len(data) > RemotePreviewMaxBytes { | |||
| 245 | + | return RemotePreviewRequest{}, ErrInvalidRemotePreviewRequest | |||
| 246 | + | } | |||
| 247 | + | r := payloadReader{b: data} | |||
| 248 | + | var q RemotePreviewRequest | |||
| 249 | + | var err error | |||
| 250 | + | if q.Version, err = r.getUint16(); err != nil { | |||
| 251 | + | return q, err | |||
| 252 | + | } | |||
| 253 | + | id, err := r.getBytes(16) | |||
| 254 | + | if err != nil { | |||
| 255 | + | return q, err | |||
| 256 | + | } | |||
| 257 | + | copy(q.Target.LifecycleID[:], id) | |||
| 258 | + | if q.Target.Endpoint, err = r.getString(); err != nil { | |||
| 259 | + | return q, err | |||
| 260 | + | } | |||
| 261 | + | if q.Target.DisplayOrigin, err = r.getString(); err != nil { | |||
| 262 | + | return q, err | |||
| 263 | + | } | |||
| 264 | + | if q.Target.SessionName, err = r.getString(); err != nil { | |||
| 265 | + | return q, err | |||
| 266 | + | } | |||
| 267 | + | tab, err := r.getString() | |||
| 268 | + | if err != nil { | |||
| 269 | + | return q, err | |||
| 270 | + | } | |||
| 271 | + | q.Target.LiveTabID = domain.TabStableID(tab) | |||
| 272 | + | if q.Width, err = r.getUint16(); err != nil { | |||
| 273 | + | return q, err | |||
| 274 | + | } | |||
| 275 | + | if q.Height, err = r.getUint16(); err != nil { | |||
| 276 | + | return q, err | |||
| 277 | + | } | |||
| 278 | + | if err := r.done(); err != nil { | |||
| 279 | + | return q, err | |||
| 280 | + | } | |||
| 281 | + | if err := ValidateRemotePreviewRequest(q); err != nil { | |||
| 282 | + | return q, err | |||
| 283 | + | } | |||
| 284 | + | return q, nil | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | const previewCellWireSize = 4 + 1 + 1 + 2 + 2 + 2 + 1 + 2 + 3 + 3 + 3 | |||
| 288 | + | ||||
| 289 | + | func MarshalRemotePreview(preview RemotePreview) []byte { | |||
| 290 | + | if ValidateRemotePreview(preview) != nil { | |||
| 291 | + | return nil | |||
| 292 | + | } | |||
| 293 | + | w := payloadWriter{} | |||
| 294 | + | w.putUint16(preview.Version) | |||
| 295 | + | w.putUint8(uint8(preview.Status)) | |||
| 296 | + | w.putBytes(preview.LifecycleID[:]) | |||
| 297 | + | w.putString(string(preview.TabID)) | |||
| 298 | + | w.putUint64(preview.Revision) | |||
| 299 | + | w.putUint16(preview.Width) | |||
| 300 | + | w.putUint16(preview.Height) | |||
| 301 | + | w.putUint32(uint32(len(preview.Cells))) | |||
| 302 | + | for _, cell := range preview.Cells { | |||
| 303 | + | var flags uint8 | |||
| 304 | + | if cell.Continuation { | |||
| 305 | + | flags = 1 | |||
| 306 | + | } | |||
| 307 | + | w.putUint32(uint32(cell.Rune)) | |||
| 308 | + | w.putUint8(flags) | |||
| 309 | + | putPreviewStyle(&w, cell.Style) | |||
| 310 | + | } | |||
| 311 | + | return w.b | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func UnmarshalRemotePreview(data []byte) (RemotePreview, error) { | |||
| 315 | + | if len(data) > RemotePreviewMaxBytes { | |||
| 316 | + | return RemotePreview{}, ErrRemotePreviewTooLarge | |||
| 317 | + | } | |||
| 318 | + | r := payloadReader{b: data} | |||
| 319 | + | var p RemotePreview | |||
| 320 | + | var err error | |||
| 321 | + | if p.Version, err = r.getUint16(); err != nil { | |||
| 322 | + | return p, err | |||
| 323 | + | } | |||
| 324 | + | status, err := r.getUint8() | |||
| 325 | + | if err != nil { | |||
| 326 | + | return p, err | |||
| 327 | + | } | |||
| 328 | + | p.Status = RemotePreviewStatus(status) | |||
| 329 | + | id, err := r.getBytes(16) | |||
| 330 | + | if err != nil { | |||
| 331 | + | return p, err | |||
| 332 | + | } | |||
| 333 | + | copy(p.LifecycleID[:], id) | |||
| 334 | + | tab, err := r.getString() | |||
| 335 | + | if err != nil { | |||
| 336 | + | return p, err | |||
| 337 | + | } | |||
| 338 | + | p.TabID = domain.TabStableID(tab) | |||
| 339 | + | if p.Revision, err = r.getUint64(); err != nil { | |||
| 340 | + | return p, err | |||
| 341 | + | } | |||
| 342 | + | if p.Width, err = r.getUint16(); err != nil { | |||
| 343 | + | return p, err | |||
| 344 | + | } | |||
| 345 | + | if p.Height, err = r.getUint16(); err != nil { | |||
| 346 | + | return p, err | |||
| 347 | + | } | |||
| 348 | + | count, err := r.getUint32() | |||
| 349 | + | if err != nil { | |||
| 350 | + | return p, err | |||
| 351 | + | } | |||
| 352 | + | if count > RemotePreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) { | |||
| 353 | + | return p, ErrRemotePreviewTooLarge | |||
| 354 | + | } | |||
| 355 | + | if count != 0 { | |||
| 356 | + | p.Cells = make([]renderer.Cell, 0, int(count)) | |||
| 357 | + | for range int(count) { | |||
| 358 | + | runeValue, e := r.getUint32() | |||
| 359 | + | if e != nil { | |||
| 360 | + | return p, e | |||
| 361 | + | } | |||
| 362 | + | flags, e := r.getUint8() | |||
| 363 | + | if e != nil { | |||
| 364 | + | return p, e | |||
| 365 | + | } | |||
| 366 | + | if flags&^uint8(1) != 0 { | |||
| 367 | + | return p, ErrInvalidRemotePreview | |||
| 368 | + | } | |||
| 369 | + | style, e := getPreviewStyle(&r) | |||
| 370 | + | if e != nil { | |||
| 371 | + | return p, e | |||
| 372 | + | } | |||
| 373 | + | p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}) | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | if err := r.done(); err != nil { | |||
| 377 | + | return p, err | |||
| 378 | + | } | |||
| 379 | + | if err := ValidateRemotePreview(p); err != nil { | |||
| 380 | + | return p, err | |||
| 381 | + | } | |||
| 382 | + | return p, nil | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func (p RemotePreview) FrameRows() [][]renderer.Cell { | |||
| 386 | + | if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) { | |||
| 387 | + | return nil | |||
| 388 | + | } | |||
| 389 | + | rows := make([][]renderer.Cell, p.Height) | |||
| 390 | + | width := int(p.Width) | |||
| 391 | + | for y := range rows { | |||
| 392 | + | rows[y] = append([]renderer.Cell(nil), p.Cells[y*width:(y+1)*width]...) | |||
| 393 | + | } | |||
| 394 | + | return rows | |||
| 395 | + | } | |||
A internal/ports/remote_preview_hardening_test.go +185 -0
| @@ -0,0 +1,185 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "math" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func remotePreviewGoldenPayload() []byte { | |||
| 14 | + | return []byte{ | |||
| 15 | + | 0x00, 0x01, 0x00, | |||
| 16 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |||
| 17 | + | 0x00, 0x05, 't', 'a', 'b', '-', '1', | |||
| 18 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, | |||
| 19 | + | 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, | |||
| 20 | + | 0x00, 0x00, 0x00, 0x6f, 0x00, | |||
| 21 | + | 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, | |||
| 22 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 23 | + | 0x00, 0x00, 0x00, 0x6b, 0x00, | |||
| 24 | + | 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, | |||
| 25 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestRemotePreviewCodecGoldenRoundTrip(t *testing.T) { | |||
| 30 | + | golden := remotePreviewGoldenPayload() | |||
| 31 | + | got, err := UnmarshalRemotePreview(golden) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatalf("UnmarshalRemotePreview() error = %v", err) | |||
| 34 | + | } | |||
| 35 | + | if got.Version != RemotePreviewSchemaVersion || got.Status != RemotePreviewOK { | |||
| 36 | + | t.Fatalf("header = (%d, %d), want (%d, %d)", got.Version, got.Status, RemotePreviewSchemaVersion, RemotePreviewOK) | |||
| 37 | + | } | |||
| 38 | + | var wantLifecycle domain.SessionLifecycleID | |||
| 39 | + | for i := range wantLifecycle { | |||
| 40 | + | wantLifecycle[i] = byte(i + 1) | |||
| 41 | + | } | |||
| 42 | + | if got.LifecycleID != wantLifecycle || got.TabID != "tab-1" || got.Revision != 1 || got.Width != 2 || got.Height != 1 { | |||
| 43 | + | t.Fatalf("metadata = %#v, want lifecycle/tab/revision/size from golden", got) | |||
| 44 | + | } | |||
| 45 | + | if len(got.Cells) != 2 || got.Cells[0].Rune != 'o' || got.Cells[1].Rune != 'k' { | |||
| 46 | + | t.Fatalf("cells = %#v, want [o k]", got.Cells) | |||
| 47 | + | } | |||
| 48 | + | if !bytes.Equal(MarshalRemotePreview(got), golden) { | |||
| 49 | + | t.Fatalf("marshal changed golden payload: %x", MarshalRemotePreview(got)) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestRemotePreviewCodecRejectsInvalidStatusFlagsAndRanges(t *testing.T) { | |||
| 54 | + | golden := remotePreviewGoldenPayload() | |||
| 55 | + | tests := []struct { | |||
| 56 | + | name string | |||
| 57 | + | mutate func([]byte) | |||
| 58 | + | }{ | |||
| 59 | + | {name: "status", mutate: func(b []byte) { b[2] = 0xff }}, | |||
| 60 | + | {name: "cell flags", mutate: func(b []byte) { b[46] = 0x02 }}, | |||
| 61 | + | {name: "style flags", mutate: func(b []byte) { b[47] = 0x80 }}, | |||
| 62 | + | {name: "style attrs", mutate: func(b []byte) { b[48] = 0x80 }}, | |||
| 63 | + | {name: "underline range", mutate: func(b []byte) { b[54] = byte(renderer.UnderlineDashed + 1) }}, | |||
| 64 | + | } | |||
| 65 | + | for _, test := range tests { | |||
| 66 | + | t.Run(test.name, func(t *testing.T) { | |||
| 67 | + | payload := append([]byte(nil), golden...) | |||
| 68 | + | test.mutate(payload) | |||
| 69 | + | if _, err := UnmarshalRemotePreview(payload); err == nil { | |||
| 70 | + | t.Fatal("malformed payload decoded successfully") | |||
| 71 | + | } | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestRemotePreviewCodecRejectsWideCellBoundaryAndInvalidIDs(t *testing.T) { | |||
| 77 | + | var lifecycle domain.SessionLifecycleID | |||
| 78 | + | lifecycle[0] = 1 | |||
| 79 | + | valid := RemotePreview{ | |||
| 80 | + | Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, | |||
| 81 | + | LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1, | |||
| 82 | + | Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true}}, | |||
| 83 | + | } | |||
| 84 | + | if MarshalRemotePreview(valid) == nil { | |||
| 85 | + | t.Fatal("valid wide-cell preview did not marshal") | |||
| 86 | + | } | |||
| 87 | + | for _, malformed := range []RemotePreview{ | |||
| 88 | + | {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 1, Height: 1, Cells: []renderer.Cell{{Rune: '界'}}}, | |||
| 89 | + | {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1, Cells: []renderer.Cell{{Continuation: true}, {Rune: 'x'}}}, | |||
| 90 | + | {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1, Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true, Rune: 'x'}}}, | |||
| 91 | + | } { | |||
| 92 | + | if MarshalRemotePreview(malformed) != nil { | |||
| 93 | + | t.Fatalf("malformed wide-cell preview marshaled: %#v", malformed.Cells) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | invalidLifecycle := valid | |||
| 97 | + | invalidLifecycle.LifecycleID = domain.SessionLifecycleID{} | |||
| 98 | + | if MarshalRemotePreview(invalidLifecycle) != nil { | |||
| 99 | + | t.Fatal("zero lifecycle ID marshaled") | |||
| 100 | + | } | |||
| 101 | + | invalidTab := valid | |||
| 102 | + | invalidTab.TabID = "tab id" | |||
| 103 | + | if MarshalRemotePreview(invalidTab) != nil { | |||
| 104 | + | t.Fatal("invalid tab ID marshaled") | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestRemotePreviewRequestRejectsOversizedNestedRouteData(t *testing.T) { | |||
| 109 | + | target := previewTargetForTest() | |||
| 110 | + | target.Endpoint = strings.Repeat("h", math.MaxUint16+1) | |||
| 111 | + | request := RemotePreviewRequest{Version: RemotePreviewSchemaVersion, Target: target, Width: 20, Height: 5} | |||
| 112 | + | if MarshalRemotePreviewRequest(request) != nil { | |||
| 113 | + | t.Fatal("oversized endpoint was truncated and marshaled") | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestRemoteTargetWireRejectsInvalidSelectorCombinations(t *testing.T) { | |||
| 118 | + | cases := []struct { | |||
| 119 | + | name string | |||
| 120 | + | mutate func(*domain.RemoteSessionTarget) | |||
| 121 | + | }{ | |||
| 122 | + | {name: "zero lifecycle", mutate: func(target *domain.RemoteSessionTarget) { target.LifecycleID = domain.SessionLifecycleID{} }}, | |||
| 123 | + | {name: "live and stopped selector", mutate: func(target *domain.RemoteSessionTarget) { | |||
| 124 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 1) | |||
| 125 | + | }}, | |||
| 126 | + | {name: "stopped with live ID", mutate: func(target *domain.RemoteSessionTarget) { | |||
| 127 | + | target.Stopped = true | |||
| 128 | + | target.LiveTabID = "tab-1" | |||
| 129 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 1) | |||
| 130 | + | }}, | |||
| 131 | + | {name: "stable selector with ordinal fields", mutate: func(target *domain.RemoteSessionTarget) { | |||
| 132 | + | target.Stopped = true | |||
| 133 | + | target.LiveTabID = "" | |||
| 134 | + | target.StoppedTab = domain.TabSelector{Kind: domain.TabSelectorByStableID, StableID: "tab-1", Ordinal: 1} | |||
| 135 | + | }}, | |||
| 136 | + | {name: "ordinal selector with zero count", mutate: func(target *domain.RemoteSessionTarget) { | |||
| 137 | + | target.Stopped = true | |||
| 138 | + | target.LiveTabID = "" | |||
| 139 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 0) | |||
| 140 | + | }}, | |||
| 141 | + | {name: "oversized tab name", mutate: func(target *domain.RemoteSessionTarget) { | |||
| 142 | + | target.Stopped = true | |||
| 143 | + | target.LiveTabID = "" | |||
| 144 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, strings.Repeat("n", 257), 1) | |||
| 145 | + | }}, | |||
| 146 | + | } | |||
| 147 | + | for _, test := range cases { | |||
| 148 | + | t.Run(test.name, func(t *testing.T) { | |||
| 149 | + | target := *testRemoteTarget(false) | |||
| 150 | + | test.mutate(&target) | |||
| 151 | + | message := AttachTarget{Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach, RemoteTarget: &target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned} | |||
| 152 | + | if MarshalAttachTarget(message) != nil { | |||
| 153 | + | t.Fatal("invalid target was marshaled") | |||
| 154 | + | } | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||
| 160 | + | target := testRemoteTarget(false) | |||
| 161 | + | got := MarshalAttachTarget(AttachTarget{ | |||
| 162 | + | Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach, | |||
| 163 | + | RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned, | |||
| 164 | + | }) | |||
| 165 | + | want := []byte{ | |||
| 166 | + | 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2', | |||
| 167 | + | 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach, | |||
| 168 | + | 0x01, 0x01, | |||
| 169 | + | 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2', | |||
| 170 | + | 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2', | |||
| 171 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |||
| 172 | + | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | |||
| 173 | + | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | |||
| 174 | + | } | |||
| 175 | + | if !bytes.Equal(got, want) { | |||
| 176 | + | t.Fatalf("rich target bytes = %x, want %x", got, want) | |||
| 177 | + | } | |||
| 178 | + | decoded, err := UnmarshalAttachTarget(got) | |||
| 179 | + | if err != nil { | |||
| 180 | + | t.Fatalf("UnmarshalAttachTarget() error = %v", err) | |||
| 181 | + | } | |||
| 182 | + | if decoded.RemoteTarget == nil || *decoded.RemoteTarget != *target || decoded.EnvironmentPolicy != EnvironmentPolicyDaemonOwned { | |||
| 183 | + | t.Fatalf("decoded target = %#v, policy = %d", decoded.RemoteTarget, decoded.EnvironmentPolicy) | |||
| 184 | + | } | |||
| 185 | + | } | |||
A internal/ports/remote_preview_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func previewTargetForTest() domain.RemoteSessionTarget { | |||
| 11 | + | var lifecycle domain.SessionLifecycleID | |||
| 12 | + | lifecycle[0] = 9 | |||
| 13 | + | return domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestRemotePreviewCodecPreservesWideStyledCells(t *testing.T) { | |||
| 17 | + | style := renderer.DefaultStyle() | |||
| 18 | + | style.Bold = true | |||
| 19 | + | style.Attrs = renderer.AttrUnderline | |||
| 20 | + | style.HasForegroundRGB = true | |||
| 21 | + | style.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 22 | + | preview := RemotePreview{ | |||
| 23 | + | Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, | |||
| 24 | + | LifecycleID: previewTargetForTest().LifecycleID, TabID: "tab-1", Revision: 4, Width: 3, Height: 1, | |||
| 25 | + | Cells: []renderer.Cell{{Rune: '界', Style: style}, {Continuation: true, Style: style}, {Rune: 'x', Style: style}}, | |||
| 26 | + | } | |||
| 27 | + | payload := MarshalRemotePreview(preview) | |||
| 28 | + | if payload == nil { | |||
| 29 | + | t.Fatal("MarshalRemotePreview returned nil") | |||
| 30 | + | } | |||
| 31 | + | got, err := UnmarshalRemotePreview(payload) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if len(got.Cells) != len(preview.Cells) || !got.Cells[0].Equal(preview.Cells[0]) || !got.Cells[1].Equal(preview.Cells[1]) { | |||
| 36 | + | t.Fatalf("cells = %#v, want %#v", got.Cells, preview.Cells) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestRemotePreviewCodecRejectsMalformedBoundsAndGarbage(t *testing.T) { | |||
| 41 | + | preview := RemotePreview{Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: previewTargetForTest().LifecycleID, TabID: "tab-1", Revision: 1, Width: 1, Height: 1, Cells: []renderer.Cell{{Rune: 'x'}}} | |||
| 42 | + | payload := MarshalRemotePreview(preview) | |||
| 43 | + | for i := 0; i < len(payload); i++ { | |||
| 44 | + | if _, err := UnmarshalRemotePreview(payload[:i]); err == nil { | |||
| 45 | + | t.Fatalf("prefix %d unexpectedly decoded", i) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | if _, err := UnmarshalRemotePreview(append(append([]byte(nil), payload...), 1)); err == nil { | |||
| 49 | + | t.Fatal("trailing garbage unexpectedly decoded") | |||
| 50 | + | } | |||
| 51 | + | bad := preview | |||
| 52 | + | bad.Width = RemotePreviewMaxWidth + 1 | |||
| 53 | + | if MarshalRemotePreview(bad) != nil { | |||
| 54 | + | t.Fatal("oversized preview marshaled") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestRemotePreviewRequestRejectsStoppedAndInvalidTargets(t *testing.T) { | |||
| 59 | + | target := previewTargetForTest() | |||
| 60 | + | request := RemotePreviewRequest{Version: RemotePreviewSchemaVersion, Target: target, Width: 20, Height: 5} | |||
| 61 | + | if MarshalRemotePreviewRequest(request) == nil { | |||
| 62 | + | t.Fatal("valid preview request did not marshal") | |||
| 63 | + | } | |||
| 64 | + | target.Stopped = true | |||
| 65 | + | target.LiveTabID = "" | |||
| 66 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, "", 1) | |||
| 67 | + | if MarshalRemotePreviewRequest(RemotePreviewRequest{Version: RemotePreviewSchemaVersion, Target: target, Width: 20, Height: 5}) != nil { | |||
| 68 | + | t.Fatal("stopped preview request marshaled") | |||
| 69 | + | } | |||
| 70 | + | } | |||
A internal/ports/remote_target_wire_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func testRemoteTarget(stopped bool) *domain.RemoteSessionTarget { | |||
| 11 | + | var lifecycle domain.SessionLifecycleID | |||
| 12 | + | for i := range lifecycle { | |||
| 13 | + | lifecycle[i] = byte(i + 1) | |||
| 14 | + | } | |||
| 15 | + | target := &domain.RemoteSessionTarget{ | |||
| 16 | + | Endpoint: "build@mule:2222", | |||
| 17 | + | DisplayOrigin: "mule:2222", | |||
| 18 | + | LifecycleID: lifecycle, | |||
| 19 | + | SessionName: "work", | |||
| 20 | + | Stopped: stopped, | |||
| 21 | + | } | |||
| 22 | + | if stopped { | |||
| 23 | + | target.StoppedTab = domain.NewOrdinalTabSelector(2, "", 4) | |||
| 24 | + | } else { | |||
| 25 | + | target.LiveTabID = "tab-3" | |||
| 26 | + | } | |||
| 27 | + | return target | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) { | |||
| 31 | + | for _, stopped := range []bool{false, true} { | |||
| 32 | + | t.Run(map[bool]string{false: "live", true: "stopped"}[stopped], func(t *testing.T) { | |||
| 33 | + | target := testRemoteTarget(stopped) | |||
| 34 | + | msg := Hello{ | |||
| 35 | + | Version: ProtocolVersion, | |||
| 36 | + | Intent: IntentAttach, | |||
| 37 | + | Name: target.SessionName, | |||
| 38 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 39 | + | RemoteTarget: target, | |||
| 40 | + | EnvironmentPolicy: EnvironmentPolicyDaemonOwned, | |||
| 41 | + | } | |||
| 42 | + | payload := MarshalHello(msg) | |||
| 43 | + | if payload == nil { | |||
| 44 | + | t.Fatal("MarshalHello returned nil") | |||
| 45 | + | } | |||
| 46 | + | got, err := UnmarshalHello(payload) | |||
| 47 | + | if err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | if got.RemoteTarget == nil || *got.RemoteTarget != *target { | |||
| 51 | + | t.Fatalf("remote target = %#v, want %#v", got.RemoteTarget, target) | |||
| 52 | + | } | |||
| 53 | + | if got.EnvironmentPolicy != EnvironmentPolicyDaemonOwned { | |||
| 54 | + | t.Fatalf("environment policy = %d, want daemon-owned", got.EnvironmentPolicy) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | attachPayload := MarshalAttachTarget(AttachTarget{ | |||
| 58 | + | Endpoint: target.Endpoint, | |||
| 59 | + | Session: target.SessionName, | |||
| 60 | + | Intent: IntentAttach, | |||
| 61 | + | RemoteTarget: target, | |||
| 62 | + | EnvironmentPolicy: EnvironmentPolicyDaemonOwned, | |||
| 63 | + | }) | |||
| 64 | + | attach, err := UnmarshalAttachTarget(attachPayload) | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | if attach.RemoteTarget == nil || *attach.RemoteTarget != *target { | |||
| 69 | + | t.Fatalf("attach target = %#v, want %#v", attach.RemoteTarget, target) | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestRemoteTargetWireRejectsTruncationAndTrailingData(t *testing.T) { | |||
| 76 | + | target := testRemoteTarget(true) | |||
| 77 | + | payload := MarshalAttachTarget(AttachTarget{ | |||
| 78 | + | Endpoint: target.Endpoint, | |||
| 79 | + | Session: target.SessionName, | |||
| 80 | + | Intent: IntentAttach, | |||
| 81 | + | RemoteTarget: target, | |||
| 82 | + | EnvironmentPolicy: EnvironmentPolicyDaemonOwned, | |||
| 83 | + | }) | |||
| 84 | + | legacyLen := len(MarshalAttachTarget(AttachTarget{Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach})) | |||
| 85 | + | for i := 0; i < len(payload); i++ { | |||
| 86 | + | if i == legacyLen { | |||
| 87 | + | // The extension is optional for old peers; the complete legacy | |||
| 88 | + | // prefix is therefore a valid count-only target by design. | |||
| 89 | + | continue | |||
| 90 | + | } | |||
| 91 | + | if _, err := UnmarshalAttachTarget(payload[:i]); err == nil { | |||
| 92 | + | t.Fatalf("prefix %d unexpectedly decoded", i) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | trailing := append(append([]byte(nil), payload...), 0xff) | |||
| 96 | + | if _, err := UnmarshalAttachTarget(trailing); err == nil { | |||
| 97 | + | t.Fatal("trailing garbage unexpectedly decoded") | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestLegacyTargetWireBytesRemainUnchanged(t *testing.T) { | |||
| 102 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach} | |||
| 103 | + | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | |||
| 104 | + | if !bytes.Equal(got, want) { | |||
| 105 | + | t.Fatalf("legacy target bytes = %x, want %x", got, want) | |||
| 106 | + | } | |||
| 107 | + | } | |||
M internal/ports/wire.go +273 -7
| @@ -65,6 +65,23 @@ const ( | |||||
| 65 | IntentResume uint8 = 3 | 65 | IntentResume uint8 = 3 | ||
| 66 | ) | 66 | ) | ||
| 67 | 67 | ||||
| 68 | + | // EnvironmentPolicy controls which side owns the environment used for future | |||
| 69 | + | // PTY children. Direct CLI attaches keep the historical client-owned policy; | |||
| 70 | + | // picker handoffs use daemon ownership so a local environment cannot leak into | |||
| 71 | + | // a remote daemon. | |||
| 72 | + | type EnvironmentPolicy uint8 | |||
| 73 | + | ||||
| 74 | + | const ( | |||
| 75 | + | EnvironmentPolicyClientOwned EnvironmentPolicy = iota | |||
| 76 | + | EnvironmentPolicyDaemonOwned | |||
| 77 | + | ) | |||
| 78 | + | ||||
| 79 | + | func validEnvironmentPolicy(policy EnvironmentPolicy) bool { | |||
| 80 | + | return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | const remoteTargetWireVersion uint8 = 1 | |||
| 84 | + | ||||
| 68 | // Capability bits advertised in Welcome. | 85 | // Capability bits advertised in Welcome. | ||
| 69 | const ( | 86 | const ( | ||
| 70 | CapabilityResume uint32 = 1 << 0 | 87 | CapabilityResume uint32 = 1 << 0 | ||
| @@ -112,6 +129,11 @@ type Hello struct { | |||||
| 112 | // Env is the complete exported client environment for future PTY children. | 129 | // Env is the complete exported client environment for future PTY children. | ||
| 113 | // Entries are opaque strings so their ordering and contents are preserved. | 130 | // Entries are opaque strings so their ordering and contents are preserved. | ||
| 114 | Env []string | 131 | Env []string | ||
| 132 | + | // RemoteTarget is present only for a picker-selected remote attach. It is | |||
| 133 | + | // carried through reconnects and is validated by the owning daemon before | |||
| 134 | + | // it creates or publishes any session resources. | |||
| 135 | + | RemoteTarget *domain.RemoteSessionTarget | |||
| 136 | + | EnvironmentPolicy EnvironmentPolicy | |||
| 115 | } | 137 | } | ||
| 116 | 138 | ||||
| 117 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | 139 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | ||
| @@ -223,12 +245,15 @@ type Output struct { | |||||
| 223 | Data []byte | 245 | Data []byte | ||
| 224 | } | 246 | } | ||
| 225 | 247 | ||||
| 226 | - | // AttachTarget identifies the endpoint and session selected for a new | 248 | + | // AttachTarget identifies the endpoint and exact session/tab selected for a |
| 227 | - | // attachment. It deliberately carries no viewport, resume, or session state. | 249 | + | // new attachment. Endpoint and Session remain the compatibility route fields; |
| 250 | + | // RemoteTarget is the authoritative picker identity when non-nil. | |||
| 228 | type AttachTarget struct { | 251 | type AttachTarget struct { | ||
| 229 | - | Endpoint string | 252 | + | Endpoint string |
| 230 | - | Session string | 253 | + | Session string |
| 231 | - | Intent uint8 | 254 | + | Intent uint8 |
| 255 | + | RemoteTarget *domain.RemoteSessionTarget | |||
| 256 | + | EnvironmentPolicy EnvironmentPolicy | |||
| 232 | } | 257 | } | ||
| 233 | 258 | ||||
| 234 | // Detached tells a client it has been disconnected from its session and why. | 259 | // Detached tells a client it has been disconnected from its session and why. | ||
| @@ -343,6 +368,175 @@ func (w *payloadWriter) putLongBytes(b []byte) { | |||||
| 343 | w.b = append(w.b, b...) | 368 | w.b = append(w.b, b...) | ||
| 344 | } | 369 | } | ||
| 345 | 370 | ||||
| 371 | + | func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) { | |||
| 372 | + | w.putUint8(remoteTargetWireVersion) | |||
| 373 | + | if target == nil { | |||
| 374 | + | w.putBool(false) | |||
| 375 | + | } else { | |||
| 376 | + | w.putBool(true) | |||
| 377 | + | w.putString(target.Endpoint) | |||
| 378 | + | w.putString(target.DisplayOrigin) | |||
| 379 | + | w.putBytes(target.LifecycleID[:]) | |||
| 380 | + | w.putString(target.SessionName) | |||
| 381 | + | w.putBool(target.Stopped) | |||
| 382 | + | w.putString(string(target.LiveTabID)) | |||
| 383 | + | w.putUint8(uint8(target.StoppedTab.Kind)) | |||
| 384 | + | switch target.StoppedTab.Kind { | |||
| 385 | + | case domain.TabSelectorByStableID: | |||
| 386 | + | w.putString(string(target.StoppedTab.StableID)) | |||
| 387 | + | case domain.TabSelectorByOrdinal: | |||
| 388 | + | w.putUint16(target.StoppedTab.Ordinal) | |||
| 389 | + | w.putString(target.StoppedTab.RawName) | |||
| 390 | + | w.putUint16(target.StoppedTab.ExpectedCount) | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | w.putUint8(uint8(policy)) | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func skipRemoteTargetExtension(r *payloadReader) error { | |||
| 397 | + | version, err := r.getUint8() | |||
| 398 | + | if err != nil { | |||
| 399 | + | return err | |||
| 400 | + | } | |||
| 401 | + | if version != remoteTargetWireVersion { | |||
| 402 | + | return errInvalidEnum | |||
| 403 | + | } | |||
| 404 | + | present, err := r.getBool() | |||
| 405 | + | if err != nil { | |||
| 406 | + | return err | |||
| 407 | + | } | |||
| 408 | + | if present { | |||
| 409 | + | if err := r.skipString(); err != nil { | |||
| 410 | + | return err | |||
| 411 | + | } | |||
| 412 | + | if err := r.skipString(); err != nil { | |||
| 413 | + | return err | |||
| 414 | + | } | |||
| 415 | + | if _, err := r.getBytes(16); err != nil { | |||
| 416 | + | return err | |||
| 417 | + | } | |||
| 418 | + | if err := r.skipString(); err != nil { | |||
| 419 | + | return err | |||
| 420 | + | } | |||
| 421 | + | if _, err := r.getBool(); err != nil { | |||
| 422 | + | return err | |||
| 423 | + | } | |||
| 424 | + | if err := r.skipString(); err != nil { | |||
| 425 | + | return err | |||
| 426 | + | } | |||
| 427 | + | kind, err := r.getUint8() | |||
| 428 | + | if err != nil { | |||
| 429 | + | return err | |||
| 430 | + | } | |||
| 431 | + | switch domain.TabSelectorKind(kind) { | |||
| 432 | + | case domain.TabSelectorByStableID: | |||
| 433 | + | if err := r.skipString(); err != nil { | |||
| 434 | + | return err | |||
| 435 | + | } | |||
| 436 | + | case domain.TabSelectorByOrdinal: | |||
| 437 | + | if _, err := r.getUint16(); err != nil { | |||
| 438 | + | return err | |||
| 439 | + | } | |||
| 440 | + | if err := r.skipString(); err != nil { | |||
| 441 | + | return err | |||
| 442 | + | } | |||
| 443 | + | if _, err := r.getUint16(); err != nil { | |||
| 444 | + | return err | |||
| 445 | + | } | |||
| 446 | + | case 0: | |||
| 447 | + | default: | |||
| 448 | + | return errInvalidEnum | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | policy, err := r.getUint8() | |||
| 452 | + | if err != nil { | |||
| 453 | + | return err | |||
| 454 | + | } | |||
| 455 | + | if !validEnvironmentPolicy(EnvironmentPolicy(policy)) { | |||
| 456 | + | return errInvalidEnum | |||
| 457 | + | } | |||
| 458 | + | return nil | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func unmarshalRemoteTarget(r *payloadReader) (*domain.RemoteSessionTarget, error) { | |||
| 462 | + | present, err := r.getBool() | |||
| 463 | + | if err != nil { | |||
| 464 | + | return nil, err | |||
| 465 | + | } | |||
| 466 | + | if !present { | |||
| 467 | + | return nil, nil | |||
| 468 | + | } | |||
| 469 | + | target := &domain.RemoteSessionTarget{} | |||
| 470 | + | if target.Endpoint, err = r.getString(); err != nil { | |||
| 471 | + | return nil, err | |||
| 472 | + | } | |||
| 473 | + | if target.DisplayOrigin, err = r.getString(); err != nil { | |||
| 474 | + | return nil, err | |||
| 475 | + | } | |||
| 476 | + | id, err := r.getBytes(16) | |||
| 477 | + | if err != nil { | |||
| 478 | + | return nil, err | |||
| 479 | + | } | |||
| 480 | + | copy(target.LifecycleID[:], id) | |||
| 481 | + | if target.SessionName, err = r.getString(); err != nil { | |||
| 482 | + | return nil, err | |||
| 483 | + | } | |||
| 484 | + | if target.Stopped, err = r.getBool(); err != nil { | |||
| 485 | + | return nil, err | |||
| 486 | + | } | |||
| 487 | + | liveID, err := r.getString() | |||
| 488 | + | if err != nil { | |||
| 489 | + | return nil, err | |||
| 490 | + | } | |||
| 491 | + | target.LiveTabID = domain.TabStableID(liveID) | |||
| 492 | + | kind, err := r.getUint8() | |||
| 493 | + | if err != nil { | |||
| 494 | + | return nil, err | |||
| 495 | + | } | |||
| 496 | + | target.StoppedTab.Kind = domain.TabSelectorKind(kind) | |||
| 497 | + | switch target.StoppedTab.Kind { | |||
| 498 | + | case domain.TabSelectorByStableID: | |||
| 499 | + | stableID, err := r.getString() | |||
| 500 | + | if err != nil { | |||
| 501 | + | return nil, err | |||
| 502 | + | } | |||
| 503 | + | target.StoppedTab.StableID = domain.TabStableID(stableID) | |||
| 504 | + | case domain.TabSelectorByOrdinal: | |||
| 505 | + | if target.StoppedTab.Ordinal, err = r.getUint16(); err != nil { | |||
| 506 | + | return nil, err | |||
| 507 | + | } | |||
| 508 | + | if target.StoppedTab.RawName, err = r.getString(); err != nil { | |||
| 509 | + | return nil, err | |||
| 510 | + | } | |||
| 511 | + | if target.StoppedTab.ExpectedCount, err = r.getUint16(); err != nil { | |||
| 512 | + | return nil, err | |||
| 513 | + | } | |||
| 514 | + | case 0: | |||
| 515 | + | default: | |||
| 516 | + | return nil, errInvalidEnum | |||
| 517 | + | } | |||
| 518 | + | return target, nil | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) { | |||
| 522 | + | version, err := r.getUint8() | |||
| 523 | + | if err != nil { | |||
| 524 | + | return nil, 0, err | |||
| 525 | + | } | |||
| 526 | + | if version != remoteTargetWireVersion { | |||
| 527 | + | return nil, 0, errInvalidEnum | |||
| 528 | + | } | |||
| 529 | + | target, err := unmarshalRemoteTarget(r) | |||
| 530 | + | if err != nil { | |||
| 531 | + | return nil, 0, err | |||
| 532 | + | } | |||
| 533 | + | policy, err := r.getUint8() | |||
| 534 | + | if err != nil { | |||
| 535 | + | return nil, 0, err | |||
| 536 | + | } | |||
| 537 | + | return target, EnvironmentPolicy(policy), nil | |||
| 538 | + | } | |||
| 539 | + | ||||
| 346 | // payloadReader consumes a message payload field by field in wire order, | 540 | // payloadReader consumes a message payload field by field in wire order, | ||
| 347 | // erroring instead of panicking on any short read. | 541 | // erroring instead of panicking on any short read. | ||
| 348 | type payloadReader struct { | 542 | type payloadReader struct { | ||
| @@ -501,6 +695,9 @@ func ValidateHello(h Hello) error { | |||||
| 501 | if err := ValidateSize(h.Size); err != nil { | 695 | if err := ValidateSize(h.Size); err != nil { | ||
| 502 | return fmt.Errorf("%w: size", ErrInvalidHello) | 696 | return fmt.Errorf("%w: size", ErrInvalidHello) | ||
| 503 | } | 697 | } | ||
| 698 | + | if !validEnvironmentPolicy(h.EnvironmentPolicy) { | |||
| 699 | + | return ErrInvalidHello | |||
| 700 | + | } | |||
| 504 | if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 { | 701 | if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 { | ||
| 505 | return ErrInvalidHello | 702 | return ErrInvalidHello | ||
| 506 | } | 703 | } | ||
| @@ -509,6 +706,21 @@ func ValidateHello(h Hello) error { | |||||
| 509 | return ErrInvalidHello | 706 | return ErrInvalidHello | ||
| 510 | } | 707 | } | ||
| 511 | } | 708 | } | ||
| 709 | + | if h.RemoteTarget == nil { | |||
| 710 | + | if h.EnvironmentPolicy != EnvironmentPolicyClientOwned { | |||
| 711 | + | return ErrInvalidHello | |||
| 712 | + | } | |||
| 713 | + | return nil | |||
| 714 | + | } | |||
| 715 | + | if h.Intent != IntentAttach && h.Intent != IntentResume { | |||
| 716 | + | return ErrInvalidHello | |||
| 717 | + | } | |||
| 718 | + | if err := validateWireRemoteTarget(*h.RemoteTarget); err != nil { | |||
| 719 | + | return fmt.Errorf("%w: remote target: %v", ErrInvalidHello, err) | |||
| 720 | + | } | |||
| 721 | + | if h.Name != h.RemoteTarget.SessionName { | |||
| 722 | + | return ErrInvalidHello | |||
| 723 | + | } | |||
| 512 | return nil | 724 | return nil | ||
| 513 | } | 725 | } | ||
| 514 | 726 | ||||
| @@ -541,8 +753,8 @@ func ValidateAck(m Ack) error { | |||||
| 541 | return nil | 753 | return nil | ||
| 542 | } | 754 | } | ||
| 543 | 755 | ||||
| 544 | - | // ValidateAttachTarget validates only the endpoint, session, and intent | 756 | + | // ValidateAttachTarget validates route fields and, when present, the exact |
| 545 | - | // fields carried by the server attach-target message. | 757 | + | // picker identity carried by the handoff. |
| 546 | func ValidateAttachTarget(m AttachTarget) error { | 758 | func ValidateAttachTarget(m AttachTarget) error { | ||
| 547 | if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { | 759 | if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { | ||
| 548 | return ErrInvalidAttachTarget | 760 | return ErrInvalidAttachTarget | ||
| @@ -550,11 +762,39 @@ func ValidateAttachTarget(m AttachTarget) error { | |||||
| 550 | if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { | 762 | if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { | ||
| 551 | return ErrInvalidAttachTarget | 763 | return ErrInvalidAttachTarget | ||
| 552 | } | 764 | } | ||
| 765 | + | if !validEnvironmentPolicy(m.EnvironmentPolicy) { | |||
| 766 | + | return ErrInvalidAttachTarget | |||
| 767 | + | } | |||
| 768 | + | if m.RemoteTarget == nil { | |||
| 769 | + | if m.EnvironmentPolicy != EnvironmentPolicyClientOwned { | |||
| 770 | + | return ErrInvalidAttachTarget | |||
| 771 | + | } | |||
| 772 | + | return nil | |||
| 773 | + | } | |||
| 774 | + | if m.Intent != IntentAttach && m.Intent != IntentResume { | |||
| 775 | + | return ErrInvalidAttachTarget | |||
| 776 | + | } | |||
| 777 | + | if err := validateWireRemoteTarget(*m.RemoteTarget); err != nil { | |||
| 778 | + | return fmt.Errorf("%w: remote target: %v", ErrInvalidAttachTarget, err) | |||
| 779 | + | } | |||
| 780 | + | if m.Endpoint != m.RemoteTarget.Endpoint || m.Session != m.RemoteTarget.SessionName { | |||
| 781 | + | return ErrInvalidAttachTarget | |||
| 782 | + | } | |||
| 553 | return nil | 783 | return nil | ||
| 554 | } | 784 | } | ||
| 555 | 785 | ||||
| 786 | + | func validateWireRemoteTarget(target domain.RemoteSessionTarget) error { | |||
| 787 | + | if len(target.Endpoint) > math.MaxUint16 || len(target.DisplayOrigin) > math.MaxUint16 || len(target.SessionName) > math.MaxUint16 || len(target.LiveTabID) > math.MaxUint16 || len(target.StoppedTab.RawName) > math.MaxUint16 { | |||
| 788 | + | return errors.New("remote target string too long") | |||
| 789 | + | } | |||
| 790 | + | return target.Validate() | |||
| 791 | + | } | |||
| 792 | + | ||||
| 556 | // MarshalHello encodes h into a Hello message payload. | 793 | // MarshalHello encodes h into a Hello message payload. | ||
| 557 | func MarshalHello(h Hello) []byte { | 794 | func MarshalHello(h Hello) []byte { | ||
| 795 | + | if err := ValidateHello(h); err != nil { | |||
| 796 | + | return nil | |||
| 797 | + | } | |||
| 558 | w := payloadWriter{} | 798 | w := payloadWriter{} | ||
| 559 | w.putUint16(h.Version) | 799 | w.putUint16(h.Version) | ||
| 560 | w.putUint8(h.Intent) | 800 | w.putUint8(h.Intent) | ||
| @@ -571,6 +811,9 @@ func MarshalHello(h Hello) []byte { | |||||
| 571 | for _, entry := range h.Env { | 811 | for _, entry := range h.Env { | ||
| 572 | w.putLongString(entry) | 812 | w.putLongString(entry) | ||
| 573 | } | 813 | } | ||
| 814 | + | if h.RemoteTarget != nil || h.EnvironmentPolicy != EnvironmentPolicyClientOwned { | |||
| 815 | + | marshalRemoteTargetExtension(&w, h.RemoteTarget, h.EnvironmentPolicy) | |||
| 816 | + | } | |||
| 574 | return w.b | 817 | return w.b | ||
| 575 | } | 818 | } | ||
| 576 | 819 | ||||
| @@ -626,6 +869,11 @@ func preflightHello(b []byte) error { | |||||
| 626 | return err | 869 | return err | ||
| 627 | } | 870 | } | ||
| 628 | } | 871 | } | ||
| 872 | + | if len(r.b) != 0 { | |||
| 873 | + | if err := skipRemoteTargetExtension(&r); err != nil { | |||
| 874 | + | return err | |||
| 875 | + | } | |||
| 876 | + | } | |||
| 629 | return r.done() | 877 | return r.done() | ||
| 630 | } | 878 | } | ||
| 631 | 879 | ||||
| @@ -698,6 +946,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 698 | h.Env = append(h.Env, entry) | 946 | h.Env = append(h.Env, entry) | ||
| 699 | } | 947 | } | ||
| 700 | } | 948 | } | ||
| 949 | + | if len(r.b) != 0 { | |||
| 950 | + | if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil { | |||
| 951 | + | return Hello{}, err | |||
| 952 | + | } | |||
| 953 | + | } | |||
| 701 | if err := r.done(); err != nil { | 954 | if err := r.done(); err != nil { | ||
| 702 | return Hello{}, err | 955 | return Hello{}, err | ||
| 703 | } | 956 | } | ||
| @@ -1141,6 +1394,9 @@ func MarshalAttachTarget(m AttachTarget) []byte { | |||||
| 1141 | w.putString(m.Endpoint) | 1394 | w.putString(m.Endpoint) | ||
| 1142 | w.putString(m.Session) | 1395 | w.putString(m.Session) | ||
| 1143 | w.putUint8(m.Intent) | 1396 | w.putUint8(m.Intent) | ||
| 1397 | + | if m.RemoteTarget != nil || m.EnvironmentPolicy != EnvironmentPolicyClientOwned { | |||
| 1398 | + | marshalRemoteTargetExtension(&w, m.RemoteTarget, m.EnvironmentPolicy) | |||
| 1399 | + | } | |||
| 1144 | return w.b | 1400 | return w.b | ||
| 1145 | } | 1401 | } | ||
| 1146 | 1402 | ||||
| @@ -1162,6 +1418,11 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||||
| 1162 | if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) { | 1418 | if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) { | ||
| 1163 | return AttachTarget{}, ErrInvalidAttachTarget | 1419 | return AttachTarget{}, ErrInvalidAttachTarget | ||
| 1164 | } | 1420 | } | ||
| 1421 | + | if len(probe.b) != 0 { | |||
| 1422 | + | if err := skipRemoteTargetExtension(&probe); err != nil { | |||
| 1423 | + | return AttachTarget{}, ErrInvalidAttachTarget | |||
| 1424 | + | } | |||
| 1425 | + | } | |||
| 1165 | if err := probe.done(); err != nil { | 1426 | if err := probe.done(); err != nil { | ||
| 1166 | return AttachTarget{}, ErrInvalidAttachTarget | 1427 | return AttachTarget{}, ErrInvalidAttachTarget | ||
| 1167 | } | 1428 | } | ||
| @@ -1177,6 +1438,11 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||||
| 1177 | if m.Intent, err = r.getUint8(); err != nil { | 1438 | if m.Intent, err = r.getUint8(); err != nil { | ||
| 1178 | return AttachTarget{}, err | 1439 | return AttachTarget{}, err | ||
| 1179 | } | 1440 | } | ||
| 1441 | + | if len(r.b) != 0 { | |||
| 1442 | + | if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil { | |||
| 1443 | + | return AttachTarget{}, err | |||
| 1444 | + | } | |||
| 1445 | + | } | |||
| 1180 | if err := r.done(); err != nil { | 1446 | if err := r.done(); err != nil { | ||
| 1181 | return AttachTarget{}, err | 1447 | return AttachTarget{}, err | ||
| 1182 | } | 1448 | } | ||
M internal/ports/wire_final_test.go +3 -3
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 14 | - | if ProtocolVersion != 23 { | 14 | + | if ProtocolVersion != 24 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion) |
| 16 | } | 16 | } | ||
| 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 18 | if len(payload) < 2 { | 18 | if len(payload) < 2 { | ||
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 26 | 26 | ||||
| 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 29 | - | want := append([]byte{0, 23, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 24, 2}, make([]byte, 16+8)...) |
| 30 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | 30 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | ||
| 31 | got := MarshalHello(msg) | 31 | got := MarshalHello(msg) | ||
| 32 | if !bytes.Equal(got, want) { | 32 | if !bytes.Equal(got, want) { | ||
M internal/ports/wire_test.go +6 -6
| @@ -124,7 +124,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 124 | } | 124 | } | ||
| 125 | got := MarshalHello(msg) | 125 | got := MarshalHello(msg) | ||
| 126 | want := []byte{ | 126 | want := []byte{ | ||
| 127 | - | 0x00, 0x17, 0x00, // version, intent | 127 | + | 0x00, 0x18, 0x00, // version, intent |
| 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -341,7 +341,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 341 | } | 341 | } | ||
| 342 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 342 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 343 | require.Equal(t, want, MarshalTheme(cleared)) | 343 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 344 | - | require.Equal(t, uint16(23), ProtocolVersion) | 344 | + | require.Equal(t, uint16(24), ProtocolVersion) |
| 345 | } | 345 | } | ||
| 346 | 346 | ||||
| 347 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 347 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -469,7 +469,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 469 | name: "minimal", | 469 | name: "minimal", | ||
| 470 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 470 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 471 | want: []byte{ | 471 | want: []byte{ | ||
| 472 | - | 0x00, 0x17, | 472 | + | 0x00, 0x18, |
| 473 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 473 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 474 | 0x00, // attached | 474 | 0x00, // attached | ||
| 475 | 0x00, // self | 475 | 0x00, // self | ||
| @@ -492,7 +492,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 492 | JSON: true, | 492 | JSON: true, | ||
| 493 | }, | 493 | }, | ||
| 494 | want: []byte{ | 494 | want: []byte{ | ||
| 495 | - | 0x00, 0x17, | 495 | + | 0x00, 0x18, |
| 496 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 496 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 497 | 0x00, // attached | 497 | 0x00, // attached | ||
| 498 | 0x01, // self | 498 | 0x01, // self | ||
| @@ -920,7 +920,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 920 | name: "attached", | 920 | name: "attached", | ||
| 921 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 921 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 922 | want: []byte{ | 922 | want: []byte{ | ||
| 923 | - | 0x00, 0x17, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 923 | + | 0x00, 0x18, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 924 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 924 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 925 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 925 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 926 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 926 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -930,7 +930,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 930 | name: "control", | 930 | name: "control", | ||
| 931 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | 931 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | ||
| 932 | want: []byte{ | 932 | want: []byte{ | ||
| 933 | - | 0x00, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 933 | + | 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 934 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 934 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 935 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 935 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 936 | }, | 936 | }, | ||
M internal/usecase/client/client.go +43 -5
| @@ -147,6 +147,8 @@ const ( | |||||
| 147 | ) | 147 | ) | ||
| 148 | 148 | ||||
| 149 | // Dependencies supplies the collaborators required by a Runner. | 149 | // Dependencies supplies the collaborators required by a Runner. | ||
| 150 | + | type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error) | |||
| 151 | + | ||||
| 150 | type Dependencies struct { | 152 | type Dependencies struct { | ||
| 151 | Dialer ports.Dialer | 153 | Dialer ports.Dialer | ||
| 152 | Terminal ports.Terminal | 154 | Terminal ports.Terminal | ||
| @@ -155,17 +157,23 @@ type Dependencies struct { | |||||
| 155 | Logger *slog.Logger | 157 | Logger *slog.Logger | ||
| 156 | RuntimeObserver ports.SerializedRuntimeObserver | 158 | RuntimeObserver ports.SerializedRuntimeObserver | ||
| 157 | RemoteHostLearner ports.RemoteHostLearner | 159 | RemoteHostLearner ports.RemoteHostLearner | ||
| 160 | + | // AttachHandoff keeps one Runner and one terminal/input ownership across a | |||
| 161 | + | // local daemon's structured remote handoff. It is nil for direct CLI attach. | |||
| 162 | + | AttachHandoff AttachHandoffFunc | |||
| 158 | // Remote selects client-side carriage presentation only; it never enters | 163 | // Remote selects client-side carriage presentation only; it never enters | ||
| 159 | // the daemon-facing session request. | 164 | // the daemon-facing session request. | ||
| 160 | Remote bool | 165 | Remote bool | ||
| 161 | } | 166 | } | ||
| 162 | 167 | ||||
| 163 | // AttachRequest identifies the session for one client run. Remote is | 168 | // AttachRequest identifies the session for one client run. Remote is | ||
| 164 | - | // client-only presentation metadata; it is never serialized into Hello. | 169 | + | // client-only presentation metadata. RemoteTarget is an optional exact picker |
| 170 | + | // handoff and is serialized into Hello only when present. | |||
| 165 | type AttachRequest struct { | 171 | type AttachRequest struct { | ||
| 166 | - | Intent uint8 | 172 | + | Intent uint8 |
| 167 | - | SessionName string | 173 | + | SessionName string |
| 168 | - | Remote bool | 174 | + | Remote bool |
| 175 | + | RemoteTarget *domain.RemoteSessionTarget | |||
| 176 | + | EnvironmentPolicy ports.EnvironmentPolicy | |||
| 169 | } | 177 | } | ||
| 170 | 178 | ||||
| 171 | // Runner owns the client lifecycle across one or more attachment attempts. | 179 | // Runner owns the client lifecycle across one or more attachment attempts. | ||
| @@ -177,6 +185,7 @@ type Runner struct { | |||||
| 177 | logger *slog.Logger | 185 | logger *slog.Logger | ||
| 178 | runtimeObserver ports.SerializedRuntimeObserver | 186 | runtimeObserver ports.SerializedRuntimeObserver | ||
| 179 | remoteHostLearner ports.RemoteHostLearner | 187 | remoteHostLearner ports.RemoteHostLearner | ||
| 188 | + | attachHandoff AttachHandoffFunc | |||
| 180 | remote bool | 189 | remote bool | ||
| 181 | 190 | ||||
| 182 | inputMu sync.Mutex | 191 | inputMu sync.Mutex | ||
| @@ -197,6 +206,7 @@ func NewRunner(deps Dependencies) *Runner { | |||||
| 197 | logger: log, | 206 | logger: log, | ||
| 198 | runtimeObserver: deps.RuntimeObserver, | 207 | runtimeObserver: deps.RuntimeObserver, | ||
| 199 | remoteHostLearner: deps.RemoteHostLearner, | 208 | remoteHostLearner: deps.RemoteHostLearner, | ||
| 209 | + | attachHandoff: deps.AttachHandoff, | |||
| 200 | remote: deps.Remote, | 210 | remote: deps.Remote, | ||
| 201 | } | 211 | } | ||
| 202 | } | 212 | } | ||
| @@ -220,6 +230,17 @@ func (r *Runner) terminalInput() *terminalInputPump { | |||||
| 220 | // above attach attempts so raw mode remains active while a live client process | 230 | // above attach attempts so raw mode remains active while a live client process | ||
| 221 | // redials a lost link. | 231 | // redials a lost link. | ||
| 222 | func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) { | 232 | func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) { | ||
| 233 | + | if request.RemoteTarget != nil { | |||
| 234 | + | if err := request.RemoteTarget.Validate(); err != nil { | |||
| 235 | + | return fmt.Errorf("vev: invalid remote attach target: %w", err) | |||
| 236 | + | } | |||
| 237 | + | if request.SessionName != request.RemoteTarget.SessionName { | |||
| 238 | + | return errors.New("vev: remote attach target session mismatch") | |||
| 239 | + | } | |||
| 240 | + | if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned && request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned { | |||
| 241 | + | return errors.New("vev: invalid remote environment policy") | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 223 | ms := milestones{} | 244 | ms := milestones{} | ||
| 224 | 245 | ||||
| 225 | defer func() { | 246 | defer func() { | ||
| @@ -393,7 +414,22 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 393 | _ = connection.Close() | 414 | _ = connection.Close() | ||
| 394 | } | 415 | } | ||
| 395 | if result.target != nil { | 416 | if result.target != nil { | ||
| 396 | - | return &AttachTargetError{Target: *result.target} | 417 | + | if r.attachHandoff == nil { |
| 418 | + | return &AttachTargetError{Target: *result.target} | |||
| 419 | + | } | |||
| 420 | + | nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target) | |||
| 421 | + | if handoffErr != nil { | |||
| 422 | + | return handoffErr | |||
| 423 | + | } | |||
| 424 | + | if nextDialer == nil { | |||
| 425 | + | return errors.New("vev: remote attach handoff returned nil dialer") | |||
| 426 | + | } | |||
| 427 | + | r.dialer = nextDialer | |||
| 428 | + | attemptRequest = nextRequest | |||
| 429 | + | remote = attemptRequest.Remote || r.remote | |||
| 430 | + | resumeToken = 0 | |||
| 431 | + | backoff = defaultReconnectBackoff.initial | |||
| 432 | + | continue | |||
| 397 | } | 433 | } | ||
| 398 | if result.resumeToken != 0 { | 434 | if result.resumeToken != 0 { | ||
| 399 | resumeToken = result.resumeToken | 435 | resumeToken = result.resumeToken | ||
| @@ -695,6 +731,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 695 | TrueColor: trueColor, | 731 | TrueColor: trueColor, | ||
| 696 | MaxOutputInFlight: requestedOutputWindow(transport), | 732 | MaxOutputInFlight: requestedOutputWindow(transport), | ||
| 697 | Env: os.Environ(), | 733 | Env: os.Environ(), | ||
| 734 | + | RemoteTarget: request.RemoteTarget, | |||
| 735 | + | EnvironmentPolicy: request.EnvironmentPolicy, | |||
| 698 | } | 736 | } | ||
| 699 | if err := sendHandshake(func() error { | 737 | if err := sendHandshake(func() error { | ||
| 700 | return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | 738 | return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | ||
M internal/usecase/daemon/client.go +12 -0
| @@ -39,6 +39,9 @@ type attachedClient struct { | |||||
| 39 | overlays *overlayRuntime | 39 | overlays *overlayRuntime | ||
| 40 | overlayOnce sync.Once | 40 | overlayOnce sync.Once | ||
| 41 | clientID [16]byte | 41 | clientID [16]byte | ||
| 42 | + | // remoteOrigin is attachment-specific presentation metadata supplied by a | |||
| 43 | + | // validated picker handoff; it never participates in routing. | |||
| 44 | + | remoteOrigin string | |||
| 42 | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | 45 | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | ||
| 43 | // the linearization gate for every attachment-bound observable operation. A role | 46 | // the linearization gate for every attachment-bound observable operation. A role | ||
| 44 | // transition freezes and drains this gate before changing either generation | 47 | // transition freezes and drains this gate before changing either generation | ||
| @@ -565,6 +568,7 @@ type attachClientOptions struct { | |||||
| 565 | clientID [16]byte | 568 | clientID [16]byte | ||
| 566 | resumeCapable bool | 569 | resumeCapable bool | ||
| 567 | maxOutputInFlight uint8 | 570 | maxOutputInFlight uint8 | ||
| 571 | + | remoteOrigin string | |||
| 568 | } | 572 | } | ||
| 569 | 573 | ||||
| 570 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | 574 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | ||
| @@ -610,6 +614,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||||
| 610 | size: sz, | 614 | size: sz, | ||
| 611 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | 615 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | ||
| 612 | clientID: opts.clientID, | 616 | clientID: opts.clientID, | ||
| 617 | + | remoteOrigin: opts.remoteOrigin, | |||
| 613 | resumeCapable: opts.resumeCapable, | 618 | resumeCapable: opts.resumeCapable, | ||
| 614 | resumeToken: resumeToken, | 619 | resumeToken: resumeToken, | ||
| 615 | } | 620 | } | ||
| @@ -875,6 +880,13 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size | |||||
| 875 | } | 880 | } | ||
| 876 | ac.sendMu.Unlock() | 881 | ac.sendMu.Unlock() | ||
| 877 | 882 | ||||
| 883 | + | if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() { | |||
| 884 | + | // The picker preview geometry is attachment-local. A resize must | |||
| 885 | + | // invalidate any in-flight remote request and fetch the selected row | |||
| 886 | + | // at the new bounded preview dimensions. | |||
| 887 | + | d.registerPreviewForSelection(ac) | |||
| 888 | + | } | |||
| 889 | + | ||||
| 878 | sess := token.sess | 890 | sess := token.sess | ||
| 879 | if sess == nil { | 891 | if sess == nil { | ||
| 880 | return false | 892 | return false | ||
M internal/usecase/daemon/control.go +130 -5
| @@ -695,9 +695,98 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||||
| 695 | } | 695 | } | ||
| 696 | e.d.mu.Lock() | 696 | e.d.mu.Lock() | ||
| 697 | sessions := sessionsSnapshot(e.d.sessions) | 697 | sessions := sessionsSnapshot(e.d.sessions) | ||
| 698 | + | stopped := make([]stoppedSession, 0, len(e.d.stopped)) | |||
| 699 | + | for _, entry := range e.d.stopped { | |||
| 700 | + | if !entry.purging { | |||
| 701 | + | stopped = append(stopped, entry) | |||
| 702 | + | } | |||
| 703 | + | } | |||
| 698 | e.d.mu.Unlock() | 704 | e.d.mu.Unlock() | ||
| 705 | + | ||||
| 706 | + | // Test doubles and pre-parity daemons may not have durable lifecycle | |||
| 707 | + | // identities. Keep their count-only catalogue form readable so direct CLI | |||
| 708 | + | // discovery remains compatible; every production session has a non-zero | |||
| 709 | + | // incarnation and therefore takes the strict complete-schema path below. | |||
| 710 | + | legacy := false | |||
| 711 | + | for _, sess := range sessions { | |||
| 712 | + | if sess.incarnation == (domain.IncarnationID{}) { | |||
| 713 | + | legacy = true | |||
| 714 | + | break | |||
| 715 | + | } | |||
| 716 | + | } | |||
| 717 | + | if !legacy { | |||
| 718 | + | for _, entry := range stopped { | |||
| 719 | + | if entry.incarnation == (domain.IncarnationID{}) { | |||
| 720 | + | legacy = true | |||
| 721 | + | break | |||
| 722 | + | } | |||
| 723 | + | } | |||
| 724 | + | } | |||
| 725 | + | if legacy { | |||
| 726 | + | return e.remoteCatalogLegacy(sessions) | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped)) | |||
| 730 | + | for _, sess := range sessions { | |||
| 731 | + | snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false}) | |||
| 732 | + | tabs, err := remoteCatalogTabs(snap) | |||
| 733 | + | if err != nil { | |||
| 734 | + | return "", err | |||
| 735 | + | } | |||
| 736 | + | row := ports.RemoteCatalogSession{ | |||
| 737 | + | LifecycleID: snap.incarnation, | |||
| 738 | + | Name: snap.name, | |||
| 739 | + | State: "running", | |||
| 740 | + | Ephemeral: snap.ephemeral, | |||
| 741 | + | Tabs: tabs, | |||
| 742 | + | Attached: snap.attached, | |||
| 743 | + | LastUsedSeq: snap.mruAt, | |||
| 744 | + | } | |||
| 745 | + | if len(tabs) != 0 { | |||
| 746 | + | row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID | |||
| 747 | + | } | |||
| 748 | + | rows = append(rows, row) | |||
| 749 | + | } | |||
| 750 | + | for _, entry := range stopped { | |||
| 751 | + | tabs, err := remoteCatalogStoppedTabs(entry) | |||
| 752 | + | if err != nil { | |||
| 753 | + | return "", err | |||
| 754 | + | } | |||
| 755 | + | state := "stopped" | |||
| 756 | + | reason := "" | |||
| 757 | + | if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" { | |||
| 758 | + | state = "broken" | |||
| 759 | + | reason = "session_broken" | |||
| 760 | + | } | |||
| 761 | + | rows = append(rows, ports.RemoteCatalogSession{ | |||
| 762 | + | LifecycleID: entry.incarnation, | |||
| 763 | + | Name: entry.name, | |||
| 764 | + | State: state, | |||
| 765 | + | Ephemeral: false, | |||
| 766 | + | Tabs: tabs, | |||
| 767 | + | LastUsedSeq: entry.lastUsedSeq, | |||
| 768 | + | Reason: reason, | |||
| 769 | + | }) | |||
| 770 | + | } | |||
| 771 | + | sort.Slice(rows, func(i, j int) bool { | |||
| 772 | + | if rows[i].Name != rows[j].Name { | |||
| 773 | + | return rows[i].Name < rows[j].Name | |||
| 774 | + | } | |||
| 775 | + | return rows[i].LifecycleID.String() < rows[j].LifecycleID.String() | |||
| 776 | + | }) | |||
| 777 | + | catalog := ports.RemoteCatalog{ | |||
| 778 | + | ProtocolVersion: ports.ProtocolVersion, | |||
| 779 | + | SchemaVersion: ports.RemoteCatalogSchemaVersion, | |||
| 780 | + | Sessions: rows, | |||
| 781 | + | } | |||
| 782 | + | if err := ports.ValidateRemoteCatalog(catalog); err != nil { | |||
| 783 | + | return "", err | |||
| 784 | + | } | |||
| 785 | + | return marshalListing(catalog) | |||
| 786 | + | } | |||
| 787 | + | ||||
| 788 | + | func (e controlExec) remoteCatalogLegacy(sessions []*session) (string, error) { | |||
| 699 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | 789 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | ||
| 700 | - | // Stopped sessions live in d.stopped and are intentionally not in this listing. | |||
| 701 | for _, sess := range sessions { | 790 | for _, sess := range sessions { | ||
| 702 | snap := sess.snapshotView(viewOptions{}) | 791 | snap := sess.snapshotView(viewOptions{}) | ||
| 703 | rows = append(rows, ports.RemoteCatalogSession{ | 792 | rows = append(rows, ports.RemoteCatalogSession{ | ||
| @@ -709,10 +798,46 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||||
| 709 | }) | 798 | }) | ||
| 710 | } | 799 | } | ||
| 711 | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | 800 | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | ||
| 712 | - | return marshalListing(ports.RemoteCatalog{ | 801 | + | catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: rows} |
| 713 | - | ProtocolVersion: ports.ProtocolVersion, | 802 | + | if err := ports.ValidateRemoteCatalog(catalog); err != nil { |
| 714 | - | Sessions: rows, | 803 | + | return "", err |
| 715 | - | }) | 804 | + | } |
| 805 | + | return marshalListing(catalog) | |||
| 806 | + | } | |||
| 807 | + | ||||
| 808 | + | func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) { | |||
| 809 | + | if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess { | |||
| 810 | + | return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name) | |||
| 811 | + | } | |||
| 812 | + | tabs := make([]ports.RemoteCatalogTab, 0, len(view.tabs)) | |||
| 813 | + | for i, tab := range view.tabs { | |||
| 814 | + | tabs = append(tabs, ports.RemoteCatalogTab{ | |||
| 815 | + | ID: string(tab.id), | |||
| 816 | + | Index: uint16(i), | |||
| 817 | + | Name: tab.name, | |||
| 818 | + | Detail: tabTitleDetail(tab.name, tab.focusedTitle), | |||
| 819 | + | Attention: tab.attention, | |||
| 820 | + | }) | |||
| 821 | + | } | |||
| 822 | + | return tabs, nil | |||
| 823 | + | } | |||
| 824 | + | ||||
| 825 | + | func remoteCatalogStoppedTabs(entry stoppedSession) ([]ports.RemoteCatalogTab, error) { | |||
| 826 | + | records := entry.tabRecords | |||
| 827 | + | if len(records) == 0 && len(entry.tabNames) != 0 { | |||
| 828 | + | records = make([]domain.CatalogueTabRecord, len(entry.tabNames)) | |||
| 829 | + | for i, name := range entry.tabNames { | |||
| 830 | + | records[i].Name = name | |||
| 831 | + | } | |||
| 832 | + | } | |||
| 833 | + | if len(records) > ports.RemoteCatalogMaxTabsPerSess { | |||
| 834 | + | return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name) | |||
| 835 | + | } | |||
| 836 | + | tabs := make([]ports.RemoteCatalogTab, 0, len(records)) | |||
| 837 | + | for i, record := range records { | |||
| 838 | + | tabs = append(tabs, ports.RemoteCatalogTab{ID: string(record.StableID), Index: uint16(i), Name: record.Name}) | |||
| 839 | + | } | |||
| 840 | + | return tabs, nil | |||
| 716 | } | 841 | } | ||
| 717 | 842 | ||||
| 718 | func (e controlExec) ListTabs(asJSON bool) (string, error) { | 843 | func (e controlExec) ListTabs(asJSON bool) (string, error) { | ||
M internal/usecase/daemon/copymode_test.go +1 -0
| @@ -483,6 +483,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 483 | releaseConn() | 483 | releaseConn() | ||
| 484 | closeOnce.Do(func() { close(readDone) }) | 484 | closeOnce.Do(func() { close(readDone) }) | ||
| 485 | hg.Wait() | 485 | hg.Wait() | ||
| 486 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |||
| 486 | d.sessWg.Wait() | 487 | d.sessWg.Wait() | ||
| 487 | } | 488 | } | ||
| 488 | 489 | ||||
M internal/usecase/daemon/daemon.go +50 -5
| @@ -288,9 +288,11 @@ type Daemon struct { | |||||
| 288 | // remoteCatalog owns cache-derived discovery state independently of the | 288 | // remoteCatalog owns cache-derived discovery state independently of the | ||
| 289 | // live attachment registry. Its cache reads and writes never hold d.mu. | 289 | // live attachment registry. Its cache reads and writes never hold d.mu. | ||
| 290 | remoteCatalog remoteCatalogState | 290 | remoteCatalog remoteCatalogState | ||
| 291 | + | remotePreview remotePreviewState | |||
| 291 | remoteHostStore ports.RemoteHostStore | 292 | remoteHostStore ports.RemoteHostStore | ||
| 292 | remoteCatalogClient ports.RemoteCatalogClient | 293 | remoteCatalogClient ports.RemoteCatalogClient | ||
| 293 | remoteCatalogCache ports.RemoteCatalogCache | 294 | remoteCatalogCache ports.RemoteCatalogCache | ||
| 295 | + | remotePreviewClient ports.RemotePreviewClient | |||
| 294 | remoteDialerFactory ports.RemoteDialerFactory | 296 | remoteDialerFactory ports.RemoteDialerFactory | ||
| 295 | remoteTransportMode ports.RemoteTransportMode | 297 | remoteTransportMode ports.RemoteTransportMode | ||
| 296 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 298 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| @@ -355,6 +357,7 @@ type stoppedSession struct { | |||||
| 355 | incarnation domain.IncarnationID | 357 | incarnation domain.IncarnationID | ||
| 356 | lastUsedSeq uint64 | 358 | lastUsedSeq uint64 | ||
| 357 | tabNames []string | 359 | tabNames []string | ||
| 360 | + | tabRecords []domain.CatalogueTabRecord | |||
| 358 | purging bool | 361 | purging bool | ||
| 359 | record domain.CatalogueRecord | 362 | record domain.CatalogueRecord | ||
| 360 | state ports.SessionState | 363 | state ports.SessionState | ||
| @@ -382,6 +385,11 @@ func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalo | |||||
| 382 | } | 385 | } | ||
| 383 | } | 386 | } | ||
| 384 | 387 | ||||
| 388 | + | // WithRemotePreview installs the optional non-attaching remote viewport client. | |||
| 389 | + | func WithRemotePreview(client ports.RemotePreviewClient) Option { | |||
| 390 | + | return func(d *Daemon) { d.remotePreviewClient = client } | |||
| 391 | + | } | |||
| 392 | + | ||||
| 385 | // WithShell overrides the command (and its args) each session spawns. The | 393 | // WithShell overrides the command (and its args) each session spawns. The | ||
| 386 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | 394 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | ||
| 387 | // deterministic program. | 395 | // deterministic program. | ||
| @@ -650,7 +658,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 650 | state, done := initialSessionState(r) | 658 | state, done := initialSessionState(r) | ||
| 651 | d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done) | 659 | d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done) | ||
| 652 | } else { | 660 | } else { | ||
| 653 | - | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)} | 661 | + | d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionStopped, nil) |
| 654 | } | 662 | } | ||
| 655 | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | 663 | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | ||
| 656 | maxCreatedAt = r.CreatedAt | 664 | maxCreatedAt = r.CreatedAt | ||
| @@ -976,6 +984,12 @@ func (d *Daemon) handleConn(tr ports.Transport) { | |||||
| 976 | if err := d.handleCommand(tr, first); err != nil { | 984 | if err := d.handleCommand(tr, first); err != nil { | ||
| 977 | d.log.Warn("command handler failed", "err", err) | 985 | d.log.Warn("command handler failed", "err", err) | ||
| 978 | } | 986 | } | ||
| 987 | + | case ports.MsgRemotePreviewRequest: | |||
| 988 | + | stopTransport() | |||
| 989 | + | finishHandshake() | |||
| 990 | + | if err := d.handleRemotePreview(tr, first); err != nil { | |||
| 991 | + | d.log.Warn("remote preview handler failed", "err", err) | |||
| 992 | + | } | |||
| 979 | case ports.MsgKill: | 993 | case ports.MsgKill: | ||
| 980 | stopTransport() | 994 | stopTransport() | ||
| 981 | finishHandshake() | 995 | finishHandshake() | ||
| @@ -1229,10 +1243,26 @@ func (e *protoErr) Error() string { return e.text } | |||||
| 1229 | // terminal and role state before releasing d.mu, then defers coordinator | 1243 | // terminal and role state before releasing d.mu, then defers coordinator | ||
| 1230 | // cleanup so obsolete workers never delay the new handshake. | 1244 | // cleanup so obsolete workers never delay the new handshake. | ||
| 1231 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) { | 1245 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) { | ||
| 1246 | + | initialTabIndex := -1 | |||
| 1247 | + | if h.RemoteTarget != nil { | |||
| 1248 | + | var ok bool | |||
| 1249 | + | initialTabIndex, ok = remoteTargetTabIndexLocked(sess, *h.RemoteTarget) | |||
| 1250 | + | if !ok { | |||
| 1251 | + | if tr != nil { | |||
| 1252 | + | _ = tr.Close() | |||
| 1253 | + | } | |||
| 1254 | + | d.mu.Unlock() | |||
| 1255 | + | return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"} | |||
| 1256 | + | } | |||
| 1257 | + | } | |||
| 1232 | // Session state is the sole source for future PTY children. Update it before | 1258 | // Session state is the sole source for future PTY children. Update it before | ||
| 1233 | // publishing the attachment; existing PTYs keep their original environment. | 1259 | // publishing the attachment; existing PTYs keep their original environment. | ||
| 1260 | + | // A picker handoff deliberately leaves the daemon-owned environment and CWD | |||
| 1261 | + | // untouched, even though Hello retains those fields for direct CLI clients. | |||
| 1234 | sess.mu.Lock() | 1262 | sess.mu.Lock() | ||
| 1235 | - | sess.env = copyEnvironment(h.Env) | 1263 | + | if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned { |
| 1264 | + | sess.env = copyEnvironment(h.Env) | |||
| 1265 | + | } | |||
| 1236 | sess.terminal = term | 1266 | sess.terminal = term | ||
| 1237 | sess.mu.Unlock() | 1267 | sess.mu.Unlock() | ||
| 1238 | opts := attachClientOptions{ | 1268 | opts := attachClientOptions{ | ||
| @@ -1240,13 +1270,17 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 1240 | resumeCapable: true, | 1270 | resumeCapable: true, | ||
| 1241 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | 1271 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | ||
| 1242 | } | 1272 | } | ||
| 1273 | + | if h.RemoteTarget != nil { | |||
| 1274 | + | opts.remoteOrigin = h.RemoteTarget.DisplayOrigin | |||
| 1275 | + | } | |||
| 1243 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | 1276 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | ||
| 1244 | d.mu.Unlock() | 1277 | d.mu.Unlock() | ||
| 1245 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | 1278 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | ||
| 1246 | - | target: sess, | 1279 | + | target: sess, |
| 1247 | - | next: ac, | 1280 | + | next: ac, |
| 1248 | - | ||||
| 1249 | expectedTransport: ac.transportSnapshot(), | 1281 | expectedTransport: ac.transportSnapshot(), | ||
| 1282 | + | activateTargetTab: initialTabIndex >= 0, | |||
| 1283 | + | targetTabIndex: initialTabIndex, | |||
| 1250 | ready: false, | 1284 | ready: false, | ||
| 1251 | }) | 1285 | }) | ||
| 1252 | if err != nil { | 1286 | if err != nil { | ||
| @@ -1330,6 +1364,9 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T | |||||
| 1330 | return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"} | 1364 | return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"} | ||
| 1331 | } | 1365 | } | ||
| 1332 | term := terminalEnv{TrueColor: h.TrueColor} | 1366 | term := terminalEnv{TrueColor: h.TrueColor} | ||
| 1367 | + | if h.RemoteTarget != nil && h.ResumeToken == 0 { | |||
| 1368 | + | return d.routeRemoteTargetWithContext(ctx, h, tr) | |||
| 1369 | + | } | |||
| 1333 | 1370 | ||||
| 1334 | // A non-zero token is an authoritative resume credential. If it is unknown, | 1371 | // A non-zero token is an authoritative resume credential. If it is unknown, | ||
| 1335 | // expired, or raced with lifecycle teardown, fail closed instead of routing | 1372 | // expired, or raced with lifecycle teardown, fail closed instead of routing | ||
| @@ -1350,12 +1387,20 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T | |||||
| 1350 | } | 1387 | } | ||
| 1351 | return nil, nil, err | 1388 | return nil, nil, err | ||
| 1352 | } else if ok { | 1389 | } else if ok { | ||
| 1390 | + | if h.RemoteTarget != nil && !d.remoteTargetMatchesSession(sess, *h.RemoteTarget) { | |||
| 1391 | + | d.clientGone(sess, ac, tr, false) | |||
| 1392 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"} | |||
| 1393 | + | } | |||
| 1353 | return sess, ac, nil | 1394 | return sess, ac, nil | ||
| 1354 | } | 1395 | } | ||
| 1355 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 1396 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 1356 | } | 1397 | } | ||
| 1357 | if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil { | 1398 | if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil { | ||
| 1358 | if ok { | 1399 | if ok { | ||
| 1400 | + | if h.RemoteTarget != nil && !d.remoteTargetMatchesSession(sess, *h.RemoteTarget) { | |||
| 1401 | + | d.clientGone(sess, ac, tr, false) | |||
| 1402 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"} | |||
| 1403 | + | } | |||
| 1359 | return sess, ac, nil | 1404 | return sess, ac, nil | ||
| 1360 | } | 1405 | } | ||
| 1361 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 1406 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | ||
M internal/usecase/daemon/overlay_runtime.go +18 -13
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "sync" | 5 | "sync" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -21,15 +22,17 @@ type overlayRuntime struct { | |||||
| 21 | // pickerGeneration identifies one open lifecycle. It advances only when a | 22 | // pickerGeneration identifies one open lifecycle. It advances only when a | ||
| 22 | // picker is published, so delayed close and registration work can prove it | 23 | // picker is published, so delayed close and registration work can prove it | ||
| 23 | // still owns the exact lifecycle it captured. | 24 | // still owns the exact lifecycle it captured. | ||
| 24 | - | pickerGeneration uint64 | 25 | + | pickerGeneration uint64 |
| 25 | - | pickerTitle string | 26 | + | pickerTitle string |
| 26 | - | pickerIntent pickerIntent | 27 | + | pickerIntent pickerIntent |
| 27 | - | pickerSource moveSourceLocator | 28 | + | pickerSource moveSourceLocator |
| 28 | - | pickerPreview *tab | 29 | + | pickerPreview *tab |
| 29 | - | pickerPreviewSession *session | 30 | + | pickerPreviewSession *session |
| 30 | - | pickerPreviewGeneration uint64 | 31 | + | pickerRemotePreview picker.Preview |
| 31 | - | pickerPending []byte | 32 | + | pickerRemotePreviewCancel context.CancelFunc |
| 32 | - | pickerESC pendingByteTimer | 33 | + | pickerPreviewGeneration uint64 |
| 34 | + | pickerPending []byte | |||
| 35 | + | pickerESC pendingByteTimer | |||
| 33 | 36 | ||||
| 34 | // Test-only, unsynchronized lifecycle seams. Assign them before picker | 37 | // Test-only, unsynchronized lifecycle seams. Assign them before picker | ||
| 35 | // publication or goroutine startup. Hooks run without pickerMu or | 38 | // publication or goroutine startup. Hooks run without pickerMu or | ||
| @@ -288,10 +291,11 @@ type overlayRenderSnapshot struct { | |||||
| 288 | statusFeedback string | 291 | statusFeedback string | ||
| 289 | resizeActive bool | 292 | resizeActive bool | ||
| 290 | 293 | ||||
| 291 | - | pickerActive bool | 294 | + | pickerActive bool |
| 292 | - | pickerModel *picker.Model | 295 | + | pickerModel *picker.Model |
| 293 | - | pickerTitle string | 296 | + | pickerTitle string |
| 294 | - | previewTab *tab | 297 | + | previewTab *tab |
| 298 | + | remotePreview picker.Preview | |||
| 295 | 299 | ||||
| 296 | noticesOverlayActive bool | 300 | noticesOverlayActive bool | ||
| 297 | noticesOverlayModel *notices.Model | 301 | noticesOverlayModel *notices.Model | ||
| @@ -363,6 +367,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 363 | snap.pickerModel = rt.picker.Clone() | 367 | snap.pickerModel = rt.picker.Clone() | ||
| 364 | snap.pickerTitle = rt.pickerTitle | 368 | snap.pickerTitle = rt.pickerTitle | ||
| 365 | snap.previewTab = rt.pickerPreview | 369 | snap.previewTab = rt.pickerPreview | ||
| 370 | + | snap.remotePreview = clonePickerPreview(rt.pickerRemotePreview) | |||
| 366 | rt.pickerMu.Unlock() | 371 | rt.pickerMu.Unlock() | ||
| 367 | 372 | ||||
| 368 | rt.paletteMu.Lock() | 373 | rt.paletteMu.Lock() | ||
M internal/usecase/daemon/picker.go +271 -10
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "sort" | 7 | "sort" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| @@ -61,7 +62,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||||
| 61 | rt.pickerMu.Lock() | 62 | rt.pickerMu.Lock() | ||
| 62 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | 63 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | ||
| 63 | rt.pickerPreviewGeneration++ | 64 | rt.pickerPreviewGeneration++ | ||
| 65 | + | if rt.pickerRemotePreviewCancel != nil { | |||
| 66 | + | rt.pickerRemotePreviewCancel() | |||
| 67 | + | rt.pickerRemotePreviewCancel = nil | |||
| 68 | + | } | |||
| 64 | rt.pickerPreviewSession = nil | 69 | rt.pickerPreviewSession = nil | ||
| 70 | + | rt.pickerRemotePreview = picker.Preview{} | |||
| 65 | rt.pickerPreview = nil | 71 | rt.pickerPreview = nil | ||
| 66 | rt.picker = model | 72 | rt.picker = model | ||
| 67 | rt.pickerGeneration++ | 73 | rt.pickerGeneration++ | ||
| @@ -163,7 +169,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||||
| 163 | catalogRows := 0 | 169 | catalogRows := 0 | ||
| 164 | for _, host := range catalog { | 170 | for _, host := range catalog { | ||
| 165 | catalogRows += len(host.entry.Sessions) | 171 | catalogRows += len(host.entry.Sessions) | ||
| 166 | - | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) { | 172 | + | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) { |
| 167 | catalogRows++ | 173 | catalogRows++ | ||
| 168 | } | 174 | } | ||
| 169 | } | 175 | } | ||
| @@ -179,7 +185,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||||
| 179 | } | 185 | } | ||
| 180 | views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt)) | 186 | views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt)) | ||
| 181 | } | 187 | } | ||
| 182 | - | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) { | 188 | + | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) { |
| 183 | views = append(views, remotePickerHostView(host.entry.Host, host.status)) | 189 | views = append(views, remotePickerHostView(host.entry.Host, host.status)) | ||
| 184 | } | 190 | } | ||
| 185 | } | 191 | } | ||
| @@ -216,6 +222,42 @@ func attentionSuffix(label string) string { | |||||
| 216 | return label + " " + string(ui.AttentionGlyph) | 222 | return label + " " + string(ui.AttentionGlyph) | ||
| 217 | } | 223 | } | ||
| 218 | 224 | ||||
| 225 | + | func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) { | |||
| 226 | + | reason := target.UnavailableReason | |||
| 227 | + | if reason == "" { | |||
| 228 | + | reason = "identity_changed" | |||
| 229 | + | } | |||
| 230 | + | message := "Remote session unavailable" | |||
| 231 | + | if target.RemoteTarget != nil { | |||
| 232 | + | message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin | |||
| 233 | + | } else if target.RemoteHost != "" { | |||
| 234 | + | message += ": " + target.RemoteHost | |||
| 235 | + | } | |||
| 236 | + | message += " — " + remotePickerReasonText(reason) | |||
| 237 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func remotePickerReasonText(reason string) string { | |||
| 241 | + | switch reason { | |||
| 242 | + | case "catalog_stale": | |||
| 243 | + | return "catalog stale" | |||
| 244 | + | case "host_unreachable": | |||
| 245 | + | return "host unreachable" | |||
| 246 | + | case "version_mismatch": | |||
| 247 | + | return "version mismatch" | |||
| 248 | + | case "session_stopped": | |||
| 249 | + | return "session stopped" | |||
| 250 | + | case "session_broken": | |||
| 251 | + | return "session broken" | |||
| 252 | + | case "refreshing": | |||
| 253 | + | return "refreshing" | |||
| 254 | + | case "identity_changed": | |||
| 255 | + | return "session identity changed" | |||
| 256 | + | default: | |||
| 257 | + | return "unavailable" | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 219 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | 261 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | ||
| 220 | rt := ac.overlays | 262 | rt := ac.overlays | ||
| 221 | var previewGeneration uint64 | 263 | var previewGeneration uint64 | ||
| @@ -271,13 +313,16 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 271 | } | 313 | } | ||
| 272 | }) | 314 | }) | ||
| 273 | var target picker.Target | 315 | var target picker.Target | ||
| 316 | + | var cursor picker.Target | |||
| 274 | var ok bool | 317 | var ok bool | ||
| 318 | + | var cursorOK bool | |||
| 275 | var intent pickerIntent | 319 | var intent pickerIntent | ||
| 276 | var source moveSourceLocator | 320 | var source moveSourceLocator | ||
| 277 | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | 321 | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | ||
| 278 | prevIdx := -1 | 322 | prevIdx := -1 | ||
| 279 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | 323 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | ||
| 280 | target, ok = rt.picker.Selected() | 324 | target, ok = rt.picker.Selected() | ||
| 325 | + | cursor, cursorOK = rt.picker.Cursor() | |||
| 281 | prevIdx = rt.picker.SelectedIndex() | 326 | prevIdx = rt.picker.SelectedIndex() | ||
| 282 | } | 327 | } | ||
| 283 | intent, source = rt.pickerIntent, rt.pickerSource | 328 | intent, source = rt.pickerIntent, rt.pickerSource | ||
| @@ -289,6 +334,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 289 | d.invalidateRender(sess, ac, true, "picker.go") | 334 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 290 | return | 335 | return | ||
| 291 | } | 336 | } | ||
| 337 | + | if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") { | |||
| 338 | + | d.notifyRemotePickerUnavailable(sess, cursor) | |||
| 339 | + | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 340 | + | return | |||
| 341 | + | } | |||
| 292 | if result.action == 'x' { | 342 | if result.action == 'x' { | ||
| 293 | var effect *attachmentEffectTicket | 343 | var effect *attachmentEffectTicket | ||
| 294 | if len(effects) != 0 { | 344 | if len(effects) != 0 { | ||
| @@ -379,13 +429,22 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 379 | previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | 429 | previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | ||
| 380 | ac.overlays.pickerPreviewGeneration++ | 430 | ac.overlays.pickerPreviewGeneration++ | ||
| 381 | generation := ac.overlays.pickerPreviewGeneration | 431 | generation := ac.overlays.pickerPreviewGeneration | ||
| 432 | + | if ac.overlays.pickerRemotePreviewCancel != nil { | |||
| 433 | + | ac.overlays.pickerRemotePreviewCancel() | |||
| 434 | + | ac.overlays.pickerRemotePreviewCancel = nil | |||
| 435 | + | } | |||
| 382 | ac.overlays.pickerPreviewSession = nil | 436 | ac.overlays.pickerPreviewSession = nil | ||
| 437 | + | ac.overlays.pickerRemotePreview = picker.Preview{} | |||
| 383 | ac.overlays.pickerPreview = nil | 438 | ac.overlays.pickerPreview = nil | ||
| 384 | ac.overlays.pickerMu.Unlock() | 439 | ac.overlays.pickerMu.Unlock() | ||
| 385 | d.teardownPreviewSubscription(ac, previous, previousGeneration) | 440 | d.teardownPreviewSubscription(ac, previous, previousGeneration) | ||
| 386 | if !ok { | 441 | if !ok { | ||
| 387 | return | 442 | return | ||
| 388 | } | 443 | } | ||
| 444 | + | if target.RemoteTarget != nil { | |||
| 445 | + | d.startRemotePickerPreview(ac, target, generation) | |||
| 446 | + | return | |||
| 447 | + | } | |||
| 389 | targetSess, next := d.previewTarget(target, intent) | 448 | targetSess, next := d.previewTarget(target, intent) | ||
| 390 | if next == nil || targetSess == nil { | 449 | if next == nil || targetSess == nil { | ||
| 391 | return | 450 | return | ||
| @@ -421,6 +480,122 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 421 | d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation) | 480 | d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation) | ||
| 422 | } | 481 | } | ||
| 423 | 482 | ||||
| 483 | + | func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Target, generation uint64) { | |||
| 484 | + | if d == nil || ac == nil || target.RemoteTarget == nil || (d.remotePreviewClient == nil && !target.RemoteTarget.Stopped) { | |||
| 485 | + | return | |||
| 486 | + | } | |||
| 487 | + | width, height := remotePickerPreviewSize(ac.sizeSnapshot()) | |||
| 488 | + | if width == 0 || height == 0 { | |||
| 489 | + | return | |||
| 490 | + | } | |||
| 491 | + | ctx := d.serveCtx | |||
| 492 | + | if ctx == nil { | |||
| 493 | + | ctx = context.Background() | |||
| 494 | + | } | |||
| 495 | + | previewCtx, cancel := context.WithCancel(ctx) | |||
| 496 | + | ac.overlays.pickerMu.Lock() | |||
| 497 | + | if ac.overlays.picker == nil || ac.overlays.pickerPreviewGeneration != generation { | |||
| 498 | + | ac.overlays.pickerMu.Unlock() | |||
| 499 | + | cancel() | |||
| 500 | + | return | |||
| 501 | + | } | |||
| 502 | + | ac.overlays.pickerRemotePreviewCancel = cancel | |||
| 503 | + | remoteTarget := *target.RemoteTarget | |||
| 504 | + | message := "loading remote preview…" | |||
| 505 | + | if remoteTarget.Stopped { | |||
| 506 | + | message = "stopped session — preview unavailable" | |||
| 507 | + | } | |||
| 508 | + | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message) | |||
| 509 | + | if remoteTarget.Stopped { | |||
| 510 | + | ac.overlays.pickerRemotePreviewCancel = nil | |||
| 511 | + | ac.overlays.pickerMu.Unlock() | |||
| 512 | + | cancel() | |||
| 513 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |||
| 514 | + | d.invalidateRender(sess, ac, false, "remote picker preview") | |||
| 515 | + | } | |||
| 516 | + | return | |||
| 517 | + | } | |||
| 518 | + | ac.overlays.pickerMu.Unlock() | |||
| 519 | + | go func() { | |||
| 520 | + | previewClock := d.clock | |||
| 521 | + | if previewClock == nil { | |||
| 522 | + | previewClock = systemClock{} | |||
| 523 | + | } | |||
| 524 | + | debounce := previewClock.NewTimer(80 * time.Millisecond) | |||
| 525 | + | defer debounce.Stop() | |||
| 526 | + | select { | |||
| 527 | + | case <-debounce.C(): | |||
| 528 | + | case <-previewCtx.Done(): | |||
| 529 | + | return | |||
| 530 | + | } | |||
| 531 | + | preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height) | |||
| 532 | + | if err != nil { | |||
| 533 | + | ac.overlays.pickerMu.Lock() | |||
| 534 | + | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | |||
| 535 | + | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | |||
| 536 | + | } | |||
| 537 | + | ac.overlays.pickerMu.Unlock() | |||
| 538 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |||
| 539 | + | d.invalidateRender(sess, ac, false, "remote picker preview") | |||
| 540 | + | } | |||
| 541 | + | return | |||
| 542 | + | } | |||
| 543 | + | if refreshed, refreshErr := d.awaitRemotePreviewRefresh(previewCtx, remoteTarget, width, height); refreshErr == nil { | |||
| 544 | + | preview = refreshed | |||
| 545 | + | } else if previewCtx.Err() != nil { | |||
| 546 | + | return | |||
| 547 | + | } | |||
| 548 | + | rows := preview.FrameRows() | |||
| 549 | + | candidate := picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)} | |||
| 550 | + | ac.overlays.pickerMu.Lock() | |||
| 551 | + | if ac.overlays.pickerPreviewGeneration != generation || ac.overlays.picker == nil { | |||
| 552 | + | ac.overlays.pickerMu.Unlock() | |||
| 553 | + | return | |||
| 554 | + | } | |||
| 555 | + | selected, stillSelected := ac.overlays.picker.Selected() | |||
| 556 | + | if !stillSelected || !pickerTargetsEqual(selected, target) { | |||
| 557 | + | ac.overlays.pickerMu.Unlock() | |||
| 558 | + | return | |||
| 559 | + | } | |||
| 560 | + | ac.overlays.pickerRemotePreview = candidate | |||
| 561 | + | ac.overlays.pickerRemotePreviewCancel = nil | |||
| 562 | + | ac.overlays.pickerMu.Unlock() | |||
| 563 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |||
| 564 | + | d.invalidateRender(sess, ac, false, "remote picker preview") | |||
| 565 | + | } | |||
| 566 | + | }() | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | func remotePickerPreviewSize(size domain.Size) (uint16, uint16) { | |||
| 570 | + | layout := picker.ChooseLayout(size) | |||
| 571 | + | width, height := layout.Preview.Width, layout.Preview.Height | |||
| 572 | + | if width <= 0 || height <= 0 { | |||
| 573 | + | return 0, 0 | |||
| 574 | + | } | |||
| 575 | + | return uint16(min(width, ports.RemotePreviewMaxWidth)), uint16(min(height, ports.RemotePreviewMaxHeight)) | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | func staticRemotePickerPreview(width, height uint16, message string) picker.Preview { | |||
| 579 | + | if width == 0 || height == 0 { | |||
| 580 | + | return picker.Preview{} | |||
| 581 | + | } | |||
| 582 | + | rows := make([][]renderer.Cell, int(height)) | |||
| 583 | + | style := renderer.DefaultStyle() | |||
| 584 | + | for y := range rows { | |||
| 585 | + | rows[y] = make([]renderer.Cell, int(width)) | |||
| 586 | + | for x := range rows[y] { | |||
| 587 | + | rows[y][x] = renderer.Cell{Rune: ' ', Style: style} | |||
| 588 | + | } | |||
| 589 | + | } | |||
| 590 | + | for x, r := range []rune(message) { | |||
| 591 | + | if x >= int(width) { | |||
| 592 | + | break | |||
| 593 | + | } | |||
| 594 | + | rows[0][x] = renderer.Cell{Rune: r, Style: style} | |||
| 595 | + | } | |||
| 596 | + | return picker.Preview{Rows: rows, Width: int(width), Height: int(height)} | |||
| 597 | + | } | |||
| 598 | + | ||||
| 424 | func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool { | 599 | func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool { | ||
| 425 | ac.overlays.pickerMu.Lock() | 600 | ac.overlays.pickerMu.Lock() | ||
| 426 | defer ac.overlays.pickerMu.Unlock() | 601 | defer ac.overlays.pickerMu.Unlock() | ||
| @@ -463,7 +638,12 @@ func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) b | |||||
| 463 | } | 638 | } | ||
| 464 | previous := ac.overlays.pickerPreviewSession | 639 | previous := ac.overlays.pickerPreviewSession | ||
| 465 | ac.overlays.pickerPreviewGeneration++ | 640 | ac.overlays.pickerPreviewGeneration++ | ||
| 641 | + | if ac.overlays.pickerRemotePreviewCancel != nil { | |||
| 642 | + | ac.overlays.pickerRemotePreviewCancel() | |||
| 643 | + | ac.overlays.pickerRemotePreviewCancel = nil | |||
| 644 | + | } | |||
| 466 | ac.overlays.pickerPreviewSession = nil | 645 | ac.overlays.pickerPreviewSession = nil | ||
| 646 | + | ac.overlays.pickerRemotePreview = picker.Preview{} | |||
| 467 | ac.overlays.pickerPreview = nil | 647 | ac.overlays.pickerPreview = nil | ||
| 468 | ac.overlays.pickerMu.Unlock() | 648 | ac.overlays.pickerMu.Unlock() | ||
| 469 | d.teardownPreviewSubscription(ac, previous, generation) | 649 | d.teardownPreviewSubscription(ac, previous, generation) | ||
| @@ -537,8 +717,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g | |||||
| 537 | 717 | ||||
| 538 | func pickerTargetsEqual(left, right picker.Target) bool { | 718 | func pickerTargetsEqual(left, right picker.Target) bool { | ||
| 539 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | 719 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | ||
| 540 | - | left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped || | 720 | + | left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped || |
| 541 | - | !remoteKeysEqual(left.RemoteKey, right.RemoteKey) { | 721 | + | left.UnavailableReason != right.UnavailableReason || !remoteKeysEqual(left.RemoteKey, right.RemoteKey) || |
| 722 | + | !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) { | |||
| 542 | return false | 723 | return false | ||
| 543 | } | 724 | } | ||
| 544 | if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil { | 725 | if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil { | ||
| @@ -554,6 +735,13 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool { | |||||
| 554 | return *left == *right | 735 | return *left == *right | ||
| 555 | } | 736 | } | ||
| 556 | 737 | ||||
| 738 | + | func remoteTargetsEqual(left, right *domain.RemoteSessionTarget) bool { | |||
| 739 | + | if left == nil || right == nil { | |||
| 740 | + | return left == nil && right == nil | |||
| 741 | + | } | |||
| 742 | + | return *left == *right | |||
| 743 | + | } | |||
| 744 | + | ||||
| 557 | func (d *Daemon) sessionByID(id domain.SessionID) *session { | 745 | func (d *Daemon) sessionByID(id domain.SessionID) *session { | ||
| 558 | d.mu.Lock() | 746 | d.mu.Lock() | ||
| 559 | defer d.mu.Unlock() | 747 | defer d.mu.Unlock() | ||
| @@ -660,6 +848,13 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||||
| 660 | if token.sess == nil || token.ac == nil || token.effect == nil { | 848 | if token.sess == nil || token.ac == nil || token.effect == nil { | ||
| 661 | return nil | 849 | return nil | ||
| 662 | } | 850 | } | ||
| 851 | + | if target.RemoteTarget != nil { | |||
| 852 | + | var key domain.RemoteSessionKey | |||
| 853 | + | if target.RemoteKey != nil { | |||
| 854 | + | key = *target.RemoteKey | |||
| 855 | + | } | |||
| 856 | + | return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action) | |||
| 857 | + | } | |||
| 663 | if target.RemoteKey != nil { | 858 | if target.RemoteKey != nil { | ||
| 664 | return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action) | 859 | return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action) | ||
| 665 | } | 860 | } | ||
| @@ -676,14 +871,35 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||||
| 676 | // the target is sent, the local attachment is detached and the client opens a | 871 | // the target is sent, the local attachment is detached and the client opens a | ||
| 677 | // fresh connection to the owning daemon. | 872 | // fresh connection to the owning daemon. | ||
| 678 | func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error { | 873 | func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error { | ||
| 679 | - | if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) { | 874 | + | var handoff ports.AttachTarget |
| 875 | + | if target.RemoteTarget != nil { | |||
| 876 | + | remoteTarget := *target.RemoteTarget | |||
| 877 | + | if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) { | |||
| 878 | + | return errAttachmentTransition | |||
| 879 | + | } | |||
| 880 | + | if target.RemoteKey != nil { | |||
| 881 | + | if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID { | |||
| 882 | + | return errAttachmentTransition | |||
| 883 | + | } | |||
| 884 | + | } | |||
| 885 | + | handoff = ports.AttachTarget{ | |||
| 886 | + | Endpoint: remoteTarget.Endpoint, | |||
| 887 | + | Session: remoteTarget.SessionName, | |||
| 888 | + | Intent: ports.IntentAttach, | |||
| 889 | + | RemoteTarget: &remoteTarget, | |||
| 890 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 891 | + | } | |||
| 892 | + | } else { | |||
| 893 | + | if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) { | |||
| 894 | + | return errAttachmentTransition | |||
| 895 | + | } | |||
| 896 | + | handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach} | |||
| 897 | + | } | |||
| 898 | + | payload := ports.MarshalAttachTarget(handoff) | |||
| 899 | + | if payload == nil { | |||
| 680 | return errAttachmentTransition | 900 | return errAttachmentTransition | ||
| 681 | } | 901 | } | ||
| 682 | - | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{ | 902 | + | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { |
| 683 | - | Endpoint: key.Host, | |||
| 684 | - | Session: key.Name, | |||
| 685 | - | Intent: ports.IntentAttach, | |||
| 686 | - | })}); err != nil { | |||
| 687 | return err | 903 | return err | ||
| 688 | } | 904 | } | ||
| 689 | if guard.closePicker { | 905 | if guard.closePicker { | ||
| @@ -712,6 +928,51 @@ func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool { | |||||
| 712 | return false | 928 | return false | ||
| 713 | } | 929 | } | ||
| 714 | 930 | ||||
| 931 | + | func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool { | |||
| 932 | + | if d == nil || target.Validate() != nil { | |||
| 933 | + | return false | |||
| 934 | + | } | |||
| 935 | + | d.remoteCatalog.mu.Lock() | |||
| 936 | + | defer d.remoteCatalog.mu.Unlock() | |||
| 937 | + | if d.remoteCatalog.status[target.Endpoint] != remoteHostFresh { | |||
| 938 | + | return false | |||
| 939 | + | } | |||
| 940 | + | for _, entry := range d.remoteCatalog.cache { | |||
| 941 | + | if entry.Host != target.Endpoint { | |||
| 942 | + | continue | |||
| 943 | + | } | |||
| 944 | + | for _, session := range entry.Sessions { | |||
| 945 | + | if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID { | |||
| 946 | + | continue | |||
| 947 | + | } | |||
| 948 | + | if target.Stopped != (session.State == "stopped") || session.State == "broken" { | |||
| 949 | + | return false | |||
| 950 | + | } | |||
| 951 | + | tabs := ports.CatalogTabs(session) | |||
| 952 | + | metadata := make([]domain.TabSelectorTab, 0, len(tabs)) | |||
| 953 | + | for _, tab := range tabs { | |||
| 954 | + | metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name}) | |||
| 955 | + | } | |||
| 956 | + | if target.Stopped { | |||
| 957 | + | _, ok := target.StoppedTab.Resolve(metadata) | |||
| 958 | + | return ok | |||
| 959 | + | } | |||
| 960 | + | found := false | |||
| 961 | + | for _, tab := range tabs { | |||
| 962 | + | if tab.ID != string(target.LiveTabID) { | |||
| 963 | + | continue | |||
| 964 | + | } | |||
| 965 | + | if found { | |||
| 966 | + | return false | |||
| 967 | + | } | |||
| 968 | + | found = true | |||
| 969 | + | } | |||
| 970 | + | return found | |||
| 971 | + | } | |||
| 972 | + | } | |||
| 973 | + | return false | |||
| 974 | + | } | |||
| 975 | + | ||||
| 715 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | 976 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | ||
| 716 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | 977 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | ||
| 717 | return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "") | 978 | return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "") | ||
A internal/usecase/daemon/remote_attach.go +170 -0
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // routeRemoteTargetWithContext is the exact-target branch for picker handoffs. | |||
| 13 | + | // It is deliberately separate from legacy name routing: a lifecycle mismatch, | |||
| 14 | + | // missing tab, broken record, or stopped-record race is a no-such-target error, | |||
| 15 | + | // never an invitation to attach a same-name replacement. | |||
| 16 | + | func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | |||
| 17 | + | if h.RemoteTarget == nil { | |||
| 18 | + | return nil, nil, errors.New("daemon: missing remote target") | |||
| 19 | + | } | |||
| 20 | + | target := *h.RemoteTarget | |||
| 21 | + | if err := target.Validate(); err != nil { | |||
| 22 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "invalid remote target"} | |||
| 23 | + | } | |||
| 24 | + | if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned { | |||
| 25 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires daemon-owned environment"} | |||
| 26 | + | } | |||
| 27 | + | if h.Intent != ports.IntentAttach && h.Intent != ports.IntentResume { | |||
| 28 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires attach or resume"} | |||
| 29 | + | } | |||
| 30 | + | if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil { | |||
| 31 | + | return nil, nil, remoteTargetError(err) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | d.mu.Lock() | |||
| 35 | + | if d.closing { | |||
| 36 | + | d.mu.Unlock() | |||
| 37 | + | return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |||
| 38 | + | } | |||
| 39 | + | if live := d.findByNameLocked(target.SessionName); live != nil { | |||
| 40 | + | if live.incarnation != target.LifecycleID { | |||
| 41 | + | d.mu.Unlock() | |||
| 42 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle has changed"} | |||
| 43 | + | } | |||
| 44 | + | if target.Stopped && target.LiveTabID != "" { | |||
| 45 | + | d.mu.Unlock() | |||
| 46 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "stopped target has a live tab selector"} | |||
| 47 | + | } | |||
| 48 | + | if _, ok := remoteTargetTabIndexLocked(live, target); !ok { | |||
| 49 | + | d.mu.Unlock() | |||
| 50 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"} | |||
| 51 | + | } | |||
| 52 | + | ac, err := d.finishAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h) | |||
| 53 | + | return live, ac, err | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | stopped, ok := d.stopped[target.SessionName] | |||
| 57 | + | if !ok || stopped.purging || stopped.incarnation != target.LifecycleID { | |||
| 58 | + | d.mu.Unlock() | |||
| 59 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"} | |||
| 60 | + | } | |||
| 61 | + | if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" { | |||
| 62 | + | d.mu.Unlock() | |||
| 63 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"} | |||
| 64 | + | } | |||
| 65 | + | if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok { | |||
| 66 | + | d.mu.Unlock() | |||
| 67 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Picker handoffs use the daemon's own environment and persisted CWD. The | |||
| 71 | + | // client's Env/Cwd fields remain present for wire compatibility but are not | |||
| 72 | + | // trusted for this branch. | |||
| 73 | + | env := copyEnvironment(os.Environ()) | |||
| 74 | + | cwd := d.dirOrHome(stopped.cwd) | |||
| 75 | + | sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, terminalEnv{TrueColor: h.TrueColor}, env, stopped.tabNames) | |||
| 76 | + | if err != nil { | |||
| 77 | + | d.mu.Unlock() | |||
| 78 | + | return nil, nil, err | |||
| 79 | + | } | |||
| 80 | + | ac, err := d.finishAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h) | |||
| 81 | + | if err != nil { | |||
| 82 | + | // The route-created session has no published client if finishAttach | |||
| 83 | + | // fails. Retire it immediately rather than leaving a shadow live row. | |||
| 84 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |||
| 85 | + | return nil, nil, err | |||
| 86 | + | } | |||
| 87 | + | ac.routeCreatedSession = true | |||
| 88 | + | return sess, ac, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (d *Daemon) remoteTargetMatchesSession(sess *session, target domain.RemoteSessionTarget) bool { | |||
| 92 | + | if sess == nil || sess.incarnation != target.LifecycleID || sess.name != target.SessionName { | |||
| 93 | + | return false | |||
| 94 | + | } | |||
| 95 | + | d.mu.Lock() | |||
| 96 | + | defer d.mu.Unlock() | |||
| 97 | + | if d.sessions[sess.id] != sess { | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 100 | + | _, ok := remoteTargetTabIndexLocked(sess, target) | |||
| 101 | + | return ok | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func remoteTargetError(err error) error { | |||
| 105 | + | var protocol *protoErr | |||
| 106 | + | if errors.As(err, &protocol) { | |||
| 107 | + | return protocol | |||
| 108 | + | } | |||
| 109 | + | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |||
| 110 | + | return err | |||
| 111 | + | } | |||
| 112 | + | return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"} | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab { | |||
| 116 | + | records := stopped.tabRecords | |||
| 117 | + | if len(records) == 0 { | |||
| 118 | + | records = make([]domain.CatalogueTabRecord, len(stopped.tabNames)) | |||
| 119 | + | for i, name := range stopped.tabNames { | |||
| 120 | + | records[i].Name = name | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | metadata := make([]domain.TabSelectorTab, 0, len(records)) | |||
| 124 | + | for _, record := range records { | |||
| 125 | + | metadata = append(metadata, domain.TabSelectorTab{ID: record.StableID, Name: record.Name}) | |||
| 126 | + | } | |||
| 127 | + | return metadata | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) { | |||
| 131 | + | return target.StoppedTab.Resolve(stoppedTabMetadata(stopped)) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // remoteTargetTabIndexLocked resolves the selector against the live session's | |||
| 135 | + | // current ordered tabs. Caller holds d.mu; this function takes only the | |||
| 136 | + | // session/tab locks needed for a coherent metadata snapshot. | |||
| 137 | + | func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget) (int, bool) { | |||
| 138 | + | if sess == nil { | |||
| 139 | + | return 0, false | |||
| 140 | + | } | |||
| 141 | + | sess.mu.Lock() | |||
| 142 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 143 | + | sess.mu.Unlock() | |||
| 144 | + | metadata := make([]domain.TabSelectorTab, 0, len(tabs)) | |||
| 145 | + | for _, tab := range tabs { | |||
| 146 | + | if tab == nil { | |||
| 147 | + | return 0, false | |||
| 148 | + | } | |||
| 149 | + | tab.mu.Lock() | |||
| 150 | + | metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name}) | |||
| 151 | + | tab.mu.Unlock() | |||
| 152 | + | } | |||
| 153 | + | if target.Stopped { | |||
| 154 | + | return target.StoppedTab.Resolve(metadata) | |||
| 155 | + | } | |||
| 156 | + | if target.LiveTabID == "" { | |||
| 157 | + | return 0, false | |||
| 158 | + | } | |||
| 159 | + | found := -1 | |||
| 160 | + | for i, tab := range metadata { | |||
| 161 | + | if tab.ID != target.LiveTabID { | |||
| 162 | + | continue | |||
| 163 | + | } | |||
| 164 | + | if found >= 0 { | |||
| 165 | + | return 0, false | |||
| 166 | + | } | |||
| 167 | + | found = i | |||
| 168 | + | } | |||
| 169 | + | return found, found >= 0 | |||
| 170 | + | } | |||
A internal/usecase/daemon/remote_attach_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func remoteLifecycleForTest() domain.SessionLifecycleID { | |||
| 15 | + | var id domain.SessionLifecycleID | |||
| 16 | + | id[0] = 7 | |||
| 17 | + | return id | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |||
| 21 | + | d := newRemotePickerDaemon(nil) | |||
| 22 | + | lifecycle := remoteLifecycleForTest() | |||
| 23 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |||
| 24 | + | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{ | |||
| 25 | + | LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |||
| 26 | + | }}, | |||
| 27 | + | }}) | |||
| 28 | + | d.remoteCatalog.mu.Lock() | |||
| 29 | + | d.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 30 | + | d.remoteCatalog.mu.Unlock() | |||
| 31 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |||
| 32 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 33 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 34 | + | require.True(t, admitted) | |||
| 35 | + | token.effect = effect | |||
| 36 | + | defer effect.End() | |||
| 37 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |||
| 38 | + | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |||
| 39 | + | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |||
| 40 | + | require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")) | |||
| 41 | + | frame := receiveRemotePicker(t, sends, "rich attach target") | |||
| 42 | + | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.NotNil(t, got.RemoteTarget) | |||
| 45 | + | require.Equal(t, remoteTarget, *got.RemoteTarget) | |||
| 46 | + | require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | |||
| 50 | + | d := newRemotePickerDaemon(nil) | |||
| 51 | + | lifecycle := remoteLifecycleForTest() | |||
| 52 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |||
| 53 | + | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{ | |||
| 54 | + | LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |||
| 55 | + | }}, | |||
| 56 | + | }}) | |||
| 57 | + | d.remoteCatalog.mu.Lock() | |||
| 58 | + | d.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 59 | + | d.remoteCatalog.mu.Unlock() | |||
| 60 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local") | |||
| 61 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 62 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 63 | + | require.True(t, admitted) | |||
| 64 | + | token.effect = effect | |||
| 65 | + | defer effect.End() | |||
| 66 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |||
| 67 | + | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "replaced"} | |||
| 68 | + | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "replaced"} | |||
| 69 | + | require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition) | |||
| 70 | + | } | |||
M internal/usecase/daemon/remote_catalog_state.go +8 -1
| @@ -15,8 +15,10 @@ const ( | |||||
| 15 | remoteHostCached remoteHostStatus = iota | 15 | remoteHostCached remoteHostStatus = iota | ||
| 16 | remoteHostRefreshing | 16 | remoteHostRefreshing | ||
| 17 | remoteHostFresh | 17 | remoteHostFresh | ||
| 18 | + | remoteHostStale | |||
| 18 | remoteHostUnreachable | 19 | remoteHostUnreachable | ||
| 19 | remoteHostVersionMismatch | 20 | remoteHostVersionMismatch | ||
| 21 | + | remoteHostMalformed | |||
| 20 | ) | 22 | ) | ||
| 21 | 23 | ||||
| 22 | // remoteCatalogState owns the cache-derived discovery state. Its mutex never | 24 | // remoteCatalogState owns the cache-derived discovery state. Its mutex never | ||
| @@ -43,7 +45,12 @@ func newRemoteCatalogState() remoteCatalogState { | |||||
| 43 | 45 | ||||
| 44 | func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry { | 46 | func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry { | ||
| 45 | sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions)) | 47 | sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions)) | ||
| 46 | - | copy(sessions, entry.Sessions) | 48 | + | for i, session := range entry.Sessions { |
| 49 | + | sessions[i] = session | |||
| 50 | + | if tabs := ports.CatalogTabs(session); tabs != nil { | |||
| 51 | + | sessions[i].Tabs = append([]ports.RemoteCatalogTab(nil), tabs...) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 47 | return ports.RemoteCatalogCacheEntry{ | 54 | return ports.RemoteCatalogCacheEntry{ | ||
| 48 | Host: entry.Host, | 55 | Host: entry.Host, | ||
| 49 | FetchedAt: entry.FetchedAt, | 56 | FetchedAt: entry.FetchedAt, | ||
M internal/usecase/daemon/remote_picker.go +153 -20
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sort" | 8 | "sort" | ||
| 9 | + | "strings" | |||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| @@ -30,11 +31,19 @@ type remotePickerInstance struct { | |||||
| 30 | // remotePickerCatalogSnapshot copies the cache-derived state needed for a | 31 | // remotePickerCatalogSnapshot copies the cache-derived state needed for a | ||
| 31 | // picker before any row sorting begins. No remote-state lock survives this | 32 | // picker before any row sorting begins. No remote-state lock survives this | ||
| 32 | // snapshot. | 33 | // snapshot. | ||
| 34 | + | const remotePickerAttachTTL = 30 * time.Second | |||
| 35 | + | ||||
| 33 | func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | 36 | func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | ||
| 34 | if d == nil { | 37 | if d == nil { | ||
| 35 | return nil | 38 | return nil | ||
| 36 | } | 39 | } | ||
| 40 | + | now := d.clock.Now() | |||
| 37 | d.remoteCatalog.mu.Lock() | 41 | d.remoteCatalog.mu.Lock() | ||
| 42 | + | for host, entry := range d.remoteCatalog.cache { | |||
| 43 | + | if d.remoteCatalog.status[host] == remoteHostFresh && remoteCatalogExpired(entry.FetchedAt, now) { | |||
| 44 | + | d.remoteCatalog.status[host] = remoteHostStale | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 38 | entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status)) | 47 | entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status)) | ||
| 39 | seen := make(map[string]struct{}, len(d.remoteCatalog.cache)) | 48 | seen := make(map[string]struct{}, len(d.remoteCatalog.cache)) | ||
| 40 | for host, entry := range d.remoteCatalog.cache { | 49 | for host, entry := range d.remoteCatalog.cache { | ||
| @@ -45,7 +54,7 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |||||
| 45 | seen[host] = struct{}{} | 54 | seen[host] = struct{}{} | ||
| 46 | } | 55 | } | ||
| 47 | for host, status := range d.remoteCatalog.status { | 56 | for host, status := range d.remoteCatalog.status { | ||
| 48 | - | if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch) { | 57 | + | if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) { |
| 49 | continue | 58 | continue | ||
| 50 | } | 59 | } | ||
| 51 | entries = append(entries, remotePickerCatalogEntry{ | 60 | entries = append(entries, remotePickerCatalogEntry{ | ||
| @@ -75,11 +84,15 @@ func (d *Daemon) remotePickerHostRanks() map[string]int { | |||||
| 75 | return ranks | 84 | return ranks | ||
| 76 | } | 85 | } | ||
| 77 | 86 | ||||
| 87 | + | func remoteCatalogExpired(fetchedAt, now time.Time) bool { | |||
| 88 | + | return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) > remotePickerAttachTTL) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 78 | func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability { | 91 | func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability { | ||
| 79 | switch status { | 92 | switch status { | ||
| 80 | case remoteHostFresh: | 93 | case remoteHostFresh: | ||
| 81 | return picker.RemoteFresh | 94 | return picker.RemoteFresh | ||
| 82 | - | case remoteHostUnreachable: | 95 | + | case remoteHostStale, remoteHostUnreachable, remoteHostMalformed: |
| 83 | return picker.RemoteStale | 96 | return picker.RemoteStale | ||
| 84 | case remoteHostVersionMismatch: | 97 | case remoteHostVersionMismatch: | ||
| 85 | return picker.RemoteVersionMismatch | 98 | return picker.RemoteVersionMismatch | ||
| @@ -88,15 +101,13 @@ func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability | |||||
| 88 | } | 101 | } | ||
| 89 | } | 102 | } | ||
| 90 | 103 | ||||
| 91 | - | func remotePickerDetail(tabs uint16) string { | |||
| 92 | - | if tabs == 1 { | |||
| 93 | - | return "1 tab" | |||
| 94 | - | } | |||
| 95 | - | return fmt.Sprintf("%d tabs", tabs) | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string { | 104 | func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string { | ||
| 99 | switch status { | 105 | switch status { | ||
| 106 | + | case remoteHostStale: | |||
| 107 | + | if !fetchedAt.IsZero() { | |||
| 108 | + | return "catalog stale since " + fetchedAt.Format(time.RFC3339) | |||
| 109 | + | } | |||
| 110 | + | return "catalog stale" | |||
| 100 | case remoteHostUnreachable: | 111 | case remoteHostUnreachable: | ||
| 101 | if !fetchedAt.IsZero() { | 112 | if !fetchedAt.IsZero() { | ||
| 102 | return "stale since " + fetchedAt.Format(time.RFC3339) | 113 | return "stale since " + fetchedAt.Format(time.RFC3339) | ||
| @@ -104,34 +115,148 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri | |||||
| 104 | return "unreachable" | 115 | return "unreachable" | ||
| 105 | case remoteHostVersionMismatch: | 116 | case remoteHostVersionMismatch: | ||
| 106 | return "version mismatch" | 117 | return "version mismatch" | ||
| 118 | + | case remoteHostMalformed: | |||
| 119 | + | return "catalog malformed" | |||
| 107 | default: | 120 | default: | ||
| 108 | return "" | 121 | return "" | ||
| 109 | } | 122 | } | ||
| 110 | } | 123 | } | ||
| 111 | 124 | ||||
| 125 | + | func remoteSessionOrigin(host string) string { | |||
| 126 | + | if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) { | |||
| 127 | + | return host[i+1:] | |||
| 128 | + | } | |||
| 129 | + | return host | |||
| 130 | + | } | |||
| 131 | + | ||||
| 112 | func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView { | 132 | func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView { | ||
| 133 | + | key.LifecycleID = session.LifecycleID | |||
| 134 | + | key.DisplayOrigin = remoteSessionOrigin(key.Host) | |||
| 135 | + | availability := remotePickerAvailability(status) | |||
| 136 | + | stopped := session.State == "stopped" | |||
| 137 | + | broken := session.State == "broken" | |||
| 138 | + | tabs := ports.CatalogTabs(session) | |||
| 139 | + | viewTabs := make([]picker.TabEntry, 0, len(tabs)) | |||
| 140 | + | for _, tab := range tabs { | |||
| 141 | + | name := tab.Name | |||
| 142 | + | if name == "" { | |||
| 143 | + | name = fmt.Sprintf("%d", int(tab.Index)+1) | |||
| 144 | + | } | |||
| 145 | + | viewTabs = append(viewTabs, picker.TabEntry{ | |||
| 146 | + | TabID: domain.TabStableID(tab.ID), | |||
| 147 | + | Name: name, | |||
| 148 | + | RawName: tab.Name, | |||
| 149 | + | Detail: tab.Detail, | |||
| 150 | + | Attention: tab.Attention, | |||
| 151 | + | }) | |||
| 152 | + | } | |||
| 153 | + | legacy := session.LifecycleID == (domain.SessionLifecycleID{}) | |||
| 154 | + | var remoteTarget *domain.RemoteSessionTarget | |||
| 155 | + | if !legacy { | |||
| 156 | + | target := domain.RemoteSessionTarget{ | |||
| 157 | + | Endpoint: key.Host, | |||
| 158 | + | DisplayOrigin: key.DisplayOrigin, | |||
| 159 | + | LifecycleID: session.LifecycleID, | |||
| 160 | + | SessionName: session.Name, | |||
| 161 | + | Stopped: stopped, | |||
| 162 | + | } | |||
| 163 | + | if len(tabs) != 0 { | |||
| 164 | + | first := tabs[0] | |||
| 165 | + | if stopped { | |||
| 166 | + | if first.ID != "" { | |||
| 167 | + | target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID)) | |||
| 168 | + | } else { | |||
| 169 | + | target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs))) | |||
| 170 | + | } | |||
| 171 | + | } else { | |||
| 172 | + | target.LiveTabID = domain.TabStableID(first.ID) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | // Keep the structured session identity on rows even when one tab ID is | |||
| 176 | + | // malformed or a broken session cannot be activated. Cursor navigation | |||
| 177 | + | // must remain possible, while sendRemoteAttachTargetForAttachment will | |||
| 178 | + | // fail closed on the invalid target. | |||
| 179 | + | remoteTarget = &target | |||
| 180 | + | } | |||
| 181 | + | reason := "" | |||
| 182 | + | if !legacy && status != remoteHostFresh { | |||
| 183 | + | reason = remoteReasonForStatus(status) | |||
| 184 | + | } | |||
| 185 | + | targetValid := remoteTarget != nil && remoteTarget.Validate() == nil | |||
| 186 | + | if broken { | |||
| 187 | + | reason = "session_broken" | |||
| 188 | + | } else if !legacy && !targetValid { | |||
| 189 | + | reason = "identity_changed" | |||
| 190 | + | } | |||
| 191 | + | ready := status == remoteHostFresh && targetValid && !broken | |||
| 192 | + | if legacy { | |||
| 193 | + | // Count-only peers cannot prove lifecycle/tab identity. Preserve the | |||
| 194 | + | // historical direct-connect row for compatibility, while all current | |||
| 195 | + | // schema rows remain gated by exact identity and fresh ownership. | |||
| 196 | + | ready = status != remoteHostVersionMismatch && !broken | |||
| 197 | + | if status == remoteHostVersionMismatch { | |||
| 198 | + | reason = remoteReasonForStatus(status) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 113 | detail := remotePickerStatusDetail(status, fetchedAt) | 201 | detail := remotePickerStatusDetail(status, fetchedAt) | ||
| 114 | if detail == "" { | 202 | if detail == "" { | ||
| 115 | - | detail = remotePickerDetail(session.Tabs) | 203 | + | switch session.State { |
| 204 | + | case "running": | |||
| 205 | + | detail = "running" | |||
| 206 | + | if session.Attached { | |||
| 207 | + | detail += " · attached" | |||
| 208 | + | } | |||
| 209 | + | case "stopped": | |||
| 210 | + | detail = "stopped" | |||
| 211 | + | case "broken": | |||
| 212 | + | detail = "broken" | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | if broken { | |||
| 216 | + | detail = "session broken" | |||
| 116 | } | 217 | } | ||
| 117 | return picker.SessionView{ | 218 | return picker.SessionView{ | ||
| 118 | ID: key.ID(), | 219 | ID: key.ID(), | ||
| 119 | Name: key.Display(), | 220 | Name: key.Display(), | ||
| 120 | RemoteKey: &key, | 221 | RemoteKey: &key, | ||
| 222 | + | RemoteTarget: remoteTarget, | |||
| 121 | RemoteHost: key.Host, | 223 | RemoteHost: key.Host, | ||
| 122 | - | RemoteAvailability: remotePickerAvailability(status), | 224 | + | RemoteAvailability: availability, |
| 123 | RemoteDetail: detail, | 225 | RemoteDetail: detail, | ||
| 124 | - | ConnectOnly: true, | 226 | + | RemoteReason: reason, |
| 125 | - | RemoteAttachReady: status != remoteHostVersionMismatch, | 227 | + | Tabs: viewTabs, |
| 228 | + | Stopped: stopped, | |||
| 229 | + | ConnectOnly: legacy, | |||
| 230 | + | RemoteAttachReady: ready, | |||
| 126 | CannotAcceptMoves: true, | 231 | CannotAcceptMoves: true, | ||
| 127 | } | 232 | } | ||
| 128 | } | 233 | } | ||
| 129 | 234 | ||||
| 235 | + | func remoteReasonForStatus(status remoteHostStatus) string { | |||
| 236 | + | switch status { | |||
| 237 | + | case remoteHostRefreshing: | |||
| 238 | + | return "refreshing" | |||
| 239 | + | case remoteHostStale: | |||
| 240 | + | return "catalog_stale" | |||
| 241 | + | case remoteHostUnreachable: | |||
| 242 | + | return "host_unreachable" | |||
| 243 | + | case remoteHostVersionMismatch: | |||
| 244 | + | return "version_mismatch" | |||
| 245 | + | case remoteHostMalformed: | |||
| 246 | + | return "malformed" | |||
| 247 | + | case remoteHostCached: | |||
| 248 | + | return "refreshing" | |||
| 249 | + | default: | |||
| 250 | + | return "" | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 130 | func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView { | 254 | func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView { | ||
| 131 | return picker.SessionView{ | 255 | return picker.SessionView{ | ||
| 132 | ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))), | 256 | ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))), | ||
| 133 | Name: host, | 257 | Name: host, | ||
| 134 | RemoteHost: host, | 258 | RemoteHost: host, | ||
| 259 | + | RemoteReason: remoteReasonForStatus(status), | |||
| 135 | RemoteAvailability: remotePickerAvailability(status), | 260 | RemoteAvailability: remotePickerAvailability(status), | ||
| 136 | RemoteDetail: remotePickerStatusDetail(status, time.Time{}), | 261 | RemoteDetail: remotePickerStatusDetail(status, time.Time{}), | ||
| 137 | ConnectOnly: true, | 262 | ConnectOnly: true, | ||
| @@ -142,7 +267,11 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi | |||||
| 142 | func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) { | 267 | func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) { | ||
| 143 | for i := range entries { | 268 | for i := range entries { | ||
| 144 | sort.Slice(entries[i].entry.Sessions, func(left, right int) bool { | 269 | sort.Slice(entries[i].entry.Sessions, func(left, right int) bool { | ||
| 145 | - | return entries[i].entry.Sessions[left].Name < entries[i].entry.Sessions[right].Name | 270 | + | l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right] |
| 271 | + | if l.LastUsedSeq != r.LastUsedSeq { | |||
| 272 | + | return l.LastUsedSeq > r.LastUsedSeq | |||
| 273 | + | } | |||
| 274 | + | return l.Name < r.Name | |||
| 146 | }) | 275 | }) | ||
| 147 | } | 276 | } | ||
| 148 | sort.Slice(entries, func(i, j int) bool { | 277 | sort.Slice(entries, func(i, j int) bool { | ||
| @@ -365,8 +494,10 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||||
| 365 | } | 494 | } | ||
| 366 | stillKnown := containsRemotePickerHost(hosts, host) | 495 | stillKnown := containsRemotePickerHost(hosts, host) | ||
| 367 | now := d.clock.Now() | 496 | now := d.clock.Now() | ||
| 368 | - | if listErr == nil && catalog.ProtocolVersion != ports.ProtocolVersion { | 497 | + | if listErr == nil { |
| 369 | - | listErr = &ports.RemoteCatalogVersionMismatchError{Got: catalog.ProtocolVersion, Want: ports.ProtocolVersion} | 498 | + | if err := ports.ValidateRemoteCatalog(catalog); err != nil { |
| 499 | + | listErr = err | |||
| 500 | + | } | |||
| 370 | } | 501 | } | ||
| 371 | 502 | ||||
| 372 | d.remoteCatalog.mu.Lock() | 503 | d.remoteCatalog.mu.Lock() | ||
| @@ -387,10 +518,12 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||||
| 387 | return cacheChanged | 518 | return cacheChanged | ||
| 388 | } | 519 | } | ||
| 389 | if listErr != nil { | 520 | if listErr != nil { | ||
| 390 | - | status := remoteHostUnreachable | 521 | + | status := remoteHostMalformed |
| 391 | var mismatch *ports.RemoteCatalogVersionMismatchError | 522 | var mismatch *ports.RemoteCatalogVersionMismatchError | ||
| 392 | if errors.As(listErr, &mismatch) { | 523 | if errors.As(listErr, &mismatch) { | ||
| 393 | status = remoteHostVersionMismatch | 524 | status = remoteHostVersionMismatch | ||
| 525 | + | } else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) && !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) { | |||
| 526 | + | status = remoteHostUnreachable | |||
| 394 | } | 527 | } | ||
| 395 | d.remoteCatalog.status[host] = status | 528 | d.remoteCatalog.status[host] = status | ||
| 396 | d.remoteCatalog.failure[host] = now | 529 | d.remoteCatalog.failure[host] = now | ||
| @@ -398,11 +531,11 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||||
| 398 | d.refreshRemoteOpenPickers() | 531 | d.refreshRemoteOpenPickers() | ||
| 399 | return false | 532 | return false | ||
| 400 | } | 533 | } | ||
| 401 | - | d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{ | 534 | + | d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{ |
| 402 | Host: host, | 535 | Host: host, | ||
| 403 | FetchedAt: now, | 536 | FetchedAt: now, | ||
| 404 | - | Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...), | 537 | + | Sessions: catalog.Sessions, |
| 405 | - | } | 538 | + | }) |
| 406 | d.remoteCatalog.status[host] = remoteHostFresh | 539 | d.remoteCatalog.status[host] = remoteHostFresh | ||
| 407 | delete(d.remoteCatalog.failure, host) | 540 | delete(d.remoteCatalog.failure, host) | ||
| 408 | d.remoteCatalog.mu.Unlock() | 541 | d.remoteCatalog.mu.Unlock() | ||
A internal/usecase/daemon/remote_preview.go +114 -0
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error { | |||
| 11 | + | defer func() { _ = tr.Close() }() | |||
| 12 | + | request, err := ports.UnmarshalRemotePreviewRequest(frame.Payload) | |||
| 13 | + | if err != nil { | |||
| 14 | + | return d.sendRemotePreview(tr, ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed}) | |||
| 15 | + | } | |||
| 16 | + | preview, err := d.captureRemotePreview(request) | |||
| 17 | + | if err != nil { | |||
| 18 | + | status := ports.RemotePreviewUnavailable | |||
| 19 | + | if errors.Is(err, errRemotePreviewNoSuchTarget) { | |||
| 20 | + | status = ports.RemotePreviewNoSuchTarget | |||
| 21 | + | } | |||
| 22 | + | preview = ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: status} | |||
| 23 | + | } | |||
| 24 | + | return d.sendRemotePreview(tr, preview) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (d *Daemon) sendRemotePreview(tr ports.Transport, preview ports.RemotePreview) error { | |||
| 28 | + | payload := ports.MarshalRemotePreview(preview) | |||
| 29 | + | if payload == nil { | |||
| 30 | + | payload = ports.MarshalRemotePreview(ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed}) | |||
| 31 | + | } | |||
| 32 | + | return d.boundedControlSend(tr, ports.Frame{Type: ports.MsgRemotePreviewResponse, Payload: payload}) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | var errRemotePreviewNoSuchTarget = errors.New("daemon: remote preview target does not exist") | |||
| 36 | + | ||||
| 37 | + | func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports.RemotePreview, error) { | |||
| 38 | + | target := request.Target | |||
| 39 | + | d.mu.Lock() | |||
| 40 | + | sess := d.findByNameLocked(target.SessionName) | |||
| 41 | + | if sess == nil || sess.incarnation != target.LifecycleID { | |||
| 42 | + | d.mu.Unlock() | |||
| 43 | + | return ports.RemotePreview{}, errRemotePreviewNoSuchTarget | |||
| 44 | + | } | |||
| 45 | + | index, ok := remoteTargetTabIndexLocked(sess, target) | |||
| 46 | + | d.mu.Unlock() | |||
| 47 | + | if !ok { | |||
| 48 | + | return ports.RemotePreview{}, errRemotePreviewNoSuchTarget | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | sess.mu.Lock() | |||
| 52 | + | if index < 0 || index >= len(sess.tabs) { | |||
| 53 | + | sess.mu.Unlock() | |||
| 54 | + | return ports.RemotePreview{}, errRemotePreviewNoSuchTarget | |||
| 55 | + | } | |||
| 56 | + | tab := sess.tabs[index] | |||
| 57 | + | sess.mu.Unlock() | |||
| 58 | + | tab.mu.Lock() | |||
| 59 | + | pane := tab.focusedPane() | |||
| 60 | + | if pane == nil { | |||
| 61 | + | tab.mu.Unlock() | |||
| 62 | + | return ports.RemotePreview{}, errRemotePreviewNoSuchTarget | |||
| 63 | + | } | |||
| 64 | + | pane.mu.Lock() | |||
| 65 | + | if pane.screen == nil { | |||
| 66 | + | pane.mu.Unlock() | |||
| 67 | + | tab.mu.Unlock() | |||
| 68 | + | return ports.RemotePreview{}, errRemotePreviewNoSuchTarget | |||
| 69 | + | } | |||
| 70 | + | source := pane.screen.Frame.Clone() | |||
| 71 | + | revision := pane.syncGen | |||
| 72 | + | pane.mu.Unlock() | |||
| 73 | + | tab.mu.Unlock() | |||
| 74 | + | if revision == 0 { | |||
| 75 | + | revision = 1 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | width := min(int(request.Width), source.Width) | |||
| 79 | + | height := min(int(request.Height), source.Height) | |||
| 80 | + | if width <= 0 || height <= 0 { | |||
| 81 | + | return ports.RemotePreview{}, errRemotePreviewNoSuchTarget | |||
| 82 | + | } | |||
| 83 | + | startY := source.Height - height | |||
| 84 | + | cells := make([]renderer.Cell, 0, width*height) | |||
| 85 | + | for y := 0; y < height; y++ { | |||
| 86 | + | row := source.Row(startY + y) | |||
| 87 | + | for x := 0; x < width; x++ { | |||
| 88 | + | cell := row[x] | |||
| 89 | + | // A crop may end immediately after the left half of a wide | |||
| 90 | + | // rune. Replace that boundary cell with a styled blank rather | |||
| 91 | + | // than sending an invalid continuation sequence to the picker. | |||
| 92 | + | if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 { | |||
| 93 | + | cell = renderer.Cell{Rune: ' ', Style: cell.Style} | |||
| 94 | + | } | |||
| 95 | + | cells = append(cells, cell) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | preview := ports.RemotePreview{ | |||
| 99 | + | Version: ports.RemotePreviewSchemaVersion, | |||
| 100 | + | Status: ports.RemotePreviewOK, | |||
| 101 | + | LifecycleID: target.LifecycleID, | |||
| 102 | + | TabID: target.LiveTabID, | |||
| 103 | + | Revision: revision, | |||
| 104 | + | Width: uint16(width), | |||
| 105 | + | Height: uint16(height), | |||
| 106 | + | Cells: cells, | |||
| 107 | + | } | |||
| 108 | + | if err := ports.ValidateRemotePreview(preview); err != nil { | |||
| 109 | + | // A crop must not split a wide cell. Return a safe unavailable response | |||
| 110 | + | // instead of exposing a malformed cell stream. | |||
| 111 | + | return ports.RemotePreview{}, err | |||
| 112 | + | } | |||
| 113 | + | return preview, nil | |||
| 114 | + | } | |||
A internal/usecase/daemon/remote_preview_cache.go +241 -0
| @@ -0,0 +1,241 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "slices" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | remotePreviewCacheTTL = 5 * time.Second | |||
| 17 | + | remotePreviewCooldown = 1 * time.Second | |||
| 18 | + | remotePreviewCacheMax = 64 | |||
| 19 | + | remotePreviewSlots = 4 | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | var errRemotePreviewCooldown = errors.New("remote preview request is cooling down") | |||
| 23 | + | ||||
| 24 | + | type remotePreviewCacheKey struct { | |||
| 25 | + | Endpoint string | |||
| 26 | + | DisplayOrigin string | |||
| 27 | + | LifecycleID domain.SessionLifecycleID | |||
| 28 | + | SessionName string | |||
| 29 | + | LiveTabID domain.TabStableID | |||
| 30 | + | Stopped bool | |||
| 31 | + | StoppedTab domain.TabSelector | |||
| 32 | + | Width uint16 | |||
| 33 | + | Height uint16 | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | type remotePreviewCacheEntry struct { | |||
| 37 | + | Preview ports.RemotePreview | |||
| 38 | + | Fetched time.Time | |||
| 39 | + | Used time.Time | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type remotePreviewFlight struct { | |||
| 43 | + | done chan struct{} | |||
| 44 | + | preview ports.RemotePreview | |||
| 45 | + | err error | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type remotePreviewState struct { | |||
| 49 | + | mu sync.Mutex | |||
| 50 | + | cache map[remotePreviewCacheKey]remotePreviewCacheEntry | |||
| 51 | + | flights map[remotePreviewCacheKey]*remotePreviewFlight | |||
| 52 | + | cooldowns map[remotePreviewCacheKey]time.Time | |||
| 53 | + | slots chan struct{} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func cloneRemotePreview(preview ports.RemotePreview) ports.RemotePreview { | |||
| 57 | + | preview.Cells = slices.Clone(preview.Cells) | |||
| 58 | + | return preview | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (s *remotePreviewState) initializeLocked() { | |||
| 62 | + | if s.cache == nil { | |||
| 63 | + | s.cache = make(map[remotePreviewCacheKey]remotePreviewCacheEntry) | |||
| 64 | + | } | |||
| 65 | + | if s.flights == nil { | |||
| 66 | + | s.flights = make(map[remotePreviewCacheKey]*remotePreviewFlight) | |||
| 67 | + | } | |||
| 68 | + | if s.cooldowns == nil { | |||
| 69 | + | s.cooldowns = make(map[remotePreviewCacheKey]time.Time) | |||
| 70 | + | } | |||
| 71 | + | if s.slots == nil { | |||
| 72 | + | s.slots = make(chan struct{}, remotePreviewSlots) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func remotePreviewKeyFor(target domain.RemoteSessionTarget, width, height uint16) remotePreviewCacheKey { | |||
| 77 | + | return remotePreviewCacheKey{ | |||
| 78 | + | Endpoint: target.Endpoint, DisplayOrigin: target.DisplayOrigin, | |||
| 79 | + | LifecycleID: target.LifecycleID, SessionName: target.SessionName, | |||
| 80 | + | LiveTabID: target.LiveTabID, Stopped: target.Stopped, | |||
| 81 | + | StoppedTab: target.StoppedTab, Width: width, Height: height, | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (d *Daemon) fetchRemotePreview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) { | |||
| 86 | + | if d == nil || d.remotePreviewClient == nil { | |||
| 87 | + | return ports.RemotePreview{}, errors.New("remote preview client unavailable") | |||
| 88 | + | } | |||
| 89 | + | if ctx == nil { | |||
| 90 | + | ctx = context.Background() | |||
| 91 | + | } | |||
| 92 | + | if err := ctx.Err(); err != nil { | |||
| 93 | + | return ports.RemotePreview{}, err | |||
| 94 | + | } | |||
| 95 | + | request := ports.RemotePreviewRequest{ | |||
| 96 | + | Version: ports.RemotePreviewSchemaVersion, | |||
| 97 | + | Target: target, | |||
| 98 | + | Width: width, | |||
| 99 | + | Height: height, | |||
| 100 | + | } | |||
| 101 | + | if err := ports.ValidateRemotePreviewRequest(request); err != nil { | |||
| 102 | + | return ports.RemotePreview{}, err | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | key := remotePreviewKeyFor(target, width, height) | |||
| 106 | + | now := d.clock.Now() | |||
| 107 | + | d.remotePreview.mu.Lock() | |||
| 108 | + | d.remotePreview.initializeLocked() | |||
| 109 | + | ||||
| 110 | + | if cached, ok := d.remotePreview.cache[key]; ok { | |||
| 111 | + | cached.Used = now | |||
| 112 | + | d.remotePreview.cache[key] = cached | |||
| 113 | + | if now.Sub(cached.Fetched) <= remotePreviewCacheTTL { | |||
| 114 | + | preview := cloneRemotePreview(cached.Preview) | |||
| 115 | + | d.remotePreview.mu.Unlock() | |||
| 116 | + | return preview, nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Expired data remains useful to the picker. Revalidation is detached | |||
| 120 | + | // from the navigation request so cursor movement never waits on remote | |||
| 121 | + | // I/O, and cooldowns prevent a failed host from being hammered. | |||
| 122 | + | if until, cooling := d.remotePreview.cooldowns[key]; cooling && now.Before(until) { | |||
| 123 | + | preview := cloneRemotePreview(cached.Preview) | |||
| 124 | + | d.remotePreview.mu.Unlock() | |||
| 125 | + | return preview, nil | |||
| 126 | + | } | |||
| 127 | + | if flight := d.remotePreview.flights[key]; flight != nil { | |||
| 128 | + | preview := cloneRemotePreview(cached.Preview) | |||
| 129 | + | d.remotePreview.mu.Unlock() | |||
| 130 | + | return preview, nil | |||
| 131 | + | } | |||
| 132 | + | flight := &remotePreviewFlight{done: make(chan struct{})} | |||
| 133 | + | d.remotePreview.flights[key] = flight | |||
| 134 | + | preview := cloneRemotePreview(cached.Preview) | |||
| 135 | + | d.remotePreview.mu.Unlock() | |||
| 136 | + | go d.runRemotePreviewFlight(d.remotePreviewContext(), key, target, width, height, flight) | |||
| 137 | + | return preview, nil | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | if until, cooling := d.remotePreview.cooldowns[key]; cooling && now.Before(until) { | |||
| 141 | + | d.remotePreview.mu.Unlock() | |||
| 142 | + | return ports.RemotePreview{}, errRemotePreviewCooldown | |||
| 143 | + | } | |||
| 144 | + | if flight := d.remotePreview.flights[key]; flight != nil { | |||
| 145 | + | d.remotePreview.mu.Unlock() | |||
| 146 | + | return waitRemotePreviewFlight(ctx, flight) | |||
| 147 | + | } | |||
| 148 | + | flight := &remotePreviewFlight{done: make(chan struct{})} | |||
| 149 | + | d.remotePreview.flights[key] = flight | |||
| 150 | + | d.remotePreview.mu.Unlock() | |||
| 151 | + | ||||
| 152 | + | d.runRemotePreviewFlight(ctx, key, target, width, height, flight) | |||
| 153 | + | return cloneRemotePreview(flight.preview), flight.err | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (d *Daemon) remotePreviewContext() context.Context { | |||
| 157 | + | if d != nil && d.serveCtx != nil { | |||
| 158 | + | return d.serveCtx | |||
| 159 | + | } | |||
| 160 | + | return context.Background() | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func waitRemotePreviewFlight(ctx context.Context, flight *remotePreviewFlight) (ports.RemotePreview, error) { | |||
| 164 | + | select { | |||
| 165 | + | case <-flight.done: | |||
| 166 | + | return cloneRemotePreview(flight.preview), flight.err | |||
| 167 | + | case <-ctx.Done(): | |||
| 168 | + | return ports.RemotePreview{}, ctx.Err() | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // awaitRemotePreviewRefresh lets a picker display expired content immediately | |||
| 173 | + | // while still publishing the refreshed result once the same-key flight finishes. | |||
| 174 | + | // A completed flight is read from the cache without starting a second remote | |||
| 175 | + | // request. | |||
| 176 | + | func (d *Daemon) awaitRemotePreviewRefresh(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) { | |||
| 177 | + | key := remotePreviewKeyFor(target, width, height) | |||
| 178 | + | d.remotePreview.mu.Lock() | |||
| 179 | + | flight := d.remotePreview.flights[key] | |||
| 180 | + | d.remotePreview.mu.Unlock() | |||
| 181 | + | if flight != nil { | |||
| 182 | + | return waitRemotePreviewFlight(ctx, flight) | |||
| 183 | + | } | |||
| 184 | + | return d.fetchRemotePreview(ctx, target, width, height) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (d *Daemon) runRemotePreviewFlight(ctx context.Context, key remotePreviewCacheKey, target domain.RemoteSessionTarget, width, height uint16, flight *remotePreviewFlight) { | |||
| 188 | + | select { | |||
| 189 | + | case d.remotePreview.slots <- struct{}{}: | |||
| 190 | + | case <-ctx.Done(): | |||
| 191 | + | d.finishRemotePreviewFlight(key, flight, ports.RemotePreview{}, ctx.Err()) | |||
| 192 | + | return | |||
| 193 | + | } | |||
| 194 | + | preview, err := d.remotePreviewClient.Preview(ctx, target, width, height) | |||
| 195 | + | <-d.remotePreview.slots | |||
| 196 | + | if err == nil { | |||
| 197 | + | if validationErr := ports.ValidateRemotePreview(preview); validationErr != nil { | |||
| 198 | + | err = validationErr | |||
| 199 | + | } else if preview.Status != ports.RemotePreviewOK { | |||
| 200 | + | err = fmt.Errorf("remote preview returned status %d", preview.Status) | |||
| 201 | + | } else if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID { | |||
| 202 | + | err = errors.New("remote preview identity changed") | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | if err != nil { | |||
| 206 | + | preview = ports.RemotePreview{} | |||
| 207 | + | } | |||
| 208 | + | d.finishRemotePreviewFlight(key, flight, preview, err) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (d *Daemon) finishRemotePreviewFlight(key remotePreviewCacheKey, flight *remotePreviewFlight, preview ports.RemotePreview, err error) { | |||
| 212 | + | now := d.clock.Now() | |||
| 213 | + | d.remotePreview.mu.Lock() | |||
| 214 | + | flight.preview = cloneRemotePreview(preview) | |||
| 215 | + | flight.err = err | |||
| 216 | + | delete(d.remotePreview.flights, key) | |||
| 217 | + | if err == nil { | |||
| 218 | + | d.remotePreview.cache[key] = remotePreviewCacheEntry{ | |||
| 219 | + | Preview: cloneRemotePreview(preview), Fetched: now, Used: now, | |||
| 220 | + | } | |||
| 221 | + | delete(d.remotePreview.cooldowns, key) | |||
| 222 | + | for len(d.remotePreview.cache) > remotePreviewCacheMax { | |||
| 223 | + | var oldestKey remotePreviewCacheKey | |||
| 224 | + | var oldest time.Time | |||
| 225 | + | for candidate, entry := range d.remotePreview.cache { | |||
| 226 | + | used := entry.Used | |||
| 227 | + | if used.IsZero() { | |||
| 228 | + | used = entry.Fetched | |||
| 229 | + | } | |||
| 230 | + | if oldest.IsZero() || used.Before(oldest) { | |||
| 231 | + | oldestKey, oldest = candidate, used | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | delete(d.remotePreview.cache, oldestKey) | |||
| 235 | + | } | |||
| 236 | + | } else if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { | |||
| 237 | + | d.remotePreview.cooldowns[key] = now.Add(remotePreviewCooldown) | |||
| 238 | + | } | |||
| 239 | + | close(flight.done) | |||
| 240 | + | d.remotePreview.mu.Unlock() | |||
| 241 | + | } | |||
A internal/usecase/daemon/remote_preview_cache_test.go +428 -0
| @@ -0,0 +1,428 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | type remotePreviewTestClock struct { | |||
| 19 | + | mu sync.Mutex | |||
| 20 | + | now time.Time | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (c *remotePreviewTestClock) Now() time.Time { | |||
| 24 | + | c.mu.Lock() | |||
| 25 | + | defer c.mu.Unlock() | |||
| 26 | + | return c.now | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (c *remotePreviewTestClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |||
| 30 | + | ||||
| 31 | + | func (c *remotePreviewTestClock) Advance(delta time.Duration) { | |||
| 32 | + | c.mu.Lock() | |||
| 33 | + | c.now = c.now.Add(delta) | |||
| 34 | + | c.mu.Unlock() | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type remotePreviewTestClient struct { | |||
| 38 | + | mu sync.Mutex | |||
| 39 | + | calls int | |||
| 40 | + | result ports.RemotePreview | |||
| 41 | + | err error | |||
| 42 | + | started chan struct{} | |||
| 43 | + | finished chan struct{} | |||
| 44 | + | release chan struct{} | |||
| 45 | + | lastWidth uint16 | |||
| 46 | + | lastHeight uint16 | |||
| 47 | + | lastTarget domain.RemoteSessionTarget | |||
| 48 | + | ignoreCancel bool | |||
| 49 | + | startedSignal bool | |||
| 50 | + | finishedSignal bool | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (c *remotePreviewTestClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) { | |||
| 54 | + | c.mu.Lock() | |||
| 55 | + | c.calls++ | |||
| 56 | + | c.lastTarget = target | |||
| 57 | + | c.lastWidth, c.lastHeight = width, height | |||
| 58 | + | result, err := c.result, c.err | |||
| 59 | + | started, release, finished := c.started, c.release, c.finished | |||
| 60 | + | if started != nil && !c.startedSignal { | |||
| 61 | + | c.startedSignal = true | |||
| 62 | + | close(started) | |||
| 63 | + | } | |||
| 64 | + | c.mu.Unlock() | |||
| 65 | + | if release != nil { | |||
| 66 | + | if c.ignoreCancel { | |||
| 67 | + | <-release | |||
| 68 | + | } else { | |||
| 69 | + | select { | |||
| 70 | + | case <-release: | |||
| 71 | + | case <-ctx.Done(): | |||
| 72 | + | return ports.RemotePreview{}, ctx.Err() | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | if finished != nil { | |||
| 77 | + | c.mu.Lock() | |||
| 78 | + | if !c.finishedSignal { | |||
| 79 | + | c.finishedSignal = true | |||
| 80 | + | close(finished) | |||
| 81 | + | } | |||
| 82 | + | c.mu.Unlock() | |||
| 83 | + | } | |||
| 84 | + | return cloneRemotePreview(result), err | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (c *remotePreviewTestClient) Calls() int { | |||
| 88 | + | c.mu.Lock() | |||
| 89 | + | defer c.mu.Unlock() | |||
| 90 | + | return c.calls | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (c *remotePreviewTestClient) LastSize() (uint16, uint16) { | |||
| 94 | + | c.mu.Lock() | |||
| 95 | + | defer c.mu.Unlock() | |||
| 96 | + | return c.lastWidth, c.lastHeight | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (c *remotePreviewTestClient) LastTarget() domain.RemoteSessionTarget { | |||
| 100 | + | c.mu.Lock() | |||
| 101 | + | defer c.mu.Unlock() | |||
| 102 | + | return c.lastTarget | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func remotePreviewCacheTarget() domain.RemoteSessionTarget { | |||
| 106 | + | var lifecycle domain.SessionLifecycleID | |||
| 107 | + | lifecycle[0] = 0x42 | |||
| 108 | + | return domain.RemoteSessionTarget{ | |||
| 109 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, | |||
| 110 | + | SessionName: "work", LiveTabID: "tab-1", | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func remotePreviewCacheResult(target domain.RemoteSessionTarget, revision uint64) ports.RemotePreview { | |||
| 115 | + | return ports.RemotePreview{ | |||
| 116 | + | Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK, | |||
| 117 | + | LifecycleID: target.LifecycleID, TabID: target.LiveTabID, | |||
| 118 | + | Revision: revision, Width: 1, Height: 1, | |||
| 119 | + | Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}}, | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestFetchRemotePreviewSingleFlightAndCopiesCache(t *testing.T) { | |||
| 124 | + | clock := &remotePreviewTestClock{now: time.Unix(100, 0)} | |||
| 125 | + | target := remotePreviewCacheTarget() | |||
| 126 | + | client := &remotePreviewTestClient{ | |||
| 127 | + | result: remotePreviewCacheResult(target, 1), | |||
| 128 | + | started: make(chan struct{}), | |||
| 129 | + | release: make(chan struct{}), | |||
| 130 | + | } | |||
| 131 | + | d := newTestDaemon(t, nil, clock) | |||
| 132 | + | d.remotePreviewClient = client | |||
| 133 | + | ||||
| 134 | + | firstDone := make(chan struct{}) | |||
| 135 | + | var first ports.RemotePreview | |||
| 136 | + | var firstErr error | |||
| 137 | + | go func() { | |||
| 138 | + | first, firstErr = d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 139 | + | close(firstDone) | |||
| 140 | + | }() | |||
| 141 | + | <-client.started | |||
| 142 | + | ||||
| 143 | + | secondDone := make(chan struct{}) | |||
| 144 | + | var second ports.RemotePreview | |||
| 145 | + | var secondErr error | |||
| 146 | + | go func() { | |||
| 147 | + | second, secondErr = d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 148 | + | close(secondDone) | |||
| 149 | + | }() | |||
| 150 | + | close(client.release) | |||
| 151 | + | <-firstDone | |||
| 152 | + | <-secondDone | |||
| 153 | + | require.NoError(t, firstErr) | |||
| 154 | + | require.NoError(t, secondErr) | |||
| 155 | + | require.Equal(t, first, second) | |||
| 156 | + | require.Equal(t, 1, client.Calls(), "same key must use one remote request") | |||
| 157 | + | ||||
| 158 | + | first.Cells[0].Rune = 'm' | |||
| 159 | + | cached, err := d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | require.Equal(t, rune('x'), cached.Cells[0].Rune, "callers must not mutate the memory-only cache") | |||
| 162 | + | require.Equal(t, 1, client.Calls()) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestFetchRemotePreviewRejectsMalformedResponseAndAppliesCooldown(t *testing.T) { | |||
| 166 | + | clock := &remotePreviewTestClock{now: time.Unix(200, 0)} | |||
| 167 | + | target := remotePreviewCacheTarget() | |||
| 168 | + | client := &remotePreviewTestClient{err: errors.New("remote unavailable")} | |||
| 169 | + | d := newTestDaemon(t, nil, clock) | |||
| 170 | + | d.remotePreviewClient = client | |||
| 171 | + | ||||
| 172 | + | _, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 173 | + | require.Error(t, firstErr) | |||
| 174 | + | _, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 175 | + | require.Error(t, secondErr) | |||
| 176 | + | require.Equal(t, 1, client.Calls(), "a failed target must be cooled down") | |||
| 177 | + | ||||
| 178 | + | clock.Advance(remotePreviewCooldown) | |||
| 179 | + | _, thirdErr := d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 180 | + | require.Error(t, thirdErr) | |||
| 181 | + | require.Equal(t, 2, client.Calls(), "cooldown expiry must permit a retry") | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestFetchRemotePreviewServesStaleWhileRefreshing(t *testing.T) { | |||
| 185 | + | clock := &remotePreviewTestClock{now: time.Unix(300, 0)} | |||
| 186 | + | target := remotePreviewCacheTarget() | |||
| 187 | + | client := &remotePreviewTestClient{result: remotePreviewCacheResult(target, 1)} | |||
| 188 | + | d := newTestDaemon(t, nil, clock) | |||
| 189 | + | d.remotePreviewClient = client | |||
| 190 | + | ||||
| 191 | + | first, err := d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 192 | + | require.NoError(t, err) | |||
| 193 | + | require.Equal(t, uint64(1), first.Revision) | |||
| 194 | + | ||||
| 195 | + | clock.Advance(remotePreviewCacheTTL + time.Second) | |||
| 196 | + | client.mu.Lock() | |||
| 197 | + | client.result = remotePreviewCacheResult(target, 2) | |||
| 198 | + | client.started = make(chan struct{}) | |||
| 199 | + | client.release = make(chan struct{}) | |||
| 200 | + | client.startedSignal = false | |||
| 201 | + | client.finishedSignal = false | |||
| 202 | + | client.mu.Unlock() | |||
| 203 | + | ||||
| 204 | + | stale, err := d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 205 | + | require.NoError(t, err) | |||
| 206 | + | require.Equal(t, uint64(1), stale.Revision, "expired content remains available during revalidation") | |||
| 207 | + | <-client.started | |||
| 208 | + | key := remotePreviewCacheKey{Endpoint: target.Endpoint, DisplayOrigin: target.DisplayOrigin, LifecycleID: target.LifecycleID, SessionName: target.SessionName, LiveTabID: target.LiveTabID, Width: 1, Height: 1} | |||
| 209 | + | d.remotePreview.mu.Lock() | |||
| 210 | + | flight := d.remotePreview.flights[key] | |||
| 211 | + | d.remotePreview.mu.Unlock() | |||
| 212 | + | require.NotNil(t, flight) | |||
| 213 | + | close(client.release) | |||
| 214 | + | <-flight.done | |||
| 215 | + | ||||
| 216 | + | fresh, err := d.fetchRemotePreview(context.Background(), target, 1, 1) | |||
| 217 | + | require.NoError(t, err) | |||
| 218 | + | require.Equal(t, uint64(2), fresh.Revision) | |||
| 219 | + | require.Equal(t, 2, client.Calls()) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | type immediateRemotePreviewTimer struct{ ch <-chan time.Time } | |||
| 223 | + | ||||
| 224 | + | func (t immediateRemotePreviewTimer) C() <-chan time.Time { return t.ch } | |||
| 225 | + | func (t immediateRemotePreviewTimer) Reset(time.Duration) bool { return false } | |||
| 226 | + | func (t immediateRemotePreviewTimer) Stop() bool { return true } | |||
| 227 | + | ||||
| 228 | + | type immediateRemotePreviewClock struct{} | |||
| 229 | + | ||||
| 230 | + | func (immediateRemotePreviewClock) Now() time.Time { return time.Unix(500, 0) } | |||
| 231 | + | func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer { | |||
| 232 | + | ch := make(chan time.Time, 1) | |||
| 233 | + | ch <- time.Unix(500, 0) | |||
| 234 | + | return immediateRemotePreviewTimer{ch: ch} | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *testing.T) { | |||
| 238 | + | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |||
| 239 | + | firstTarget := remotePreviewCacheTarget() | |||
| 240 | + | firstStarted := make(chan struct{}) | |||
| 241 | + | firstRelease := make(chan struct{}) | |||
| 242 | + | client := &remotePreviewTestClient{ | |||
| 243 | + | result: remotePreviewCacheResult(firstTarget, 1), | |||
| 244 | + | started: firstStarted, | |||
| 245 | + | release: firstRelease, | |||
| 246 | + | ignoreCancel: true, | |||
| 247 | + | } | |||
| 248 | + | d.remotePreviewClient = client | |||
| 249 | + | _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |||
| 250 | + | ||||
| 251 | + | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |||
| 252 | + | firstView := picker.SessionView{ | |||
| 253 | + | ID: firstKey.ID(), Name: firstTarget.SessionName, | |||
| 254 | + | Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}}, | |||
| 255 | + | RemoteKey: &firstKey, RemoteTarget: &firstTarget, | |||
| 256 | + | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | |||
| 257 | + | } | |||
| 258 | + | firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 259 | + | firstSelected, ok := firstModel.Selected() | |||
| 260 | + | require.True(t, ok) | |||
| 261 | + | ac.overlays.pickerMu.Lock() | |||
| 262 | + | ac.overlays.picker = firstModel | |||
| 263 | + | ac.overlays.pickerIntent = pickerNavigate | |||
| 264 | + | ac.overlays.pickerPreviewGeneration = 1 | |||
| 265 | + | ac.overlays.pickerMu.Unlock() | |||
| 266 | + | d.startRemotePickerPreview(ac, firstSelected, 1) | |||
| 267 | + | <-firstStarted | |||
| 268 | + | ||||
| 269 | + | d.closePicker(ac) | |||
| 270 | + | ||||
| 271 | + | secondTarget := firstTarget | |||
| 272 | + | secondTarget.LifecycleID[0]++ | |||
| 273 | + | secondTarget.LiveTabID = "tab-2" | |||
| 274 | + | client.mu.Lock() | |||
| 275 | + | client.result = remotePreviewCacheResult(secondTarget, 2) | |||
| 276 | + | client.started = make(chan struct{}) | |||
| 277 | + | client.release = make(chan struct{}) | |||
| 278 | + | client.startedSignal = false | |||
| 279 | + | client.finishedSignal = false | |||
| 280 | + | client.mu.Unlock() | |||
| 281 | + | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |||
| 282 | + | secondView := picker.SessionView{ | |||
| 283 | + | ID: secondKey.ID(), Name: secondTarget.SessionName, | |||
| 284 | + | Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}}, | |||
| 285 | + | RemoteKey: &secondKey, RemoteTarget: &secondTarget, | |||
| 286 | + | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | |||
| 287 | + | } | |||
| 288 | + | secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 289 | + | secondSelected, ok := secondModel.Selected() | |||
| 290 | + | require.True(t, ok) | |||
| 291 | + | ac.overlays.pickerMu.Lock() | |||
| 292 | + | ac.overlays.picker = secondModel | |||
| 293 | + | ac.overlays.pickerIntent = pickerNavigate | |||
| 294 | + | ac.overlays.pickerPreviewGeneration++ | |||
| 295 | + | secondGeneration := ac.overlays.pickerPreviewGeneration | |||
| 296 | + | ac.overlays.pickerMu.Unlock() | |||
| 297 | + | d.startRemotePickerPreview(ac, secondSelected, secondGeneration) | |||
| 298 | + | <-client.started | |||
| 299 | + | ||||
| 300 | + | close(firstRelease) | |||
| 301 | + | close(client.release) | |||
| 302 | + | width, height := remotePickerPreviewSize(ac.sizeSnapshot()) | |||
| 303 | + | firstKeyForCache := remotePreviewCacheKey{Endpoint: firstTarget.Endpoint, DisplayOrigin: firstTarget.DisplayOrigin, LifecycleID: firstTarget.LifecycleID, SessionName: firstTarget.SessionName, LiveTabID: firstTarget.LiveTabID, Width: width, Height: height} | |||
| 304 | + | secondKeyForCache := remotePreviewCacheKey{Endpoint: secondTarget.Endpoint, DisplayOrigin: secondTarget.DisplayOrigin, LifecycleID: secondTarget.LifecycleID, SessionName: secondTarget.SessionName, LiveTabID: secondTarget.LiveTabID, Width: width, Height: height} | |||
| 305 | + | for _, key := range []remotePreviewCacheKey{firstKeyForCache, secondKeyForCache} { | |||
| 306 | + | d.remotePreview.mu.Lock() | |||
| 307 | + | flight := d.remotePreview.flights[key] | |||
| 308 | + | d.remotePreview.mu.Unlock() | |||
| 309 | + | if flight != nil { | |||
| 310 | + | <-flight.done | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | var preview picker.Preview | |||
| 314 | + | require.Eventually(t, func() bool { | |||
| 315 | + | ac.overlays.pickerMu.Lock() | |||
| 316 | + | preview = ac.overlays.pickerRemotePreview | |||
| 317 | + | currentGeneration := ac.overlays.pickerPreviewGeneration | |||
| 318 | + | ac.overlays.pickerMu.Unlock() | |||
| 319 | + | return currentGeneration == secondGeneration && len(preview.Rows) != 0 && len(preview.Rows[0]) != 0 && preview.Rows[0][0].Rune == 'x' | |||
| 320 | + | }, time.Second, time.Millisecond) | |||
| 321 | + | require.Equal(t, rune('x'), preview.Rows[0][0].Rune) | |||
| 322 | + | require.Equal(t, 2, client.Calls()) | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) { | |||
| 326 | + | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |||
| 327 | + | firstTarget := remotePreviewCacheTarget() | |||
| 328 | + | secondTarget := firstTarget | |||
| 329 | + | secondTarget.LifecycleID[0]++ | |||
| 330 | + | secondTarget.LiveTabID = "tab-2" | |||
| 331 | + | client := &remotePreviewTestClient{result: remotePreviewCacheResult(firstTarget, 1), started: make(chan struct{})} | |||
| 332 | + | d.remotePreviewClient = client | |||
| 333 | + | _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |||
| 334 | + | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |||
| 335 | + | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |||
| 336 | + | views := []picker.SessionView{ | |||
| 337 | + | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |||
| 338 | + | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |||
| 339 | + | } | |||
| 340 | + | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 341 | + | ac.overlays.pickerMu.Lock() | |||
| 342 | + | ac.overlays.picker = model | |||
| 343 | + | ac.overlays.pickerIntent = pickerNavigate | |||
| 344 | + | ac.overlays.pickerPreviewGeneration = 1 | |||
| 345 | + | ac.overlays.pickerMu.Unlock() | |||
| 346 | + | d.registerPreviewForSelection(ac) | |||
| 347 | + | <-client.started | |||
| 348 | + | require.Equal(t, firstTarget, client.LastTarget()) | |||
| 349 | + | ||||
| 350 | + | client.mu.Lock() | |||
| 351 | + | client.result = remotePreviewCacheResult(secondTarget, 2) | |||
| 352 | + | client.started = make(chan struct{}) | |||
| 353 | + | client.startedSignal = false | |||
| 354 | + | client.mu.Unlock() | |||
| 355 | + | ac.overlays.pickerMu.Lock() | |||
| 356 | + | model.Down() | |||
| 357 | + | ac.overlays.pickerMu.Unlock() | |||
| 358 | + | d.registerPreviewForSelection(ac) | |||
| 359 | + | <-client.started | |||
| 360 | + | require.Equal(t, secondTarget, client.LastTarget()) | |||
| 361 | + | require.Equal(t, 2, client.Calls()) | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) { | |||
| 365 | + | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |||
| 366 | + | target := remotePreviewCacheTarget() | |||
| 367 | + | firstStarted := make(chan struct{}) | |||
| 368 | + | client := &remotePreviewTestClient{ | |||
| 369 | + | result: remotePreviewCacheResult(target, 1), | |||
| 370 | + | started: firstStarted, | |||
| 371 | + | release: make(chan struct{}), | |||
| 372 | + | } | |||
| 373 | + | d.remotePreviewClient = client | |||
| 374 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |||
| 375 | + | key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin} | |||
| 376 | + | view := picker.SessionView{ | |||
| 377 | + | ID: key.ID(), Name: target.SessionName, | |||
| 378 | + | Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}}, | |||
| 379 | + | RemoteKey: &key, RemoteTarget: &target, | |||
| 380 | + | RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true, | |||
| 381 | + | } | |||
| 382 | + | model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 383 | + | selected, ok := model.Selected() | |||
| 384 | + | require.True(t, ok) | |||
| 385 | + | ac.overlays.pickerMu.Lock() | |||
| 386 | + | ac.overlays.picker = model | |||
| 387 | + | ac.overlays.pickerIntent = pickerNavigate | |||
| 388 | + | ac.overlays.pickerPreviewGeneration = 1 | |||
| 389 | + | ac.overlays.pickerMu.Unlock() | |||
| 390 | + | d.startRemotePickerPreview(ac, selected, 1) | |||
| 391 | + | <-firstStarted | |||
| 392 | + | ||||
| 393 | + | secondStarted := make(chan struct{}) | |||
| 394 | + | client.mu.Lock() | |||
| 395 | + | client.started = secondStarted | |||
| 396 | + | client.release = nil | |||
| 397 | + | client.startedSignal = false | |||
| 398 | + | client.mu.Unlock() | |||
| 399 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 400 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 401 | + | require.True(t, admitted) | |||
| 402 | + | token.effect = effect | |||
| 403 | + | defer effect.End() | |||
| 404 | + | require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30})) | |||
| 405 | + | <-secondStarted | |||
| 406 | + | wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30}) | |||
| 407 | + | gotWidth, gotHeight := client.LastSize() | |||
| 408 | + | require.Equal(t, wantWidth, gotWidth) | |||
| 409 | + | require.Equal(t, wantHeight, gotHeight) | |||
| 410 | + | ||||
| 411 | + | require.Eventually(t, func() bool { | |||
| 412 | + | ac.overlays.pickerMu.Lock() | |||
| 413 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 414 | + | return ac.overlays.pickerRemotePreview.Width == 1 && ac.overlays.pickerRemotePreview.Height == 1 | |||
| 415 | + | }, time.Second, time.Millisecond) | |||
| 416 | + | require.Equal(t, 2, client.Calls()) | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) { | |||
| 420 | + | clock := &remotePreviewTestClock{now: time.Unix(400, 0)} | |||
| 421 | + | client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)} | |||
| 422 | + | d := newTestDaemon(t, nil, clock) | |||
| 423 | + | d.remotePreviewClient = client | |||
| 424 | + | ||||
| 425 | + | _, err := d.fetchRemotePreview(context.Background(), remotePreviewCacheTarget(), ports.RemotePreviewMaxWidth+1, 1) | |||
| 426 | + | require.ErrorIs(t, err, ports.ErrInvalidRemotePreviewRequest) | |||
| 427 | + | require.Zero(t, client.Calls()) | |||
| 428 | + | } | |||
A internal/usecase/daemon/remote_preview_capture_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | "github.com/bnema/vev/pkg/vt" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) { | |||
| 15 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 16 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |||
| 17 | + | sess.ephemeral = false | |||
| 18 | + | sess.incarnation = remoteLifecycleForTest() | |||
| 19 | + | pane := sess.tabs[0].focusedPane() | |||
| 20 | + | pane.mu.Lock() | |||
| 21 | + | pane.screen = vt.NewScreen(3, 1) | |||
| 22 | + | style := renderer.DefaultStyle() | |||
| 23 | + | style.Bold = true | |||
| 24 | + | pane.screen.Frame.Set(1, 0, renderer.Cell{Rune: '界', Style: style}) | |||
| 25 | + | pane.screen.Frame.Set(2, 0, renderer.Cell{Continuation: true, Style: style}) | |||
| 26 | + | pane.mu.Unlock() | |||
| 27 | + | ||||
| 28 | + | target := domain.RemoteSessionTarget{ | |||
| 29 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation, | |||
| 30 | + | SessionName: "work", LiveTabID: "tab-1", | |||
| 31 | + | } | |||
| 32 | + | preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{ | |||
| 33 | + | Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 1, | |||
| 34 | + | }) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | require.Equal(t, uint16(2), preview.Width) | |||
| 37 | + | require.Equal(t, rune(' '), preview.Cells[1].Rune, "a crop cannot expose a dangling wide rune") | |||
| 38 | + | require.False(t, preview.Cells[1].Continuation) | |||
| 39 | + | require.True(t, preview.Cells[1].Style.Equal(style), "boundary replacement preserves the cell style") | |||
| 40 | + | require.NoError(t, ports.ValidateRemotePreview(preview)) | |||
| 41 | + | } | |||
A internal/usecase/daemon/remote_route_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRouteRemoteTargetSelectsExactLiveTab(t *testing.T) { | |||
| 14 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 15 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |||
| 16 | + | sess.ephemeral = false | |||
| 17 | + | sess.incarnation = remoteLifecycleForTest() | |||
| 18 | + | tr, _ := newCapturingTransport(t) | |||
| 19 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation, SessionName: "work", LiveTabID: "tab-1"} | |||
| 20 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned} | |||
| 21 | + | _, ac, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.NotNil(t, ac) | |||
| 24 | + | require.Same(t, sess, ac.currentAttachmentSession()) | |||
| 25 | + | require.Equal(t, domain.TabStableID("tab-1"), ac.viewSnapshot().tabID) | |||
| 26 | + | d.clientGone(sess, ac, tr, false) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) { | |||
| 30 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 31 | + | sess := addControlSession(d, "work", "tab-new", "pane-new") | |||
| 32 | + | sess.ephemeral = false | |||
| 33 | + | sess.incarnation = remoteLifecycleForTest() | |||
| 34 | + | tr, _ := newCapturingTransport(t) | |||
| 35 | + | var old domain.SessionLifecycleID | |||
| 36 | + | old[0] = 8 | |||
| 37 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: old, SessionName: "work", LiveTabID: "tab-new"} | |||
| 38 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned} | |||
| 39 | + | _, _, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 40 | + | var protocol *protoErr | |||
| 41 | + | require.ErrorAs(t, err, &protocol) | |||
| 42 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |||
| 43 | + | } | |||
A internal/usecase/daemon/remote_stopped_route_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) { | |||
| 14 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 15 | + | lifecycle := remoteLifecycleForTest() | |||
| 16 | + | d.mu.Lock() | |||
| 17 | + | d.stopped["work"] = stoppedSession{ | |||
| 18 | + | name: "work", cwd: "/remote/work", incarnation: lifecycle, | |||
| 19 | + | tabNames: []string{"alpha", "beta"}, | |||
| 20 | + | tabRecords: []domain.CatalogueTabRecord{ | |||
| 21 | + | {StableID: "tab-a", Name: "alpha"}, | |||
| 22 | + | {StableID: "tab-b", Name: "beta"}, | |||
| 23 | + | }, | |||
| 24 | + | } | |||
| 25 | + | d.mu.Unlock() | |||
| 26 | + | ||||
| 27 | + | target := domain.RemoteSessionTarget{ | |||
| 28 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, | |||
| 29 | + | SessionName: "work", Stopped: true, | |||
| 30 | + | StoppedTab: domain.NewStableTabSelector("tab-b"), | |||
| 31 | + | } | |||
| 32 | + | tr, _ := newCapturingTransport(t) | |||
| 33 | + | hello := ports.Hello{ | |||
| 34 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", | |||
| 35 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |||
| 36 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 37 | + | Env: []string{"VEV_REMOTE_PICKER_SENTINEL=untrusted"}, | |||
| 38 | + | } | |||
| 39 | + | sess, ac, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | require.NotNil(t, sess) | |||
| 42 | + | require.NotNil(t, ac) | |||
| 43 | + | require.Equal(t, lifecycle, sess.incarnation) | |||
| 44 | + | require.Equal(t, domain.TabStableID("tab-b"), ac.viewSnapshot().tabID) | |||
| 45 | + | require.NotContains(t, sess.env, "VEV_REMOTE_PICKER_SENTINEL=untrusted") | |||
| 46 | + | d.clientGone(sess, ac, tr, false) | |||
| 47 | + | } | |||
A internal/usecase/daemon/remote_stopped_selector_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) { | |||
| 12 | + | lifecycle := remoteLifecycleForTest() | |||
| 13 | + | stopped := stoppedSession{ | |||
| 14 | + | name: "work", incarnation: lifecycle, | |||
| 15 | + | tabNames: []string{"alpha", "beta"}, | |||
| 16 | + | tabRecords: []domain.CatalogueTabRecord{ | |||
| 17 | + | {StableID: "tab-a", Name: "alpha"}, | |||
| 18 | + | {StableID: "tab-b", Name: "beta"}, | |||
| 19 | + | }, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | stable := domain.RemoteSessionTarget{ | |||
| 23 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, | |||
| 24 | + | SessionName: "work", Stopped: true, | |||
| 25 | + | StoppedTab: domain.NewStableTabSelector("tab-b"), | |||
| 26 | + | } | |||
| 27 | + | index, ok := remoteTargetTabIndexStopped(stopped, stable) | |||
| 28 | + | require.True(t, ok) | |||
| 29 | + | require.Equal(t, 1, index) | |||
| 30 | + | ||||
| 31 | + | ordinal := stable | |||
| 32 | + | ordinal.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2) | |||
| 33 | + | index, ok = remoteTargetTabIndexStopped(stopped, ordinal) | |||
| 34 | + | require.True(t, ok) | |||
| 35 | + | require.Equal(t, 1, index) | |||
| 36 | + | ||||
| 37 | + | for _, invalid := range []domain.RemoteSessionTarget{ | |||
| 38 | + | func() domain.RemoteSessionTarget { | |||
| 39 | + | candidate := stable | |||
| 40 | + | candidate.StoppedTab = domain.NewStableTabSelector("missing") | |||
| 41 | + | return candidate | |||
| 42 | + | }(), | |||
| 43 | + | func() domain.RemoteSessionTarget { | |||
| 44 | + | candidate := ordinal | |||
| 45 | + | candidate.StoppedTab = domain.NewOrdinalTabSelector(1, "replaced", 2) | |||
| 46 | + | return candidate | |||
| 47 | + | }(), | |||
| 48 | + | func() domain.RemoteSessionTarget { | |||
| 49 | + | candidate := ordinal | |||
| 50 | + | candidate.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 3) | |||
| 51 | + | return candidate | |||
| 52 | + | }(), | |||
| 53 | + | } { | |||
| 54 | + | _, ok := remoteTargetTabIndexStopped(stopped, invalid) | |||
| 55 | + | require.False(t, ok) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | reordered := stopped | |||
| 59 | + | reordered.tabRecords = []domain.CatalogueTabRecord{ | |||
| 60 | + | {StableID: "tab-b", Name: "beta"}, | |||
| 61 | + | {StableID: "tab-a", Name: "alpha"}, | |||
| 62 | + | } | |||
| 63 | + | index, ok = remoteTargetTabIndexStopped(reordered, stable) | |||
| 64 | + | require.True(t, ok) | |||
| 65 | + | require.Equal(t, 0, index, "stable selectors follow identity, not the old ordinal") | |||
| 66 | + | } | |||
M internal/usecase/daemon/render.go +3 -0
| @@ -375,6 +375,9 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||||
| 375 | preview := snapshotPickerPreview(nil) | 375 | preview := snapshotPickerPreview(nil) | ||
| 376 | if local && overlays.previewTab != tb { | 376 | if local && overlays.previewTab != tb { | ||
| 377 | preview = snapshotPickerPreview(overlays.previewTab) | 377 | preview = snapshotPickerPreview(overlays.previewTab) | ||
| 378 | + | if overlays.previewTab == nil && overlays.remotePreview.Height > 0 { | |||
| 379 | + | preview = overlays.remotePreview | |||
| 380 | + | } | |||
| 378 | } | 381 | } | ||
| 379 | if local { | 382 | if local { | ||
| 380 | d.refreshSessionFocusedTitles(sess) | 383 | d.refreshSessionFocusedTitles(sess) | ||
M internal/usecase/daemon/session.go +44 -16
| @@ -226,12 +226,12 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||||
| 226 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | 226 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | ||
| 227 | } | 227 | } | ||
| 228 | } | 228 | } | ||
| 229 | - | if incarnation == (domain.IncarnationID{}) { | 229 | + | } |
| 230 | - | var err error | 230 | + | if incarnation == (domain.IncarnationID{}) { |
| 231 | - | incarnation, err = domain.NewIncarnationID(rand.Reader) | 231 | + | var err error |
| 232 | - | if err != nil { | 232 | + | incarnation, err = domain.NewSessionLifecycleID(rand.Reader) |
| 233 | - | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate durable identity: %w", err)) | 233 | + | if err != nil { |
| 234 | - | } | 234 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate lifecycle identity: %w", err)) |
| 235 | } | 235 | } | ||
| 236 | } | 236 | } | ||
| 237 | 237 | ||||
| @@ -240,12 +240,22 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||||
| 240 | } | 240 | } | ||
| 241 | tbSize := contentSize(sz) | 241 | tbSize := contentSize(sz) | ||
| 242 | var names []string | 242 | var names []string | ||
| 243 | - | if len(restoredTabNames) > 0 { | 243 | + | var tabRecords []domain.CatalogueTabRecord |
| 244 | + | if resuming && len(stopped.tabRecords) != 0 { | |||
| 245 | + | tabRecords = append([]domain.CatalogueTabRecord(nil), stopped.tabRecords...) | |||
| 246 | + | names = make([]string, len(tabRecords)) | |||
| 247 | + | for i, record := range tabRecords { | |||
| 248 | + | names[i] = record.Name | |||
| 249 | + | } | |||
| 250 | + | } else if len(restoredTabNames) > 0 { | |||
| 244 | names = append([]string(nil), restoredTabNames[0]...) | 251 | names = append([]string(nil), restoredTabNames[0]...) | ||
| 245 | } | 252 | } | ||
| 246 | tabs := make([]*tab, 0, max(1, len(names))) | 253 | tabs := make([]*tab, 0, max(1, len(names))) | ||
| 247 | for i := range max(1, len(names)) { | 254 | for i := range max(1, len(names)) { | ||
| 248 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | 255 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | ||
| 256 | + | if i < len(tabRecords) && tabRecords[i].StableID != "" { | |||
| 257 | + | tabStableID = string(tabRecords[i].StableID) | |||
| 258 | + | } | |||
| 249 | if err != nil { | 259 | if err != nil { | ||
| 250 | closeTabs(tabs) | 260 | closeTabs(tabs) | ||
| 251 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | 261 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | ||
| @@ -317,7 +327,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||||
| 317 | } | 327 | } | ||
| 318 | var rollbackDurable func() error | 328 | var rollbackDurable func() error | ||
| 319 | if !ephemeral && d.persistEnabled { | 329 | if !ephemeral && d.persistEnabled { | ||
| 320 | - | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} | 330 | + | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, TabRecords: tabRecordsFromTabs(tabs)} |
| 321 | if d.recovery == nil { | 331 | if d.recovery == nil { | ||
| 322 | closeTabs(tabs) | 332 | closeTabs(tabs) | ||
| 323 | cancel() | 333 | cancel() | ||
| @@ -844,11 +854,13 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 844 | d.mu.Unlock() | 854 | d.mu.Unlock() | ||
| 845 | return err | 855 | return err | ||
| 846 | } | 856 | } | ||
| 847 | - | sess.incarnation, err = domain.NewIncarnationID(rand.Reader) | 857 | + | if sess.incarnation == (domain.IncarnationID{}) { |
| 848 | - | if err != nil { | 858 | + | sess.incarnation, err = domain.NewSessionLifecycleID(rand.Reader) |
| 849 | - | sess.mu.Unlock() | 859 | + | if err != nil { |
| 850 | - | d.mu.Unlock() | 860 | + | sess.mu.Unlock() |
| 851 | - | return fmt.Errorf("generate durable identity: %w", err) | 861 | + | d.mu.Unlock() |
| 862 | + | return fmt.Errorf("generate durable identity: %w", err) | |||
| 863 | + | } | |||
| 852 | } | 864 | } | ||
| 853 | } | 865 | } | ||
| 854 | lastUsedSeq := sess.mruAt.Load() | 866 | lastUsedSeq := sess.mruAt.Load() | ||
| @@ -925,6 +937,20 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | |||||
| 925 | return nil | 937 | return nil | ||
| 926 | } | 938 | } | ||
| 927 | 939 | ||||
| 940 | + | func tabRecordsFromTabs(tabs []*tab) []domain.CatalogueTabRecord { | |||
| 941 | + | if len(tabs) == 0 { | |||
| 942 | + | return nil | |||
| 943 | + | } | |||
| 944 | + | records := make([]domain.CatalogueTabRecord, len(tabs)) | |||
| 945 | + | for i, tb := range tabs { | |||
| 946 | + | if tb == nil { | |||
| 947 | + | continue | |||
| 948 | + | } | |||
| 949 | + | records[i] = domain.CatalogueTabRecord{StableID: domain.TabStableID(tb.stableID), Name: tb.name} | |||
| 950 | + | } | |||
| 951 | + | return records | |||
| 952 | + | } | |||
| 953 | + | ||||
| 928 | func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | 954 | func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | ||
| 929 | createdAt := s.createdAt | 955 | createdAt := s.createdAt | ||
| 930 | tabNames := make([]string, len(s.tabs)) | 956 | tabNames := make([]string, len(s.tabs)) | ||
| @@ -940,7 +966,7 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | |||||
| 940 | } else { | 966 | } else { | ||
| 941 | tabNames = tabNames[:lastCustom+1] | 967 | tabNames = tabNames[:lastCustom+1] | ||
| 942 | } | 968 | } | ||
| 943 | - | return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames} | 969 | + | return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, TabRecords: tabRecordsFromTabs(s.tabs)} |
| 944 | } | 970 | } | ||
| 945 | 971 | ||||
| 946 | // closeTab is the public close boundary for callers that are not already | 972 | // closeTab is the public close boundary for callers that are not already | ||
| @@ -1545,13 +1571,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1545 | stoppedName := sess.name | 1571 | stoppedName := sess.name | ||
| 1546 | stoppedCwd := sess.cwd | 1572 | stoppedCwd := sess.cwd | ||
| 1547 | createdAt := sess.createdAt | 1573 | createdAt := sess.createdAt | ||
| 1548 | - | tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames | 1574 | + | stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))) |
| 1575 | + | tabNames := stoppedRecord.TabNames | |||
| 1576 | + | tabRecords := stoppedRecord.TabRecords | |||
| 1549 | ephemeral := sess.ephemeral | 1577 | ephemeral := sess.ephemeral | ||
| 1550 | unlockCoordinators() | 1578 | unlockCoordinators() | ||
| 1551 | unlockSessions() | 1579 | unlockSessions() | ||
| 1552 | d.notices.routingMu.Unlock() | 1580 | d.notices.routingMu.Unlock() | ||
| 1553 | if !ephemeral { | 1581 | if !ephemeral { | ||
| 1554 | - | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge} | 1582 | + | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, tabRecords: tabRecords, record: stoppedRecord, state: ports.SessionStopped, purging: purge} |
| 1555 | d.stopped[stoppedName] = stopped | 1583 | d.stopped[stoppedName] = stopped | ||
| 1556 | } | 1584 | } | ||
| 1557 | empty := len(d.sessions) == 0 | 1585 | empty := len(d.sessions) == 0 | ||
M internal/usecase/daemon/snapshot_restore_incremental.go +18 -1
| @@ -69,13 +69,30 @@ func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.Catalogu | |||||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
| 71 | func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) stoppedSession { | 71 | func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) stoppedSession { | ||
| 72 | + | tabRecords := append([]domain.CatalogueTabRecord(nil), record.TabRecords...) | |||
| 73 | + | tabNames := append([]string(nil), record.TabNames...) | |||
| 74 | + | if len(tabRecords) != 0 { | |||
| 75 | + | tabNames = make([]string, len(tabRecords)) | |||
| 76 | + | for i, tab := range tabRecords { | |||
| 77 | + | tabNames[i] = tab.Name | |||
| 78 | + | } | |||
| 79 | + | } else if len(tabNames) != 0 { | |||
| 80 | + | // Legacy catalogue records had no stable IDs. Preserve every encoded | |||
| 81 | + | // ordinal, including unnamed entries, and resolve them by exact name | |||
| 82 | + | // and expected count during stopped restoration. | |||
| 83 | + | tabRecords = make([]domain.CatalogueTabRecord, len(tabNames)) | |||
| 84 | + | for i, name := range tabNames { | |||
| 85 | + | tabRecords[i] = domain.CatalogueTabRecord{Name: name} | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 72 | return stoppedSession{ | 88 | return stoppedSession{ | ||
| 73 | name: record.Name, | 89 | name: record.Name, | ||
| 74 | cwd: record.Cwd, | 90 | cwd: record.Cwd, | ||
| 75 | createdAt: record.CreatedAt, | 91 | createdAt: record.CreatedAt, | ||
| 76 | incarnation: record.IncarnationID, | 92 | incarnation: record.IncarnationID, | ||
| 77 | lastUsedSeq: record.LastUsedSeq, | 93 | lastUsedSeq: record.LastUsedSeq, | ||
| 78 | - | tabNames: append([]string(nil), record.TabNames...), | 94 | + | tabNames: tabNames, |
| 95 | + | tabRecords: tabRecords, | |||
| 79 | record: record, | 96 | record: record, | ||
| 80 | state: state, | 97 | state: state, | ||
| 81 | restoreDone: done, | 98 | restoreDone: done, | ||
M internal/usecase/picker/model.go +88 -25
| @@ -35,10 +35,14 @@ type SessionView struct { | |||||
| 35 | Stopped bool | 35 | Stopped bool | ||
| 36 | ExpectedCreatedAt *int64 | 36 | ExpectedCreatedAt *int64 | ||
| 37 | RemoteKey *domain.RemoteSessionKey | 37 | RemoteKey *domain.RemoteSessionKey | ||
| 38 | + | // RemoteTarget is the structured route/lifecycle identity for picker rows. | |||
| 39 | + | // It is never reconstructed from Name or a rendered label. | |||
| 40 | + | RemoteTarget *domain.RemoteSessionTarget | |||
| 38 | // RemoteHost marks remote host status rows that have no session key. | 41 | // RemoteHost marks remote host status rows that have no session key. | ||
| 39 | RemoteHost string | 42 | RemoteHost string | ||
| 40 | RemoteAvailability RemoteAvailability | 43 | RemoteAvailability RemoteAvailability | ||
| 41 | RemoteDetail string | 44 | RemoteDetail string | ||
| 45 | + | RemoteReason string | |||
| 42 | ConnectOnly bool | 46 | ConnectOnly bool | ||
| 43 | RemoteAttachReady bool | 47 | RemoteAttachReady bool | ||
| 44 | // CannotAcceptMoves reports whether this session cannot receive a moved tab | 48 | // CannotAcceptMoves reports whether this session cannot receive a moved tab | ||
| @@ -51,6 +55,7 @@ type SessionView struct { | |||||
| 51 | type TabEntry struct { | 55 | type TabEntry struct { | ||
| 52 | TabID domain.TabStableID // stable tab identity; independent of its current index | 56 | TabID domain.TabStableID // stable tab identity; independent of its current index | ||
| 53 | Name string // tab display name | 57 | Name string // tab display name | ||
| 58 | + | RawName string // unformatted name used by exact remote selectors | |||
| 54 | Detail string // " (paneTitle)" or "", drawn muted | 59 | Detail string // " (paneTitle)" or "", drawn muted | ||
| 55 | Attention bool // draw the attention marker right after Name, before Detail | 60 | Attention bool // draw the attention marker right after Name, before Detail | ||
| 56 | } | 61 | } | ||
| @@ -110,13 +115,16 @@ func defaultRenderStyles() RenderStyles { | |||||
| 110 | } | 115 | } | ||
| 111 | 116 | ||||
| 112 | type Target struct { | 117 | type Target struct { | ||
| 113 | - | Session domain.SessionID | 118 | + | Session domain.SessionID |
| 114 | - | Incarnation domain.IncarnationID | 119 | + | Incarnation domain.IncarnationID |
| 115 | - | Name string | 120 | + | Name string |
| 116 | - | RemoteKey *domain.RemoteSessionKey | 121 | + | RemoteKey *domain.RemoteSessionKey |
| 117 | - | TabID domain.TabStableID | 122 | + | RemoteTarget *domain.RemoteSessionTarget |
| 118 | - | TabIndex int | 123 | + | RemoteHost string |
| 119 | - | Stopped bool | 124 | + | UnavailableReason string |
| 125 | + | TabID domain.TabStableID | |||
| 126 | + | TabIndex int | |||
| 127 | + | Stopped bool | |||
| 120 | // ExpectedCreatedAt optionally pins this target to a particular named | 128 | // ExpectedCreatedAt optionally pins this target to a particular named | ||
| 121 | // session lifecycle. Callers that obtain a snapshot outside the daemon use | 129 | // session lifecycle. Callers that obtain a snapshot outside the daemon use | ||
| 122 | // it to reject a same-name replacement at commit time. | 130 | // it to reject a same-name replacement at commit time. | ||
| @@ -187,7 +195,12 @@ type row struct { | |||||
| 187 | remoteKey domain.RemoteSessionKey | 195 | remoteKey domain.RemoteSessionKey | ||
| 188 | hasRemoteKey bool | 196 | hasRemoteKey bool | ||
| 189 | remote bool | 197 | remote bool | ||
| 198 | + | remoteTarget domain.RemoteSessionTarget | |||
| 199 | + | hasRemoteTarget bool | |||
| 200 | + | remoteHost string | |||
| 201 | + | unavailableReason string | |||
| 190 | selectable bool | 202 | selectable bool | ||
| 203 | + | focusable bool | |||
| 191 | dim bool | 204 | dim bool | ||
| 192 | } | 205 | } | ||
| 193 | 206 | ||||
| @@ -202,7 +215,7 @@ func New(sessions []SessionView, config SelectionConfig) *Model { | |||||
| 202 | if selectionMatches(pickerRow, config.Current, config.Mode) { | 215 | if selectionMatches(pickerRow, config.Current, config.Mode) { | ||
| 203 | m.selected = idx | 216 | m.selected = idx | ||
| 204 | } | 217 | } | ||
| 205 | - | if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.selectable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) { | 218 | + | if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.focusable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) { |
| 206 | activeSelection = idx | 219 | activeSelection = idx | ||
| 207 | } | 220 | } | ||
| 208 | } | 221 | } | ||
| @@ -211,7 +224,7 @@ func New(sessions []SessionView, config SelectionConfig) *Model { | |||||
| 211 | m.selected = activeSelection | 224 | m.selected = activeSelection | ||
| 212 | } | 225 | } | ||
| 213 | if m.selected < 0 { | 226 | if m.selected < 0 { | ||
| 214 | - | m.selected = m.firstSelectable() | 227 | + | m.selected = m.firstFocusable() |
| 215 | } | 228 | } | ||
| 216 | if m.selected < 0 && len(m.rows) > 0 { | 229 | if m.selected < 0 && len(m.rows) > 0 { | ||
| 217 | m.selected = 0 | 230 | m.selected = 0 | ||
| @@ -247,20 +260,38 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 247 | if session.RemoteKey != nil { | 260 | if session.RemoteKey != nil { | ||
| 248 | common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true | 261 | common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true | ||
| 249 | } | 262 | } | ||
| 250 | - | common.remote = common.hasRemoteKey || session.RemoteHost != "" | 263 | + | if session.RemoteTarget != nil { |
| 264 | + | common.remoteTarget, common.hasRemoteTarget = *session.RemoteTarget, true | |||
| 265 | + | } | |||
| 266 | + | common.remoteHost = session.RemoteHost | |||
| 267 | + | common.remote = common.hasRemoteKey || common.hasRemoteTarget || session.RemoteHost != "" | |||
| 251 | header := common | 268 | header := common | ||
| 252 | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | 269 | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | ||
| 253 | header.selectable = header.kind.selectable(config.Mode) | 270 | header.selectable = header.kind.selectable(config.Mode) | ||
| 271 | + | header.focusable = header.selectable | |||
| 254 | if common.remote { | 272 | if common.remote { | ||
| 255 | header.detail = remoteRowDetail(session) | 273 | header.detail = remoteRowDetail(session) | ||
| 256 | - | header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session) | 274 | + | // Rich catalog rows use tab targets. Legacy count-only/connect-only |
| 275 | + | // callers retain their header target until all peers carry the richer | |||
| 276 | + | // identity contract. | |||
| 277 | + | if common.hasRemoteTarget { | |||
| 278 | + | header.selectable = false | |||
| 279 | + | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | |||
| 280 | + | } else { | |||
| 281 | + | header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session) | |||
| 282 | + | header.focusable = header.selectable | |||
| 283 | + | if !common.hasRemoteKey && !common.hasRemoteTarget { | |||
| 284 | + | header.focusable = config.Mode == SelectNavigationTab | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | header.unavailableReason = session.RemoteReason | |||
| 257 | header.dim = remoteDim(session) | 288 | header.dim = remoteDim(session) | ||
| 258 | } | 289 | } | ||
| 259 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | 290 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | ||
| 260 | - | header.selectable, header.dim = false, true | 291 | + | header.selectable, header.focusable, header.dim = false, false, true |
| 261 | } | 292 | } | ||
| 262 | rows := []row{header} | 293 | rows := []row{header} | ||
| 263 | - | if !session.ConnectOnly { | 294 | + | if !session.ConnectOnly || common.hasRemoteTarget { |
| 264 | for i, tab := range session.Tabs { | 295 | for i, tab := range session.Tabs { | ||
| 265 | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | 296 | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | ||
| 266 | continue | 297 | continue | ||
| @@ -269,12 +300,39 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 269 | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | 300 | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | ||
| 270 | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | 301 | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | ||
| 271 | tabRow.selectable = tabRow.kind.selectable(config.Mode) | 302 | tabRow.selectable = tabRow.kind.selectable(config.Mode) | ||
| 303 | + | tabRow.focusable = tabRow.selectable | |||
| 272 | if common.remote { | 304 | if common.remote { | ||
| 273 | - | tabRow.selectable = false | 305 | + | tabRow.unavailableReason = session.RemoteReason |
| 306 | + | if common.hasRemoteTarget { | |||
| 307 | + | remoteTarget := common.remoteTarget | |||
| 308 | + | if session.Stopped { | |||
| 309 | + | remoteTarget.LiveTabID = "" | |||
| 310 | + | if tab.TabID != "" { | |||
| 311 | + | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | |||
| 312 | + | } else { | |||
| 313 | + | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(len(session.Tabs))) | |||
| 314 | + | } | |||
| 315 | + | } else { | |||
| 316 | + | remoteTarget.StoppedTab = domain.TabSelector{} | |||
| 317 | + | remoteTarget.LiveTabID = tab.TabID | |||
| 318 | + | } | |||
| 319 | + | // Keep the selected tab's structured identity even when it is | |||
| 320 | + | // invalid. Focus remains possible for diagnostics, while both | |||
| 321 | + | // the readiness gate and daemon wire validation reject it. | |||
| 322 | + | tabRow.remoteTarget = remoteTarget | |||
| 323 | + | tabRow.hasRemoteTarget = true | |||
| 324 | + | } | |||
| 325 | + | if !tabRow.hasRemoteTarget { | |||
| 326 | + | tabRow.focusable = false | |||
| 327 | + | tabRow.selectable = false | |||
| 328 | + | } else { | |||
| 329 | + | tabRow.focusable = true | |||
| 330 | + | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteSelectable(session) | |||
| 331 | + | } | |||
| 274 | tabRow.dim = remoteDim(session) | 332 | tabRow.dim = remoteDim(session) | ||
| 275 | } | 333 | } | ||
| 276 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | 334 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | ||
| 277 | - | tabRow.selectable, tabRow.dim = false, true | 335 | + | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true |
| 278 | } | 336 | } | ||
| 279 | rows = append(rows, tabRow) | 337 | rows = append(rows, tabRow) | ||
| 280 | } | 338 | } | ||
| @@ -286,7 +344,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 286 | } | 344 | } | ||
| 287 | 345 | ||||
| 288 | func remoteSelectable(session SessionView) bool { | 346 | func remoteSelectable(session SessionView) bool { | ||
| 289 | - | return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch | 347 | + | return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch && session.RemoteReason == "" |
| 290 | } | 348 | } | ||
| 291 | 349 | ||||
| 292 | func remoteDim(session SessionView) bool { | 350 | func remoteDim(session SessionView) bool { | ||
| @@ -429,13 +487,13 @@ func (m *Model) SelectNearestRow(idx int) { | |||||
| 429 | } | 487 | } | ||
| 430 | idx = clamp(idx, 0, len(m.rows)-1) | 488 | idx = clamp(idx, 0, len(m.rows)-1) | ||
| 431 | for i := idx; i < len(m.rows); i++ { | 489 | for i := idx; i < len(m.rows); i++ { | ||
| 432 | - | if m.rows[i].selectable { | 490 | + | if m.rows[i].focusable { |
| 433 | m.selected = i | 491 | m.selected = i | ||
| 434 | return | 492 | return | ||
| 435 | } | 493 | } | ||
| 436 | } | 494 | } | ||
| 437 | for i := idx - 1; i >= 0; i-- { | 495 | for i := idx - 1; i >= 0; i-- { | ||
| 438 | - | if m.rows[i].selectable { | 496 | + | if m.rows[i].focusable { |
| 439 | m.selected = i | 497 | m.selected = i | ||
| 440 | return | 498 | return | ||
| 441 | } | 499 | } | ||
| @@ -474,21 +532,21 @@ func (m *Model) move(delta int) { | |||||
| 474 | if m == nil || len(m.rows) == 0 { | 532 | if m == nil || len(m.rows) == 0 { | ||
| 475 | return | 533 | return | ||
| 476 | } | 534 | } | ||
| 477 | - | if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable { | 535 | + | if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].focusable { |
| 478 | - | m.selected = m.firstSelectable() | 536 | + | m.selected = m.firstFocusable() |
| 479 | return | 537 | return | ||
| 480 | } | 538 | } | ||
| 481 | for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { | 539 | for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { | ||
| 482 | - | if m.rows[i].selectable { | 540 | + | if m.rows[i].focusable { |
| 483 | m.selected = i | 541 | m.selected = i | ||
| 484 | return | 542 | return | ||
| 485 | } | 543 | } | ||
| 486 | } | 544 | } | ||
| 487 | } | 545 | } | ||
| 488 | 546 | ||||
| 489 | - | func (m *Model) firstSelectable() int { | 547 | + | func (m *Model) firstFocusable() int { |
| 490 | for i, r := range m.rows { | 548 | for i, r := range m.rows { | ||
| 491 | - | if r.selectable { | 549 | + | if r.focusable { |
| 492 | return i | 550 | return i | ||
| 493 | } | 551 | } | ||
| 494 | } | 552 | } | ||
| @@ -496,9 +554,14 @@ func (m *Model) firstSelectable() int { | |||||
| 496 | } | 554 | } | ||
| 497 | 555 | ||||
| 498 | func (r row) target() Target { | 556 | func (r row) target() Target { | ||
| 557 | + | var remoteTarget *domain.RemoteSessionTarget | |||
| 558 | + | if r.hasRemoteTarget { | |||
| 559 | + | copyTarget := r.remoteTarget | |||
| 560 | + | remoteTarget = ©Target | |||
| 561 | + | } | |||
| 499 | return Target{ | 562 | return Target{ | ||
| 500 | - | Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), TabID: r.tabID, | 563 | + | Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), RemoteTarget: remoteTarget, |
| 501 | - | TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(), | 564 | + | RemoteHost: r.remoteHost, UnavailableReason: r.unavailableReason, TabID: r.tabID, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(), |
| 502 | } | 565 | } | ||
| 503 | } | 566 | } | ||
| 504 | 567 | ||||
A internal/usecase/picker/remote_parity_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package picker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func remoteParityLifecycle(seed byte) domain.SessionLifecycleID { | |||
| 12 | + | var id domain.SessionLifecycleID | |||
| 13 | + | id[0] = seed | |||
| 14 | + | return id | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) { | |||
| 18 | + | firstLifecycle := remoteParityLifecycle(1) | |||
| 19 | + | secondLifecycle := remoteParityLifecycle(2) | |||
| 20 | + | firstKey := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: firstLifecycle, DisplayOrigin: "same-origin"} | |||
| 21 | + | secondKey := domain.RemoteSessionKey{Host: "mule", Name: "work", LifecycleID: secondLifecycle, DisplayOrigin: "same-origin"} | |||
| 22 | + | firstTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "same-origin", LifecycleID: firstLifecycle, SessionName: "work", LiveTabID: "tab-a"} | |||
| 23 | + | secondTarget := domain.RemoteSessionTarget{Endpoint: "mule", DisplayOrigin: "same-origin", LifecycleID: secondLifecycle, SessionName: "work", LiveTabID: "tab-b"} | |||
| 24 | + | model := New([]SessionView{ | |||
| 25 | + | { | |||
| 26 | + | ID: firstKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-a", Name: "main"}}, | |||
| 27 | + | RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | ID: secondKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-b", Name: "main"}}, | |||
| 31 | + | RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true, | |||
| 32 | + | }, | |||
| 33 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 34 | + | ||||
| 35 | + | first, ok := model.Selected() | |||
| 36 | + | require.True(t, ok) | |||
| 37 | + | require.Equal(t, firstKey.ID(), first.Session) | |||
| 38 | + | require.NotNil(t, first.RemoteTarget) | |||
| 39 | + | require.Equal(t, firstLifecycle, first.RemoteTarget.LifecycleID) | |||
| 40 | + | require.Equal(t, domain.TabStableID("tab-a"), first.RemoteTarget.LiveTabID) | |||
| 41 | + | ||||
| 42 | + | model.Down() | |||
| 43 | + | second, ok := model.Selected() | |||
| 44 | + | require.True(t, ok) | |||
| 45 | + | require.Equal(t, secondKey.ID(), second.Session) | |||
| 46 | + | require.NotNil(t, second.RemoteTarget) | |||
| 47 | + | require.Equal(t, secondLifecycle, second.RemoteTarget.LifecycleID) | |||
| 48 | + | require.Equal(t, domain.TabStableID("tab-b"), second.RemoteTarget.LiveTabID) | |||
| 49 | + | } | |||
A scripts/remote-picker-harness/Dockerfile +48 -0
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | ARG BASE_IMAGE=ubuntu:24.04 | |||
| 2 | + | FROM ${BASE_IMAGE} | |||
| 3 | + | ||||
| 4 | + | ENV DEBIAN_FRONTEND=noninteractive \ | |||
| 5 | + | XDG_STATE_HOME=/tmp/vev-state \ | |||
| 6 | + | XDG_RUNTIME_DIR=/tmp/vev-runtime \ | |||
| 7 | + | PATH=/home/test/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ | |||
| 8 | + | TERM=xterm-256color \ | |||
| 9 | + | COLORTERM=truecolor | |||
| 10 | + | ||||
| 11 | + | RUN apt-get update \ | |||
| 12 | + | && apt-get install -y --no-install-recommends ca-certificates openssh-client openssh-server \ | |||
| 13 | + | && rm -rf /var/lib/apt/lists/* \ | |||
| 14 | + | && useradd --create-home --shell /bin/bash test \ | |||
| 15 | + | && usermod --password x test \ | |||
| 16 | + | && install -d -m 0755 /run/sshd /tmp/vev-state /tmp/vev-runtime /home/test/bin \ | |||
| 17 | + | && chmod 0777 /tmp/vev-state /tmp/vev-runtime \ | |||
| 18 | + | && install -d -m 0700 -o test -g test /home/test/.ssh | |||
| 19 | + | ||||
| 20 | + | COPY vev /usr/local/bin/vev.real | |||
| 21 | + | COPY harness /usr/local/bin/remote-picker-harness | |||
| 22 | + | COPY vev-wrapper /usr/local/bin/vev | |||
| 23 | + | COPY id_ed25519.pub /home/test/.ssh/authorized_keys | |||
| 24 | + | ||||
| 25 | + | RUN chown test:test /home/test/.ssh/authorized_keys \ | |||
| 26 | + | && chmod 0600 /home/test/.ssh/authorized_keys \ | |||
| 27 | + | && chmod 0755 /usr/local/bin/vev /usr/local/bin/vev.real /usr/local/bin/remote-picker-harness \ | |||
| 28 | + | && printf '%s\n' \ | |||
| 29 | + | 'Host *' \ | |||
| 30 | + | ' IdentityFile /home/test/.ssh/id_ed25519' \ | |||
| 31 | + | ' StrictHostKeyChecking no' \ | |||
| 32 | + | ' UserKnownHostsFile /dev/null' \ | |||
| 33 | + | ' LogLevel ERROR' \ | |||
| 34 | + | > /home/test/.ssh/config \ | |||
| 35 | + | && chown test:test /home/test/.ssh/config \ | |||
| 36 | + | && chmod 0600 /home/test/.ssh/config \ | |||
| 37 | + | && printf '%s\n' \ | |||
| 38 | + | 'PermitRootLogin no' \ | |||
| 39 | + | 'PasswordAuthentication no' \ | |||
| 40 | + | 'KbdInteractiveAuthentication no' \ | |||
| 41 | + | 'PubkeyAuthentication yes' \ | |||
| 42 | + | 'UsePAM no' \ | |||
| 43 | + | 'AllowUsers test' \ | |||
| 44 | + | 'PrintMotd no' \ | |||
| 45 | + | > /etc/ssh/sshd_config.d/vev-harness.conf \ | |||
| 46 | + | && ssh-keygen -A | |||
| 47 | + | ||||
| 48 | + | CMD ["/usr/sbin/sshd", "-D", "-e"] | |||
A scripts/remote-picker-harness/README.md +23 -0
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | # Remote picker container acceptance | |||
| 2 | + | ||||
| 3 | + | Run the disposable two-container acceptance harness with: | |||
| 4 | + | ||||
| 5 | + | ```sh | |||
| 6 | + | make remote-acceptance | |||
| 7 | + | ``` | |||
| 8 | + | ||||
| 9 | + | The harness builds the current binary, starts a local client container and an | |||
| 10 | + | SSH-enabled remote daemon container on a private Docker network, and removes | |||
| 11 | + | all resources when it exits. Docker uses the normal CLI configuration, so | |||
| 12 | + | `DOCKER_HOST` or the active context selects a rootless daemon; the harness does | |||
| 13 | + | not assume a socket pathname. | |||
| 14 | + | ||||
| 15 | + | The run exercises real SSH-stdio and UDP attachment, typed live catalog and | |||
| 16 | + | preview requests, tab creation/removal fencing, exact lifecycle fencing after | |||
| 17 | + | replacement, daemon restart resume, slow-preview cancellation, version | |||
| 18 | + | mismatch, and unreachable-host handling. It checks environment values in | |||
| 19 | + | memory without printing terminal viewport data. | |||
| 20 | + | ||||
| 21 | + | Stopped-selector restoration and daemon-owned environment assertions are also | |||
| 22 | + | covered by the repository's daemon and route integration tests, where the | |||
| 23 | + | fixture can deterministically hold restoration at the stopped state. | |||
A scripts/remote-picker-harness/main.go +549 -0
| @@ -0,0 +1,549 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "log/slog" | |||
| 9 | + | "os" | |||
| 10 | + | "os/exec" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync/atomic" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/adapters/dgram" | |||
| 16 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |||
| 17 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |||
| 18 | + | "github.com/bnema/vev/internal/domain" | |||
| 19 | + | "github.com/bnema/vev/internal/ports" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | const ( | |||
| 23 | + | defaultTarget = "test@remote" | |||
| 24 | + | sessionName = "harness" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | var requestID atomic.Uint64 | |||
| 28 | + | ||||
| 29 | + | func main() { | |||
| 30 | + | if err := run(); err != nil { | |||
| 31 | + | fmt.Fprintf(os.Stderr, "remote picker harness: %v\n", err) | |||
| 32 | + | os.Exit(1) | |||
| 33 | + | } | |||
| 34 | + | fmt.Println("remote picker harness: PASS") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func run() error { | |||
| 38 | + | target := os.Getenv("VEV_HARNESS_TARGET") | |||
| 39 | + | if target == "" { | |||
| 40 | + | target = defaultTarget | |||
| 41 | + | } | |||
| 42 | + | ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) | |||
| 43 | + | defer cancel() | |||
| 44 | + | ||||
| 45 | + | catalog := remoteadapter.NewCatalogClient() | |||
| 46 | + | preview := remoteadapter.NewPreviewClient() | |||
| 47 | + | ||||
| 48 | + | stdio, err := openTransport(ctx, ports.RemoteTransportStdio, target) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return fmt.Errorf("open stdio transport: %w", err) | |||
| 51 | + | } | |||
| 52 | + | if err := attachAndCheck(ctx, stdio, ports.Hello{ | |||
| 53 | + | Intent: ports.IntentNew, | |||
| 54 | + | Name: sessionName, | |||
| 55 | + | Env: localEnvironment(), | |||
| 56 | + | Cwd: "/tmp/local-cwd", | |||
| 57 | + | TermEnv: "xterm-256color", | |||
| 58 | + | TrueColor: true, | |||
| 59 | + | }); err != nil { | |||
| 60 | + | _ = stdio.Close() | |||
| 61 | + | return fmt.Errorf("stdio live attach: %w", err) | |||
| 62 | + | } | |||
| 63 | + | if err := sendInputAndAwait(ctx, stdio, "printf 'VEV_REMOTE_HARNESS_ENV=%s:%s:%s:%s\\n' \"$VEV_TEST_ENV\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$VEV\"", "local:/run/local:wayland-local:"); err != nil { | |||
| 64 | + | _ = stdio.Close() | |||
| 65 | + | return fmt.Errorf("direct client environment: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | live, err := waitForSession(ctx, catalog, target, sessionName, "running") | |||
| 69 | + | if err != nil { | |||
| 70 | + | _ = stdio.Close() | |||
| 71 | + | return fmt.Errorf("live catalog: %w", err) | |||
| 72 | + | } | |||
| 73 | + | liveTarget, err := targetForSession(target, live, false) | |||
| 74 | + | if err != nil { | |||
| 75 | + | _ = stdio.Close() | |||
| 76 | + | return fmt.Errorf("live target: %w", err) | |||
| 77 | + | } | |||
| 78 | + | if _, err := preview.Preview(ctx, liveTarget, 40, 8); err != nil { | |||
| 79 | + | _ = stdio.Close() | |||
| 80 | + | return fmt.Errorf("live preview: %w", err) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | if err := sendCommand(ctx, stdio, "new-tab"); err != nil { | |||
| 84 | + | _ = stdio.Close() | |||
| 85 | + | return fmt.Errorf("create second tab: %w", err) | |||
| 86 | + | } | |||
| 87 | + | liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 2) | |||
| 88 | + | if err != nil { | |||
| 89 | + | _ = stdio.Close() | |||
| 90 | + | return fmt.Errorf("multi-tab catalog: %w", err) | |||
| 91 | + | } | |||
| 92 | + | removedTargets := make([]domain.RemoteSessionTarget, 0, len(ports.CatalogTabs(liveWithTabs))) | |||
| 93 | + | for _, tab := range ports.CatalogTabs(liveWithTabs) { | |||
| 94 | + | candidate := liveWithTabs | |||
| 95 | + | candidate.ActiveTabID = tab.ID | |||
| 96 | + | removed, targetErr := targetForSession(target, candidate, false) | |||
| 97 | + | if targetErr != nil { | |||
| 98 | + | _ = stdio.Close() | |||
| 99 | + | return fmt.Errorf("removed-tab target: %w", targetErr) | |||
| 100 | + | } | |||
| 101 | + | removedTargets = append(removedTargets, removed) | |||
| 102 | + | } | |||
| 103 | + | if err := sendCommand(ctx, stdio, "close-tab"); err != nil { | |||
| 104 | + | _ = stdio.Close() | |||
| 105 | + | return fmt.Errorf("remove tab: %w", err) | |||
| 106 | + | } | |||
| 107 | + | if _, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 1); err != nil { | |||
| 108 | + | _ = stdio.Close() | |||
| 109 | + | return fmt.Errorf("removed-tab catalog: %w", err) | |||
| 110 | + | } | |||
| 111 | + | removed := false | |||
| 112 | + | for _, removedTarget := range removedTargets { | |||
| 113 | + | if _, previewErr := preview.Preview(ctx, removedTarget, 40, 8); previewErr != nil { | |||
| 114 | + | removed = true | |||
| 115 | + | break | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | if !removed { | |||
| 119 | + | _ = stdio.Close() | |||
| 120 | + | return errors.New("removed tab unexpectedly produced a preview") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | if err := detach(ctx, stdio); err != nil { | |||
| 124 | + | _ = stdio.Close() | |||
| 125 | + | return fmt.Errorf("stop session: %w", err) | |||
| 126 | + | } | |||
| 127 | + | _ = stdio.Close() | |||
| 128 | + | if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil { | |||
| 129 | + | return fmt.Errorf("stop remote daemon: %w", err) | |||
| 130 | + | } | |||
| 131 | + | if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil { | |||
| 132 | + | return fmt.Errorf("restart remote daemon: %w", err) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "running") | |||
| 136 | + | if err != nil { | |||
| 137 | + | return fmt.Errorf("resumed catalog: %w", err) | |||
| 138 | + | } | |||
| 139 | + | resumedTarget, err := targetForSession(target, resumedCatalog, false) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return fmt.Errorf("resumed target: %w", err) | |||
| 142 | + | } | |||
| 143 | + | resumed, err := openTransport(ctx, ports.RemoteTransportStdio, target) | |||
| 144 | + | if err != nil { | |||
| 145 | + | return fmt.Errorf("open restart stdio transport: %w", err) | |||
| 146 | + | } | |||
| 147 | + | if err := attachAndCheck(ctx, resumed, ports.Hello{ | |||
| 148 | + | Intent: ports.IntentResume, | |||
| 149 | + | Name: sessionName, | |||
| 150 | + | Env: localEnvironment(), | |||
| 151 | + | Cwd: "/tmp/local-cwd", | |||
| 152 | + | TermEnv: "xterm-256color", | |||
| 153 | + | TrueColor: true, | |||
| 154 | + | RemoteTarget: &resumedTarget, | |||
| 155 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 156 | + | }); err != nil { | |||
| 157 | + | _ = resumed.Close() | |||
| 158 | + | return fmt.Errorf("exact restart resume: %w", err) | |||
| 159 | + | } | |||
| 160 | + | if _, err := preview.Preview(ctx, resumedTarget, 40, 8); err != nil { | |||
| 161 | + | _ = resumed.Close() | |||
| 162 | + | return fmt.Errorf("restart preview: %w", err) | |||
| 163 | + | } | |||
| 164 | + | if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview"); err != nil { | |||
| 165 | + | _ = resumed.Close() | |||
| 166 | + | return fmt.Errorf("enable slow preview: %w", err) | |||
| 167 | + | } | |||
| 168 | + | slowCtx, slowCancel := context.WithTimeout(ctx, 150*time.Millisecond) | |||
| 169 | + | _, slowErr := preview.Preview(slowCtx, resumedTarget, 40, 8) | |||
| 170 | + | slowCancel() | |||
| 171 | + | if slowErr == nil { | |||
| 172 | + | _ = resumed.Close() | |||
| 173 | + | return errors.New("slow preview did not honor cancellation") | |||
| 174 | + | } | |||
| 175 | + | _ = touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview-off") | |||
| 176 | + | ||||
| 177 | + | if err := detach(ctx, resumed); err != nil { | |||
| 178 | + | _ = resumed.Close() | |||
| 179 | + | return fmt.Errorf("stop resumed session: %w", err) | |||
| 180 | + | } | |||
| 181 | + | _ = resumed.Close() | |||
| 182 | + | ||||
| 183 | + | udp, err := openTransport(ctx, ports.RemoteTransportUDP, target) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return fmt.Errorf("open UDP transport: %w", err) | |||
| 186 | + | } | |||
| 187 | + | if err := attachAndCheck(ctx, udp, ports.Hello{ | |||
| 188 | + | Intent: ports.IntentNew, | |||
| 189 | + | Name: "udp-harness", | |||
| 190 | + | Env: localEnvironment(), | |||
| 191 | + | Cwd: "/tmp/local-cwd", | |||
| 192 | + | TermEnv: "xterm-256color", | |||
| 193 | + | TrueColor: true, | |||
| 194 | + | }); err != nil { | |||
| 195 | + | _ = udp.Close() | |||
| 196 | + | return fmt.Errorf("UDP attach: %w", err) | |||
| 197 | + | } | |||
| 198 | + | if err := detach(ctx, udp); err != nil { | |||
| 199 | + | _ = udp.Close() | |||
| 200 | + | return fmt.Errorf("UDP detach: %w", err) | |||
| 201 | + | } | |||
| 202 | + | _ = udp.Close() | |||
| 203 | + | ||||
| 204 | + | if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog"); err != nil { | |||
| 205 | + | return fmt.Errorf("enable mismatched catalog: %w", err) | |||
| 206 | + | } | |||
| 207 | + | if _, err := catalog.List(ctx, target); err == nil { | |||
| 208 | + | return errors.New("version-mismatched catalog unexpectedly decoded") | |||
| 209 | + | } | |||
| 210 | + | _ = touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog-off") | |||
| 211 | + | ||||
| 212 | + | if _, err := catalog.List(ctx, "test@unreachable.invalid"); err == nil { | |||
| 213 | + | return errors.New("unreachable catalog unexpectedly succeeded") | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | if err := runRemoteCommand(ctx, target, "vev", "kill", sessionName); err != nil { | |||
| 217 | + | return fmt.Errorf("destroy session for stale-target test: %w", err) | |||
| 218 | + | } | |||
| 219 | + | if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil { | |||
| 220 | + | return fmt.Errorf("wait for destroyed session: %w", err) | |||
| 221 | + | } | |||
| 222 | + | stale, err := openTransport(ctx, ports.RemoteTransportStdio, target) | |||
| 223 | + | if err != nil { | |||
| 224 | + | return fmt.Errorf("open stale-target transport: %w", err) | |||
| 225 | + | } | |||
| 226 | + | if err := expectNoSuchTarget(ctx, stale, resumedTarget); err != nil { | |||
| 227 | + | _ = stale.Close() | |||
| 228 | + | return fmt.Errorf("stale target fence: %w", err) | |||
| 229 | + | } | |||
| 230 | + | _ = stale.Close() | |||
| 231 | + | ||||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func localEnvironment() []string { | |||
| 236 | + | return []string{ | |||
| 237 | + | "VEV_TEST_ENV=local", | |||
| 238 | + | "XDG_RUNTIME_DIR=/run/local", | |||
| 239 | + | "WAYLAND_DISPLAY=wayland-local", | |||
| 240 | + | "TERM=xterm-256color", | |||
| 241 | + | "COLORTERM=truecolor", | |||
| 242 | + | "TERM_PROGRAM=local-harness", | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target string) (ports.Transport, error) { | |||
| 247 | + | switch mode { | |||
| 248 | + | case ports.RemoteTransportStdio: | |||
| 249 | + | return sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler)) | |||
| 250 | + | case ports.RemoteTransportUDP: | |||
| 251 | + | dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler)) | |||
| 252 | + | dialer.BootstrapTimeout = 8 * time.Second | |||
| 253 | + | dialer.ProbeTimeout = 3 * time.Second | |||
| 254 | + | return dialer.Dial(ctx) | |||
| 255 | + | default: | |||
| 256 | + | return nil, fmt.Errorf("unsupported transport %q", mode) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func attachAndCheck(ctx context.Context, tr ports.Transport, hello ports.Hello) error { | |||
| 261 | + | hello.Version = ports.ProtocolVersion | |||
| 262 | + | hello.Size = domain.Size{Cols: 80, Rows: 24} | |||
| 263 | + | if hello.EnvironmentPolicy == 0 { | |||
| 264 | + | hello.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned | |||
| 265 | + | } | |||
| 266 | + | payload := ports.MarshalHello(hello) | |||
| 267 | + | if err := tr.Send(ports.Frame{Type: ports.MsgHello, Payload: payload}); err != nil { | |||
| 268 | + | return err | |||
| 269 | + | } | |||
| 270 | + | frame, err := receiveWithTimeout(ctx, tr) | |||
| 271 | + | if err != nil { | |||
| 272 | + | return err | |||
| 273 | + | } | |||
| 274 | + | if frame.Type == ports.MsgError { | |||
| 275 | + | message, decodeErr := ports.UnmarshalErrorMsg(frame.Payload) | |||
| 276 | + | if decodeErr != nil { | |||
| 277 | + | return decodeErr | |||
| 278 | + | } | |||
| 279 | + | return fmt.Errorf("remote error code %d", message.Code) | |||
| 280 | + | } | |||
| 281 | + | if frame.Type != ports.MsgWelcome { | |||
| 282 | + | return fmt.Errorf("welcome frame type %d", frame.Type) | |||
| 283 | + | } | |||
| 284 | + | if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil { | |||
| 285 | + | return fmt.Errorf("send initial theme: %w", err) | |||
| 286 | + | } | |||
| 287 | + | return nil | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func sendInputAndAwait(ctx context.Context, tr ports.Transport, command, want string) error { | |||
| 291 | + | if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil { | |||
| 292 | + | return err | |||
| 293 | + | } | |||
| 294 | + | var received strings.Builder | |||
| 295 | + | var outputFrames int | |||
| 296 | + | var outputBytes int | |||
| 297 | + | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |||
| 298 | + | defer cancel() | |||
| 299 | + | for { | |||
| 300 | + | frame, err := receiveWithTimeout(deadline, tr) | |||
| 301 | + | if err != nil { | |||
| 302 | + | return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", outputFrames, outputBytes, err) | |||
| 303 | + | } | |||
| 304 | + | if frame.Type != ports.MsgOutput { | |||
| 305 | + | continue | |||
| 306 | + | } | |||
| 307 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 308 | + | if err != nil { | |||
| 309 | + | return err | |||
| 310 | + | } | |||
| 311 | + | outputFrames++ | |||
| 312 | + | outputBytes += len(output.Data) | |||
| 313 | + | if output.Epoch != 0 && output.New != 0 { | |||
| 314 | + | ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New}) | |||
| 315 | + | if err != nil { | |||
| 316 | + | return fmt.Errorf("encode output ack: %w", err) | |||
| 317 | + | } | |||
| 318 | + | if err := tr.Send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil { | |||
| 319 | + | return fmt.Errorf("ack output: %w", err) | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | received.Write(output.Data) | |||
| 323 | + | if strings.Contains(received.String(), want) { | |||
| 324 | + | return nil | |||
| 325 | + | } | |||
| 326 | + | if received.Len() > 64<<10 { | |||
| 327 | + | text := received.String() | |||
| 328 | + | received.Reset() | |||
| 329 | + | received.WriteString(text[len(text)-16<<10:]) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func sendCommand(ctx context.Context, tr ports.Transport, slug string) error { | |||
| 335 | + | id := requestID.Add(1) | |||
| 336 | + | request := ports.CommandRequest{ | |||
| 337 | + | Version: ports.ProtocolVersion, | |||
| 338 | + | RequestID: id, | |||
| 339 | + | Attached: true, | |||
| 340 | + | Slug: slug, | |||
| 341 | + | } | |||
| 342 | + | payload, err := ports.MarshalCommandRequest(request) | |||
| 343 | + | if err != nil { | |||
| 344 | + | return err | |||
| 345 | + | } | |||
| 346 | + | if err := tr.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil { | |||
| 347 | + | return err | |||
| 348 | + | } | |||
| 349 | + | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |||
| 350 | + | defer cancel() | |||
| 351 | + | for { | |||
| 352 | + | frame, err := receiveWithTimeout(deadline, tr) | |||
| 353 | + | if err != nil { | |||
| 354 | + | return err | |||
| 355 | + | } | |||
| 356 | + | if frame.Type != ports.MsgCommandResult { | |||
| 357 | + | continue | |||
| 358 | + | } | |||
| 359 | + | result, err := ports.UnmarshalCommandResult(frame.Payload) | |||
| 360 | + | if err != nil { | |||
| 361 | + | return err | |||
| 362 | + | } | |||
| 363 | + | if !result.OK { | |||
| 364 | + | return fmt.Errorf("remote command failed (code %d): %s", result.Code, result.Text) | |||
| 365 | + | } | |||
| 366 | + | return nil | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func detach(ctx context.Context, tr ports.Transport) error { | |||
| 371 | + | if err := tr.Send(ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}); err != nil { | |||
| 372 | + | return err | |||
| 373 | + | } | |||
| 374 | + | deadline, cancel := context.WithTimeout(ctx, 5*time.Second) | |||
| 375 | + | defer cancel() | |||
| 376 | + | for { | |||
| 377 | + | frame, err := receiveWithTimeout(deadline, tr) | |||
| 378 | + | if err != nil { | |||
| 379 | + | return err | |||
| 380 | + | } | |||
| 381 | + | if frame.Type == ports.MsgDetached { | |||
| 382 | + | return nil | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func expectNoSuchTarget(ctx context.Context, tr ports.Transport, target domain.RemoteSessionTarget) error { | |||
| 388 | + | target.Endpoint = targetEndpoint(target.Endpoint) | |||
| 389 | + | target.DisplayOrigin = "remote" | |||
| 390 | + | target.SessionName = sessionName | |||
| 391 | + | if err := target.Validate(); err != nil { | |||
| 392 | + | return err | |||
| 393 | + | } | |||
| 394 | + | hello := ports.Hello{ | |||
| 395 | + | Version: ports.ProtocolVersion, | |||
| 396 | + | Intent: ports.IntentResume, | |||
| 397 | + | Name: sessionName, | |||
| 398 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 399 | + | TermEnv: "xterm-256color", | |||
| 400 | + | TrueColor: true, | |||
| 401 | + | Env: localEnvironment(), | |||
| 402 | + | Cwd: "/tmp/local-cwd", | |||
| 403 | + | RemoteTarget: &target, | |||
| 404 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 405 | + | } | |||
| 406 | + | payload := ports.MarshalHello(hello) | |||
| 407 | + | if err := tr.Send(ports.Frame{Type: ports.MsgHello, Payload: payload}); err != nil { | |||
| 408 | + | return err | |||
| 409 | + | } | |||
| 410 | + | frame, err := receiveWithTimeout(ctx, tr) | |||
| 411 | + | if err != nil { | |||
| 412 | + | return err | |||
| 413 | + | } | |||
| 414 | + | if frame.Type != ports.MsgError { | |||
| 415 | + | return fmt.Errorf("stale target returned frame type %d", frame.Type) | |||
| 416 | + | } | |||
| 417 | + | message, err := ports.UnmarshalErrorMsg(frame.Payload) | |||
| 418 | + | if err != nil { | |||
| 419 | + | return err | |||
| 420 | + | } | |||
| 421 | + | if message.Code != ports.ErrNoSuchTarget { | |||
| 422 | + | return fmt.Errorf("stale target returned error code %d", message.Code) | |||
| 423 | + | } | |||
| 424 | + | return nil | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func targetForSession(endpoint string, session ports.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) { | |||
| 428 | + | tabs := ports.CatalogTabs(session) | |||
| 429 | + | if len(tabs) == 0 { | |||
| 430 | + | return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs") | |||
| 431 | + | } | |||
| 432 | + | tab := tabs[0] | |||
| 433 | + | if !stopped && session.ActiveTabID != "" { | |||
| 434 | + | for _, candidate := range tabs { | |||
| 435 | + | if candidate.ID == session.ActiveTabID { | |||
| 436 | + | tab = candidate | |||
| 437 | + | break | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | target := domain.RemoteSessionTarget{ | |||
| 442 | + | Endpoint: endpoint, | |||
| 443 | + | DisplayOrigin: "remote", | |||
| 444 | + | LifecycleID: session.LifecycleID, | |||
| 445 | + | SessionName: session.Name, | |||
| 446 | + | Stopped: stopped, | |||
| 447 | + | } | |||
| 448 | + | if stopped { | |||
| 449 | + | target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(tab.ID)) | |||
| 450 | + | } else { | |||
| 451 | + | target.LiveTabID = domain.TabStableID(tab.ID) | |||
| 452 | + | } | |||
| 453 | + | if err := target.Validate(); err != nil { | |||
| 454 | + | return domain.RemoteSessionTarget{}, err | |||
| 455 | + | } | |||
| 456 | + | return target, nil | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func targetEndpoint(endpoint string) string { | |||
| 460 | + | if endpoint == "" { | |||
| 461 | + | return defaultTarget | |||
| 462 | + | } | |||
| 463 | + | return endpoint | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string) (ports.RemoteCatalogSession, error) { | |||
| 467 | + | return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string, tabs int) (ports.RemoteCatalogSession, error) { | |||
| 471 | + | deadline, cancel := context.WithTimeout(ctx, 10*time.Second) | |||
| 472 | + | defer cancel() | |||
| 473 | + | for { | |||
| 474 | + | result, err := catalog.List(deadline, endpoint) | |||
| 475 | + | if err == nil { | |||
| 476 | + | for _, session := range result.Sessions { | |||
| 477 | + | if session.Name == name && session.State == state && (tabs == 0 || len(ports.CatalogTabs(session)) == tabs) { | |||
| 478 | + | return session, nil | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | } | |||
| 482 | + | select { | |||
| 483 | + | case <-deadline.Done(): | |||
| 484 | + | if err != nil { | |||
| 485 | + | return ports.RemoteCatalogSession{}, err | |||
| 486 | + | } | |||
| 487 | + | return ports.RemoteCatalogSession{}, fmt.Errorf("session %q did not reach state %q", name, state) | |||
| 488 | + | case <-time.After(100 * time.Millisecond): | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | func waitForMissingSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string) error { | |||
| 494 | + | deadline, cancel := context.WithTimeout(ctx, 10*time.Second) | |||
| 495 | + | defer cancel() | |||
| 496 | + | for { | |||
| 497 | + | result, err := catalog.List(deadline, endpoint) | |||
| 498 | + | if err == nil { | |||
| 499 | + | found := false | |||
| 500 | + | for _, session := range result.Sessions { | |||
| 501 | + | found = found || session.Name == name | |||
| 502 | + | } | |||
| 503 | + | if !found { | |||
| 504 | + | return nil | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | select { | |||
| 508 | + | case <-deadline.Done(): | |||
| 509 | + | return errors.New("destroyed session remained in catalog") | |||
| 510 | + | case <-time.After(100 * time.Millisecond): | |||
| 511 | + | } | |||
| 512 | + | } | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | func touchRemoteFlag(ctx context.Context, target, path string) error { | |||
| 516 | + | if strings.HasSuffix(path, "-off") { | |||
| 517 | + | path = strings.TrimSuffix(path, "-off") | |||
| 518 | + | return runRemoteCommand(ctx, target, "rm", "-f", path) | |||
| 519 | + | } | |||
| 520 | + | return runRemoteCommand(ctx, target, "touch", path) | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | func runRemoteCommand(ctx context.Context, target string, command ...string) error { | |||
| 524 | + | spec := sshstdio.BuildCommandForRemoteCommand(target, command...) | |||
| 525 | + | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |||
| 526 | + | cmd.Stdout = io.Discard | |||
| 527 | + | cmd.Stderr = io.Discard | |||
| 528 | + | return cmd.Run() | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | func receiveWithTimeout(ctx context.Context, tr ports.Transport) (ports.Frame, error) { | |||
| 532 | + | result := make(chan struct { | |||
| 533 | + | frame ports.Frame | |||
| 534 | + | err error | |||
| 535 | + | }, 1) | |||
| 536 | + | go func() { | |||
| 537 | + | frame, err := tr.Recv() | |||
| 538 | + | result <- struct { | |||
| 539 | + | frame ports.Frame | |||
| 540 | + | err error | |||
| 541 | + | }{frame: frame, err: err} | |||
| 542 | + | }() | |||
| 543 | + | select { | |||
| 544 | + | case <-ctx.Done(): | |||
| 545 | + | return ports.Frame{}, ctx.Err() | |||
| 546 | + | case outcome := <-result: | |||
| 547 | + | return outcome.frame, outcome.err | |||
| 548 | + | } | |||
| 549 | + | } | |||
A scripts/remote-picker-harness/run.sh +96 -0
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | set -euo pipefail | |||
| 3 | + | ||||
| 4 | + | usage() { | |||
| 5 | + | cat >&2 <<'USAGE' | |||
| 6 | + | Usage: scripts/remote-picker-harness/run.sh | |||
| 7 | + | ||||
| 8 | + | Builds the current vev binary into a disposable two-container environment and | |||
| 9 | + | runs the SSH-stdio, UDP, catalog, preview, lifecycle-fence, and environment | |||
| 10 | + | acceptance checks. Docker is invoked normally, so DOCKER_HOST and the active | |||
| 11 | + | Docker context select the daemon; no socket path is assumed. | |||
| 12 | + | ||||
| 13 | + | Environment: | |||
| 14 | + | DOCKER_HOST Docker endpoint, when the active context does not | |||
| 15 | + | already select the rootless daemon. | |||
| 16 | + | VEV_HARNESS_BASE_IMAGE Base image for the disposable containers | |||
| 17 | + | (default: ubuntu:24.04). | |||
| 18 | + | USAGE | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | case "${1:-}" in | |||
| 22 | + | -h|--help) | |||
| 23 | + | usage | |||
| 24 | + | exit 0 | |||
| 25 | + | ;; | |||
| 26 | + | "") ;; | |||
| 27 | + | *) | |||
| 28 | + | usage | |||
| 29 | + | exit 2 | |||
| 30 | + | ;; | |||
| 31 | + | esac | |||
| 32 | + | ||||
| 33 | + | repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" | |||
| 34 | + | base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}" | |||
| 35 | + | run_id="$(date +%s)-$$" | |||
| 36 | + | image="vev-remote-picker-harness:${run_id}" | |||
| 37 | + | network="vev-remote-picker-harness-${run_id}" | |||
| 38 | + | local_container="${network}-local" | |||
| 39 | + | remote_container="${network}-remote" | |||
| 40 | + | context_dir="$(mktemp -d "${TMPDIR:-/tmp}/vev-remote-picker-harness.XXXXXX")" | |||
| 41 | + | ||||
| 42 | + | cleanup() { | |||
| 43 | + | set +e | |||
| 44 | + | docker rm -f "$local_container" "$remote_container" >/dev/null 2>&1 | |||
| 45 | + | docker network rm "$network" >/dev/null 2>&1 | |||
| 46 | + | docker image rm "$image" >/dev/null 2>&1 | |||
| 47 | + | rm -rf "$context_dir" | |||
| 48 | + | } | |||
| 49 | + | trap cleanup EXIT INT TERM | |||
| 50 | + | ||||
| 51 | + | command -v docker >/dev/null || { echo 'remote picker harness: docker is required' >&2; exit 127; } | |||
| 52 | + | command -v ssh-keygen >/dev/null || { echo 'remote picker harness: ssh-keygen is required' >&2; exit 127; } | |||
| 53 | + | ||||
| 54 | + | if ! docker info >/dev/null 2>&1; then | |||
| 55 | + | echo 'remote picker harness: Docker is unavailable through the active DOCKER_HOST/context' >&2 | |||
| 56 | + | exit 1 | |||
| 57 | + | fi | |||
| 58 | + | ||||
| 59 | + | printf 'remote picker harness: building %s\n' "$image" | |||
| 60 | + | go build -o "$context_dir/vev" "$repo_root" | |||
| 61 | + | go build -o "$context_dir/harness" "$repo_root/scripts/remote-picker-harness" | |||
| 62 | + | ssh-keygen -q -t ed25519 -N '' -f "$context_dir/id_ed25519" | |||
| 63 | + | cp "$repo_root/scripts/remote-picker-harness/Dockerfile" "$context_dir/Dockerfile" | |||
| 64 | + | cp "$repo_root/scripts/remote-picker-harness/vev-wrapper" "$context_dir/vev-wrapper" | |||
| 65 | + | chmod 0600 "$context_dir/id_ed25519" | |||
| 66 | + | ||||
| 67 | + | docker build --build-arg "BASE_IMAGE=$base_image" -t "$image" "$context_dir" >/dev/null | |||
| 68 | + | docker network create "$network" >/dev/null | |||
| 69 | + | ||||
| 70 | + | docker run -d --name "$remote_container" \ | |||
| 71 | + | --network "$network" --network-alias remote \ | |||
| 72 | + | -e VEV_UDP_PORT_RANGE=61000 \ | |||
| 73 | + | "$image" >/dev/null | |||
| 74 | + | ||||
| 75 | + | docker create --name "$local_container" \ | |||
| 76 | + | --network "$network" --network-alias local \ | |||
| 77 | + | "$image" sleep infinity >/dev/null | |||
| 78 | + | docker cp "$context_dir/id_ed25519" "$local_container:/home/test/.ssh/id_ed25519" | |||
| 79 | + | docker start "$local_container" >/dev/null | |||
| 80 | + | docker exec "$local_container" chown test:test /home/test/.ssh/id_ed25519 | |||
| 81 | + | docker exec "$local_container" chmod 0600 /home/test/.ssh/id_ed25519 | |||
| 82 | + | ||||
| 83 | + | printf 'remote picker harness: waiting for SSH service\n' | |||
| 84 | + | for _ in $(seq 1 60); do | |||
| 85 | + | if docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=1 test@remote true >/dev/null 2>&1; then | |||
| 86 | + | break | |||
| 87 | + | fi | |||
| 88 | + | sleep 0.2 | |||
| 89 | + | done | |||
| 90 | + | if ! docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=2 test@remote true >/dev/null 2>&1; then | |||
| 91 | + | echo 'remote picker harness: SSH service did not become ready' >&2 | |||
| 92 | + | exit 1 | |||
| 93 | + | fi | |||
| 94 | + | ||||
| 95 | + | printf 'remote picker harness: running acceptance checks\n' | |||
| 96 | + | docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness | |||
A scripts/remote-picker-harness/vev-wrapper +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | #!/bin/sh | |||
| 2 | + | set -eu | |||
| 3 | + | ||||
| 4 | + | export XDG_STATE_HOME=/tmp/vev-state | |||
| 5 | + | export XDG_RUNTIME_DIR=/tmp/vev-runtime | |||
| 6 | + | export VEV_TEST_ENV=remote | |||
| 7 | + | export WAYLAND_DISPLAY=wayland-remote | |||
| 8 | + | export TERM=xterm-256color | |||
| 9 | + | export COLORTERM=truecolor | |||
| 10 | + | ||||
| 11 | + | ||||
| 12 | + | case "$*" in | |||
| 13 | + | *remote-catalog*) | |||
| 14 | + | if [ -e /tmp/vev-bad-catalog ]; then | |||
| 15 | + | printf '%s\n' '{"protocol_version":23,"schema_version":1,"sessions":[]}' | |||
| 16 | + | exit 0 | |||
| 17 | + | fi | |||
| 18 | + | ;; | |||
| 19 | + | *_remote-preview*) | |||
| 20 | + | if [ -e /tmp/vev-slow-preview ]; then | |||
| 21 | + | sleep 3 | |||
| 22 | + | fi | |||
| 23 | + | ;; | |||
| 24 | + | esac | |||
| 25 | + | ||||
| 26 | + | exec /usr/local/bin/vev.real "$@" | |||