1package app
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "io"
10 "log/slog"
11 "net"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/bnema/vev/internal/adapters/lifecycle"
20 "github.com/bnema/vev/internal/domain"
21 "github.com/bnema/vev/internal/persist"
22 "github.com/bnema/vev/internal/ports"
23 portsmocks "github.com/bnema/vev/internal/ports/mocks"
24 "github.com/bnema/vev/internal/usecase/client"
25 "github.com/bnema/vev/internal/usecase/confirm"
26 "github.com/bnema/vev/internal/usecase/daemon"
27 "github.com/bnema/vev/pkg/kv"
28 "github.com/bnema/vev/pkg/safedir"
29 "github.com/stretchr/testify/mock"
30 "github.com/stretchr/testify/require"
31)
32
33func decodeJSONLogs(t *testing.T, data []byte) []map[string]any {
34 t.Helper()
35 var entries []map[string]any
36 scanner := bufio.NewScanner(bytes.NewReader(data))
37 for scanner.Scan() {
38 var entry map[string]any
39 require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
40 entries = append(entries, entry)
41 }
42 require.NoError(t, scanner.Err())
43 return entries
44}
45
46func eventNames(entries []map[string]any) []string {
47 names := make([]string, 0, len(entries))
48 for _, entry := range entries {
49 if name, ok := entry["msg"].(string); ok {
50 names = append(names, name)
51 }
52 }
53 return names
54}
55
56func requireEvent(t *testing.T, entries []map[string]any, name string) map[string]any {
57 t.Helper()
58 for _, entry := range entries {
59 if entry["msg"] == name {
60 return entry
61 }
62 }
63 t.Fatalf("event %q not found in %v", name, eventNames(entries))
64 return nil
65}
66
67func TestRecoveryObservability(t *testing.T) {
68 var logBuffer bytes.Buffer
69 log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
70 owner := fakeLifecycleOwnership{release: func() error { return nil }}
71 deps := lifecycleStartupDeps{
72 ensurePrivate: func(string) error { return nil },
73 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
74 return owner, nil
75 },
76 log: log,
77 }
78 ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
79 records := []domain.CatalogueRecord{
80 {Committed: ref},
81 {Committed: ref},
82 {},
83 {Committed: ref, DegradedReason: "checkpoint validation failed"},
84 }
85 require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
86 logCatalogueRecovery(log, records, "current")
87 logStartupRecoveryCounts(log, records, 0)
88 return nil
89 }, deps))
90
91 entries := decodeJSONLogs(t, logBuffer.Bytes())
92 for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
93 require.Contains(t, eventNames(entries), name)
94 }
95 startup := requireEvent(t, entries, "daemon_startup_complete")
96 require.EqualValues(t, 2, startup["healthy"])
97 require.EqualValues(t, 1, startup["fresh"])
98 require.EqualValues(t, 0, startup["restoring"])
99 require.EqualValues(t, 1, startup["broken"])
100 require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
101}
102
103// A session-scoped conflict no longer aborts startup, so the daemon comes up
104// looking healthy. Startup must therefore name the fenced session in the log,
105// persist a notice for it, and never report the recovery as clean.
106type fakeLifecycleOwnership struct {
107 release func() error
108}
109
110func (o fakeLifecycleOwnership) Release() error { return o.release() }
111
112type fakeLifecycleProbe struct {
113 owner lifecycleOwnership
114 err error
115}
116
117func (p fakeLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) {
118 return p.owner, p.err
119}
120
121func TestJoinLifecycleReleaseError(t *testing.T) {
122 operationErr := errors.New("operation failed")
123 releaseErr := errors.New("release failed")
124
125 tests := []struct {
126 name string
127 operationErr error
128 releaseErr error
129 wantErrors []error
130 }{
131 {
132 name: "successful operation preserves release failure",
133 releaseErr: releaseErr,
134 wantErrors: []error{releaseErr},
135 },
136 {
137 name: "operation and release failures are joined",
138 operationErr: operationErr,
139 releaseErr: releaseErr,
140 wantErrors: []error{operationErr, releaseErr},
141 },
142 }
143
144 for _, tt := range tests {
145 t.Run(tt.name, func(t *testing.T) {
146 got := tt.operationErr
147 joinLifecycleReleaseError(&got, fakeLifecycleOwnership{release: func() error {
148 return tt.releaseErr
149 }})
150
151 for _, wantErr := range tt.wantErrors {
152 require.ErrorIs(t, got, wantErr)
153 }
154 })
155 }
156}
157
158func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
159 releaseErr := errors.New("release failed")
160
161 tests := []struct {
162 name string
163 run func(context.Context) error
164 wantOperationErr string
165 }{
166 {
167 name: "list joins release failure to success",
168 run: runList,
169 },
170 {
171 name: "kill joins release failure to operation failure",
172 run: func(ctx context.Context) error {
173 return runKill(ctx, "", false, false)
174 },
175 wantOperationErr: "vev: no daemon running",
176 },
177 }
178
179 originalProbe := daemonLifecycleProbe
180 t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
181 for _, tt := range tests {
182 t.Run(tt.name, func(t *testing.T) {
183 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
184 t.Setenv("XDG_STATE_HOME", t.TempDir())
185 daemonLifecycleProbe = fakeLifecycleProbe{
186 owner: fakeLifecycleOwnership{release: func() error { return releaseErr }},
187 }
188
189 err := tt.run(context.Background())
190 require.ErrorIs(t, err, releaseErr)
191 if tt.wantOperationErr != "" {
192 require.ErrorContains(t, err, tt.wantOperationErr)
193 }
194 })
195 }
196}
197
198func TestCatalogueFailureDoesNotListen(t *testing.T) {
199 originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
200 t.Cleanup(func() {
201 openCatalogue = originalOpenCatalogue
202 listenDaemon = originalListenDaemon
203 })
204
205 for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
206 t.Run(catalogueErr.Error(), func(t *testing.T) {
207 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
208 runtimeDir := filepath.Join(runtimeRoot, "vev")
209 stateDir := filepath.Join(stateRoot, "vev")
210 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
211 t.Setenv("XDG_STATE_HOME", stateRoot)
212
213 listenCalls := 0
214 openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
215 require.Equal(t, stateDir, gotStateDir)
216 return persist.OpenResult{}, catalogueErr
217 }
218 listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
219 listenCalls++
220 return nil, errors.New("IPC listen must not run after catalogue failure")
221 }
222
223 err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
224 return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
225 })
226 require.ErrorIs(t, err, catalogueErr)
227 require.Zero(t, listenCalls)
228 require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
229
230 owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
231 require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
232 require.NoError(t, owner.Release())
233 })
234 }
235}
236
237func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
238 var events []string
239 _, _, err := constructDaemonBeforeSocketPublication(
240 func() *daemon.Daemon {
241 events = append(events, "catalogue-registry")
242 return nil
243 },
244 func(*daemon.Daemon) error {
245 require.Equal(t, []string{"catalogue-registry"}, events)
246 events = append(events, "startup-garbage-collection")
247 return nil
248 },
249 func() (ports.Listener, error) {
250 require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)
251 events = append(events, "socket-publication")
252 return nil, nil
253 },
254 )
255 require.NoError(t, err)
256 require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection", "socket-publication"}, events)
257}
258
259func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
260 t.Run("ownership prefix is exact", func(t *testing.T) {
261 var events []string
262 owner := fakeLifecycleOwnership{release: func() error {
263 events = append(events, "unlock")
264 return nil
265 }}
266 deps := lifecycleStartupDeps{
267 ensurePrivate: func(path string) error {
268 if path == "runtime" {
269 events = append(events, "ensure-runtime")
270 } else {
271 events = append(events, "ensure-state")
272 }
273 return nil
274 },
275 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
276 events = append(events, "lock")
277 return owner, nil
278 },
279 }
280
281 err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
282 events = append(events, "durable-open", "listen", "serve")
283 return nil
284 }, deps)
285 require.NoError(t, err)
286 require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock"}, events[:3])
287 require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock", "durable-open", "listen", "serve", "unlock"}, events)
288 })
289
290 t.Run("callback observes held lock and failure releases it", func(t *testing.T) {
291 runtimeDir := filepath.Join(t.TempDir(), "runtime")
292 stateDir := filepath.Join(t.TempDir(), "state")
293 startErr := errors.New("catalogue corrupt")
294
295 err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
296 _, lockErr := lifecycle.TryAcquire(runtimeDir)
297 require.ErrorIs(t, lockErr, lifecycle.ErrBusy)
298 return startErr
299 })
300 require.ErrorIs(t, err, startErr)
301
302 owner, err := lifecycle.TryAcquire(runtimeDir)
303 require.NoError(t, err, "callback failure must release lifecycle ownership")
304 require.NoError(t, owner.Release())
305 })
306
307 t.Run("busy and unavailable ownership fail before startup", func(t *testing.T) {
308 runtimeDir := filepath.Join(t.TempDir(), "runtime")
309 stateDir := filepath.Join(t.TempDir(), "state")
310 owner, err := lifecycle.TryAcquire(runtimeDir)
311 require.NoError(t, err)
312 defer func() { require.NoError(t, owner.Release()) }()
313
314 ctx, cancel := context.WithCancel(context.Background())
315 cancel()
316 started := false
317 err = runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(context.Context) error {
318 started = true
319 return nil
320 })
321 require.ErrorIs(t, err, context.Canceled)
322 require.False(t, started)
323
324 badState := filepath.Join(t.TempDir(), "state-file")
325 require.NoError(t, os.WriteFile(badState, []byte("unavailable"), 0o600))
326 err = runWithLifecycleOwner(context.Background(), filepath.Join(t.TempDir(), "other-runtime"), badState, func(context.Context) error {
327 started = true
328 return nil
329 })
330 require.Error(t, err)
331 require.False(t, started)
332 })
333
334 t.Run("corrupt catalogue fails before socket publication", func(t *testing.T) {
335 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
336 runtimeDir := filepath.Join(runtimeRoot, "vev")
337 stateDir := filepath.Join(stateRoot, "vev")
338 require.NoError(t, safedir.EnsurePrivate(stateDir))
339 store, err := kv.Open(persist.StorePath(stateDir))
340 require.NoError(t, err)
341 require.NoError(t, store.Set([]byte("work"), []byte("malformed catalogue value")))
342 require.NoError(t, store.Close())
343
344 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
345 t.Setenv("XDG_STATE_HOME", stateRoot)
346 before, err := os.ReadFile(persist.StorePath(stateDir))
347 require.NoError(t, err)
348 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
349 require.Error(t, err)
350 require.Contains(t, err.Error(), stateDir)
351 require.Contains(t, err.Error(), "rm -rf "+stateDir)
352 after, readErr := os.ReadFile(persist.StorePath(stateDir))
353 require.NoError(t, readErr)
354 require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
355 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
356 require.ErrorIs(t, statErr, os.ErrNotExist)
357 })
358
359 t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
360 runtimeRoot, err := os.MkdirTemp("/tmp", "vev-")
361 require.NoError(t, err)
362 t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
363 stateRoot := t.TempDir()
364 runtimeDir := filepath.Join(runtimeRoot, "vev")
365 stateDir := filepath.Join(stateRoot, "vev")
366 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
367 t.Setenv("XDG_STATE_HOME", stateRoot)
368 ctx, cancel := context.WithCancel(t.Context())
369 result := make(chan error, 1)
370 go func() {
371 result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
372 }()
373 t.Cleanup(func() {
374 cancel()
375 select {
376 case err := <-result:
377 require.NoError(t, err)
378 case <-time.After(5 * time.Second):
379 t.Error("daemon lifecycle did not stop after cancellation")
380 }
381 })
382
383 require.Eventually(t, func() bool {
384 _, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
385 return err == nil
386 }, 5*time.Second, time.Millisecond, "daemon socket was not published")
387 require.FileExists(t, persist.StorePath(stateDir))
388 })
389
390 t.Run("callback and release errors are joined", func(t *testing.T) {
391 startErr := errors.New("catalogue unavailable")
392 releaseErr := errors.New("unlock failed")
393 deps := lifecycleStartupDeps{
394 ensurePrivate: func(string) error { return nil },
395 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
396 return fakeLifecycleOwnership{release: func() error { return releaseErr }}, nil
397 },
398 }
399
400 err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
401 return startErr
402 }, deps)
403 require.ErrorIs(t, err, startErr)
404 require.ErrorIs(t, err, releaseErr)
405 })
406}
407
408// scriptRecv makes a transport yield frames in order, then wait for done
409// before returning EOF. The shared done channel coordinates proxy readers
410// without relying on scheduling or sleeps.
411func scriptRecv(tr *portsmocks.MockTransport, done <-chan struct{}, frames ...ports.Frame) *portsmocks.MockTransport_Recv_Call {
412 script := make(chan ports.Frame, len(frames))
413 for _, frame := range frames {
414 script <- frame
415 }
416 return tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
417 select {
418 case frame := <-script:
419 return frame, nil
420 case <-done:
421 return ports.Frame{}, io.EOF
422 }
423 })
424}
425
426func TestFirstHelloTransportReplacesRemoteEnvironment(t *testing.T) {
427 for _, tt := range []struct {
428 name string
429 session string
430 wantName string
431 }{
432 {name: "stdio replaces environment", wantName: ""},
433 {name: "udp proxy replaces environment and retains session rewrite", session: "work", wantName: "work"},
434 } {
435 t.Run(tt.name, func(t *testing.T) {
436 original := ports.Hello{
437 Version: ports.ProtocolVersion,
438 Intent: ports.IntentAttach,
439 ClientID: [16]byte{1},
440 ResumeToken: 2,
441 Size: domain.Size{Cols: 80, Rows: 24},
442 TermEnv: "client-term",
443 Cwd: "/client",
444 TrueColor: true,
445 MaxOutputInFlight: 8,
446 Env: []string{"CLIENT=value"},
447 }
448 nonHello := ports.Frame{Type: ports.MsgInput, Payload: []byte("unchanged")}
449 transport := portsmocks.NewMockTransport(t)
450 done := make(chan struct{})
451 scriptRecv(transport, done,
452 ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(original)},
453 nonHello,
454 ).Times(2)
455 wrapped := newFirstHelloTransport(transport, tt.session, []string{"REMOTE=one", "TOKEN=a=b=c"})
456
457 frame, err := wrapped.Recv()
458 require.NoError(t, err)
459 got, err := ports.UnmarshalHello(frame.Payload)
460 require.NoError(t, err)
461 original.Name = tt.wantName
462 original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
463 require.Equal(t, original, got)
464
465 frame, err = wrapped.Recv()
466 require.NoError(t, err)
467 require.Equal(t, nonHello, frame)
468 })
469 }
470}
471
472func TestRunStdioProxyReplacesHelloEnvironmentAtDaemonBoundary(t *testing.T) {
473 original := remoteProxyHello()
474 client, daemon, sent := newRemoteProxyTransports(t, original)
475
476 err := runStdioProxy(context.Background(), client, daemon, []string{"REMOTE=one", "TOKEN=a=b=c"}, nil)
477 require.NoError(t, err)
478
479 got := firstHello(t, sent)
480 original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
481 require.Equal(t, original, got)
482}
483
484func TestRunUDPProxyRuntimeReplacesHelloEnvironmentAndSessionAtDaemonBoundary(t *testing.T) {
485 original := remoteProxyHello()
486 client, daemon, sent := newRemoteProxyTransports(t, original)
487
488 err := runUDPProxyRuntime(context.Background(), "remote-work", client, daemon, []string{"REMOTE=udp", "TOKEN=a=b=c"}, nil)
489 require.NoError(t, err)
490
491 got := firstHello(t, sent)
492 original.Name = "remote-work"
493 original.Env = []string{"REMOTE=udp", "TOKEN=a=b=c"}
494 require.Equal(t, original, got)
495}
496
497func remoteProxyHello() ports.Hello {
498 return ports.Hello{
499 Version: ports.ProtocolVersion,
500 Intent: ports.IntentAttach,
501 ClientID: [16]byte{1},
502 ResumeToken: 2,
503 Size: domain.Size{Cols: 80, Rows: 24},
504 TermEnv: "client-term",
505 Cwd: "/client",
506 TrueColor: true,
507 MaxOutputInFlight: 1,
508 Env: []string{"CLIENT=value"},
509 }
510}
511
512func newRemoteProxyTransports(t *testing.T, hello ports.Hello) (*portsmocks.MockTransport, *portsmocks.MockTransport, <-chan ports.Frame) {
513 t.Helper()
514 client := portsmocks.NewMockTransport(t)
515 daemon := portsmocks.NewMockTransport(t)
516 clientDone := make(chan struct{})
517 daemonDone := make(chan struct{})
518 sent := make(chan ports.Frame, 1)
519
520 scriptRecv(client, clientDone, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}).Maybe()
521 client.EXPECT().Close().RunAndReturn(func() error {
522 close(clientDone)
523 return nil
524 }).Once()
525 daemon.EXPECT().Send(mock.Anything).RunAndReturn(func(frame ports.Frame) error {
526 sent <- frame
527 close(daemonDone)
528 return nil
529 }).Once()
530 scriptRecv(daemon, daemonDone).Once()
531 daemon.EXPECT().Close().Return(nil).Once()
532
533 return client, daemon, sent
534}
535
536func firstHello(t *testing.T, sent <-chan ports.Frame) ports.Hello {
537 t.Helper()
538 frame := <-sent
539 hello, err := ports.UnmarshalHello(frame.Payload)
540 require.NoError(t, err)
541 return hello
542}
543
544func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
545 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
546}
547
548func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
549 require.Positive(t, udpProxyIdleTTL)
550 require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
551}
552
553func TestParseArgs(t *testing.T) {
554 tests := []struct {
555 name string
556 args []string
557 wantKind cmdKind
558 wantIntent uint8
559 wantName string
560 wantRemote string
561 wantAll bool
562 wantDaemon bool
563 wantErr bool
564 }{
565 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
566 {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
567 {name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: ports.IntentNew, wantName: "work"},
568 {name: "new without name", args: []string{"new"}, wantErr: true},
569 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
570 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
571 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
572 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
573 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
574 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
575 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
576 {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
577 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
578 {name: "attach without name", args: []string{"attach"}, wantErr: true},
579 {name: "ls", args: []string{"ls"}, wantKind: kindList},
580 {name: "list", args: []string{"list"}, wantKind: kindList},
581 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
582 {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
583 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
584 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
585 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
586 {name: "kill without name", args: []string{"kill"}, wantErr: true},
587 {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
588 {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
589 {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
590 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
591 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
592 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
593 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
594 {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
595 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
596 {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
597 {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
598 {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
599 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
600 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
601 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
602 {name: "unknown", args: []string{"frobnicate"}, wantErr: true},
603 }
604
605 for _, tt := range tests {
606 t.Run(tt.name, func(t *testing.T) {
607 got, err := parseArgs(tt.args)
608 if tt.wantErr {
609 if err == nil {
610 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
611 }
612 var ue *usageError
613 if !errors.As(err, &ue) {
614 t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
615 }
616 return
617 }
618 if err != nil {
619 t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err)
620 }
621 if got.kind != tt.wantKind {
622 t.Errorf("kind = %v, want %v", got.kind, tt.wantKind)
623 }
624 if got.kind == kindAttach && got.intent != tt.wantIntent {
625 t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent)
626 }
627 if got.name != tt.wantName {
628 t.Errorf("name = %q, want %q", got.name, tt.wantName)
629 }
630 if got.remoteTarget != tt.wantRemote {
631 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
632 }
633 if got.killAll != tt.wantAll {
634 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
635 }
636 if got.killDaemon != tt.wantDaemon {
637 t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
638 }
639 })
640 }
641}
642
643func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
644 _, err := parseArgs([]string{"new", "my work"})
645 if !errors.Is(err, domain.ErrInvalidSessionName) {
646 t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
647 }
648}
649
650func TestListShowsBroken(t *testing.T) {
651 var out bytes.Buffer
652 printSessions(&out, []ports.SessionInfo{
653 {Name: "fresh", State: ports.SessionStopped},
654 {Name: "loading", State: ports.SessionStopped},
655 {Name: "broken", State: ports.SessionBroken},
656 })
657 require.Contains(t, out.String(), "fresh")
658 require.Contains(t, out.String(), "stopped")
659 require.Contains(t, out.String(), "loading")
660 require.Contains(t, out.String(), "broken")
661 require.NotContains(t, out.String(), "restoring")
662 require.NotContains(t, out.String(), "degraded")
663}
664
665func TestPrintSessionsShowsStoppedState(t *testing.T) {
666 var out bytes.Buffer
667 printSessions(&out, []ports.SessionInfo{
668 {Name: "main", State: ports.SessionRunning, Tabs: 2, Attached: true},
669 {Name: "old", State: ports.SessionStopped},
670 })
671 got := out.String()
672 for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
673 if !strings.Contains(got, want) {
674 t.Fatalf("printSessions output %q missing %q", got, want)
675 }
676 }
677}
678
679func TestPrintSessionsMarksEphemeral(t *testing.T) {
680 var buf bytes.Buffer
681 printSessions(&buf, []ports.SessionInfo{
682 {Name: "0", State: ports.SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
683 {Name: "work", State: ports.SessionRunning, Tabs: 2, Attached: true},
684 {Name: "old", State: ports.SessionStopped},
685 })
686 out := buf.String()
687 for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
688 require.Contains(t, out, want)
689 }
690}
691
692func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
693 t.Helper()
694 store, err := persist.OpenStore(persist.StorePath(stateDir))
695 require.NoError(t, err)
696 return persist.New(store)
697}
698
699func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
700 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
701 t.Setenv("XDG_STATE_HOME", stateRoot)
702 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
703
704 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
705 now := time.Now().UnixNano()
706 if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
707 t.Fatalf("Save error = %v", err)
708 }
709 if err := p.Close(); err != nil {
710 t.Fatalf("Close error = %v", err)
711 }
712
713 got := captureStdout(t, func() {
714 if err := runList(context.Background()); err != nil {
715 t.Fatalf("runList error = %v", err)
716 }
717 })
718 for _, want := range []string{"stored", "stopped", "-"} {
719 if !strings.Contains(got, want) {
720 t.Fatalf("runList output %q missing %q", got, want)
721 }
722 }
723}
724
725func TestRunListUnreadableCatalogueProvidesResetGuidance(t *testing.T) {
726 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
727 stateDir := filepath.Join(stateRoot, "vev")
728 runtimeDir := filepath.Join(runtimeRoot, "vev")
729 t.Setenv("XDG_STATE_HOME", stateRoot)
730 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
731 require.NoError(t, safedir.EnsurePrivate(stateDir))
732
733 catalogue := persist.StorePath(stateDir)
734 before := []byte("corrupt catalogue")
735 require.NoError(t, os.WriteFile(catalogue, before, 0o600))
736
737 var runErr error
738 stdout := captureStdout(t, func() { runErr = Run([]string{"ls"}) })
739 require.Empty(t, stdout)
740 require.Error(t, runErr)
741 require.ErrorContains(t, runErr, stateDir)
742 require.ErrorContains(t, runErr, "rm -rf "+stateDir)
743 require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
744 after, err := os.ReadFile(catalogue)
745 require.NoError(t, err)
746 require.Equal(t, before, after)
747}
748
749func TestRunKillMissingStoppedSessionDoesNotCreateStore(t *testing.T) {
750 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
751 t.Setenv("XDG_STATE_HOME", stateRoot)
752 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
753
754 err := runKill(context.Background(), "missing", false, false)
755 if err == nil || !strings.Contains(err.Error(), "no such session: missing") {
756 t.Fatalf("runKill error = %v, want no such session", err)
757 }
758 if _, statErr := os.Stat(filepath.Join(stateRoot, "vev", "sessions.kv")); !errors.Is(statErr, os.ErrNotExist) {
759 t.Fatalf("sessions.kv stat error = %v, want not exist", statErr)
760 }
761}
762
763func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
764 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
765 t.Setenv("XDG_STATE_HOME", stateRoot)
766 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
767
768 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
769 now := time.Now().UnixNano()
770 if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
771 t.Fatalf("Save error = %v", err)
772 }
773 if err := p.Close(); err != nil {
774 t.Fatalf("Close error = %v", err)
775 }
776 got := captureStdout(t, func() {
777 if err := runKill(context.Background(), "stored", false, false); err != nil {
778 t.Fatalf("runKill error = %v", err)
779 }
780 })
781 if !strings.Contains(got, "killed stored") {
782 t.Fatalf("runKill output = %q, want success", got)
783 }
784 records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
785 if err != nil {
786 t.Fatalf("LoadReadOnly error = %v", err)
787 }
788 if len(records) != 0 {
789 t.Fatalf("records after kill = %#v, want none", records)
790 }
791}
792
793func captureStdout(t *testing.T, fn func()) string {
794 t.Helper()
795 old := os.Stdout
796 r, w, err := os.Pipe()
797 if err != nil {
798 t.Fatalf("os.Pipe error = %v", err)
799 }
800 os.Stdout = w
801 defer func() { os.Stdout = old }()
802 defer func() { _ = r.Close() }()
803 outCh := make(chan []byte, 1)
804 errCh := make(chan error, 1)
805 go func() {
806 out, err := io.ReadAll(r)
807 outCh <- out
808 errCh <- err
809 }()
810
811 fn()
812 _ = w.Close()
813 out := <-outCh
814 if err := <-errCh; err != nil {
815 t.Fatalf("ReadAll stdout error = %v", err)
816 }
817 return string(out)
818}
819
820func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
821 var prompts bytes.Buffer
822 answers := strings.NewReader("y\n")
823 attachCalls := 0
824 killCalls := 0
825
826 err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
827 confirmer: confirm.NewConfirmer(answers, &prompts),
828 attach: func(context.Context, uint8, string) error {
829 attachCalls++
830 if attachCalls == 1 {
831 return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
832 }
833 return nil
834 },
835 killDaemon: func(context.Context) error {
836 killCalls++
837 return nil
838 },
839 })
840 if err != nil {
841 t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
842 }
843 if attachCalls != 2 {
844 t.Fatalf("attach calls = %d, want 2", attachCalls)
845 }
846 if killCalls != 1 {
847 t.Fatalf("kill calls = %d, want 1", killCalls)
848 }
849 if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") {
850 t.Fatalf("prompt = %q, want version/kill prompt", got)
851 }
852}
853
854func TestRunLocalAttachPropagatesPromptError(t *testing.T) {
855 readErr := errors.New("read failed")
856 err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
857 confirmer: confirm.NewConfirmer(errorReader{err: readErr}, &bytes.Buffer{}),
858 attach: func(context.Context, uint8, string) error {
859 return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
860 },
861 killDaemon: func(context.Context) error {
862 t.Fatal("killDaemon should not be called after prompt error")
863 return nil
864 },
865 })
866 if !errors.Is(err, readErr) {
867 t.Fatalf("error = %v, want wrapped %v", err, readErr)
868 }
869}
870
871func TestRunLocalAttachPropagatesKillError(t *testing.T) {
872 killErr := errors.New("kill failed")
873 err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
874 confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
875 attach: func(context.Context, uint8, string) error {
876 return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
877 },
878 killDaemon: func(context.Context) error { return killErr },
879 })
880 if !errors.Is(err, killErr) {
881 t.Fatalf("error = %v, want %v", err, killErr)
882 }
883}
884
885func TestRunLocalAttachSettlesBeforeRetry(t *testing.T) {
886 var order []string
887 err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
888 confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
889 attach: func(context.Context, uint8, string) error {
890 order = append(order, "attach")
891 if len(order) == 1 {
892 return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
893 }
894 return nil
895 },
896 killDaemon: func(context.Context) error {
897 order = append(order, "kill")
898 return nil
899 },
900 settleAfterKill: func(context.Context) error {
901 order = append(order, "settle")
902 return nil
903 },
904 })
905 if err != nil {
906 t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
907 }
908 if got, want := strings.Join(order, ","), "attach,kill,settle,attach"; got != want {
909 t.Fatalf("order = %s, want %s", got, want)
910 }
911}
912
913type errorReader struct{ err error }
914
915func (r errorReader) Read([]byte) (int, error) { return 0, r.err }
916
917func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
918 answers := strings.NewReader("n\n")
919 wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"}
920 attachCalls := 0
921 killCalls := 0
922
923 err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
924 confirmer: confirm.NewConfirmer(answers, &bytes.Buffer{}),
925 attach: func(context.Context, uint8, string) error {
926 attachCalls++
927 return wantErr
928 },
929 killDaemon: func(context.Context) error {
930 killCalls++
931 return nil
932 },
933 })
934 if !errors.Is(err, wantErr) {
935 t.Fatalf("error = %v, want original %v", err, wantErr)
936 }
937 if attachCalls != 1 {
938 t.Fatalf("attach calls = %d, want 1", attachCalls)
939 }
940 if killCalls != 0 {
941 t.Fatalf("kill calls = %d, want 0", killCalls)
942 }
943}
944
945func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
946 called := false
947 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
948 createDetached: func(context.Context, string) error {
949 called = true
950 return nil
951 },
952 })
953 if err == nil {
954 t.Fatal("runAttach with VEV set returned nil error")
955 }
956 if called {
957 t.Fatal("runAttach dialed while nested attach should be rejected")
958 }
959 if !strings.Contains(err.Error(), "sessions should be nested with care") {
960 t.Fatalf("runAttach error = %q, want nested-session warning", err)
961 }
962}
963
964func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
965 var gotName string
966 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
967 createDetached: func(_ context.Context, name string) error {
968 gotName = name
969 return nil
970 },
971 })
972 if err != nil {
973 t.Fatalf("runAttachWithDeps returned error: %v", err)
974 }
975 if gotName != "scratch" {
976 t.Fatalf("detached name = %q, want scratch", gotName)
977 }
978}
979
980func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
981 t.Setenv("TERM", "xterm-direct")
982 t.Setenv("COLORTERM", "")
983
984 hello := detachedLocalHello("scratch", "/tmp/work")
985
986 require.Equal(t, ports.IntentNew, hello.Intent)
987 require.Equal(t, "scratch", hello.Name)
988 require.Equal(t, "xterm-direct", hello.TermEnv)
989 require.Equal(t, "/tmp/work", hello.Cwd)
990 require.True(t, hello.TrueColor)
991 require.Equal(t, os.Environ(), hello.Env)
992}
993
994type namedDialer struct{ name string }
995
996func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
997 return nil, errors.New("not used")
998}
999
1000// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
1001// verify identity (that runAttachWithDeps threads the *same* reader through),
1002// never actually invoked in these wiring tests.
1003type fakeClipboardReader struct{ ports.ClipboardReader }
1004
1005func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1006 tests := []struct {
1007 name string
1008 selectedTransport string
1009 wantMode ports.RemoteTransportMode
1010 }{
1011 {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
1012 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
1013 }
1014
1015 for _, tt := range tests {
1016 t.Run(tt.name, func(t *testing.T) {
1017 var gotDialer string
1018 var gotRemote bool
1019 var gotClipboard ports.ClipboardReader
1020 clip := &fakeClipboardReader{}
1021 factory := portsmocks.NewMockRemoteDialerFactory(t)
1022 factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
1023
1024 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1025 remoteDialerFactory: factory,
1026 selectedRemoteTransport: tt.selectedTransport,
1027 clipboard: clip,
1028 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
1029 nd, ok := deps.Dialer.(namedDialer)
1030 if !ok {
1031 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1032 }
1033 gotDialer = nd.name
1034 gotRemote = request.Remote
1035 gotClipboard = deps.Clipboard
1036 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
1037 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
1038 }
1039 return nil
1040 },
1041 })
1042 if err != nil {
1043 t.Fatalf("runAttachWithDeps returned error: %v", err)
1044 }
1045 if gotDialer != "remote" {
1046 t.Fatalf("remote dialer = %q, want remote", gotDialer)
1047 }
1048 if !gotRemote {
1049 t.Fatal("remote attach must pass remote=true to runClient")
1050 }
1051 if gotClipboard != ports.ClipboardReader(clip) {
1052 t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
1053 }
1054 })
1055 }
1056}
1057
1058func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1059 factory := portsmocks.NewMockRemoteDialerFactory(t)
1060 runClientCalled := false
1061
1062 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1063 remoteDialerFactory: factory,
1064 selectedRemoteTransport: "serial",
1065 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1066 runClientCalled = true
1067 return nil
1068 },
1069 })
1070 if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
1071 t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
1072 }
1073 if runClientCalled {
1074 t.Fatal("runClient called after invalid remote transport")
1075 }
1076}
1077
1078func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
1079 factoryErr := errors.New("factory failed")
1080 factory := portsmocks.NewMockRemoteDialerFactory(t)
1081 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
1082 runClientCalled := false
1083
1084 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1085 remoteDialerFactory: factory,
1086 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1087 runClientCalled = true
1088 return nil
1089 },
1090 })
1091 if !errors.Is(err, factoryErr) {
1092 t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
1093 }
1094 if runClientCalled {
1095 t.Fatal("runClient called after remote factory error")
1096 }
1097}
1098
1099func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1100 var gotDialer string
1101 gotRemote := true
1102 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
1103 factory := portsmocks.NewMockRemoteDialerFactory(t)
1104 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
1105 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
1106 remoteDialerFactory: factory,
1107 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
1108 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
1109 nd, ok := deps.Dialer.(namedDialer)
1110 if !ok {
1111 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1112 }
1113 gotDialer = nd.name
1114 gotRemote = request.Remote
1115 gotClipboard = deps.Clipboard
1116 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
1117 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
1118 }
1119 return nil
1120 },
1121 })
1122 if err != nil {
1123 t.Fatalf("runAttachWithDeps returned error: %v", err)
1124 }
1125 if gotDialer != "local" {
1126 t.Fatalf("local dialer = %q, want local", gotDialer)
1127 }
1128 if gotRemote {
1129 t.Fatal("local attach must pass remote=false to runClient")
1130 }
1131 if gotClipboard != nil {
1132 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
1133 }
1134}
1135
1136func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
1137 oldTimeout := udpBootstrapTimeout
1138 udpBootstrapTimeout = time.Second
1139 t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1140 oldCommand := udpProxyCommand
1141 var gotCmd *exec.Cmd
1142 udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1143 cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
1144 gotCmd = cmd
1145 return cmd
1146 }
1147 t.Cleanup(func() { udpProxyCommand = oldCommand })
1148
1149 got := captureStdout(t, func() {
1150 if err := runUDPBootstrap(context.Background(), "work"); err != nil {
1151 t.Fatalf("runUDPBootstrap() error = %v", err)
1152 }
1153 })
1154 if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
1155 t.Fatalf("readiness = %q", got)
1156 }
1157 if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
1158 t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
1159 }
1160 if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
1161 t.Fatalf("proxy inherited SSH stdio handles")
1162 }
1163}
1164
1165func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
1166 oldTimeout := udpBootstrapTimeout
1167 udpBootstrapTimeout = time.Second
1168 t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1169 oldCommand := udpProxyCommand
1170 udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1171 return exec.Command("/bin/sh", "-c", "exit 7")
1172 }
1173 t.Cleanup(func() { udpProxyCommand = oldCommand })
1174
1175 err := runUDPBootstrap(context.Background(), "work")
1176 if !errors.Is(err, io.EOF) {
1177 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
1178 }
1179}
1180
1181func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
1182 oldTimeout := udpBootstrapTimeout
1183 udpBootstrapTimeout = 10 * time.Millisecond
1184 t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1185 oldCommand := udpProxyCommand
1186 udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1187 return exec.Command("/bin/sh", "-c", "sleep 5")
1188 }
1189 t.Cleanup(func() { udpProxyCommand = oldCommand })
1190
1191 err := runUDPBootstrap(context.Background(), "work")
1192 if !errors.Is(err, context.DeadlineExceeded) {
1193 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
1194 }
1195}
1196
1197func TestParseUDPPortRange(t *testing.T) {
1198 tests := []struct {
1199 name string
1200 value string
1201 want udpPortRange
1202 wantError bool
1203 }{
1204 {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
1205 {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
1206 {name: "zero range rejected", value: "0-100", wantError: true},
1207 {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
1208 {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
1209 {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
1210 {name: "reversed range", value: "61023-61000", wantError: true},
1211 {name: "non-numeric", value: "abc", wantError: true},
1212 {name: "missing end", value: "61000-", wantError: true},
1213 {name: "port too high", value: "70000", wantError: true},
1214 }
1215 for _, tt := range tests {
1216 t.Run(tt.name, func(t *testing.T) {
1217 got, err := parseUDPPortRange(tt.value)
1218 if tt.wantError {
1219 if err == nil {
1220 t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
1221 }
1222 return
1223 }
1224 if err != nil {
1225 t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
1226 }
1227 if got != tt.want {
1228 t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
1229 }
1230 })
1231 }
1232}
1233
1234func TestListenUDPInRange(t *testing.T) {
1235 // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
1236 var lc net.ListenConfig
1237 occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
1238 if err != nil {
1239 t.Fatal(err)
1240 }
1241 defer func() { _ = occupied.Close() }()
1242 udpPort := func(t *testing.T, conn net.PacketConn) int {
1243 t.Helper()
1244 addr, ok := conn.LocalAddr().(*net.UDPAddr)
1245 if !ok {
1246 t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
1247 }
1248 return addr.Port
1249 }
1250 busyPort := udpPort(t, occupied)
1251
1252 t.Run("skips busy port", func(t *testing.T) {
1253 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
1254 if err != nil {
1255 t.Fatalf("listenUDPInRange error = %v", err)
1256 }
1257 defer func() { _ = conn.Close() }()
1258 got := udpPort(t, conn)
1259 if got == busyPort {
1260 t.Fatalf("bound busy port %d, want a different port in range", busyPort)
1261 }
1262 if got < busyPort || got > busyPort+8 {
1263 t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
1264 }
1265 })
1266
1267 t.Run("exhausted range errors", func(t *testing.T) {
1268 _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
1269 if err == nil {
1270 t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
1271 }
1272 if !strings.Contains(err.Error(), "no free UDP port in range") {
1273 t.Fatalf("error = %v, want 'no free UDP port in range'", err)
1274 }
1275 })
1276
1277 t.Run("invalid direct range errors", func(t *testing.T) {
1278 _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
1279 if err == nil {
1280 t.Fatal("listenUDPInRange error = nil, want invalid-range error")
1281 }
1282 if !strings.Contains(err.Error(), "invalid UDP port range") {
1283 t.Fatalf("error = %v, want 'invalid UDP port range'", err)
1284 }
1285 })
1286
1287 t.Run("ephemeral binds", func(t *testing.T) {
1288 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
1289 if err != nil {
1290 t.Fatalf("listenUDPInRange ephemeral error = %v", err)
1291 }
1292 defer func() { _ = conn.Close() }()
1293 if udpPort(t, conn) == 0 {
1294 t.Fatal("ephemeral bind returned port 0")
1295 }
1296 })
1297}
1298
1299func TestPprofAddrIsLoopback(t *testing.T) {
1300 tests := []struct {
1301 addr string
1302 want bool
1303 }{
1304 {addr: "127.0.0.1:6060", want: true},
1305 {addr: "localhost:6060", want: true},
1306 {addr: "[::1]:6060", want: true},
1307 {addr: ":6060", want: false},
1308 {addr: "0.0.0.0:6060", want: false},
1309 {addr: "192.168.1.5:6060", want: false},
1310 {addr: "garbage", want: false},
1311 }
1312
1313 for _, tt := range tests {
1314 t.Run(tt.addr, func(t *testing.T) {
1315 if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
1316 t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
1317 }
1318 })
1319 }
1320}