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 | 3 | install: | |
| 4 | 4 | go install . | |
| @@ -25,6 +25,9 @@ mocks: | |||
| 25 | 25 | fi | |
| 26 | 26 | mockery | |
| 27 | 27 | ||
| 28 | + | remote-acceptance: | |
| 29 | + | scripts/remote-picker-harness/run.sh | |
| 30 | + | ||
| 28 | 31 | demo: | |
| 29 | 32 | docker build -f scripts/demo/Dockerfile -t vev-demo . | |
| 30 | 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 | 13 | attachment without replacing its peers or changing their attachment-local | |
| 14 | 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 | 41 | ## Connection limits | |
| 17 | 42 | ||
| 18 | 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 | 115 | type catalogEnvelope struct { | |
| 116 | 116 | ProtocolVersion *uint16 `json:"protocol_version"` | |
| 117 | + | SchemaVersion *uint16 `json:"schema_version"` | |
| 117 | 118 | Sessions *[]ports.RemoteCatalogSession `json:"sessions"` | |
| 118 | 119 | } | |
| 119 | 120 | ||
| @@ -130,10 +131,22 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) { | |||
| 130 | 131 | return ports.RemoteCatalog{}, err | |
| 131 | 132 | } | |
| 132 | 133 | ||
| 134 | + | schemaVersion := uint16(0) | |
| 135 | + | if envelope.SchemaVersion != nil { | |
| 136 | + | schemaVersion = *envelope.SchemaVersion | |
| 137 | + | } | |
| 133 | 138 | if *envelope.ProtocolVersion != ports.ProtocolVersion { | |
| 134 | 139 | return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{ | |
| 135 | 140 | Got: *envelope.ProtocolVersion, | |
| 136 | 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 | 154 | if sessions == nil { | |
| 142 | 155 | sessions = []ports.RemoteCatalogSession{} | |
| 143 | 156 | } | |
| 144 | - | for _, session := range sessions { | |
| 145 | - | if err := domain.ValidateSessionName(session.Name); err != nil { | |
| 157 | + | catalog := ports.RemoteCatalog{ | |
| 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 | 167 | return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err) | |
| 147 | 168 | } | |
| 148 | - | if !validCatalogState(session.State) { | |
| 149 | - | return ports.RemoteCatalog{}, fmt.Errorf("%w: invalid state %q", errCatalogSession, session.State) | |
| 150 | - | } | |
| 169 | + | return ports.RemoteCatalog{}, err | |
| 151 | 170 | } | |
| 152 | - | ||
| 153 | - | return ports.RemoteCatalog{ | |
| 154 | - | ProtocolVersion: *envelope.ProtocolVersion, | |
| 155 | - | Sessions: sessions, | |
| 156 | - | }, nil | |
| 171 | + | return catalog, nil | |
| 157 | 172 | } | |
| 158 | 173 | ||
| 159 | 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 | 37 | type catalogCacheSession struct { | |
| 38 | - | Name *string `json:"name"` | |
| 39 | - | State *string `json:"state"` | |
| 40 | - | Ephemeral *bool `json:"ephemeral"` | |
| 41 | - | Tabs *uint16 `json:"tabs"` | |
| 42 | - | Attached *bool `json:"attached"` | |
| 38 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |
| 39 | + | Name *string `json:"name"` | |
| 40 | + | State *string `json:"state"` | |
| 41 | + | Ephemeral *bool `json:"ephemeral"` | |
| 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 | 49 | type fileCatalogCache struct { | |
| @@ -103,19 +107,33 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 103 | 107 | Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)), | |
| 104 | 108 | } | |
| 105 | 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 | 111 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | |
| 108 | 112 | } | |
| 109 | 113 | if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) { | |
| 110 | 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 | 117 | Name: *session.Name, | |
| 114 | 118 | State: *session.State, | |
| 115 | 119 | Ephemeral: *session.Ephemeral, | |
| 116 | - | Tabs: *session.Tabs, | |
| 117 | 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 | 138 | entries = append(entries, entry) | |
| 121 | 139 | } | |
| @@ -141,15 +159,28 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error | |||
| 141 | 159 | name := session.Name | |
| 142 | 160 | state := session.State | |
| 143 | 161 | ephemeral := session.Ephemeral | |
| 144 | - | tabs := session.Tabs | |
| 145 | 162 | attached := session.Attached | |
| 146 | - | sessions = append(sessions, catalogCacheSession{ | |
| 147 | - | Name: &name, | |
| 148 | - | State: &state, | |
| 149 | - | Ephemeral: &ephemeral, | |
| 150 | - | Tabs: &tabs, | |
| 151 | - | Attached: &attached, | |
| 152 | - | }) | |
| 163 | + | row := catalogCacheSession{Name: &name, State: &state, Ephemeral: &ephemeral, Attached: &attached} | |
| 164 | + | if session.LastUsedSeq != 0 { | |
| 165 | + | lastUsed := session.LastUsedSeq | |
| 166 | + | row.LastUsedSeq = &lastUsed | |
| 167 | + | } | |
| 168 | + | if session.LifecycleID != (domain.SessionLifecycleID{}) { | |
| 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 | 185 | hosts = append(hosts, catalogCacheHost{ | |
| 155 | 186 | Target: &target, | |
| @@ -166,6 +197,9 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error | |||
| 166 | 197 | } | |
| 167 | 198 | ||
| 168 | 199 | func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) { | |
| 200 | + | if err := ports.ValidateRemoteCatalogCacheEntries(entries); err != nil { | |
| 201 | + | return nil, err | |
| 202 | + | } | |
| 169 | 203 | normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 170 | 204 | hosts := make(map[string]struct{}, len(entries)) | |
| 171 | 205 | for _, entry := range entries { | |
| @@ -200,7 +234,15 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po | |||
| 200 | 234 | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | |
| 201 | 235 | } | |
| 202 | 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 | 247 | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | |
| 206 | 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 | 201 | info := ports.SessionInfo{ | |
| 202 | 202 | Name: session.Name + "@" + host, | |
| 203 | 203 | Ephemeral: session.Ephemeral, | |
| 204 | - | Tabs: session.Tabs, | |
| 204 | + | Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)), | |
| 205 | 205 | Attached: session.Attached, | |
| 206 | 206 | } | |
| 207 | 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 | 67 | kindStdio | |
| 68 | 68 | kindUDPBootstrap | |
| 69 | 69 | kindUDPProxy | |
| 70 | + | kindRemotePreview | |
| 70 | 71 | kindHelp | |
| 71 | 72 | kindVersion | |
| 72 | 73 | ) | |
| @@ -74,17 +75,18 @@ const ( | |||
| 74 | 75 | // command is the parsed CLI invocation: what to do, plus the attach intent | |
| 75 | 76 | // and session name where relevant. | |
| 76 | 77 | type command struct { | |
| 77 | - | kind cmdKind | |
| 78 | - | intent uint8 | |
| 79 | - | name string | |
| 80 | - | remoteTarget string | |
| 81 | - | listHost string | |
| 82 | - | listAll bool | |
| 83 | - | hostAction string | |
| 84 | - | hostTarget string | |
| 85 | - | killAll bool | |
| 86 | - | killDaemon bool | |
| 87 | - | cmd cmdInvocation | |
| 78 | + | kind cmdKind | |
| 79 | + | intent uint8 | |
| 80 | + | name string | |
| 81 | + | remoteTarget string | |
| 82 | + | listHost string | |
| 83 | + | listAll bool | |
| 84 | + | hostAction string | |
| 85 | + | hostTarget string | |
| 86 | + | killAll bool | |
| 87 | + | killDaemon bool | |
| 88 | + | cmd cmdInvocation | |
| 89 | + | remotePreviewPayload string | |
| 88 | 90 | } | |
| 89 | 91 | ||
| 90 | 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 | 177 | return command{}, usagef("`_udp-proxy` does not accept a session name") | |
| 176 | 178 | } | |
| 177 | 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 | 185 | case "new": | |
| 179 | 186 | if len(args) < 2 || args[1] == "" { | |
| 180 | 187 | return command{}, usagef("`new` requires a session name") | |
| @@ -269,6 +276,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 269 | 276 | return runUDPBootstrap(ctx) | |
| 270 | 277 | case kindUDPProxy: | |
| 271 | 278 | return runUDPProxy(ctx, os.Stdout) | |
| 279 | + | case kindRemotePreview: | |
| 280 | + | return runRemotePreview(ctx, cmd.remotePreviewPayload) | |
| 272 | 281 | case kindList: | |
| 273 | 282 | return runList(ctx, cmd) | |
| 274 | 283 | case kindHost: | |
| @@ -341,6 +350,7 @@ var ( | |||
| 341 | 350 | newRemoteHostStore = remoteadapter.NewFileHostStore | |
| 342 | 351 | newRemoteCatalogCache = remoteadapter.NewFileCatalogCache | |
| 343 | 352 | newRemoteCatalogClient = remoteadapter.NewCatalogClient | |
| 353 | + | newRemotePreviewClient = remoteadapter.NewPreviewClient | |
| 344 | 354 | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |
| 345 | 355 | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | |
| 346 | 356 | } | |
| @@ -790,7 +800,18 @@ func validateRemoteAttachHandoff(target ports.AttachTarget) error { | |||
| 790 | 800 | if err := domain.ValidateRemoteHostTarget(target.Endpoint); err != nil { | |
| 791 | 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 | 817 | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | |
| @@ -800,19 +821,27 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti | |||
| 800 | 821 | if err != nil { | |
| 801 | 822 | return nil, err | |
| 802 | 823 | } | |
| 803 | - | return daemon.WithRemoteDiscovery( | |
| 824 | + | discovery := daemon.WithRemoteDiscovery( | |
| 804 | 825 | newRemoteHostStore(remoteadapter.HostStorePath(stateDir)), | |
| 805 | 826 | newRemoteCatalogClient(), | |
| 806 | 827 | newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)), | |
| 807 | 828 | newRemoteDialerFactoryWithRuntimeObserver(observer), | |
| 808 | 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 | 838 | type attachHandoffKey struct { | |
| 813 | - | endpoint string | |
| 814 | - | session string | |
| 815 | - | intent uint8 | |
| 839 | + | endpoint string | |
| 840 | + | session string | |
| 841 | + | intent uint8 | |
| 842 | + | lifecycle domain.SessionLifecycleID | |
| 843 | + | liveTab domain.TabStableID | |
| 844 | + | stopped domain.TabSelector | |
| 816 | 845 | } | |
| 817 | 846 | ||
| 818 | 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 | 863 | mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport) | |
| 835 | 864 | factory := deps.remoteDialerFactory | |
| 865 | + | var remoteSelection *domain.RemoteSessionTarget | |
| 866 | + | var remoteEnvironmentPolicy ports.EnvironmentPolicy | |
| 836 | 867 | seenHandoffs := make(map[attachHandoffKey]struct{}) | |
| 837 | 868 | if remoteTarget != "" { | |
| 838 | 869 | if modeErr != nil { | |
| @@ -843,6 +874,36 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 843 | 874 | } | |
| 844 | 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 | 908 | for { | |
| 848 | 909 | var err error | |
| @@ -862,8 +923,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 862 | 923 | Logger: log, | |
| 863 | 924 | RuntimeObserver: deps.runtimeObserver, | |
| 864 | 925 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | |
| 926 | + | AttachHandoff: handoff, | |
| 865 | 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 | 934 | } else { | |
| 868 | 935 | if log != nil { | |
| 869 | 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 | 941 | Clock: clock.New(), | |
| 875 | 942 | Logger: log, | |
| 876 | 943 | RuntimeObserver: deps.runtimeObserver, | |
| 944 | + | AttachHandoff: handoff, | |
| 877 | 945 | Remote: false, | |
| 878 | 946 | }, client.AttachRequest{Intent: intent, SessionName: name}) | |
| 879 | 947 | } | |
| @@ -889,6 +957,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 889 | 957 | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | |
| 890 | 958 | } | |
| 891 | 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 | 965 | if _, seen := seenHandoffs[next]; seen { | |
| 893 | 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 | 969 | remoteTarget = handoff.Target.Endpoint | |
| 897 | 970 | name = handoff.Target.Session | |
| 898 | 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 | 978 | if modeErr != nil { | |
| 900 | 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 | 7 | "io" | |
| 8 | 8 | "slices" | |
| 9 | 9 | "strings" | |
| 10 | + | "unicode/utf8" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // IncarnationID permanently distinguishes uses of the same session name. | |
| @@ -18,15 +19,26 @@ type CheckpointRef struct { | |||
| 18 | 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 | 29 | // CatalogueRecord is the durable source of truth for one named session. | |
| 22 | 30 | type CatalogueRecord struct { | |
| 23 | - | Name string | |
| 24 | - | IncarnationID IncarnationID | |
| 25 | - | Cwd string | |
| 26 | - | CreatedAt int64 | |
| 27 | - | UpdatedAt int64 | |
| 28 | - | LastUsedSeq uint64 | |
| 31 | + | Name string | |
| 32 | + | IncarnationID IncarnationID | |
| 33 | + | Cwd string | |
| 34 | + | CreatedAt int64 | |
| 35 | + | UpdatedAt int64 | |
| 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 | 40 | TabNames []string | |
| 41 | + | TabRecords []CatalogueTabRecord | |
| 30 | 42 | Committed *CheckpointRef | |
| 31 | 43 | DegradedReason string | |
| 32 | 44 | } | |
| @@ -44,7 +56,7 @@ func (r *CheckpointRef) Equal(other *CheckpointRef) bool { | |||
| 44 | 56 | func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | |
| 45 | 57 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | |
| 46 | 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 | 60 | r.DegradedReason == other.DegradedReason | |
| 49 | 61 | } | |
| 50 | 62 | ||
| @@ -57,6 +69,7 @@ type CatalogueMetadataUpdate struct { | |||
| 57 | 69 | UpdatedAt *int64 | |
| 58 | 70 | LastUsedSeq *uint64 | |
| 59 | 71 | TabNames *[]string | |
| 72 | + | TabRecords *[]CatalogueTabRecord | |
| 60 | 73 | } | |
| 61 | 74 | ||
| 62 | 75 | // MetadataUpdate returns a complete mutable-metadata update for r. Catalogue | |
| @@ -64,9 +77,10 @@ type CatalogueMetadataUpdate struct { | |||
| 64 | 77 | func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate { | |
| 65 | 78 | cwd, updatedAt, lastUsedSeq := r.Cwd, r.UpdatedAt, r.LastUsedSeq | |
| 66 | 79 | tabNames := append([]string(nil), r.TabNames...) | |
| 80 | + | tabRecords := append([]CatalogueTabRecord(nil), r.TabRecords...) | |
| 67 | 81 | return CatalogueMetadataUpdate{ | |
| 68 | 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 | 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 | 141 | if r.DegradedReason != "" && r.Committed == nil { | |
| 123 | 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 | 8 | // RemoteSessionKey identifies a discovered remote session independently from | |
| 9 | 9 | // its display label. | |
| 10 | 10 | type RemoteSessionKey struct { | |
| 11 | - | Host string | |
| 12 | - | Name string | |
| 11 | + | Host 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 | 20 | // Validate reports whether the host target and session name are both valid. | |
| @@ -25,14 +30,21 @@ func (k RemoteSessionKey) Validate() error { | |||
| 25 | 30 | func (k RemoteSessionKey) ID() SessionID { | |
| 26 | 31 | host := base64.RawURLEncoding.EncodeToString([]byte(k.Host)) | |
| 27 | 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 | 37 | return SessionID("remote:" + host + "." + name) | |
| 29 | 38 | } | |
| 30 | 39 | ||
| 31 | 40 | // Display returns the presentation label for this remote session. | |
| 32 | 41 | func (k RemoteSessionKey) Display() string { | |
| 33 | - | host := k.Host | |
| 34 | - | if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 { | |
| 35 | - | host = host[i+1:] | |
| 42 | + | origin := k.DisplayOrigin | |
| 43 | + | if origin == "" { | |
| 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 | 402 | if update.TabNames != nil { | |
| 403 | 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 | 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 | 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 | 17 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | |
| 15 | 18 | ||
| @@ -44,6 +47,20 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) { | |||
| 44 | 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 | 64 | buf = appendRef(buf, record.Committed) | |
| 48 | 65 | buf, err = appendCheckedString(buf, record.DegradedReason) | |
| 49 | 66 | return buf, err | |
| @@ -59,7 +76,7 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||
| 59 | 76 | return domain.CatalogueRecord{}, errMalformedRecord | |
| 60 | 77 | } | |
| 61 | 78 | version, ok := reader.u16() | |
| 62 | - | if !ok || version != catalogueRecordVersion { | |
| 79 | + | if !ok || (version != catalogueRecordVersion && version != legacyCatalogueRecordVersion) { | |
| 63 | 80 | return domain.CatalogueRecord{}, errMalformedRecord | |
| 64 | 81 | } | |
| 65 | 82 | id, ok := reader.take(16) | |
| @@ -99,6 +116,30 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||
| 99 | 116 | } | |
| 100 | 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 | 143 | if record.Committed, ok = reader.ref(); !ok { | |
| 103 | 144 | return domain.CatalogueRecord{}, errMalformedRecord | |
| 104 | 145 | } | |
M internal/ports/frame.go +28 -26
| @@ -7,7 +7,7 @@ import "time" | |||
| 7 | 7 | const HandshakeTimeout = 15 * time.Second | |
| 8 | 8 | ||
| 9 | 9 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 10 | - | const ProtocolVersion uint16 = 23 | |
| 10 | + | const ProtocolVersion uint16 = 24 | |
| 11 | 11 | ||
| 12 | 12 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 13 | 13 | // and excluding the four-byte length prefix. | |
| @@ -17,32 +17,34 @@ const MaxFrameLen = 16 << 20 | |||
| 17 | 17 | type MsgType uint8 | |
| 18 | 18 | ||
| 19 | 19 | // Frame message types. Client-originated messages are numbered from 1 | |
| 20 | - | // through 15, server-originated messages from 16, leaving room for growth in | |
| 21 | - | // each band. | |
| 20 | + | // through 15, server-originated messages from 16, with reserved values kept | |
| 21 | + | // for future extensions. | |
| 22 | 22 | const ( | |
| 23 | - | MsgHello MsgType = 1 | |
| 24 | - | MsgInput MsgType = 2 | |
| 25 | - | MsgResize MsgType = 3 | |
| 26 | - | MsgDetach MsgType = 4 | |
| 27 | - | MsgPing MsgType = 5 | |
| 28 | - | MsgList MsgType = 6 | |
| 29 | - | MsgKill MsgType = 7 | |
| 30 | - | MsgTheme MsgType = 8 | |
| 31 | - | MsgAck MsgType = 9 | |
| 32 | - | MsgImagePush MsgType = 10 | |
| 33 | - | MsgClientNotice MsgType = 11 | |
| 34 | - | MsgCommand MsgType = 12 | |
| 35 | - | MsgOutputResetRequest MsgType = 13 | |
| 36 | - | ||
| 37 | - | MsgWelcome MsgType = 16 | |
| 38 | - | MsgError MsgType = 17 | |
| 39 | - | MsgOutput MsgType = 18 | |
| 40 | - | MsgDetached MsgType = 19 | |
| 41 | - | MsgPong MsgType = 20 | |
| 42 | - | MsgSessions MsgType = 21 | |
| 43 | - | MsgCommandResult MsgType = 22 | |
| 44 | - | MsgSessionMeta MsgType = 23 | |
| 45 | - | MsgAttachTarget MsgType = 25 | |
| 23 | + | MsgHello MsgType = 1 | |
| 24 | + | MsgInput MsgType = 2 | |
| 25 | + | MsgResize MsgType = 3 | |
| 26 | + | MsgDetach MsgType = 4 | |
| 27 | + | MsgPing MsgType = 5 | |
| 28 | + | MsgList MsgType = 6 | |
| 29 | + | MsgKill MsgType = 7 | |
| 30 | + | MsgTheme MsgType = 8 | |
| 31 | + | MsgAck MsgType = 9 | |
| 32 | + | MsgImagePush MsgType = 10 | |
| 33 | + | MsgClientNotice MsgType = 11 | |
| 34 | + | MsgCommand MsgType = 12 | |
| 35 | + | MsgOutputResetRequest MsgType = 13 | |
| 36 | + | MsgRemotePreviewRequest MsgType = 14 | |
| 37 | + | ||
| 38 | + | MsgWelcome MsgType = 16 | |
| 39 | + | MsgError MsgType = 17 | |
| 40 | + | MsgOutput MsgType = 18 | |
| 41 | + | MsgDetached MsgType = 19 | |
| 42 | + | MsgPong MsgType = 20 | |
| 43 | + | MsgSessions MsgType = 21 | |
| 44 | + | MsgCommandResult MsgType = 22 | |
| 45 | + | MsgSessionMeta MsgType = 23 | |
| 46 | + | MsgRemotePreviewResponse MsgType = 24 | |
| 47 | + | MsgAttachTarget MsgType = 25 | |
| 46 | 48 | ) | |
| 47 | 49 | ||
| 48 | 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 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 23 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 24 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -32,11 +32,11 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 32 | 32 | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, | |
| 33 | 33 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | |
| 34 | 34 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | |
| 35 | - | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, | |
| 35 | + | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, | |
| 36 | 36 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | |
| 37 | 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | |
| 38 | 38 | {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta}, | |
| 39 | - | {"MsgAttachTarget", MsgAttachTarget}, | |
| 39 | + | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | seen := make(map[MsgType]string, len(tests)) | |
M internal/ports/mocks/remote_mock.go +106 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "log/slog" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 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 | 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 | 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 | 747 | // The first argument is typically a *testing.T value. | |
| 642 | 748 | func NewMockRemoteCatalogCache(t interface { | |
M internal/ports/remote.go +356 -13
| @@ -2,9 +2,17 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | "log/slog" | |
| 9 | + | "math" | |
| 10 | + | "slices" | |
| 7 | 11 | "time" | |
| 12 | + | "unicode" | |
| 13 | + | "unicode/utf8" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | 16 | ) | |
| 9 | 17 | ||
| 10 | 18 | // RemoteTransportMode selects the transport used for remote attach after CLI parsing. | |
| @@ -47,14 +55,55 @@ type RemoteCatalogClient interface { | |||
| 47 | 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 | 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 | 82 | type RemoteCatalogCacheEntry struct { | |
| 53 | 83 | Host string | |
| 54 | 84 | FetchedAt time.Time | |
| 55 | 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 | 107 | // RemoteCatalogCache persists complete remote discovery snapshots independently | |
| 59 | 108 | // from the remote host registry. | |
| 60 | 109 | type RemoteCatalogCache interface { | |
| @@ -62,40 +111,334 @@ type RemoteCatalogCache interface { | |||
| 62 | 111 | Store([]RemoteCatalogCacheEntry) error | |
| 63 | 112 | } | |
| 64 | 113 | ||
| 65 | - | // RemoteCatalogSession is one live session in the remote discovery catalog. | |
| 66 | - | // State is an explicit string contract (running|stopped|broken), not SessionState. | |
| 67 | - | type RemoteCatalogSession struct { | |
| 114 | + | // RemoteCatalogTab is one bounded, untrusted tab presentation hint. Detail | |
| 115 | + | // and Attention are deliberately omitted from the durable cache. | |
| 116 | + | type RemoteCatalogTab struct { | |
| 117 | + | ID string `json:"id"` | |
| 118 | + | Index uint16 `json:"index"` | |
| 68 | 119 | Name string `json:"name"` | |
| 69 | - | State string `json:"state"` | |
| 70 | - | Ephemeral bool `json:"ephemeral"` | |
| 71 | - | Tabs uint16 `json:"tabs"` | |
| 72 | - | Attached bool `json:"attached"` | |
| 120 | + | Detail string `json:"detail,omitempty"` | |
| 121 | + | Attention bool `json:"attention,omitempty"` | |
| 122 | + | } | |
| 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 | 234 | func SaturateUint16(n int) uint16 { | |
| 77 | 235 | if n <= 0 { | |
| 78 | 236 | return 0 | |
| 79 | 237 | } | |
| 80 | 238 | if n >= 1<<16 { | |
| 81 | - | return ^uint16(0) | |
| 239 | + | return math.MaxUint16 | |
| 82 | 240 | } | |
| 83 | 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 | 247 | type RemoteCatalog struct { | |
| 88 | 248 | ProtocolVersion uint16 `json:"protocol_version"` | |
| 249 | + | SchemaVersion uint16 `json:"schema_version,omitempty"` | |
| 89 | 250 | Sessions []RemoteCatalogSession `json:"sessions"` | |
| 90 | 251 | } | |
| 91 | 252 | ||
| 92 | 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 | 255 | type RemoteCatalogVersionMismatchError struct { | |
| 95 | 256 | Got uint16 | |
| 96 | 257 | Want uint16 | |
| 258 | + | Kind string | |
| 97 | 259 | } | |
| 98 | 260 | ||
| 99 | 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 | 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 | 85 | // Capability bits advertised in Welcome. | |
| 69 | 86 | const ( | |
| 70 | 87 | CapabilityResume uint32 = 1 << 0 | |
| @@ -112,6 +129,11 @@ type Hello struct { | |||
| 112 | 129 | // Env is the complete exported client environment for future PTY children. | |
| 113 | 130 | // Entries are opaque strings so their ordering and contents are preserved. | |
| 114 | 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 | 139 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| @@ -223,12 +245,15 @@ type Output struct { | |||
| 223 | 245 | Data []byte | |
| 224 | 246 | } | |
| 225 | 247 | ||
| 226 | - | // AttachTarget identifies the endpoint and session selected for a new | |
| 227 | - | // attachment. It deliberately carries no viewport, resume, or session state. | |
| 248 | + | // AttachTarget identifies the endpoint and exact session/tab selected for a | |
| 249 | + | // new attachment. Endpoint and Session remain the compatibility route fields; | |
| 250 | + | // RemoteTarget is the authoritative picker identity when non-nil. | |
| 228 | 251 | type AttachTarget struct { | |
| 229 | - | Endpoint string | |
| 230 | - | Session string | |
| 231 | - | Intent uint8 | |
| 252 | + | Endpoint string | |
| 253 | + | Session string | |
| 254 | + | Intent uint8 | |
| 255 | + | RemoteTarget *domain.RemoteSessionTarget | |
| 256 | + | EnvironmentPolicy EnvironmentPolicy | |
| 232 | 257 | } | |
| 233 | 258 | ||
| 234 | 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 | 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 | 540 | // payloadReader consumes a message payload field by field in wire order, | |
| 347 | 541 | // erroring instead of panicking on any short read. | |
| 348 | 542 | type payloadReader struct { | |
| @@ -501,6 +695,9 @@ func ValidateHello(h Hello) error { | |||
| 501 | 695 | if err := ValidateSize(h.Size); err != nil { | |
| 502 | 696 | return fmt.Errorf("%w: size", ErrInvalidHello) | |
| 503 | 697 | } | |
| 698 | + | if !validEnvironmentPolicy(h.EnvironmentPolicy) { | |
| 699 | + | return ErrInvalidHello | |
| 700 | + | } | |
| 504 | 701 | if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 { | |
| 505 | 702 | return ErrInvalidHello | |
| 506 | 703 | } | |
| @@ -509,6 +706,21 @@ func ValidateHello(h Hello) error { | |||
| 509 | 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 | 724 | return nil | |
| 513 | 725 | } | |
| 514 | 726 | ||
| @@ -541,8 +753,8 @@ func ValidateAck(m Ack) error { | |||
| 541 | 753 | return nil | |
| 542 | 754 | } | |
| 543 | 755 | ||
| 544 | - | // ValidateAttachTarget validates only the endpoint, session, and intent | |
| 545 | - | // fields carried by the server attach-target message. | |
| 756 | + | // ValidateAttachTarget validates route fields and, when present, the exact | |
| 757 | + | // picker identity carried by the handoff. | |
| 546 | 758 | func ValidateAttachTarget(m AttachTarget) error { | |
| 547 | 759 | if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { | |
| 548 | 760 | return ErrInvalidAttachTarget | |
| @@ -550,11 +762,39 @@ func ValidateAttachTarget(m AttachTarget) error { | |||
| 550 | 762 | if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { | |
| 551 | 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 | 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 | 793 | // MarshalHello encodes h into a Hello message payload. | |
| 557 | 794 | func MarshalHello(h Hello) []byte { | |
| 795 | + | if err := ValidateHello(h); err != nil { | |
| 796 | + | return nil | |
| 797 | + | } | |
| 558 | 798 | w := payloadWriter{} | |
| 559 | 799 | w.putUint16(h.Version) | |
| 560 | 800 | w.putUint8(h.Intent) | |
| @@ -571,6 +811,9 @@ func MarshalHello(h Hello) []byte { | |||
| 571 | 811 | for _, entry := range h.Env { | |
| 572 | 812 | w.putLongString(entry) | |
| 573 | 813 | } | |
| 814 | + | if h.RemoteTarget != nil || h.EnvironmentPolicy != EnvironmentPolicyClientOwned { | |
| 815 | + | marshalRemoteTargetExtension(&w, h.RemoteTarget, h.EnvironmentPolicy) | |
| 816 | + | } | |
| 574 | 817 | return w.b | |
| 575 | 818 | } | |
| 576 | 819 | ||
| @@ -626,6 +869,11 @@ func preflightHello(b []byte) error { | |||
| 626 | 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 | 877 | return r.done() | |
| 630 | 878 | } | |
| 631 | 879 | ||
| @@ -698,6 +946,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 698 | 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 | 954 | if err := r.done(); err != nil { | |
| 702 | 955 | return Hello{}, err | |
| 703 | 956 | } | |
| @@ -1141,6 +1394,9 @@ func MarshalAttachTarget(m AttachTarget) []byte { | |||
| 1141 | 1394 | w.putString(m.Endpoint) | |
| 1142 | 1395 | w.putString(m.Session) | |
| 1143 | 1396 | w.putUint8(m.Intent) | |
| 1397 | + | if m.RemoteTarget != nil || m.EnvironmentPolicy != EnvironmentPolicyClientOwned { | |
| 1398 | + | marshalRemoteTargetExtension(&w, m.RemoteTarget, m.EnvironmentPolicy) | |
| 1399 | + | } | |
| 1144 | 1400 | return w.b | |
| 1145 | 1401 | } | |
| 1146 | 1402 | ||
| @@ -1162,6 +1418,11 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1162 | 1418 | if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) { | |
| 1163 | 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 | 1426 | if err := probe.done(); err != nil { | |
| 1166 | 1427 | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1167 | 1428 | } | |
| @@ -1177,6 +1438,11 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1177 | 1438 | if m.Intent, err = r.getUint8(); err != nil { | |
| 1178 | 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 | 1446 | if err := r.done(); err != nil { | |
| 1181 | 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 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 23 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 24 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 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 | 30 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 31 | 31 | got := MarshalHello(msg) | |
| 32 | 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 | 125 | got := MarshalHello(msg) | |
| 126 | 126 | want := []byte{ | |
| 127 | - | 0x00, 0x17, 0x00, // version, intent | |
| 127 | + | 0x00, 0x18, 0x00, // version, intent | |
| 128 | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 129 | 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 130 | 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -341,7 +341,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 341 | 341 | } | |
| 342 | 342 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 343 | 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 | 347 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -469,7 +469,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 469 | 469 | name: "minimal", | |
| 470 | 470 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 471 | 471 | want: []byte{ | |
| 472 | - | 0x00, 0x17, | |
| 472 | + | 0x00, 0x18, | |
| 473 | 473 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 474 | 474 | 0x00, // attached | |
| 475 | 475 | 0x00, // self | |
| @@ -492,7 +492,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 492 | 492 | JSON: true, | |
| 493 | 493 | }, | |
| 494 | 494 | want: []byte{ | |
| 495 | - | 0x00, 0x17, | |
| 495 | + | 0x00, 0x18, | |
| 496 | 496 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 497 | 497 | 0x00, // attached | |
| 498 | 498 | 0x01, // self | |
| @@ -920,7 +920,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 920 | 920 | name: "attached", | |
| 921 | 921 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 922 | 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 | 924 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 925 | 925 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 926 | 926 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -930,7 +930,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 930 | 930 | name: "control", | |
| 931 | 931 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 932 | 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 | 934 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 935 | 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 | 149 | // Dependencies supplies the collaborators required by a Runner. | |
| 150 | + | type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error) | |
| 151 | + | ||
| 150 | 152 | type Dependencies struct { | |
| 151 | 153 | Dialer ports.Dialer | |
| 152 | 154 | Terminal ports.Terminal | |
| @@ -155,17 +157,23 @@ type Dependencies struct { | |||
| 155 | 157 | Logger *slog.Logger | |
| 156 | 158 | RuntimeObserver ports.SerializedRuntimeObserver | |
| 157 | 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 | 163 | // Remote selects client-side carriage presentation only; it never enters | |
| 159 | 164 | // the daemon-facing session request. | |
| 160 | 165 | Remote bool | |
| 161 | 166 | } | |
| 162 | 167 | ||
| 163 | 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 | 171 | type AttachRequest struct { | |
| 166 | - | Intent uint8 | |
| 167 | - | SessionName string | |
| 168 | - | Remote bool | |
| 172 | + | Intent uint8 | |
| 173 | + | SessionName string | |
| 174 | + | Remote bool | |
| 175 | + | RemoteTarget *domain.RemoteSessionTarget | |
| 176 | + | EnvironmentPolicy ports.EnvironmentPolicy | |
| 169 | 177 | } | |
| 170 | 178 | ||
| 171 | 179 | // Runner owns the client lifecycle across one or more attachment attempts. | |
| @@ -177,6 +185,7 @@ type Runner struct { | |||
| 177 | 185 | logger *slog.Logger | |
| 178 | 186 | runtimeObserver ports.SerializedRuntimeObserver | |
| 179 | 187 | remoteHostLearner ports.RemoteHostLearner | |
| 188 | + | attachHandoff AttachHandoffFunc | |
| 180 | 189 | remote bool | |
| 181 | 190 | ||
| 182 | 191 | inputMu sync.Mutex | |
| @@ -197,6 +206,7 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 197 | 206 | logger: log, | |
| 198 | 207 | runtimeObserver: deps.RuntimeObserver, | |
| 199 | 208 | remoteHostLearner: deps.RemoteHostLearner, | |
| 209 | + | attachHandoff: deps.AttachHandoff, | |
| 200 | 210 | remote: deps.Remote, | |
| 201 | 211 | } | |
| 202 | 212 | } | |
| @@ -220,6 +230,17 @@ func (r *Runner) terminalInput() *terminalInputPump { | |||
| 220 | 230 | // above attach attempts so raw mode remains active while a live client process | |
| 221 | 231 | // redials a lost link. | |
| 222 | 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 | 244 | ms := milestones{} | |
| 224 | 245 | ||
| 225 | 246 | defer func() { | |
| @@ -393,7 +414,22 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 393 | 414 | _ = connection.Close() | |
| 394 | 415 | } | |
| 395 | 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 | 434 | if result.resumeToken != 0 { | |
| 399 | 435 | resumeToken = result.resumeToken | |
| @@ -695,6 +731,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 695 | 731 | TrueColor: trueColor, | |
| 696 | 732 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 697 | 733 | Env: os.Environ(), | |
| 734 | + | RemoteTarget: request.RemoteTarget, | |
| 735 | + | EnvironmentPolicy: request.EnvironmentPolicy, | |
| 698 | 736 | } | |
| 699 | 737 | if err := sendHandshake(func() error { | |
| 700 | 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 | 39 | overlays *overlayRuntime | |
| 40 | 40 | overlayOnce sync.Once | |
| 41 | 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 | 45 | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | |
| 43 | 46 | // the linearization gate for every attachment-bound observable operation. A role | |
| 44 | 47 | // transition freezes and drains this gate before changing either generation | |
| @@ -565,6 +568,7 @@ type attachClientOptions struct { | |||
| 565 | 568 | clientID [16]byte | |
| 566 | 569 | resumeCapable bool | |
| 567 | 570 | maxOutputInFlight uint8 | |
| 571 | + | remoteOrigin string | |
| 568 | 572 | } | |
| 569 | 573 | ||
| 570 | 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 | 614 | size: sz, | |
| 611 | 615 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | |
| 612 | 616 | clientID: opts.clientID, | |
| 617 | + | remoteOrigin: opts.remoteOrigin, | |
| 613 | 618 | resumeCapable: opts.resumeCapable, | |
| 614 | 619 | resumeToken: resumeToken, | |
| 615 | 620 | } | |
| @@ -875,6 +880,13 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size | |||
| 875 | 880 | } | |
| 876 | 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 | 890 | sess := token.sess | |
| 879 | 891 | if sess == nil { | |
| 880 | 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 | 696 | e.d.mu.Lock() | |
| 697 | 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 | 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 | 789 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | |
| 700 | - | // Stopped sessions live in d.stopped and are intentionally not in this listing. | |
| 701 | 790 | for _, sess := range sessions { | |
| 702 | 791 | snap := sess.snapshotView(viewOptions{}) | |
| 703 | 792 | rows = append(rows, ports.RemoteCatalogSession{ | |
| @@ -709,10 +798,46 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 709 | 798 | }) | |
| 710 | 799 | } | |
| 711 | 800 | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | |
| 712 | - | return marshalListing(ports.RemoteCatalog{ | |
| 713 | - | ProtocolVersion: ports.ProtocolVersion, | |
| 714 | - | Sessions: rows, | |
| 715 | - | }) | |
| 801 | + | catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: rows} | |
| 802 | + | if err := ports.ValidateRemoteCatalog(catalog); err != nil { | |
| 803 | + | return "", err | |
| 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 | 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 | 483 | releaseConn() | |
| 484 | 484 | closeOnce.Do(func() { close(readDone) }) | |
| 485 | 485 | hg.Wait() | |
| 486 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 486 | 487 | d.sessWg.Wait() | |
| 487 | 488 | } | |
| 488 | 489 | ||
M internal/usecase/daemon/daemon.go +50 -5
| @@ -288,9 +288,11 @@ type Daemon struct { | |||
| 288 | 288 | // remoteCatalog owns cache-derived discovery state independently of the | |
| 289 | 289 | // live attachment registry. Its cache reads and writes never hold d.mu. | |
| 290 | 290 | remoteCatalog remoteCatalogState | |
| 291 | + | remotePreview remotePreviewState | |
| 291 | 292 | remoteHostStore ports.RemoteHostStore | |
| 292 | 293 | remoteCatalogClient ports.RemoteCatalogClient | |
| 293 | 294 | remoteCatalogCache ports.RemoteCatalogCache | |
| 295 | + | remotePreviewClient ports.RemotePreviewClient | |
| 294 | 296 | remoteDialerFactory ports.RemoteDialerFactory | |
| 295 | 297 | remoteTransportMode ports.RemoteTransportMode | |
| 296 | 298 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| @@ -355,6 +357,7 @@ type stoppedSession struct { | |||
| 355 | 357 | incarnation domain.IncarnationID | |
| 356 | 358 | lastUsedSeq uint64 | |
| 357 | 359 | tabNames []string | |
| 360 | + | tabRecords []domain.CatalogueTabRecord | |
| 358 | 361 | purging bool | |
| 359 | 362 | record domain.CatalogueRecord | |
| 360 | 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 | 393 | // WithShell overrides the command (and its args) each session spawns. The | |
| 386 | 394 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | |
| 387 | 395 | // deterministic program. | |
| @@ -650,7 +658,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 650 | 658 | state, done := initialSessionState(r) | |
| 651 | 659 | d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done) | |
| 652 | 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 | 663 | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | |
| 656 | 664 | maxCreatedAt = r.CreatedAt | |
| @@ -976,6 +984,12 @@ func (d *Daemon) handleConn(tr ports.Transport) { | |||
| 976 | 984 | if err := d.handleCommand(tr, first); err != nil { | |
| 977 | 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 | 993 | case ports.MsgKill: | |
| 980 | 994 | stopTransport() | |
| 981 | 995 | finishHandshake() | |
| @@ -1229,10 +1243,26 @@ func (e *protoErr) Error() string { return e.text } | |||
| 1229 | 1243 | // terminal and role state before releasing d.mu, then defers coordinator | |
| 1230 | 1244 | // cleanup so obsolete workers never delay the new handshake. | |
| 1231 | 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 | 1258 | // Session state is the sole source for future PTY children. Update it before | |
| 1233 | 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 | 1262 | sess.mu.Lock() | |
| 1235 | - | sess.env = copyEnvironment(h.Env) | |
| 1263 | + | if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned { | |
| 1264 | + | sess.env = copyEnvironment(h.Env) | |
| 1265 | + | } | |
| 1236 | 1266 | sess.terminal = term | |
| 1237 | 1267 | sess.mu.Unlock() | |
| 1238 | 1268 | opts := attachClientOptions{ | |
| @@ -1240,13 +1270,17 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1240 | 1270 | resumeCapable: true, | |
| 1241 | 1271 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1242 | 1272 | } | |
| 1273 | + | if h.RemoteTarget != nil { | |
| 1274 | + | opts.remoteOrigin = h.RemoteTarget.DisplayOrigin | |
| 1275 | + | } | |
| 1243 | 1276 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 1244 | 1277 | d.mu.Unlock() | |
| 1245 | 1278 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1246 | - | target: sess, | |
| 1247 | - | next: ac, | |
| 1248 | - | ||
| 1279 | + | target: sess, | |
| 1280 | + | next: ac, | |
| 1249 | 1281 | expectedTransport: ac.transportSnapshot(), | |
| 1282 | + | activateTargetTab: initialTabIndex >= 0, | |
| 1283 | + | targetTabIndex: initialTabIndex, | |
| 1250 | 1284 | ready: false, | |
| 1251 | 1285 | }) | |
| 1252 | 1286 | if err != nil { | |
| @@ -1330,6 +1364,9 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T | |||
| 1330 | 1364 | return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"} | |
| 1331 | 1365 | } | |
| 1332 | 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 | 1371 | // A non-zero token is an authoritative resume credential. If it is unknown, | |
| 1335 | 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 | 1388 | return nil, nil, err | |
| 1352 | 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 | 1394 | return sess, ac, nil | |
| 1354 | 1395 | } | |
| 1355 | 1396 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 1356 | 1397 | } | |
| 1357 | 1398 | if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil { | |
| 1358 | 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 | 1404 | return sess, ac, nil | |
| 1360 | 1405 | } | |
| 1361 | 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 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "sync" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -21,15 +22,17 @@ type overlayRuntime struct { | |||
| 21 | 22 | // pickerGeneration identifies one open lifecycle. It advances only when a | |
| 22 | 23 | // picker is published, so delayed close and registration work can prove it | |
| 23 | 24 | // still owns the exact lifecycle it captured. | |
| 24 | - | pickerGeneration uint64 | |
| 25 | - | pickerTitle string | |
| 26 | - | pickerIntent pickerIntent | |
| 27 | - | pickerSource moveSourceLocator | |
| 28 | - | pickerPreview *tab | |
| 29 | - | pickerPreviewSession *session | |
| 30 | - | pickerPreviewGeneration uint64 | |
| 31 | - | pickerPending []byte | |
| 32 | - | pickerESC pendingByteTimer | |
| 25 | + | pickerGeneration uint64 | |
| 26 | + | pickerTitle string | |
| 27 | + | pickerIntent pickerIntent | |
| 28 | + | pickerSource moveSourceLocator | |
| 29 | + | pickerPreview *tab | |
| 30 | + | pickerPreviewSession *session | |
| 31 | + | pickerRemotePreview picker.Preview | |
| 32 | + | pickerRemotePreviewCancel context.CancelFunc | |
| 33 | + | pickerPreviewGeneration uint64 | |
| 34 | + | pickerPending []byte | |
| 35 | + | pickerESC pendingByteTimer | |
| 33 | 36 | ||
| 34 | 37 | // Test-only, unsynchronized lifecycle seams. Assign them before picker | |
| 35 | 38 | // publication or goroutine startup. Hooks run without pickerMu or | |
| @@ -288,10 +291,11 @@ type overlayRenderSnapshot struct { | |||
| 288 | 291 | statusFeedback string | |
| 289 | 292 | resizeActive bool | |
| 290 | 293 | ||
| 291 | - | pickerActive bool | |
| 292 | - | pickerModel *picker.Model | |
| 293 | - | pickerTitle string | |
| 294 | - | previewTab *tab | |
| 294 | + | pickerActive bool | |
| 295 | + | pickerModel *picker.Model | |
| 296 | + | pickerTitle string | |
| 297 | + | previewTab *tab | |
| 298 | + | remotePreview picker.Preview | |
| 295 | 299 | ||
| 296 | 300 | noticesOverlayActive bool | |
| 297 | 301 | noticesOverlayModel *notices.Model | |
| @@ -363,6 +367,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 363 | 367 | snap.pickerModel = rt.picker.Clone() | |
| 364 | 368 | snap.pickerTitle = rt.pickerTitle | |
| 365 | 369 | snap.previewTab = rt.pickerPreview | |
| 370 | + | snap.remotePreview = clonePickerPreview(rt.pickerRemotePreview) | |
| 366 | 371 | rt.pickerMu.Unlock() | |
| 367 | 372 | ||
| 368 | 373 | rt.paletteMu.Lock() | |
M internal/usecase/daemon/picker.go +271 -10
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "sort" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| @@ -61,7 +62,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||
| 61 | 62 | rt.pickerMu.Lock() | |
| 62 | 63 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | |
| 63 | 64 | rt.pickerPreviewGeneration++ | |
| 65 | + | if rt.pickerRemotePreviewCancel != nil { | |
| 66 | + | rt.pickerRemotePreviewCancel() | |
| 67 | + | rt.pickerRemotePreviewCancel = nil | |
| 68 | + | } | |
| 64 | 69 | rt.pickerPreviewSession = nil | |
| 70 | + | rt.pickerRemotePreview = picker.Preview{} | |
| 65 | 71 | rt.pickerPreview = nil | |
| 66 | 72 | rt.picker = model | |
| 67 | 73 | rt.pickerGeneration++ | |
| @@ -163,7 +169,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 163 | 169 | catalogRows := 0 | |
| 164 | 170 | for _, host := range catalog { | |
| 165 | 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 | 173 | catalogRows++ | |
| 168 | 174 | } | |
| 169 | 175 | } | |
| @@ -179,7 +185,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 179 | 185 | } | |
| 180 | 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 | 189 | views = append(views, remotePickerHostView(host.entry.Host, host.status)) | |
| 184 | 190 | } | |
| 185 | 191 | } | |
| @@ -216,6 +222,42 @@ func attentionSuffix(label string) string { | |||
| 216 | 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 | 261 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |
| 220 | 262 | rt := ac.overlays | |
| 221 | 263 | var previewGeneration uint64 | |
| @@ -271,13 +313,16 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 271 | 313 | } | |
| 272 | 314 | }) | |
| 273 | 315 | var target picker.Target | |
| 316 | + | var cursor picker.Target | |
| 274 | 317 | var ok bool | |
| 318 | + | var cursorOK bool | |
| 275 | 319 | var intent pickerIntent | |
| 276 | 320 | var source moveSourceLocator | |
| 277 | 321 | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | |
| 278 | 322 | prevIdx := -1 | |
| 279 | 323 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | |
| 280 | 324 | target, ok = rt.picker.Selected() | |
| 325 | + | cursor, cursorOK = rt.picker.Cursor() | |
| 281 | 326 | prevIdx = rt.picker.SelectedIndex() | |
| 282 | 327 | } | |
| 283 | 328 | intent, source = rt.pickerIntent, rt.pickerSource | |
| @@ -289,6 +334,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 289 | 334 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 290 | 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 | 342 | if result.action == 'x' { | |
| 293 | 343 | var effect *attachmentEffectTicket | |
| 294 | 344 | if len(effects) != 0 { | |
| @@ -379,13 +429,22 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 379 | 429 | previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | |
| 380 | 430 | ac.overlays.pickerPreviewGeneration++ | |
| 381 | 431 | generation := ac.overlays.pickerPreviewGeneration | |
| 432 | + | if ac.overlays.pickerRemotePreviewCancel != nil { | |
| 433 | + | ac.overlays.pickerRemotePreviewCancel() | |
| 434 | + | ac.overlays.pickerRemotePreviewCancel = nil | |
| 435 | + | } | |
| 382 | 436 | ac.overlays.pickerPreviewSession = nil | |
| 437 | + | ac.overlays.pickerRemotePreview = picker.Preview{} | |
| 383 | 438 | ac.overlays.pickerPreview = nil | |
| 384 | 439 | ac.overlays.pickerMu.Unlock() | |
| 385 | 440 | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 386 | 441 | if !ok { | |
| 387 | 442 | return | |
| 388 | 443 | } | |
| 444 | + | if target.RemoteTarget != nil { | |
| 445 | + | d.startRemotePickerPreview(ac, target, generation) | |
| 446 | + | return | |
| 447 | + | } | |
| 389 | 448 | targetSess, next := d.previewTarget(target, intent) | |
| 390 | 449 | if next == nil || targetSess == nil { | |
| 391 | 450 | return | |
| @@ -421,6 +480,122 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 421 | 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 | 599 | func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool { | |
| 425 | 600 | ac.overlays.pickerMu.Lock() | |
| 426 | 601 | defer ac.overlays.pickerMu.Unlock() | |
| @@ -463,7 +638,12 @@ func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) b | |||
| 463 | 638 | } | |
| 464 | 639 | previous := ac.overlays.pickerPreviewSession | |
| 465 | 640 | ac.overlays.pickerPreviewGeneration++ | |
| 641 | + | if ac.overlays.pickerRemotePreviewCancel != nil { | |
| 642 | + | ac.overlays.pickerRemotePreviewCancel() | |
| 643 | + | ac.overlays.pickerRemotePreviewCancel = nil | |
| 644 | + | } | |
| 466 | 645 | ac.overlays.pickerPreviewSession = nil | |
| 646 | + | ac.overlays.pickerRemotePreview = picker.Preview{} | |
| 467 | 647 | ac.overlays.pickerPreview = nil | |
| 468 | 648 | ac.overlays.pickerMu.Unlock() | |
| 469 | 649 | d.teardownPreviewSubscription(ac, previous, generation) | |
| @@ -537,8 +717,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g | |||
| 537 | 717 | ||
| 538 | 718 | func pickerTargetsEqual(left, right picker.Target) bool { | |
| 539 | 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 || | |
| 541 | - | !remoteKeysEqual(left.RemoteKey, right.RemoteKey) { | |
| 720 | + | left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped || | |
| 721 | + | left.UnavailableReason != right.UnavailableReason || !remoteKeysEqual(left.RemoteKey, right.RemoteKey) || | |
| 722 | + | !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) { | |
| 542 | 723 | return false | |
| 543 | 724 | } | |
| 544 | 725 | if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil { | |
| @@ -554,6 +735,13 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool { | |||
| 554 | 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 | 745 | func (d *Daemon) sessionByID(id domain.SessionID) *session { | |
| 558 | 746 | d.mu.Lock() | |
| 559 | 747 | defer d.mu.Unlock() | |
| @@ -660,6 +848,13 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 660 | 848 | if token.sess == nil || token.ac == nil || token.effect == nil { | |
| 661 | 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 | 858 | if target.RemoteKey != nil { | |
| 664 | 859 | return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action) | |
| 665 | 860 | } | |
| @@ -676,14 +871,35 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 676 | 871 | // the target is sent, the local attachment is detached and the client opens a | |
| 677 | 872 | // fresh connection to the owning daemon. | |
| 678 | 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 | 900 | return errAttachmentTransition | |
| 681 | 901 | } | |
| 682 | - | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{ | |
| 683 | - | Endpoint: key.Host, | |
| 684 | - | Session: key.Name, | |
| 685 | - | Intent: ports.IntentAttach, | |
| 686 | - | })}); err != nil { | |
| 902 | + | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { | |
| 687 | 903 | return err | |
| 688 | 904 | } | |
| 689 | 905 | if guard.closePicker { | |
| @@ -712,6 +928,51 @@ func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool { | |||
| 712 | 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 | 976 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | |
| 716 | 977 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |
| 717 | 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 | 15 | remoteHostCached remoteHostStatus = iota | |
| 16 | 16 | remoteHostRefreshing | |
| 17 | 17 | remoteHostFresh | |
| 18 | + | remoteHostStale | |
| 18 | 19 | remoteHostUnreachable | |
| 19 | 20 | remoteHostVersionMismatch | |
| 21 | + | remoteHostMalformed | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| 22 | 24 | // remoteCatalogState owns the cache-derived discovery state. Its mutex never | |
| @@ -43,7 +45,12 @@ func newRemoteCatalogState() remoteCatalogState { | |||
| 43 | 45 | ||
| 44 | 46 | func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry { | |
| 45 | 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 | 54 | return ports.RemoteCatalogCacheEntry{ | |
| 48 | 55 | Host: entry.Host, | |
| 49 | 56 | FetchedAt: entry.FetchedAt, | |
M internal/usecase/daemon/remote_picker.go +153 -20
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "sort" | |
| 9 | + | "strings" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/domain" | |
| @@ -30,11 +31,19 @@ type remotePickerInstance struct { | |||
| 30 | 31 | // remotePickerCatalogSnapshot copies the cache-derived state needed for a | |
| 31 | 32 | // picker before any row sorting begins. No remote-state lock survives this | |
| 32 | 33 | // snapshot. | |
| 34 | + | const remotePickerAttachTTL = 30 * time.Second | |
| 35 | + | ||
| 33 | 36 | func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |
| 34 | 37 | if d == nil { | |
| 35 | 38 | return nil | |
| 36 | 39 | } | |
| 40 | + | now := d.clock.Now() | |
| 37 | 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 | 47 | entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status)) | |
| 39 | 48 | seen := make(map[string]struct{}, len(d.remoteCatalog.cache)) | |
| 40 | 49 | for host, entry := range d.remoteCatalog.cache { | |
| @@ -45,7 +54,7 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry { | |||
| 45 | 54 | seen[host] = struct{}{} | |
| 46 | 55 | } | |
| 47 | 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 | 58 | continue | |
| 50 | 59 | } | |
| 51 | 60 | entries = append(entries, remotePickerCatalogEntry{ | |
| @@ -75,11 +84,15 @@ func (d *Daemon) remotePickerHostRanks() map[string]int { | |||
| 75 | 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 | 91 | func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability { | |
| 79 | 92 | switch status { | |
| 80 | 93 | case remoteHostFresh: | |
| 81 | 94 | return picker.RemoteFresh | |
| 82 | - | case remoteHostUnreachable: | |
| 95 | + | case remoteHostStale, remoteHostUnreachable, remoteHostMalformed: | |
| 83 | 96 | return picker.RemoteStale | |
| 84 | 97 | case remoteHostVersionMismatch: | |
| 85 | 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 | 104 | func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string { | |
| 99 | 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 | 111 | case remoteHostUnreachable: | |
| 101 | 112 | if !fetchedAt.IsZero() { | |
| 102 | 113 | return "stale since " + fetchedAt.Format(time.RFC3339) | |
| @@ -104,34 +115,148 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri | |||
| 104 | 115 | return "unreachable" | |
| 105 | 116 | case remoteHostVersionMismatch: | |
| 106 | 117 | return "version mismatch" | |
| 118 | + | case remoteHostMalformed: | |
| 119 | + | return "catalog malformed" | |
| 107 | 120 | default: | |
| 108 | 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 | 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 | 201 | detail := remotePickerStatusDetail(status, fetchedAt) | |
| 114 | 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 | 218 | return picker.SessionView{ | |
| 118 | 219 | ID: key.ID(), | |
| 119 | 220 | Name: key.Display(), | |
| 120 | 221 | RemoteKey: &key, | |
| 222 | + | RemoteTarget: remoteTarget, | |
| 121 | 223 | RemoteHost: key.Host, | |
| 122 | - | RemoteAvailability: remotePickerAvailability(status), | |
| 224 | + | RemoteAvailability: availability, | |
| 123 | 225 | RemoteDetail: detail, | |
| 124 | - | ConnectOnly: true, | |
| 125 | - | RemoteAttachReady: status != remoteHostVersionMismatch, | |
| 226 | + | RemoteReason: reason, | |
| 227 | + | Tabs: viewTabs, | |
| 228 | + | Stopped: stopped, | |
| 229 | + | ConnectOnly: legacy, | |
| 230 | + | RemoteAttachReady: ready, | |
| 126 | 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 | 254 | func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView { | |
| 131 | 255 | return picker.SessionView{ | |
| 132 | 256 | ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))), | |
| 133 | 257 | Name: host, | |
| 134 | 258 | RemoteHost: host, | |
| 259 | + | RemoteReason: remoteReasonForStatus(status), | |
| 135 | 260 | RemoteAvailability: remotePickerAvailability(status), | |
| 136 | 261 | RemoteDetail: remotePickerStatusDetail(status, time.Time{}), | |
| 137 | 262 | ConnectOnly: true, | |
| @@ -142,7 +267,11 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi | |||
| 142 | 267 | func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) { | |
| 143 | 268 | for i := range entries { | |
| 144 | 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 | 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 | 495 | stillKnown := containsRemotePickerHost(hosts, host) | |
| 367 | 496 | now := d.clock.Now() | |
| 368 | - | if listErr == nil && catalog.ProtocolVersion != ports.ProtocolVersion { | |
| 369 | - | listErr = &ports.RemoteCatalogVersionMismatchError{Got: catalog.ProtocolVersion, Want: ports.ProtocolVersion} | |
| 497 | + | if listErr == nil { | |
| 498 | + | if err := ports.ValidateRemoteCatalog(catalog); err != nil { | |
| 499 | + | listErr = err | |
| 500 | + | } | |
| 370 | 501 | } | |
| 371 | 502 | ||
| 372 | 503 | d.remoteCatalog.mu.Lock() | |
| @@ -387,10 +518,12 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||
| 387 | 518 | return cacheChanged | |
| 388 | 519 | } | |
| 389 | 520 | if listErr != nil { | |
| 390 | - | status := remoteHostUnreachable | |
| 521 | + | status := remoteHostMalformed | |
| 391 | 522 | var mismatch *ports.RemoteCatalogVersionMismatchError | |
| 392 | 523 | if errors.As(listErr, &mismatch) { | |
| 393 | 524 | status = remoteHostVersionMismatch | |
| 525 | + | } else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) && !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) { | |
| 526 | + | status = remoteHostUnreachable | |
| 394 | 527 | } | |
| 395 | 528 | d.remoteCatalog.status[host] = status | |
| 396 | 529 | d.remoteCatalog.failure[host] = now | |
| @@ -398,11 +531,11 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo | |||
| 398 | 531 | d.refreshRemoteOpenPickers() | |
| 399 | 532 | return false | |
| 400 | 533 | } | |
| 401 | - | d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{ | |
| 534 | + | d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{ | |
| 402 | 535 | Host: host, | |
| 403 | 536 | FetchedAt: now, | |
| 404 | - | Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...), | |
| 405 | - | } | |
| 537 | + | Sessions: catalog.Sessions, | |
| 538 | + | }) | |
| 406 | 539 | d.remoteCatalog.status[host] = remoteHostFresh | |
| 407 | 540 | delete(d.remoteCatalog.failure, host) | |
| 408 | 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 | 375 | preview := snapshotPickerPreview(nil) | |
| 376 | 376 | if local && overlays.previewTab != tb { | |
| 377 | 377 | preview = snapshotPickerPreview(overlays.previewTab) | |
| 378 | + | if overlays.previewTab == nil && overlays.remotePreview.Height > 0 { | |
| 379 | + | preview = overlays.remotePreview | |
| 380 | + | } | |
| 378 | 381 | } | |
| 379 | 382 | if local { | |
| 380 | 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 | 226 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | |
| 227 | 227 | } | |
| 228 | 228 | } | |
| 229 | - | if incarnation == (domain.IncarnationID{}) { | |
| 230 | - | var err error | |
| 231 | - | incarnation, err = domain.NewIncarnationID(rand.Reader) | |
| 232 | - | if err != nil { | |
| 233 | - | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate durable identity: %w", err)) | |
| 234 | - | } | |
| 229 | + | } | |
| 230 | + | if incarnation == (domain.IncarnationID{}) { | |
| 231 | + | var err error | |
| 232 | + | incarnation, err = domain.NewSessionLifecycleID(rand.Reader) | |
| 233 | + | if err != nil { | |
| 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 | 241 | tbSize := contentSize(sz) | |
| 242 | 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 | 251 | names = append([]string(nil), restoredTabNames[0]...) | |
| 245 | 252 | } | |
| 246 | 253 | tabs := make([]*tab, 0, max(1, len(names))) | |
| 247 | 254 | for i := range max(1, len(names)) { | |
| 248 | 255 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 256 | + | if i < len(tabRecords) && tabRecords[i].StableID != "" { | |
| 257 | + | tabStableID = string(tabRecords[i].StableID) | |
| 258 | + | } | |
| 249 | 259 | if err != nil { | |
| 250 | 260 | closeTabs(tabs) | |
| 251 | 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 | 328 | var rollbackDurable func() error | |
| 319 | 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 | 331 | if d.recovery == nil { | |
| 322 | 332 | closeTabs(tabs) | |
| 323 | 333 | cancel() | |
| @@ -844,11 +854,13 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 844 | 854 | d.mu.Unlock() | |
| 845 | 855 | return err | |
| 846 | 856 | } | |
| 847 | - | sess.incarnation, err = domain.NewIncarnationID(rand.Reader) | |
| 848 | - | if err != nil { | |
| 849 | - | sess.mu.Unlock() | |
| 850 | - | d.mu.Unlock() | |
| 851 | - | return fmt.Errorf("generate durable identity: %w", err) | |
| 857 | + | if sess.incarnation == (domain.IncarnationID{}) { | |
| 858 | + | sess.incarnation, err = domain.NewSessionLifecycleID(rand.Reader) | |
| 859 | + | if err != nil { | |
| 860 | + | sess.mu.Unlock() | |
| 861 | + | d.mu.Unlock() | |
| 862 | + | return fmt.Errorf("generate durable identity: %w", err) | |
| 863 | + | } | |
| 852 | 864 | } | |
| 853 | 865 | } | |
| 854 | 866 | lastUsedSeq := sess.mruAt.Load() | |
| @@ -925,6 +937,20 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | |||
| 925 | 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 | 954 | func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | |
| 929 | 955 | createdAt := s.createdAt | |
| 930 | 956 | tabNames := make([]string, len(s.tabs)) | |
| @@ -940,7 +966,7 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | |||
| 940 | 966 | } else { | |
| 941 | 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 | 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 | 1571 | stoppedName := sess.name | |
| 1546 | 1572 | stoppedCwd := sess.cwd | |
| 1547 | 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 | 1577 | ephemeral := sess.ephemeral | |
| 1550 | 1578 | unlockCoordinators() | |
| 1551 | 1579 | unlockSessions() | |
| 1552 | 1580 | d.notices.routingMu.Unlock() | |
| 1553 | 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 | 1583 | d.stopped[stoppedName] = stopped | |
| 1556 | 1584 | } | |
| 1557 | 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 | 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 | 88 | return stoppedSession{ | |
| 73 | 89 | name: record.Name, | |
| 74 | 90 | cwd: record.Cwd, | |
| 75 | 91 | createdAt: record.CreatedAt, | |
| 76 | 92 | incarnation: record.IncarnationID, | |
| 77 | 93 | lastUsedSeq: record.LastUsedSeq, | |
| 78 | - | tabNames: append([]string(nil), record.TabNames...), | |
| 94 | + | tabNames: tabNames, | |
| 95 | + | tabRecords: tabRecords, | |
| 79 | 96 | record: record, | |
| 80 | 97 | state: state, | |
| 81 | 98 | restoreDone: done, | |
M internal/usecase/picker/model.go +88 -25
| @@ -35,10 +35,14 @@ type SessionView struct { | |||
| 35 | 35 | Stopped bool | |
| 36 | 36 | ExpectedCreatedAt *int64 | |
| 37 | 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 | 41 | // RemoteHost marks remote host status rows that have no session key. | |
| 39 | 42 | RemoteHost string | |
| 40 | 43 | RemoteAvailability RemoteAvailability | |
| 41 | 44 | RemoteDetail string | |
| 45 | + | RemoteReason string | |
| 42 | 46 | ConnectOnly bool | |
| 43 | 47 | RemoteAttachReady bool | |
| 44 | 48 | // CannotAcceptMoves reports whether this session cannot receive a moved tab | |
| @@ -51,6 +55,7 @@ type SessionView struct { | |||
| 51 | 55 | type TabEntry struct { | |
| 52 | 56 | TabID domain.TabStableID // stable tab identity; independent of its current index | |
| 53 | 57 | Name string // tab display name | |
| 58 | + | RawName string // unformatted name used by exact remote selectors | |
| 54 | 59 | Detail string // " (paneTitle)" or "", drawn muted | |
| 55 | 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 | 117 | type Target struct { | |
| 113 | - | Session domain.SessionID | |
| 114 | - | Incarnation domain.IncarnationID | |
| 115 | - | Name string | |
| 116 | - | RemoteKey *domain.RemoteSessionKey | |
| 117 | - | TabID domain.TabStableID | |
| 118 | - | TabIndex int | |
| 119 | - | Stopped bool | |
| 118 | + | Session domain.SessionID | |
| 119 | + | Incarnation domain.IncarnationID | |
| 120 | + | Name string | |
| 121 | + | RemoteKey *domain.RemoteSessionKey | |
| 122 | + | RemoteTarget *domain.RemoteSessionTarget | |
| 123 | + | RemoteHost string | |
| 124 | + | UnavailableReason string | |
| 125 | + | TabID domain.TabStableID | |
| 126 | + | TabIndex int | |
| 127 | + | Stopped bool | |
| 120 | 128 | // ExpectedCreatedAt optionally pins this target to a particular named | |
| 121 | 129 | // session lifecycle. Callers that obtain a snapshot outside the daemon use | |
| 122 | 130 | // it to reject a same-name replacement at commit time. | |
| @@ -187,7 +195,12 @@ type row struct { | |||
| 187 | 195 | remoteKey domain.RemoteSessionKey | |
| 188 | 196 | hasRemoteKey bool | |
| 189 | 197 | remote bool | |
| 198 | + | remoteTarget domain.RemoteSessionTarget | |
| 199 | + | hasRemoteTarget bool | |
| 200 | + | remoteHost string | |
| 201 | + | unavailableReason string | |
| 190 | 202 | selectable bool | |
| 203 | + | focusable bool | |
| 191 | 204 | dim bool | |
| 192 | 205 | } | |
| 193 | 206 | ||
| @@ -202,7 +215,7 @@ func New(sessions []SessionView, config SelectionConfig) *Model { | |||
| 202 | 215 | if selectionMatches(pickerRow, config.Current, config.Mode) { | |
| 203 | 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 | 219 | activeSelection = idx | |
| 207 | 220 | } | |
| 208 | 221 | } | |
| @@ -211,7 +224,7 @@ func New(sessions []SessionView, config SelectionConfig) *Model { | |||
| 211 | 224 | m.selected = activeSelection | |
| 212 | 225 | } | |
| 213 | 226 | if m.selected < 0 { | |
| 214 | - | m.selected = m.firstSelectable() | |
| 227 | + | m.selected = m.firstFocusable() | |
| 215 | 228 | } | |
| 216 | 229 | if m.selected < 0 && len(m.rows) > 0 { | |
| 217 | 230 | m.selected = 0 | |
| @@ -247,20 +260,38 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 247 | 260 | if session.RemoteKey != nil { | |
| 248 | 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 | 268 | header := common | |
| 252 | 269 | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | |
| 253 | 270 | header.selectable = header.kind.selectable(config.Mode) | |
| 271 | + | header.focusable = header.selectable | |
| 254 | 272 | if common.remote { | |
| 255 | 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 | 288 | header.dim = remoteDim(session) | |
| 258 | 289 | } | |
| 259 | 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 | 293 | rows := []row{header} | |
| 263 | - | if !session.ConnectOnly { | |
| 294 | + | if !session.ConnectOnly || common.hasRemoteTarget { | |
| 264 | 295 | for i, tab := range session.Tabs { | |
| 265 | 296 | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | |
| 266 | 297 | continue | |
| @@ -269,12 +300,39 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 269 | 300 | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | |
| 270 | 301 | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | |
| 271 | 302 | tabRow.selectable = tabRow.kind.selectable(config.Mode) | |
| 303 | + | tabRow.focusable = tabRow.selectable | |
| 272 | 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 | 332 | tabRow.dim = remoteDim(session) | |
| 275 | 333 | } | |
| 276 | 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 | 337 | rows = append(rows, tabRow) | |
| 280 | 338 | } | |
| @@ -286,7 +344,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 286 | 344 | } | |
| 287 | 345 | ||
| 288 | 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 | 350 | func remoteDim(session SessionView) bool { | |
| @@ -429,13 +487,13 @@ func (m *Model) SelectNearestRow(idx int) { | |||
| 429 | 487 | } | |
| 430 | 488 | idx = clamp(idx, 0, len(m.rows)-1) | |
| 431 | 489 | for i := idx; i < len(m.rows); i++ { | |
| 432 | - | if m.rows[i].selectable { | |
| 490 | + | if m.rows[i].focusable { | |
| 433 | 491 | m.selected = i | |
| 434 | 492 | return | |
| 435 | 493 | } | |
| 436 | 494 | } | |
| 437 | 495 | for i := idx - 1; i >= 0; i-- { | |
| 438 | - | if m.rows[i].selectable { | |
| 496 | + | if m.rows[i].focusable { | |
| 439 | 497 | m.selected = i | |
| 440 | 498 | return | |
| 441 | 499 | } | |
| @@ -474,21 +532,21 @@ func (m *Model) move(delta int) { | |||
| 474 | 532 | if m == nil || len(m.rows) == 0 { | |
| 475 | 533 | return | |
| 476 | 534 | } | |
| 477 | - | if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable { | |
| 478 | - | m.selected = m.firstSelectable() | |
| 535 | + | if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].focusable { | |
| 536 | + | m.selected = m.firstFocusable() | |
| 479 | 537 | return | |
| 480 | 538 | } | |
| 481 | 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 | 541 | m.selected = i | |
| 484 | 542 | return | |
| 485 | 543 | } | |
| 486 | 544 | } | |
| 487 | 545 | } | |
| 488 | 546 | ||
| 489 | - | func (m *Model) firstSelectable() int { | |
| 547 | + | func (m *Model) firstFocusable() int { | |
| 490 | 548 | for i, r := range m.rows { | |
| 491 | - | if r.selectable { | |
| 549 | + | if r.focusable { | |
| 492 | 550 | return i | |
| 493 | 551 | } | |
| 494 | 552 | } | |
| @@ -496,9 +554,14 @@ func (m *Model) firstSelectable() int { | |||
| 496 | 554 | } | |
| 497 | 555 | ||
| 498 | 556 | func (r row) target() Target { | |
| 557 | + | var remoteTarget *domain.RemoteSessionTarget | |
| 558 | + | if r.hasRemoteTarget { | |
| 559 | + | copyTarget := r.remoteTarget | |
| 560 | + | remoteTarget = ©Target | |
| 561 | + | } | |
| 499 | 562 | return Target{ | |
| 500 | - | Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), TabID: r.tabID, | |
| 501 | - | TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(), | |
| 563 | + | Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), RemoteTarget: remoteTarget, | |
| 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 "$@" | |