feat(attach): prompt to create missing session on attach (#275)
* feat(attach): prompt to create missing session on attach * feat(attach): make missing-session prompt cancellation-aware * feat(attach): scope prompt probe and require exact missing-session text * feat(attach): check session existence before attach attempt * feat(attach): bound the preflight session listing * feat(attach): propagate cancellation into the preflight exchange * feat(attach): clarify the create prompt also attaches * test(attach): use generated dialer mocks and isolate lifecycle probe * refactor(attach): simplify preflight prompt and listing seams * refactor(attach): isolate preflight in its own file * refactor(attach): collapse preflight intent plumbing * refactor(attach): replace hand fakes with generated mocks * refactor(attach): own cancellable confirm in confirm usecase * refactor(attach): merge prompt chain, extract bounded exchange * refactor(attach): prompt through the terminal port * refactor(attach): probe real files for TTY, stub the rest * refactor(attach): inject the console probe for the create prompt Probing the terminal type inside the preflight made the non-console branch reachable only from tests, where a canned-answer reader never is an *os.File. Production reads os.Stdin through term.New, so the branch only existed to keep the prompt matrix alive. Move the decision behind runAttachDeps.interactiveConsole: production probes the client terminal's input file, fail-closed, and tests inject the answer instead of relying on a test-only branch. The prompt matrix now drives every case through the same seam, the preflight-skip and unavailable-preflight cases inject an interactive console so their no-prompt assertions stay meaningful, and TestTerminalIsInteractive covers the probe's non-console paths. * test(app): use the generated clipboard mock in attach wiring tests The attach wiring tests only needed a distinguishable ports.ClipboardReader to prove runAttachWithDeps threads the same reader through. A generated portsmocks.MockClipboardReader expresses that without a hand-written fake that embeds the port interface. * fix(attach): flush the create prompt before reading the answer Terminal output is buffered, so the missing-session question stayed in the 64 KiB writer while the preflight blocked on stdin: users saw a blank console, and the question only surfaced once the client repainted. A PTY run reproduced it against the previous build. Write the question through a flushing writer that pushes each prompt write to the console before the confirmer blocks, and treat an unusable console as a non-interactive one: the preflight keeps the plain attach path rather than reading an answer nobody was asked for. Cancellation still surfaces. Coverage: a terminal-adapter test asserts the question reaches the console before the answer, and a matrix covers write/flush failures with a blocked input stream so a prompt that reads it can only return on context expiry. * test(attach): cover the console probe on a real PTY and the observed terminal The probe's true branch was untested: only a kernel-reported terminal file can reach it, so the suite now opens a real PTY pair through rawterm.PreparePty and asserts the adapter's stdin counts as a console. The flush regression test now runs against both the direct terminal and the observedTerminal wrapper used by --ui-observe, whose embedding must keep the question visible end to end.
- commit
- 0dec724dcc9ffc0d13bcb868ef96385fe9724fc0
- author
- bnema · · 2026-09-10 15:33 UTC
- committer
- GitHub · · 2026-09-10 15:33 UTC
- parent
- db3b9a5
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
6 files · +742 -17
A internal/app/attach_preflight.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "os" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/confirm" | |
| 13 | + | "github.com/bnema/vev/pkg/rawterm" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const maxAttachTargetHandoffs = 32 | |
| 17 | + | ||
| 18 | + | // resolveMissingSessionAttach decides the attach intent for a direct local | |
| 19 | + | // attach before any client attempt runs. When the session exists, or when | |
| 20 | + | // existence cannot be determined, it keeps IntentAttach and the daemon | |
| 21 | + | // rejection (if any) surfaces unchanged. When the session is absent it | |
| 22 | + | // offers creation on an interactive console and returns IntentNew on | |
| 23 | + | // confirmation; decline keeps IntentAttach, and a cancelled wait surfaces | |
| 24 | + | // the cancellation error. | |
| 25 | + | // | |
| 26 | + | // The check runs before the client owns any console reader, so the create | |
| 27 | + | // prompt is always the sole stdin consumer: no failed attempt can leave a | |
| 28 | + | // blocked input-pump read behind to steal the answer. Provenance comes from | |
| 29 | + | // the listing itself, so no error-text matching is needed and in-client | |
| 30 | + | // navigation handoffs can never trigger creation of the requested session. | |
| 31 | + | func resolveMissingSessionAttach(ctx context.Context, name string, deps runAttachDeps, log *slog.Logger) (uint8, error) { | |
| 32 | + | switch exists, err := sessionExists(ctx, name, deps); { | |
| 33 | + | case err != nil: | |
| 34 | + | if log != nil { | |
| 35 | + | log.Debug("missing-session preflight unavailable; proceeding with attach", "err", err) | |
| 36 | + | } | |
| 37 | + | case !exists: | |
| 38 | + | return confirmMissingSessionCreate(ctx, name, deps) | |
| 39 | + | } | |
| 40 | + | return protocol.IntentAttach, nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // terminalIsInteractive reports whether the prompt can read its answer from | |
| 44 | + | // a console. The client terminal's input has to be a real terminal file: | |
| 45 | + | // pipes, redirected files, and detached readers are not consoles, so the | |
| 46 | + | // prompt never blocks on a stream nobody reads from the keyboard. | |
| 47 | + | func terminalIsInteractive(terminal ports.Terminal) bool { | |
| 48 | + | if terminal == nil { | |
| 49 | + | return false | |
| 50 | + | } | |
| 51 | + | file, ok := terminal.In().(*os.File) | |
| 52 | + | return ok && rawterm.IsTerminal(int(file.Fd())) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // consoleProbe returns the console probe used by the create prompt. | |
| 56 | + | // Production probes the client terminal's input; tests inject the decision | |
| 57 | + | // so the prompt matrix runs without owning a real TTY. | |
| 58 | + | func consoleProbe(deps runAttachDeps) func(ports.Terminal) bool { | |
| 59 | + | if deps.interactiveConsole != nil { | |
| 60 | + | return deps.interactiveConsole | |
| 61 | + | } | |
| 62 | + | return terminalIsInteractive | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // flushingWriter pushes every prompt write to the console before the | |
| 66 | + | // confirmer blocks on the answer. Terminal output is buffered, so without | |
| 67 | + | // this flush the user waits in front of a blank console until the client | |
| 68 | + | // repaints. | |
| 69 | + | type flushingWriter struct { | |
| 70 | + | out io.Writer | |
| 71 | + | flush func() error | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (w flushingWriter) Write(data []byte) (int, error) { | |
| 75 | + | written, err := w.out.Write(data) | |
| 76 | + | if err != nil { | |
| 77 | + | return written, err | |
| 78 | + | } | |
| 79 | + | if err := w.flush(); err != nil { | |
| 80 | + | return written, err | |
| 81 | + | } | |
| 82 | + | return written, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // confirmMissingSessionCreate prompts to create an absent session and maps | |
| 86 | + | // the answer to the attach intent: IntentNew on confirmation, IntentAttach | |
| 87 | + | // on decline. A cancelled wait surfaces the cancellation error. | |
| 88 | + | // Non-interactive consoles, a declined answer, or unreadable input keep | |
| 89 | + | // IntentAttach. Only y/yes answers confirm; empty, no, and unknown answers | |
| 90 | + | // decline. | |
| 91 | + | func confirmMissingSessionCreate(ctx context.Context, name string, deps runAttachDeps) (uint8, error) { | |
| 92 | + | if name == "" || ctx.Err() != nil { | |
| 93 | + | return protocol.IntentAttach, nil | |
| 94 | + | } | |
| 95 | + | terminal := clientTerminal(deps) | |
| 96 | + | if !consoleProbe(deps)(terminal) { | |
| 97 | + | return protocol.IntentAttach, nil | |
| 98 | + | } | |
| 99 | + | in, out := terminal.In(), terminal.Out() | |
| 100 | + | if in == nil || out == nil { | |
| 101 | + | return protocol.IntentAttach, nil | |
| 102 | + | } | |
| 103 | + | prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush}) | |
| 104 | + | create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name)) | |
| 105 | + | if err != nil { | |
| 106 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 107 | + | return protocol.IntentAttach, ctxErr | |
| 108 | + | } | |
| 109 | + | // A console that cannot show the question must not consume an | |
| 110 | + | // answer: keep the plain attach path and let the daemon decide. | |
| 111 | + | return protocol.IntentAttach, nil | |
| 112 | + | } | |
| 113 | + | if !create { | |
| 114 | + | return protocol.IntentAttach, nil | |
| 115 | + | } | |
| 116 | + | if err := ctx.Err(); err != nil { | |
| 117 | + | return protocol.IntentAttach, err | |
| 118 | + | } | |
| 119 | + | return protocol.IntentNew, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // sessionExists reports whether a directly attached local session exists. | |
| 123 | + | // An error means existence could not be determined. | |
| 124 | + | func sessionExists(ctx context.Context, name string, deps runAttachDeps) (bool, error) { | |
| 125 | + | if name == "" { | |
| 126 | + | return false, nil | |
| 127 | + | } | |
| 128 | + | dial := deps.localDialer | |
| 129 | + | if dial == nil { | |
| 130 | + | dial = defaultLocalDialer | |
| 131 | + | } | |
| 132 | + | sessions, err := listSessionsWithDialer(ctx, dial().Dial) | |
| 133 | + | if err != nil { | |
| 134 | + | return false, err | |
| 135 | + | } | |
| 136 | + | for _, session := range sessions { | |
| 137 | + | if session.Name == name { | |
| 138 | + | return true, nil | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | return false, nil | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "os" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/confirm" | |||
| 13 | + | "github.com/bnema/vev/pkg/rawterm" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const maxAttachTargetHandoffs = 32 | |||
| 17 | + | ||||
| 18 | + | // resolveMissingSessionAttach decides the attach intent for a direct local | |||
| 19 | + | // attach before any client attempt runs. When the session exists, or when | |||
| 20 | + | // existence cannot be determined, it keeps IntentAttach and the daemon | |||
| 21 | + | // rejection (if any) surfaces unchanged. When the session is absent it | |||
| 22 | + | // offers creation on an interactive console and returns IntentNew on | |||
| 23 | + | // confirmation; decline keeps IntentAttach, and a cancelled wait surfaces | |||
| 24 | + | // the cancellation error. | |||
| 25 | + | // | |||
| 26 | + | // The check runs before the client owns any console reader, so the create | |||
| 27 | + | // prompt is always the sole stdin consumer: no failed attempt can leave a | |||
| 28 | + | // blocked input-pump read behind to steal the answer. Provenance comes from | |||
| 29 | + | // the listing itself, so no error-text matching is needed and in-client | |||
| 30 | + | // navigation handoffs can never trigger creation of the requested session. | |||
| 31 | + | func resolveMissingSessionAttach(ctx context.Context, name string, deps runAttachDeps, log *slog.Logger) (uint8, error) { | |||
| 32 | + | switch exists, err := sessionExists(ctx, name, deps); { | |||
| 33 | + | case err != nil: | |||
| 34 | + | if log != nil { | |||
| 35 | + | log.Debug("missing-session preflight unavailable; proceeding with attach", "err", err) | |||
| 36 | + | } | |||
| 37 | + | case !exists: | |||
| 38 | + | return confirmMissingSessionCreate(ctx, name, deps) | |||
| 39 | + | } | |||
| 40 | + | return protocol.IntentAttach, nil | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // terminalIsInteractive reports whether the prompt can read its answer from | |||
| 44 | + | // a console. The client terminal's input has to be a real terminal file: | |||
| 45 | + | // pipes, redirected files, and detached readers are not consoles, so the | |||
| 46 | + | // prompt never blocks on a stream nobody reads from the keyboard. | |||
| 47 | + | func terminalIsInteractive(terminal ports.Terminal) bool { | |||
| 48 | + | if terminal == nil { | |||
| 49 | + | return false | |||
| 50 | + | } | |||
| 51 | + | file, ok := terminal.In().(*os.File) | |||
| 52 | + | return ok && rawterm.IsTerminal(int(file.Fd())) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // consoleProbe returns the console probe used by the create prompt. | |||
| 56 | + | // Production probes the client terminal's input; tests inject the decision | |||
| 57 | + | // so the prompt matrix runs without owning a real TTY. | |||
| 58 | + | func consoleProbe(deps runAttachDeps) func(ports.Terminal) bool { | |||
| 59 | + | if deps.interactiveConsole != nil { | |||
| 60 | + | return deps.interactiveConsole | |||
| 61 | + | } | |||
| 62 | + | return terminalIsInteractive | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // flushingWriter pushes every prompt write to the console before the | |||
| 66 | + | // confirmer blocks on the answer. Terminal output is buffered, so without | |||
| 67 | + | // this flush the user waits in front of a blank console until the client | |||
| 68 | + | // repaints. | |||
| 69 | + | type flushingWriter struct { | |||
| 70 | + | out io.Writer | |||
| 71 | + | flush func() error | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (w flushingWriter) Write(data []byte) (int, error) { | |||
| 75 | + | written, err := w.out.Write(data) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return written, err | |||
| 78 | + | } | |||
| 79 | + | if err := w.flush(); err != nil { | |||
| 80 | + | return written, err | |||
| 81 | + | } | |||
| 82 | + | return written, nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // confirmMissingSessionCreate prompts to create an absent session and maps | |||
| 86 | + | // the answer to the attach intent: IntentNew on confirmation, IntentAttach | |||
| 87 | + | // on decline. A cancelled wait surfaces the cancellation error. | |||
| 88 | + | // Non-interactive consoles, a declined answer, or unreadable input keep | |||
| 89 | + | // IntentAttach. Only y/yes answers confirm; empty, no, and unknown answers | |||
| 90 | + | // decline. | |||
| 91 | + | func confirmMissingSessionCreate(ctx context.Context, name string, deps runAttachDeps) (uint8, error) { | |||
| 92 | + | if name == "" || ctx.Err() != nil { | |||
| 93 | + | return protocol.IntentAttach, nil | |||
| 94 | + | } | |||
| 95 | + | terminal := clientTerminal(deps) | |||
| 96 | + | if !consoleProbe(deps)(terminal) { | |||
| 97 | + | return protocol.IntentAttach, nil | |||
| 98 | + | } | |||
| 99 | + | in, out := terminal.In(), terminal.Out() | |||
| 100 | + | if in == nil || out == nil { | |||
| 101 | + | return protocol.IntentAttach, nil | |||
| 102 | + | } | |||
| 103 | + | prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush}) | |||
| 104 | + | create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name)) | |||
| 105 | + | if err != nil { | |||
| 106 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 107 | + | return protocol.IntentAttach, ctxErr | |||
| 108 | + | } | |||
| 109 | + | // A console that cannot show the question must not consume an | |||
| 110 | + | // answer: keep the plain attach path and let the daemon decide. | |||
| 111 | + | return protocol.IntentAttach, nil | |||
| 112 | + | } | |||
| 113 | + | if !create { | |||
| 114 | + | return protocol.IntentAttach, nil | |||
| 115 | + | } | |||
| 116 | + | if err := ctx.Err(); err != nil { | |||
| 117 | + | return protocol.IntentAttach, err | |||
| 118 | + | } | |||
| 119 | + | return protocol.IntentNew, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // sessionExists reports whether a directly attached local session exists. | |||
| 123 | + | // An error means existence could not be determined. | |||
| 124 | + | func sessionExists(ctx context.Context, name string, deps runAttachDeps) (bool, error) { | |||
| 125 | + | if name == "" { | |||
| 126 | + | return false, nil | |||
| 127 | + | } | |||
| 128 | + | dial := deps.localDialer | |||
| 129 | + | if dial == nil { | |||
| 130 | + | dial = defaultLocalDialer | |||
| 131 | + | } | |||
| 132 | + | sessions, err := listSessionsWithDialer(ctx, dial().Dial) | |||
| 133 | + | if err != nil { | |||
| 134 | + | return false, err | |||
| 135 | + | } | |||
| 136 | + | for _, session := range sessions { | |||
| 137 | + | if session.Name == name { | |||
| 138 | + | return true, nil | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | return false, nil | |||
| 142 | + | } | |||
A internal/app/missing_session_preflight_test.go +446 -0
| @@ -0,0 +1,446 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "syscall" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 17 | + | "github.com/bnema/vev/internal/ports" | |
| 18 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | + | "github.com/bnema/vev/internal/protocol" | |
| 20 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 21 | + | wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks" | |
| 22 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 23 | + | "github.com/bnema/vev/pkg/rawterm" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // sessionListDialer serves a canned session listing over generated mocks. | |
| 27 | + | func sessionListDialer(t *testing.T, sessions []protocol.SessionInfo) func() wire.Dialer { | |
| 28 | + | t.Helper() | |
| 29 | + | transport := wiremocks.NewMockTransport(t) | |
| 30 | + | transport.EXPECT().Send(mock.Anything).RunAndReturn(func(frame wire.Frame) error { | |
| 31 | + | require.Equal(t, wire.MsgList, frame.Type) | |
| 32 | + | return nil | |
| 33 | + | }).Once() | |
| 34 | + | transport.EXPECT().Recv().Return(wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(protocol.Sessions{Sessions: sessions})}, nil).Once() | |
| 35 | + | transport.EXPECT().Close().Return(nil) | |
| 36 | + | dialer := wiremocks.NewMockDialer(t) | |
| 37 | + | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |
| 38 | + | return func() wire.Dialer { return dialer } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // failingSessionListDialer fails the listing dial so the preflight falls | |
| 42 | + | // back to a plain attach. | |
| 43 | + | func failingSessionListDialer(t *testing.T, dialErr error) func() wire.Dialer { | |
| 44 | + | t.Helper() | |
| 45 | + | dialer := wiremocks.NewMockDialer(t) | |
| 46 | + | dialer.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Maybe() | |
| 47 | + | return func() wire.Dialer { return dialer } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // terminalStub serves canned prompt I/O through a generated terminal mock. | |
| 51 | + | func terminalStub(t *testing.T, in io.Reader, out io.Writer) func() ports.Terminal { | |
| 52 | + | t.Helper() | |
| 53 | + | terminal := portsmocks.NewMockTerminal(t) | |
| 54 | + | terminal.EXPECT().In().Return(in).Maybe() | |
| 55 | + | terminal.EXPECT().Out().Return(out).Maybe() | |
| 56 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |
| 57 | + | return func() ports.Terminal { return terminal } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // interactiveProbe injects the console decision, standing in for the | |
| 61 | + | // production probe so the prompt matrix runs without owning a real TTY. | |
| 62 | + | func interactiveProbe(interactive bool) func(ports.Terminal) bool { | |
| 63 | + | return func(ports.Terminal) bool { return interactive } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestRunAttachWithDepsMissingSessionCreatePrompt(t *testing.T) { | |
| 67 | + | tests := []struct { | |
| 68 | + | name string | |
| 69 | + | sessions []protocol.SessionInfo | |
| 70 | + | answer string | |
| 71 | + | notInteractive bool | |
| 72 | + | wantIntent uint8 | |
| 73 | + | wantPromptPart string | |
| 74 | + | wantNoPrompt bool | |
| 75 | + | }{ | |
| 76 | + | {name: "missing confirm creates", sessions: nil, answer: "y\n", wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "scratch" doesn't exist, want to create and attach to it? [y/N]`}, | |
| 77 | + | {name: "missing decline attaches", sessions: nil, answer: "n\n", wantIntent: protocol.IntentAttach, wantPromptPart: "scratch"}, | |
| 78 | + | {name: "missing empty answer attaches", sessions: nil, answer: "\n", wantIntent: protocol.IntentAttach}, | |
| 79 | + | {name: "missing unknown answer attaches", sessions: nil, answer: "later\n", wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"}, | |
| 80 | + | {name: "present attaches without prompt", sessions: []protocol.SessionInfo{{Name: "scratch"}}, answer: "y\n", wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |
| 81 | + | {name: "non-interactive console attaches without prompt", sessions: nil, answer: "y\n", notInteractive: true, wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |
| 82 | + | } | |
| 83 | + | for _, tt := range tests { | |
| 84 | + | t.Run(tt.name, func(t *testing.T) { | |
| 85 | + | var intents []uint8 | |
| 86 | + | var promptOut strings.Builder | |
| 87 | + | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 88 | + | localDialer: sessionListDialer(t, tt.sessions), | |
| 89 | + | terminal: terminalStub(t, strings.NewReader(tt.answer), &promptOut), | |
| 90 | + | interactiveConsole: interactiveProbe(!tt.notInteractive), | |
| 91 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 92 | + | intents = append(intents, request.Intent) | |
| 93 | + | return nil | |
| 94 | + | }, | |
| 95 | + | }) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | require.Equal(t, []uint8{tt.wantIntent}, intents, "preflight must select the attach intent before any client attempt") | |
| 98 | + | if tt.wantPromptPart != "" { | |
| 99 | + | require.Contains(t, promptOut.String(), tt.wantPromptPart) | |
| 100 | + | } | |
| 101 | + | if tt.wantNoPrompt { | |
| 102 | + | require.Empty(t, promptOut.String()) | |
| 103 | + | } | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // TestTerminalIsInteractive covers the production probe on both sides of the | |
| 109 | + | // console check: a real terminal file and the streams that are not consoles. | |
| 110 | + | func TestTerminalIsInteractive(t *testing.T) { | |
| 111 | + | require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt") | |
| 112 | + | pty := openPtySlave(t) | |
| 113 | + | require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console") | |
| 114 | + | ||
| 115 | + | console, writer, err := os.Pipe() | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | t.Cleanup(func() { | |
| 118 | + | _ = console.Close() | |
| 119 | + | _ = writer.Close() | |
| 120 | + | }) | |
| 121 | + | ||
| 122 | + | for _, tt := range []struct { | |
| 123 | + | name string | |
| 124 | + | in io.Reader | |
| 125 | + | }{ | |
| 126 | + | {name: "detached reader", in: strings.NewReader("y\n")}, | |
| 127 | + | {name: "non-terminal file", in: console}, | |
| 128 | + | } { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | require.False(t, terminalIsInteractive(terminalStub(t, tt.in, io.Discard)())) | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // openPtySlave returns the slave side of a real PTY pair: the only way to | |
| 136 | + | // hand the probe an *os.File the kernel reports as a terminal. | |
| 137 | + | func openPtySlave(t *testing.T) *os.File { | |
| 138 | + | t.Helper() | |
| 139 | + | master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |
| 140 | + | if err != nil { | |
| 141 | + | t.Skipf("open /dev/ptmx: %v", err) | |
| 142 | + | } | |
| 143 | + | t.Cleanup(func() { _ = master.Close() }) | |
| 144 | + | slave, err := rawterm.PreparePty(int(master.Fd())) | |
| 145 | + | require.NoError(t, err, "prepare pty") | |
| 146 | + | t.Cleanup(func() { _ = slave.Close() }) | |
| 147 | + | return slave | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // TestRunAttachWithDepsMissingSessionPromptFlushesQuestion drives the real | |
| 151 | + | // terminal adapter, whose output is buffered: the preflight has to flush the | |
| 152 | + | // question before it blocks on the answer, or the user waits in front of a | |
| 153 | + | // blank console and the question only shows up after the daemon replies. The | |
| 154 | + | // observed wrapper used by `--ui-observe` embeds the port, so it has to keep | |
| 155 | + | // flushing end to end. | |
| 156 | + | func TestRunAttachWithDepsMissingSessionPromptFlushesQuestion(t *testing.T) { | |
| 157 | + | for _, tt := range []struct { | |
| 158 | + | name string | |
| 159 | + | wrap func(ports.Terminal) ports.Terminal | |
| 160 | + | }{ | |
| 161 | + | {name: "direct terminal", wrap: func(terminal ports.Terminal) ports.Terminal { return terminal }}, | |
| 162 | + | {name: "observed terminal", wrap: func(terminal ports.Terminal) ports.Terminal { | |
| 163 | + | return observedTerminal{Terminal: terminal} | |
| 164 | + | }}, | |
| 165 | + | } { | |
| 166 | + | t.Run(tt.name, func(t *testing.T) { | |
| 167 | + | consoleIn, answers, err := os.Pipe() | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | questions, consoleOut, err := os.Pipe() | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | t.Cleanup(func() { | |
| 172 | + | _ = consoleIn.Close() | |
| 173 | + | _ = answers.Close() | |
| 174 | + | _ = questions.Close() | |
| 175 | + | _ = consoleOut.Close() | |
| 176 | + | }) | |
| 177 | + | ||
| 178 | + | terminal := tt.wrap(term.NewWithFiles(consoleIn, consoleOut)) | |
| 179 | + | var intents []uint8 | |
| 180 | + | done := make(chan error, 1) | |
| 181 | + | go func() { | |
| 182 | + | done <- runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 183 | + | localDialer: sessionListDialer(t, nil), | |
| 184 | + | terminal: func() ports.Terminal { return terminal }, | |
| 185 | + | interactiveConsole: interactiveProbe(true), | |
| 186 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 187 | + | intents = append(intents, request.Intent) | |
| 188 | + | return nil | |
| 189 | + | }, | |
| 190 | + | }) | |
| 191 | + | }() | |
| 192 | + | ||
| 193 | + | question := readPrompt(t, questions) | |
| 194 | + | require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the preflight reads an answer") | |
| 195 | + | ||
| 196 | + | _, err = answers.WriteString("y\n") | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | select { | |
| 199 | + | case err := <-done: | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | case <-time.After(5 * time.Second): | |
| 202 | + | t.Fatal("the preflight did not release after the answer") | |
| 203 | + | } | |
| 204 | + | require.Equal(t, []uint8{protocol.IntentNew}, intents) | |
| 205 | + | }) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // readPrompt returns the console output that ends the create question, | |
| 210 | + | // failing the test instead of hanging when nothing arrives. | |
| 211 | + | func readPrompt(t *testing.T, console *os.File) string { | |
| 212 | + | t.Helper() | |
| 213 | + | require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second))) | |
| 214 | + | var output []byte | |
| 215 | + | frame := make([]byte, 64) | |
| 216 | + | for { | |
| 217 | + | read, err := console.Read(frame) | |
| 218 | + | output = append(output, frame[:read]...) | |
| 219 | + | if strings.Contains(string(output), "[y/N] ") { | |
| 220 | + | return string(output) | |
| 221 | + | } | |
| 222 | + | if err != nil { | |
| 223 | + | t.Fatalf("preflight prompt never reached the console (read %q: %v)", output, err) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // TestRunAttachWithDepsMissingSessionPromptFailureAttaches covers a console | |
| 229 | + | // that cannot show the question: the preflight must keep the plain attach | |
| 230 | + | // path instead of consuming an answer nobody was asked for. The input blocks | |
| 231 | + | // forever, so a prompt that reads it would only return on context expiry. | |
| 232 | + | func TestRunAttachWithDepsMissingSessionPromptFailureAttaches(t *testing.T) { | |
| 233 | + | closedOut, err := os.CreateTemp(t.TempDir(), "vev-console") | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | require.NoError(t, closedOut.Close()) | |
| 236 | + | ||
| 237 | + | for _, tt := range []struct { | |
| 238 | + | name string | |
| 239 | + | out io.Writer | |
| 240 | + | flushErr error | |
| 241 | + | }{ | |
| 242 | + | {name: "write fails", out: closedOut}, | |
| 243 | + | {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")}, | |
| 244 | + | } { | |
| 245 | + | t.Run(tt.name, func(t *testing.T) { | |
| 246 | + | consoleIn, consoleOut, err := os.Pipe() | |
| 247 | + | require.NoError(t, err) | |
| 248 | + | t.Cleanup(func() { | |
| 249 | + | _ = consoleIn.Close() | |
| 250 | + | _ = consoleOut.Close() | |
| 251 | + | }) | |
| 252 | + | terminal := portsmocks.NewMockTerminal(t) | |
| 253 | + | terminal.EXPECT().In().Return(consoleIn).Maybe() | |
| 254 | + | terminal.EXPECT().Out().Return(tt.out).Maybe() | |
| 255 | + | terminal.EXPECT().Flush().Return(tt.flushErr).Maybe() | |
| 256 | + | ||
| 257 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 258 | + | defer cancel() | |
| 259 | + | var intents []uint8 | |
| 260 | + | err = runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 261 | + | localDialer: sessionListDialer(t, nil), | |
| 262 | + | terminal: func() ports.Terminal { return terminal }, | |
| 263 | + | interactiveConsole: interactiveProbe(true), | |
| 264 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 265 | + | intents = append(intents, request.Intent) | |
| 266 | + | return nil | |
| 267 | + | }, | |
| 268 | + | }) | |
| 269 | + | require.NoError(t, err, "an unusable console must not fail the attach") | |
| 270 | + | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |
| 271 | + | }) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestRunAttachWithDepsMissingSessionPreflightSkipsNonAttach(t *testing.T) { | |
| 276 | + | for _, tt := range []struct { | |
| 277 | + | name string | |
| 278 | + | intent uint8 | |
| 279 | + | remote string | |
| 280 | + | }{ | |
| 281 | + | {name: "create", intent: protocol.IntentNew}, | |
| 282 | + | {name: "remote", intent: protocol.IntentAttach, remote: "remote.example"}, | |
| 283 | + | {name: "ephemeral", intent: protocol.IntentEphemeral}, | |
| 284 | + | } { | |
| 285 | + | t.Run(tt.name, func(t *testing.T) { | |
| 286 | + | var intents []uint8 | |
| 287 | + | var promptOut strings.Builder | |
| 288 | + | session := "" | |
| 289 | + | if tt.intent != protocol.IntentEphemeral { | |
| 290 | + | session = "scratch" | |
| 291 | + | } | |
| 292 | + | err := runAttachWithDeps(context.Background(), tt.intent, session, tt.remote, "", nil, runAttachDeps{ | |
| 293 | + | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |
| 294 | + | interactiveConsole: interactiveProbe(true), | |
| 295 | + | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 296 | + | intents = append(intents, request.Intent) | |
| 297 | + | if tt.remote != "" { | |
| 298 | + | require.True(t, deps.Remote) | |
| 299 | + | } | |
| 300 | + | return nil | |
| 301 | + | }, | |
| 302 | + | }) | |
| 303 | + | require.NoError(t, err) | |
| 304 | + | require.Equal(t, []uint8{tt.intent}, intents) | |
| 305 | + | require.Empty(t, promptOut.String(), "preflight must not run outside direct local attach") | |
| 306 | + | }) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func TestRunAttachWithDepsMissingSessionPreflightUnavailableAttaches(t *testing.T) { | |
| 311 | + | for _, tt := range []struct { | |
| 312 | + | name string | |
| 313 | + | dialErr error | |
| 314 | + | cancel bool | |
| 315 | + | }{ | |
| 316 | + | {name: "dial fails", dialErr: errors.New("socket unreachable")}, | |
| 317 | + | {name: "context cancelled", dialErr: context.Canceled, cancel: true}, | |
| 318 | + | } { | |
| 319 | + | t.Run(tt.name, func(t *testing.T) { | |
| 320 | + | stubLifecycleUnavailable(t) | |
| 321 | + | ctx := context.Background() | |
| 322 | + | if tt.cancel { | |
| 323 | + | var cancel context.CancelFunc | |
| 324 | + | ctx, cancel = context.WithCancel(ctx) | |
| 325 | + | cancel() | |
| 326 | + | } | |
| 327 | + | var promptOut strings.Builder | |
| 328 | + | var intents []uint8 | |
| 329 | + | err := runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 330 | + | localDialer: failingSessionListDialer(t, tt.dialErr), | |
| 331 | + | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |
| 332 | + | interactiveConsole: interactiveProbe(true), | |
| 333 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 334 | + | intents = append(intents, request.Intent) | |
| 335 | + | return nil | |
| 336 | + | }, | |
| 337 | + | }) | |
| 338 | + | require.NoError(t, err) | |
| 339 | + | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |
| 340 | + | require.Empty(t, promptOut.String(), "an undeterminable preflight must not prompt; the daemon rejection decides") | |
| 341 | + | }) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // stubLifecycleUnavailable fails lifecycle acquisition so the preflight | |
| 346 | + | // cannot fall back to the on-disk catalogue, independent of the machine's | |
| 347 | + | // real daemon state. | |
| 348 | + | func stubLifecycleUnavailable(t *testing.T) { | |
| 349 | + | t.Helper() | |
| 350 | + | originalProbe := daemonLifecycleProbe | |
| 351 | + | t.Cleanup(func() { daemonLifecycleProbe = originalProbe }) | |
| 352 | + | daemonLifecycleProbe = fakeLifecycleProbe{err: errors.New("lifecycle unavailable")} | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // stalledListTransport parks the list exchange inside one operation until | |
| 356 | + | // closed, letting tests exercise the preflight bound mid-flight. entered | |
| 357 | + | // fires when the exchange reaches the parked operation. | |
| 358 | + | func stalledListTransport(t *testing.T, blockOnSend bool) (*wiremocks.MockTransport, chan struct{}, chan struct{}) { | |
| 359 | + | t.Helper() | |
| 360 | + | entered := make(chan struct{}) | |
| 361 | + | closed := make(chan struct{}) | |
| 362 | + | transport := wiremocks.NewMockTransport(t) | |
| 363 | + | block := func() { | |
| 364 | + | close(entered) | |
| 365 | + | <-closed | |
| 366 | + | } | |
| 367 | + | if blockOnSend { | |
| 368 | + | transport.EXPECT().Send(mock.Anything).Run(func(wire.Frame) { block() }).Return(errors.New("transport closed")).Once() | |
| 369 | + | } else { | |
| 370 | + | transport.EXPECT().Send(mock.Anything).Return(nil).Once() | |
| 371 | + | transport.EXPECT().Recv().Run(func() { block() }).Return(wire.Frame{}, errors.New("transport closed")).Once() | |
| 372 | + | } | |
| 373 | + | transport.EXPECT().Close().Run(func() { | |
| 374 | + | select { | |
| 375 | + | case <-closed: | |
| 376 | + | default: | |
| 377 | + | close(closed) | |
| 378 | + | } | |
| 379 | + | }).Return(nil) | |
| 380 | + | return transport, entered, closed | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestListSessionsTimesOutOnSilentDaemon(t *testing.T) { | |
| 384 | + | oldTimeout := preflightListTimeout | |
| 385 | + | preflightListTimeout = 50 * time.Millisecond | |
| 386 | + | defer func() { preflightListTimeout = oldTimeout }() | |
| 387 | + | ||
| 388 | + | transport, _, closed := stalledListTransport(t, false) | |
| 389 | + | started := time.Now() | |
| 390 | + | _, err := listSessionsWithDialer(context.Background(), func(context.Context) (wire.Transport, error) { | |
| 391 | + | return transport, nil | |
| 392 | + | }) | |
| 393 | + | elapsed := time.Since(started) | |
| 394 | + | require.Error(t, err) | |
| 395 | + | require.Less(t, elapsed, 5*time.Second, "a silent daemon must not stall the preflight past its bound") | |
| 396 | + | select { | |
| 397 | + | case <-closed: | |
| 398 | + | default: | |
| 399 | + | t.Fatal("preflight timeout must close the exchange transport") | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func TestListSessionsHonorsCancellation(t *testing.T) { | |
| 404 | + | for _, tt := range []struct { | |
| 405 | + | name string | |
| 406 | + | blockOnSend bool | |
| 407 | + | }{ | |
| 408 | + | {name: "blocked send", blockOnSend: true}, | |
| 409 | + | {name: "blocked recv", blockOnSend: false}, | |
| 410 | + | } { | |
| 411 | + | t.Run(tt.name, func(t *testing.T) { | |
| 412 | + | transport, entered, closed := stalledListTransport(t, tt.blockOnSend) | |
| 413 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 414 | + | defer cancel() | |
| 415 | + | done := make(chan error, 1) | |
| 416 | + | go func() { | |
| 417 | + | _, err := listSessionsWithDialer(ctx, func(context.Context) (wire.Transport, error) { | |
| 418 | + | return transport, nil | |
| 419 | + | }) | |
| 420 | + | done <- err | |
| 421 | + | }() | |
| 422 | + | // Wait until the exchange is blocked inside the transport | |
| 423 | + | // before cancelling, so the test exercises in-flight | |
| 424 | + | // cancellation rather than the earlier dial-path check. | |
| 425 | + | select { | |
| 426 | + | case <-entered: | |
| 427 | + | case <-time.After(5 * time.Second): | |
| 428 | + | t.Fatal("exchange did not reach the blocked transport") | |
| 429 | + | } | |
| 430 | + | started := time.Now() | |
| 431 | + | cancel() | |
| 432 | + | select { | |
| 433 | + | case err := <-done: | |
| 434 | + | require.ErrorIs(t, err, context.Canceled) | |
| 435 | + | require.Less(t, time.Since(started), preflightListTimeout, "in-flight cancellation must beat the list bound") | |
| 436 | + | case <-time.After(5 * time.Second): | |
| 437 | + | t.Fatal("preflight did not release on context cancellation") | |
| 438 | + | } | |
| 439 | + | select { | |
| 440 | + | case <-closed: | |
| 441 | + | default: | |
| 442 | + | t.Fatal("cancellation must close the exchange transport") | |
| 443 | + | } | |
| 444 | + | }) | |
| 445 | + | } | |
| 446 | + | } | |
| @@ -0,0 +1,446 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | "syscall" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/adapters/term" | |||
| 17 | + | "github.com/bnema/vev/internal/ports" | |||
| 18 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 19 | + | "github.com/bnema/vev/internal/protocol" | |||
| 20 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 21 | + | wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks" | |||
| 22 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 23 | + | "github.com/bnema/vev/pkg/rawterm" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // sessionListDialer serves a canned session listing over generated mocks. | |||
| 27 | + | func sessionListDialer(t *testing.T, sessions []protocol.SessionInfo) func() wire.Dialer { | |||
| 28 | + | t.Helper() | |||
| 29 | + | transport := wiremocks.NewMockTransport(t) | |||
| 30 | + | transport.EXPECT().Send(mock.Anything).RunAndReturn(func(frame wire.Frame) error { | |||
| 31 | + | require.Equal(t, wire.MsgList, frame.Type) | |||
| 32 | + | return nil | |||
| 33 | + | }).Once() | |||
| 34 | + | transport.EXPECT().Recv().Return(wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(protocol.Sessions{Sessions: sessions})}, nil).Once() | |||
| 35 | + | transport.EXPECT().Close().Return(nil) | |||
| 36 | + | dialer := wiremocks.NewMockDialer(t) | |||
| 37 | + | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |||
| 38 | + | return func() wire.Dialer { return dialer } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // failingSessionListDialer fails the listing dial so the preflight falls | |||
| 42 | + | // back to a plain attach. | |||
| 43 | + | func failingSessionListDialer(t *testing.T, dialErr error) func() wire.Dialer { | |||
| 44 | + | t.Helper() | |||
| 45 | + | dialer := wiremocks.NewMockDialer(t) | |||
| 46 | + | dialer.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Maybe() | |||
| 47 | + | return func() wire.Dialer { return dialer } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // terminalStub serves canned prompt I/O through a generated terminal mock. | |||
| 51 | + | func terminalStub(t *testing.T, in io.Reader, out io.Writer) func() ports.Terminal { | |||
| 52 | + | t.Helper() | |||
| 53 | + | terminal := portsmocks.NewMockTerminal(t) | |||
| 54 | + | terminal.EXPECT().In().Return(in).Maybe() | |||
| 55 | + | terminal.EXPECT().Out().Return(out).Maybe() | |||
| 56 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |||
| 57 | + | return func() ports.Terminal { return terminal } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // interactiveProbe injects the console decision, standing in for the | |||
| 61 | + | // production probe so the prompt matrix runs without owning a real TTY. | |||
| 62 | + | func interactiveProbe(interactive bool) func(ports.Terminal) bool { | |||
| 63 | + | return func(ports.Terminal) bool { return interactive } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestRunAttachWithDepsMissingSessionCreatePrompt(t *testing.T) { | |||
| 67 | + | tests := []struct { | |||
| 68 | + | name string | |||
| 69 | + | sessions []protocol.SessionInfo | |||
| 70 | + | answer string | |||
| 71 | + | notInteractive bool | |||
| 72 | + | wantIntent uint8 | |||
| 73 | + | wantPromptPart string | |||
| 74 | + | wantNoPrompt bool | |||
| 75 | + | }{ | |||
| 76 | + | {name: "missing confirm creates", sessions: nil, answer: "y\n", wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "scratch" doesn't exist, want to create and attach to it? [y/N]`}, | |||
| 77 | + | {name: "missing decline attaches", sessions: nil, answer: "n\n", wantIntent: protocol.IntentAttach, wantPromptPart: "scratch"}, | |||
| 78 | + | {name: "missing empty answer attaches", sessions: nil, answer: "\n", wantIntent: protocol.IntentAttach}, | |||
| 79 | + | {name: "missing unknown answer attaches", sessions: nil, answer: "later\n", wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"}, | |||
| 80 | + | {name: "present attaches without prompt", sessions: []protocol.SessionInfo{{Name: "scratch"}}, answer: "y\n", wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |||
| 81 | + | {name: "non-interactive console attaches without prompt", sessions: nil, answer: "y\n", notInteractive: true, wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |||
| 82 | + | } | |||
| 83 | + | for _, tt := range tests { | |||
| 84 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 85 | + | var intents []uint8 | |||
| 86 | + | var promptOut strings.Builder | |||
| 87 | + | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |||
| 88 | + | localDialer: sessionListDialer(t, tt.sessions), | |||
| 89 | + | terminal: terminalStub(t, strings.NewReader(tt.answer), &promptOut), | |||
| 90 | + | interactiveConsole: interactiveProbe(!tt.notInteractive), | |||
| 91 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |||
| 92 | + | intents = append(intents, request.Intent) | |||
| 93 | + | return nil | |||
| 94 | + | }, | |||
| 95 | + | }) | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | require.Equal(t, []uint8{tt.wantIntent}, intents, "preflight must select the attach intent before any client attempt") | |||
| 98 | + | if tt.wantPromptPart != "" { | |||
| 99 | + | require.Contains(t, promptOut.String(), tt.wantPromptPart) | |||
| 100 | + | } | |||
| 101 | + | if tt.wantNoPrompt { | |||
| 102 | + | require.Empty(t, promptOut.String()) | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // TestTerminalIsInteractive covers the production probe on both sides of the | |||
| 109 | + | // console check: a real terminal file and the streams that are not consoles. | |||
| 110 | + | func TestTerminalIsInteractive(t *testing.T) { | |||
| 111 | + | require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt") | |||
| 112 | + | pty := openPtySlave(t) | |||
| 113 | + | require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console") | |||
| 114 | + | ||||
| 115 | + | console, writer, err := os.Pipe() | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | t.Cleanup(func() { | |||
| 118 | + | _ = console.Close() | |||
| 119 | + | _ = writer.Close() | |||
| 120 | + | }) | |||
| 121 | + | ||||
| 122 | + | for _, tt := range []struct { | |||
| 123 | + | name string | |||
| 124 | + | in io.Reader | |||
| 125 | + | }{ | |||
| 126 | + | {name: "detached reader", in: strings.NewReader("y\n")}, | |||
| 127 | + | {name: "non-terminal file", in: console}, | |||
| 128 | + | } { | |||
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | + | require.False(t, terminalIsInteractive(terminalStub(t, tt.in, io.Discard)())) | |||
| 131 | + | }) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // openPtySlave returns the slave side of a real PTY pair: the only way to | |||
| 136 | + | // hand the probe an *os.File the kernel reports as a terminal. | |||
| 137 | + | func openPtySlave(t *testing.T) *os.File { | |||
| 138 | + | t.Helper() | |||
| 139 | + | master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |||
| 140 | + | if err != nil { | |||
| 141 | + | t.Skipf("open /dev/ptmx: %v", err) | |||
| 142 | + | } | |||
| 143 | + | t.Cleanup(func() { _ = master.Close() }) | |||
| 144 | + | slave, err := rawterm.PreparePty(int(master.Fd())) | |||
| 145 | + | require.NoError(t, err, "prepare pty") | |||
| 146 | + | t.Cleanup(func() { _ = slave.Close() }) | |||
| 147 | + | return slave | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // TestRunAttachWithDepsMissingSessionPromptFlushesQuestion drives the real | |||
| 151 | + | // terminal adapter, whose output is buffered: the preflight has to flush the | |||
| 152 | + | // question before it blocks on the answer, or the user waits in front of a | |||
| 153 | + | // blank console and the question only shows up after the daemon replies. The | |||
| 154 | + | // observed wrapper used by `--ui-observe` embeds the port, so it has to keep | |||
| 155 | + | // flushing end to end. | |||
| 156 | + | func TestRunAttachWithDepsMissingSessionPromptFlushesQuestion(t *testing.T) { | |||
| 157 | + | for _, tt := range []struct { | |||
| 158 | + | name string | |||
| 159 | + | wrap func(ports.Terminal) ports.Terminal | |||
| 160 | + | }{ | |||
| 161 | + | {name: "direct terminal", wrap: func(terminal ports.Terminal) ports.Terminal { return terminal }}, | |||
| 162 | + | {name: "observed terminal", wrap: func(terminal ports.Terminal) ports.Terminal { | |||
| 163 | + | return observedTerminal{Terminal: terminal} | |||
| 164 | + | }}, | |||
| 165 | + | } { | |||
| 166 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 167 | + | consoleIn, answers, err := os.Pipe() | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | questions, consoleOut, err := os.Pipe() | |||
| 170 | + | require.NoError(t, err) | |||
| 171 | + | t.Cleanup(func() { | |||
| 172 | + | _ = consoleIn.Close() | |||
| 173 | + | _ = answers.Close() | |||
| 174 | + | _ = questions.Close() | |||
| 175 | + | _ = consoleOut.Close() | |||
| 176 | + | }) | |||
| 177 | + | ||||
| 178 | + | terminal := tt.wrap(term.NewWithFiles(consoleIn, consoleOut)) | |||
| 179 | + | var intents []uint8 | |||
| 180 | + | done := make(chan error, 1) | |||
| 181 | + | go func() { | |||
| 182 | + | done <- runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |||
| 183 | + | localDialer: sessionListDialer(t, nil), | |||
| 184 | + | terminal: func() ports.Terminal { return terminal }, | |||
| 185 | + | interactiveConsole: interactiveProbe(true), | |||
| 186 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |||
| 187 | + | intents = append(intents, request.Intent) | |||
| 188 | + | return nil | |||
| 189 | + | }, | |||
| 190 | + | }) | |||
| 191 | + | }() | |||
| 192 | + | ||||
| 193 | + | question := readPrompt(t, questions) | |||
| 194 | + | require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the preflight reads an answer") | |||
| 195 | + | ||||
| 196 | + | _, err = answers.WriteString("y\n") | |||
| 197 | + | require.NoError(t, err) | |||
| 198 | + | select { | |||
| 199 | + | case err := <-done: | |||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | case <-time.After(5 * time.Second): | |||
| 202 | + | t.Fatal("the preflight did not release after the answer") | |||
| 203 | + | } | |||
| 204 | + | require.Equal(t, []uint8{protocol.IntentNew}, intents) | |||
| 205 | + | }) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // readPrompt returns the console output that ends the create question, | |||
| 210 | + | // failing the test instead of hanging when nothing arrives. | |||
| 211 | + | func readPrompt(t *testing.T, console *os.File) string { | |||
| 212 | + | t.Helper() | |||
| 213 | + | require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second))) | |||
| 214 | + | var output []byte | |||
| 215 | + | frame := make([]byte, 64) | |||
| 216 | + | for { | |||
| 217 | + | read, err := console.Read(frame) | |||
| 218 | + | output = append(output, frame[:read]...) | |||
| 219 | + | if strings.Contains(string(output), "[y/N] ") { | |||
| 220 | + | return string(output) | |||
| 221 | + | } | |||
| 222 | + | if err != nil { | |||
| 223 | + | t.Fatalf("preflight prompt never reached the console (read %q: %v)", output, err) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // TestRunAttachWithDepsMissingSessionPromptFailureAttaches covers a console | |||
| 229 | + | // that cannot show the question: the preflight must keep the plain attach | |||
| 230 | + | // path instead of consuming an answer nobody was asked for. The input blocks | |||
| 231 | + | // forever, so a prompt that reads it would only return on context expiry. | |||
| 232 | + | func TestRunAttachWithDepsMissingSessionPromptFailureAttaches(t *testing.T) { | |||
| 233 | + | closedOut, err := os.CreateTemp(t.TempDir(), "vev-console") | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | require.NoError(t, closedOut.Close()) | |||
| 236 | + | ||||
| 237 | + | for _, tt := range []struct { | |||
| 238 | + | name string | |||
| 239 | + | out io.Writer | |||
| 240 | + | flushErr error | |||
| 241 | + | }{ | |||
| 242 | + | {name: "write fails", out: closedOut}, | |||
| 243 | + | {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")}, | |||
| 244 | + | } { | |||
| 245 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 246 | + | consoleIn, consoleOut, err := os.Pipe() | |||
| 247 | + | require.NoError(t, err) | |||
| 248 | + | t.Cleanup(func() { | |||
| 249 | + | _ = consoleIn.Close() | |||
| 250 | + | _ = consoleOut.Close() | |||
| 251 | + | }) | |||
| 252 | + | terminal := portsmocks.NewMockTerminal(t) | |||
| 253 | + | terminal.EXPECT().In().Return(consoleIn).Maybe() | |||
| 254 | + | terminal.EXPECT().Out().Return(tt.out).Maybe() | |||
| 255 | + | terminal.EXPECT().Flush().Return(tt.flushErr).Maybe() | |||
| 256 | + | ||||
| 257 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 258 | + | defer cancel() | |||
| 259 | + | var intents []uint8 | |||
| 260 | + | err = runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |||
| 261 | + | localDialer: sessionListDialer(t, nil), | |||
| 262 | + | terminal: func() ports.Terminal { return terminal }, | |||
| 263 | + | interactiveConsole: interactiveProbe(true), | |||
| 264 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |||
| 265 | + | intents = append(intents, request.Intent) | |||
| 266 | + | return nil | |||
| 267 | + | }, | |||
| 268 | + | }) | |||
| 269 | + | require.NoError(t, err, "an unusable console must not fail the attach") | |||
| 270 | + | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |||
| 271 | + | }) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestRunAttachWithDepsMissingSessionPreflightSkipsNonAttach(t *testing.T) { | |||
| 276 | + | for _, tt := range []struct { | |||
| 277 | + | name string | |||
| 278 | + | intent uint8 | |||
| 279 | + | remote string | |||
| 280 | + | }{ | |||
| 281 | + | {name: "create", intent: protocol.IntentNew}, | |||
| 282 | + | {name: "remote", intent: protocol.IntentAttach, remote: "remote.example"}, | |||
| 283 | + | {name: "ephemeral", intent: protocol.IntentEphemeral}, | |||
| 284 | + | } { | |||
| 285 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 286 | + | var intents []uint8 | |||
| 287 | + | var promptOut strings.Builder | |||
| 288 | + | session := "" | |||
| 289 | + | if tt.intent != protocol.IntentEphemeral { | |||
| 290 | + | session = "scratch" | |||
| 291 | + | } | |||
| 292 | + | err := runAttachWithDeps(context.Background(), tt.intent, session, tt.remote, "", nil, runAttachDeps{ | |||
| 293 | + | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |||
| 294 | + | interactiveConsole: interactiveProbe(true), | |||
| 295 | + | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |||
| 296 | + | intents = append(intents, request.Intent) | |||
| 297 | + | if tt.remote != "" { | |||
| 298 | + | require.True(t, deps.Remote) | |||
| 299 | + | } | |||
| 300 | + | return nil | |||
| 301 | + | }, | |||
| 302 | + | }) | |||
| 303 | + | require.NoError(t, err) | |||
| 304 | + | require.Equal(t, []uint8{tt.intent}, intents) | |||
| 305 | + | require.Empty(t, promptOut.String(), "preflight must not run outside direct local attach") | |||
| 306 | + | }) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func TestRunAttachWithDepsMissingSessionPreflightUnavailableAttaches(t *testing.T) { | |||
| 311 | + | for _, tt := range []struct { | |||
| 312 | + | name string | |||
| 313 | + | dialErr error | |||
| 314 | + | cancel bool | |||
| 315 | + | }{ | |||
| 316 | + | {name: "dial fails", dialErr: errors.New("socket unreachable")}, | |||
| 317 | + | {name: "context cancelled", dialErr: context.Canceled, cancel: true}, | |||
| 318 | + | } { | |||
| 319 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 320 | + | stubLifecycleUnavailable(t) | |||
| 321 | + | ctx := context.Background() | |||
| 322 | + | if tt.cancel { | |||
| 323 | + | var cancel context.CancelFunc | |||
| 324 | + | ctx, cancel = context.WithCancel(ctx) | |||
| 325 | + | cancel() | |||
| 326 | + | } | |||
| 327 | + | var promptOut strings.Builder | |||
| 328 | + | var intents []uint8 | |||
| 329 | + | err := runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |||
| 330 | + | localDialer: failingSessionListDialer(t, tt.dialErr), | |||
| 331 | + | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |||
| 332 | + | interactiveConsole: interactiveProbe(true), | |||
| 333 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |||
| 334 | + | intents = append(intents, request.Intent) | |||
| 335 | + | return nil | |||
| 336 | + | }, | |||
| 337 | + | }) | |||
| 338 | + | require.NoError(t, err) | |||
| 339 | + | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |||
| 340 | + | require.Empty(t, promptOut.String(), "an undeterminable preflight must not prompt; the daemon rejection decides") | |||
| 341 | + | }) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // stubLifecycleUnavailable fails lifecycle acquisition so the preflight | |||
| 346 | + | // cannot fall back to the on-disk catalogue, independent of the machine's | |||
| 347 | + | // real daemon state. | |||
| 348 | + | func stubLifecycleUnavailable(t *testing.T) { | |||
| 349 | + | t.Helper() | |||
| 350 | + | originalProbe := daemonLifecycleProbe | |||
| 351 | + | t.Cleanup(func() { daemonLifecycleProbe = originalProbe }) | |||
| 352 | + | daemonLifecycleProbe = fakeLifecycleProbe{err: errors.New("lifecycle unavailable")} | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // stalledListTransport parks the list exchange inside one operation until | |||
| 356 | + | // closed, letting tests exercise the preflight bound mid-flight. entered | |||
| 357 | + | // fires when the exchange reaches the parked operation. | |||
| 358 | + | func stalledListTransport(t *testing.T, blockOnSend bool) (*wiremocks.MockTransport, chan struct{}, chan struct{}) { | |||
| 359 | + | t.Helper() | |||
| 360 | + | entered := make(chan struct{}) | |||
| 361 | + | closed := make(chan struct{}) | |||
| 362 | + | transport := wiremocks.NewMockTransport(t) | |||
| 363 | + | block := func() { | |||
| 364 | + | close(entered) | |||
| 365 | + | <-closed | |||
| 366 | + | } | |||
| 367 | + | if blockOnSend { | |||
| 368 | + | transport.EXPECT().Send(mock.Anything).Run(func(wire.Frame) { block() }).Return(errors.New("transport closed")).Once() | |||
| 369 | + | } else { | |||
| 370 | + | transport.EXPECT().Send(mock.Anything).Return(nil).Once() | |||
| 371 | + | transport.EXPECT().Recv().Run(func() { block() }).Return(wire.Frame{}, errors.New("transport closed")).Once() | |||
| 372 | + | } | |||
| 373 | + | transport.EXPECT().Close().Run(func() { | |||
| 374 | + | select { | |||
| 375 | + | case <-closed: | |||
| 376 | + | default: | |||
| 377 | + | close(closed) | |||
| 378 | + | } | |||
| 379 | + | }).Return(nil) | |||
| 380 | + | return transport, entered, closed | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func TestListSessionsTimesOutOnSilentDaemon(t *testing.T) { | |||
| 384 | + | oldTimeout := preflightListTimeout | |||
| 385 | + | preflightListTimeout = 50 * time.Millisecond | |||
| 386 | + | defer func() { preflightListTimeout = oldTimeout }() | |||
| 387 | + | ||||
| 388 | + | transport, _, closed := stalledListTransport(t, false) | |||
| 389 | + | started := time.Now() | |||
| 390 | + | _, err := listSessionsWithDialer(context.Background(), func(context.Context) (wire.Transport, error) { | |||
| 391 | + | return transport, nil | |||
| 392 | + | }) | |||
| 393 | + | elapsed := time.Since(started) | |||
| 394 | + | require.Error(t, err) | |||
| 395 | + | require.Less(t, elapsed, 5*time.Second, "a silent daemon must not stall the preflight past its bound") | |||
| 396 | + | select { | |||
| 397 | + | case <-closed: | |||
| 398 | + | default: | |||
| 399 | + | t.Fatal("preflight timeout must close the exchange transport") | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | func TestListSessionsHonorsCancellation(t *testing.T) { | |||
| 404 | + | for _, tt := range []struct { | |||
| 405 | + | name string | |||
| 406 | + | blockOnSend bool | |||
| 407 | + | }{ | |||
| 408 | + | {name: "blocked send", blockOnSend: true}, | |||
| 409 | + | {name: "blocked recv", blockOnSend: false}, | |||
| 410 | + | } { | |||
| 411 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 412 | + | transport, entered, closed := stalledListTransport(t, tt.blockOnSend) | |||
| 413 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 414 | + | defer cancel() | |||
| 415 | + | done := make(chan error, 1) | |||
| 416 | + | go func() { | |||
| 417 | + | _, err := listSessionsWithDialer(ctx, func(context.Context) (wire.Transport, error) { | |||
| 418 | + | return transport, nil | |||
| 419 | + | }) | |||
| 420 | + | done <- err | |||
| 421 | + | }() | |||
| 422 | + | // Wait until the exchange is blocked inside the transport | |||
| 423 | + | // before cancelling, so the test exercises in-flight | |||
| 424 | + | // cancellation rather than the earlier dial-path check. | |||
| 425 | + | select { | |||
| 426 | + | case <-entered: | |||
| 427 | + | case <-time.After(5 * time.Second): | |||
| 428 | + | t.Fatal("exchange did not reach the blocked transport") | |||
| 429 | + | } | |||
| 430 | + | started := time.Now() | |||
| 431 | + | cancel() | |||
| 432 | + | select { | |||
| 433 | + | case err := <-done: | |||
| 434 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 435 | + | require.Less(t, time.Since(started), preflightListTimeout, "in-flight cancellation must beat the list bound") | |||
| 436 | + | case <-time.After(5 * time.Second): | |||
| 437 | + | t.Fatal("preflight did not release on context cancellation") | |||
| 438 | + | } | |||
| 439 | + | select { | |||
| 440 | + | case <-closed: | |||
| 441 | + | default: | |||
| 442 | + | t.Fatal("cancellation must close the exchange transport") | |||
| 443 | + | } | |||
| 444 | + | }) | |||
| 445 | + | } | |||
| 446 | + | } | |||
M internal/app/run.go +58 -10
| @@ -900,10 +900,13 @@ type runAttachDeps struct { | |||
| 900 | 900 | runtimeObserver ports.SerializedRuntimeObserver | |
| 901 | 901 | ui *client.UI | |
| 902 | 902 | terminal func() ports.Terminal | |
| 903 | - | clock func() ports.Clock | |
| 904 | - | disableCapabilityProbe bool | |
| 905 | - | localEnvironment []string | |
| 906 | - | remoteEnvironment func(string) []string | |
| 903 | + | // interactiveConsole decides whether the missing-session prompt may read | |
| 904 | + | // its answer from a console. Nil probes the client terminal's input. | |
| 905 | + | interactiveConsole func(ports.Terminal) bool | |
| 906 | + | clock func() ports.Clock | |
| 907 | + | disableCapabilityProbe bool | |
| 908 | + | localEnvironment []string | |
| 909 | + | remoteEnvironment func(string) []string | |
| 907 | 910 | // clipboard reads a clipboard image on a remote route's Ctrl+V. | |
| 908 | 911 | // The client retains it across local-to-remote handoffs and only enables | |
| 909 | 912 | // interception while the active route is remote. | |
| @@ -974,8 +977,6 @@ func remoteDiscoveryDaemonOption(stateDir, transport string, clk ports.Clock, lo | |||
| 974 | 977 | }, nil | |
| 975 | 978 | } | |
| 976 | 979 | ||
| 977 | - | const maxAttachTargetHandoffs = 32 | |
| 978 | - | ||
| 979 | 980 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | |
| 980 | 981 | if activeSession != "" { | |
| 981 | 982 | if remoteTarget == "" && intent == protocol.IntentNew { | |
| @@ -984,6 +985,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 984 | 985 | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |
| 985 | 986 | } | |
| 986 | 987 | ||
| 988 | + | if intent == protocol.IntentAttach && remoteTarget == "" { | |
| 989 | + | resolved, err := resolveMissingSessionAttach(ctx, name, deps, log) | |
| 990 | + | if err != nil { | |
| 991 | + | return err | |
| 992 | + | } | |
| 993 | + | intent = resolved | |
| 994 | + | } | |
| 995 | + | ||
| 987 | 996 | runClient := deps.runClient | |
| 988 | 997 | if runClient == nil { | |
| 989 | 998 | runClient = runClientWithDeps | |
| @@ -1609,7 +1618,18 @@ func runList(ctx context.Context, cmd command) (retErr error) { | |||
| 1609 | 1618 | } | |
| 1610 | 1619 | ||
| 1611 | 1620 | func listLocalSessions(ctx context.Context) (_ []protocol.SessionInfo, retErr error) { | |
| 1612 | - | transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff) | |
| 1621 | + | return listSessionsWithDialer(ctx, func(ctx context.Context) (wire.Transport, error) { | |
| 1622 | + | return realDial(ctx, ipc.SocketDir()) | |
| 1623 | + | }) | |
| 1624 | + | } | |
| 1625 | + | ||
| 1626 | + | // listSessionsWithDialer runs the session listing over an explicit dialer. | |
| 1627 | + | // The attach pre-flight passes the local dialer (spawning the daemon when | |
| 1628 | + | // needed, like any attach); tests inject mocks. | |
| 1629 | + | func listSessionsWithDialer(ctx context.Context, dial func(context.Context) (wire.Transport, error)) (_ []protocol.SessionInfo, retErr error) { | |
| 1630 | + | transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, _ string) (wire.Transport, error) { | |
| 1631 | + | return dial(ctx) | |
| 1632 | + | }, defaultBackoff) | |
| 1613 | 1633 | if err != nil { | |
| 1614 | 1634 | return nil, fmt.Errorf("vev: waiting for durable session state: %w", err) | |
| 1615 | 1635 | } | |
| @@ -1632,16 +1652,44 @@ func listLocalSessions(ctx context.Context) (_ []protocol.SessionInfo, retErr er | |||
| 1632 | 1652 | } | |
| 1633 | 1653 | return infos, nil | |
| 1634 | 1654 | } | |
| 1655 | + | reply, err := boundedListExchange(ctx, transport) | |
| 1656 | + | if err != nil { | |
| 1657 | + | return nil, err | |
| 1658 | + | } | |
| 1659 | + | return decodeSessionListReply(reply) | |
| 1660 | + | } | |
| 1661 | + | ||
| 1662 | + | // preflightListTimeout bounds the attach pre-flight session listing so a | |
| 1663 | + | // socket that accepts but never replies cannot delay the attach fallback | |
| 1664 | + | // or block termination. | |
| 1665 | + | var preflightListTimeout = 5 * time.Second | |
| 1666 | + | ||
| 1667 | + | // boundedListExchange sends a session listing request and reads the reply, | |
| 1668 | + | // closing the transport if the bound lapses or the parent context ends so | |
| 1669 | + | // a socket that accepts but never replies neither delays the attach | |
| 1670 | + | // fallback nor blocks Ctrl-C exit. Transport.Close interrupts blocked Send | |
| 1671 | + | // and Recv. | |
| 1672 | + | func boundedListExchange(ctx context.Context, transport wire.Transport) (wire.Frame, error) { | |
| 1673 | + | listCtx, cancel := context.WithTimeout(ctx, preflightListTimeout) | |
| 1674 | + | defer cancel() | |
| 1675 | + | stopClose := context.AfterFunc(listCtx, func() { _ = transport.Close() }) | |
| 1676 | + | defer stopClose() | |
| 1635 | 1677 | defer func() { _ = transport.Close() }() | |
| 1636 | 1678 | ||
| 1637 | 1679 | if err := transport.Send(wire.Frame{Type: wire.MsgList, Payload: wire.MarshalList(protocol.List{})}); err != nil { | |
| 1638 | - | return nil, fmt.Errorf("vev: requesting session list: %w", err) | |
| 1680 | + | if listCtx.Err() != nil { | |
| 1681 | + | return wire.Frame{}, fmt.Errorf("vev: requesting session list: %w", listCtx.Err()) | |
| 1682 | + | } | |
| 1683 | + | return wire.Frame{}, fmt.Errorf("vev: requesting session list: %w", err) | |
| 1639 | 1684 | } | |
| 1640 | 1685 | reply, err := transport.Recv() | |
| 1641 | 1686 | if err != nil { | |
| 1642 | - | return nil, fmt.Errorf("vev: reading session list: %w", err) | |
| 1687 | + | if listCtx.Err() != nil { | |
| 1688 | + | return wire.Frame{}, fmt.Errorf("vev: reading session list: %w", listCtx.Err()) | |
| 1689 | + | } | |
| 1690 | + | return wire.Frame{}, fmt.Errorf("vev: reading session list: %w", err) | |
| 1643 | 1691 | } | |
| 1644 | - | return decodeSessionListReply(reply) | |
| 1692 | + | return reply, nil | |
| 1645 | 1693 | } | |
| 1646 | 1694 | ||
| 1647 | 1695 | func decodeSessionListReply(reply wire.Frame) ([]protocol.SessionInfo, error) { | |
| @@ -900,10 +900,13 @@ type runAttachDeps struct { | |||||
| 900 | runtimeObserver ports.SerializedRuntimeObserver | 900 | runtimeObserver ports.SerializedRuntimeObserver | ||
| 901 | ui *client.UI | 901 | ui *client.UI | ||
| 902 | terminal func() ports.Terminal | 902 | terminal func() ports.Terminal | ||
| 903 | - | clock func() ports.Clock | 903 | + | // interactiveConsole decides whether the missing-session prompt may read |
| 904 | - | disableCapabilityProbe bool | 904 | + | // its answer from a console. Nil probes the client terminal's input. |
| 905 | - | localEnvironment []string | 905 | + | interactiveConsole func(ports.Terminal) bool |
| 906 | - | remoteEnvironment func(string) []string | 906 | + | clock func() ports.Clock |
| 907 | + | disableCapabilityProbe bool | |||
| 908 | + | localEnvironment []string | |||
| 909 | + | remoteEnvironment func(string) []string | |||
| 907 | // clipboard reads a clipboard image on a remote route's Ctrl+V. | 910 | // clipboard reads a clipboard image on a remote route's Ctrl+V. | ||
| 908 | // The client retains it across local-to-remote handoffs and only enables | 911 | // The client retains it across local-to-remote handoffs and only enables | ||
| 909 | // interception while the active route is remote. | 912 | // interception while the active route is remote. | ||
| @@ -974,8 +977,6 @@ func remoteDiscoveryDaemonOption(stateDir, transport string, clk ports.Clock, lo | |||||
| 974 | }, nil | 977 | }, nil | ||
| 975 | } | 978 | } | ||
| 976 | 979 | ||||
| 977 | - | const maxAttachTargetHandoffs = 32 | |||
| 978 | - | ||||
| 979 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | 980 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | ||
| 980 | if activeSession != "" { | 981 | if activeSession != "" { | ||
| 981 | if remoteTarget == "" && intent == protocol.IntentNew { | 982 | if remoteTarget == "" && intent == protocol.IntentNew { | ||
| @@ -984,6 +985,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 984 | return errors.New("vev: sessions should be nested with care; unset VEV to force") | 985 | return errors.New("vev: sessions should be nested with care; unset VEV to force") | ||
| 985 | } | 986 | } | ||
| 986 | 987 | ||||
| 988 | + | if intent == protocol.IntentAttach && remoteTarget == "" { | |||
| 989 | + | resolved, err := resolveMissingSessionAttach(ctx, name, deps, log) | |||
| 990 | + | if err != nil { | |||
| 991 | + | return err | |||
| 992 | + | } | |||
| 993 | + | intent = resolved | |||
| 994 | + | } | |||
| 995 | + | ||||
| 987 | runClient := deps.runClient | 996 | runClient := deps.runClient | ||
| 988 | if runClient == nil { | 997 | if runClient == nil { | ||
| 989 | runClient = runClientWithDeps | 998 | runClient = runClientWithDeps | ||
| @@ -1609,7 +1618,18 @@ func runList(ctx context.Context, cmd command) (retErr error) { | |||||
| 1609 | } | 1618 | } | ||
| 1610 | 1619 | ||||
| 1611 | func listLocalSessions(ctx context.Context) (_ []protocol.SessionInfo, retErr error) { | 1620 | func listLocalSessions(ctx context.Context) (_ []protocol.SessionInfo, retErr error) { | ||
| 1612 | - | transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff) | 1621 | + | return listSessionsWithDialer(ctx, func(ctx context.Context) (wire.Transport, error) { |
| 1622 | + | return realDial(ctx, ipc.SocketDir()) | |||
| 1623 | + | }) | |||
| 1624 | + | } | |||
| 1625 | + | ||||
| 1626 | + | // listSessionsWithDialer runs the session listing over an explicit dialer. | |||
| 1627 | + | // The attach pre-flight passes the local dialer (spawning the daemon when | |||
| 1628 | + | // needed, like any attach); tests inject mocks. | |||
| 1629 | + | func listSessionsWithDialer(ctx context.Context, dial func(context.Context) (wire.Transport, error)) (_ []protocol.SessionInfo, retErr error) { | |||
| 1630 | + | transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, _ string) (wire.Transport, error) { | |||
| 1631 | + | return dial(ctx) | |||
| 1632 | + | }, defaultBackoff) | |||
| 1613 | if err != nil { | 1633 | if err != nil { | ||
| 1614 | return nil, fmt.Errorf("vev: waiting for durable session state: %w", err) | 1634 | return nil, fmt.Errorf("vev: waiting for durable session state: %w", err) | ||
| 1615 | } | 1635 | } | ||
| @@ -1632,16 +1652,44 @@ func listLocalSessions(ctx context.Context) (_ []protocol.SessionInfo, retErr er | |||||
| 1632 | } | 1652 | } | ||
| 1633 | return infos, nil | 1653 | return infos, nil | ||
| 1634 | } | 1654 | } | ||
| 1655 | + | reply, err := boundedListExchange(ctx, transport) | |||
| 1656 | + | if err != nil { | |||
| 1657 | + | return nil, err | |||
| 1658 | + | } | |||
| 1659 | + | return decodeSessionListReply(reply) | |||
| 1660 | + | } | |||
| 1661 | + | ||||
| 1662 | + | // preflightListTimeout bounds the attach pre-flight session listing so a | |||
| 1663 | + | // socket that accepts but never replies cannot delay the attach fallback | |||
| 1664 | + | // or block termination. | |||
| 1665 | + | var preflightListTimeout = 5 * time.Second | |||
| 1666 | + | ||||
| 1667 | + | // boundedListExchange sends a session listing request and reads the reply, | |||
| 1668 | + | // closing the transport if the bound lapses or the parent context ends so | |||
| 1669 | + | // a socket that accepts but never replies neither delays the attach | |||
| 1670 | + | // fallback nor blocks Ctrl-C exit. Transport.Close interrupts blocked Send | |||
| 1671 | + | // and Recv. | |||
| 1672 | + | func boundedListExchange(ctx context.Context, transport wire.Transport) (wire.Frame, error) { | |||
| 1673 | + | listCtx, cancel := context.WithTimeout(ctx, preflightListTimeout) | |||
| 1674 | + | defer cancel() | |||
| 1675 | + | stopClose := context.AfterFunc(listCtx, func() { _ = transport.Close() }) | |||
| 1676 | + | defer stopClose() | |||
| 1635 | defer func() { _ = transport.Close() }() | 1677 | defer func() { _ = transport.Close() }() | ||
| 1636 | 1678 | ||||
| 1637 | if err := transport.Send(wire.Frame{Type: wire.MsgList, Payload: wire.MarshalList(protocol.List{})}); err != nil { | 1679 | if err := transport.Send(wire.Frame{Type: wire.MsgList, Payload: wire.MarshalList(protocol.List{})}); err != nil { | ||
| 1638 | - | return nil, fmt.Errorf("vev: requesting session list: %w", err) | 1680 | + | if listCtx.Err() != nil { |
| 1681 | + | return wire.Frame{}, fmt.Errorf("vev: requesting session list: %w", listCtx.Err()) | |||
| 1682 | + | } | |||
| 1683 | + | return wire.Frame{}, fmt.Errorf("vev: requesting session list: %w", err) | |||
| 1639 | } | 1684 | } | ||
| 1640 | reply, err := transport.Recv() | 1685 | reply, err := transport.Recv() | ||
| 1641 | if err != nil { | 1686 | if err != nil { | ||
| 1642 | - | return nil, fmt.Errorf("vev: reading session list: %w", err) | 1687 | + | if listCtx.Err() != nil { |
| 1688 | + | return wire.Frame{}, fmt.Errorf("vev: reading session list: %w", listCtx.Err()) | |||
| 1689 | + | } | |||
| 1690 | + | return wire.Frame{}, fmt.Errorf("vev: reading session list: %w", err) | |||
| 1643 | } | 1691 | } | ||
| 1644 | - | return decodeSessionListReply(reply) | 1692 | + | return reply, nil |
| 1645 | } | 1693 | } | ||
| 1646 | 1694 | ||||
| 1647 | func decodeSessionListReply(reply wire.Frame) ([]protocol.SessionInfo, error) { | 1695 | func decodeSessionListReply(reply wire.Frame) ([]protocol.SessionInfo, error) { | ||
M internal/app/run_test.go +24 -7
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | "github.com/bnema/vev/internal/domain" | |
| 25 | 25 | "github.com/bnema/vev/internal/persist" | |
| 26 | 26 | "github.com/bnema/vev/internal/ports" | |
| 27 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 27 | 28 | "github.com/bnema/vev/internal/protocol" | |
| 28 | 29 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 29 | 30 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -900,11 +901,6 @@ func requireNamedClientDialer(t *testing.T, ctx context.Context, dialer ports.Cl | |||
| 900 | 901 | require.EqualError(t, err, "not used: "+name) | |
| 901 | 902 | } | |
| 902 | 903 | ||
| 903 | - | // fakeClipboardReader is a distinguishable ports.ClipboardReader used only to | |
| 904 | - | // verify identity (that runAttachWithDeps threads the *same* reader through), | |
| 905 | - | // never actually invoked in these wiring tests. | |
| 906 | - | type fakeClipboardReader struct{ ports.ClipboardReader } | |
| 907 | - | ||
| 908 | 904 | func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |
| 909 | 905 | tests := []struct { | |
| 910 | 906 | name string | |
| @@ -920,7 +916,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||
| 920 | 916 | var gotDialer string | |
| 921 | 917 | var gotRemote bool | |
| 922 | 918 | var gotClipboard ports.ClipboardReader | |
| 923 | - | clip := &fakeClipboardReader{} | |
| 919 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 924 | 920 | factory := newRemoteDialerFactoryMock(t) | |
| 925 | 921 | factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |
| 926 | 922 | ||
| @@ -1162,7 +1158,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 1162 | 1158 | var gotDialer string | |
| 1163 | 1159 | gotRemote := true | |
| 1164 | 1160 | var gotClipboard ports.ClipboardReader | |
| 1165 | - | clip := &fakeClipboardReader{} | |
| 1161 | + | clip := portsmocks.NewMockClipboardReader(t) | |
| 1166 | 1162 | factory := newRemoteDialerFactoryMock(t) | |
| 1167 | 1163 | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | |
| 1168 | 1164 | localDialer: func() wire.Dialer { return namedDialer{name: "local"} }, | |
| @@ -1431,3 +1427,24 @@ func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) { | |||
| 1431 | 1427 | _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial", clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1432 | 1428 | require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`) | |
| 1433 | 1429 | } | |
| 1430 | + | ||
| 1431 | + | func TestRunAttachWithDepsDaemonRejectionStillSurfaces(t *testing.T) { | |
| 1432 | + | // The preflight found the session, but the daemon still rejects the | |
| 1433 | + | // attach (e.g. it died between listing and Hello): the rejection must | |
| 1434 | + | // surface unchanged and must never trigger a create prompt. | |
| 1435 | + | var promptOut strings.Builder | |
| 1436 | + | rejected := &client.ProtocolError{Code: protocol.ErrNoSuchSession, Text: "no such resumable session: scratch"} | |
| 1437 | + | var intents []uint8 | |
| 1438 | + | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 1439 | + | localDialer: sessionListDialer(t, []protocol.SessionInfo{{Name: "scratch"}}), | |
| 1440 | + | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |
| 1441 | + | interactiveConsole: interactiveProbe(true), | |
| 1442 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 1443 | + | intents = append(intents, request.Intent) | |
| 1444 | + | return rejected | |
| 1445 | + | }, | |
| 1446 | + | }) | |
| 1447 | + | require.Equal(t, rejected, err) | |
| 1448 | + | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |
| 1449 | + | require.Empty(t, promptOut.String()) | |
| 1450 | + | } | |
| @@ -24,6 +24,7 @@ import ( | |||||
| 24 | "github.com/bnema/vev/internal/domain" | 24 | "github.com/bnema/vev/internal/domain" | ||
| 25 | "github.com/bnema/vev/internal/persist" | 25 | "github.com/bnema/vev/internal/persist" | ||
| 26 | "github.com/bnema/vev/internal/ports" | 26 | "github.com/bnema/vev/internal/ports" | ||
| 27 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 27 | "github.com/bnema/vev/internal/protocol" | 28 | "github.com/bnema/vev/internal/protocol" | ||
| 28 | "github.com/bnema/vev/internal/protocol/catalogue" | 29 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 29 | "github.com/bnema/vev/internal/protocol/wire" | 30 | "github.com/bnema/vev/internal/protocol/wire" | ||
| @@ -900,11 +901,6 @@ func requireNamedClientDialer(t *testing.T, ctx context.Context, dialer ports.Cl | |||||
| 900 | require.EqualError(t, err, "not used: "+name) | 901 | require.EqualError(t, err, "not used: "+name) | ||
| 901 | } | 902 | } | ||
| 902 | 903 | ||||
| 903 | - | // fakeClipboardReader is a distinguishable ports.ClipboardReader used only to | |||
| 904 | - | // verify identity (that runAttachWithDeps threads the *same* reader through), | |||
| 905 | - | // never actually invoked in these wiring tests. | |||
| 906 | - | type fakeClipboardReader struct{ ports.ClipboardReader } | |||
| 907 | - | ||||
| 908 | func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | 904 | func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | ||
| 909 | tests := []struct { | 905 | tests := []struct { | ||
| 910 | name string | 906 | name string | ||
| @@ -920,7 +916,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||||
| 920 | var gotDialer string | 916 | var gotDialer string | ||
| 921 | var gotRemote bool | 917 | var gotRemote bool | ||
| 922 | var gotClipboard ports.ClipboardReader | 918 | var gotClipboard ports.ClipboardReader | ||
| 923 | - | clip := &fakeClipboardReader{} | 919 | + | clip := portsmocks.NewMockClipboardReader(t) |
| 924 | factory := newRemoteDialerFactoryMock(t) | 920 | factory := newRemoteDialerFactoryMock(t) | ||
| 925 | factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil) | 921 | factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil) | ||
| 926 | 922 | ||||
| @@ -1162,7 +1158,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 1162 | var gotDialer string | 1158 | var gotDialer string | ||
| 1163 | gotRemote := true | 1159 | gotRemote := true | ||
| 1164 | var gotClipboard ports.ClipboardReader | 1160 | var gotClipboard ports.ClipboardReader | ||
| 1165 | - | clip := &fakeClipboardReader{} | 1161 | + | clip := portsmocks.NewMockClipboardReader(t) |
| 1166 | factory := newRemoteDialerFactoryMock(t) | 1162 | factory := newRemoteDialerFactoryMock(t) | ||
| 1167 | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | 1163 | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | ||
| 1168 | localDialer: func() wire.Dialer { return namedDialer{name: "local"} }, | 1164 | localDialer: func() wire.Dialer { return namedDialer{name: "local"} }, | ||
| @@ -1431,3 +1427,24 @@ func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) { | |||||
| 1431 | _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial", clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil))) | 1427 | _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial", clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 1432 | require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`) | 1428 | require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`) | ||
| 1433 | } | 1429 | } | ||
| 1430 | + | ||||
| 1431 | + | func TestRunAttachWithDepsDaemonRejectionStillSurfaces(t *testing.T) { | |||
| 1432 | + | // The preflight found the session, but the daemon still rejects the | |||
| 1433 | + | // attach (e.g. it died between listing and Hello): the rejection must | |||
| 1434 | + | // surface unchanged and must never trigger a create prompt. | |||
| 1435 | + | var promptOut strings.Builder | |||
| 1436 | + | rejected := &client.ProtocolError{Code: protocol.ErrNoSuchSession, Text: "no such resumable session: scratch"} | |||
| 1437 | + | var intents []uint8 | |||
| 1438 | + | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |||
| 1439 | + | localDialer: sessionListDialer(t, []protocol.SessionInfo{{Name: "scratch"}}), | |||
| 1440 | + | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |||
| 1441 | + | interactiveConsole: interactiveProbe(true), | |||
| 1442 | + | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |||
| 1443 | + | intents = append(intents, request.Intent) | |||
| 1444 | + | return rejected | |||
| 1445 | + | }, | |||
| 1446 | + | }) | |||
| 1447 | + | require.Equal(t, rejected, err) | |||
| 1448 | + | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |||
| 1449 | + | require.Empty(t, promptOut.String()) | |||
| 1450 | + | } | |||
M internal/usecase/confirm/confirm.go +27 -0
| @@ -2,6 +2,7 @@ package confirm | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| @@ -20,6 +21,32 @@ func NewConfirmer(in io.Reader, out io.Writer) Confirmer { | |||
| 20 | 21 | return Confirmer{in: in, out: out} | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 24 | + | // ConfirmContext writes question with a [y/N] default and reads one answer | |
| 25 | + | // line, releasing the caller with the cancellation error if ctx ends first. | |
| 26 | + | // The reader goroutine may stay blocked, so use this only where the process | |
| 27 | + | // exits (or detaches from the console) afterward rather than continuing to | |
| 28 | + | // interact on the same stream. | |
| 29 | + | func (c Confirmer) ConfirmContext(ctx context.Context, question string) (bool, error) { | |
| 30 | + | if ctx == nil { | |
| 31 | + | return c.Confirm(question) | |
| 32 | + | } | |
| 33 | + | type outcome struct { | |
| 34 | + | ok bool | |
| 35 | + | err error | |
| 36 | + | } | |
| 37 | + | result := make(chan outcome, 1) | |
| 38 | + | go func() { | |
| 39 | + | ok, err := c.Confirm(question) | |
| 40 | + | result <- outcome{ok: ok, err: err} | |
| 41 | + | }() | |
| 42 | + | select { | |
| 43 | + | case <-ctx.Done(): | |
| 44 | + | return false, ctx.Err() | |
| 45 | + | case reply := <-result: | |
| 46 | + | return reply.ok, reply.err | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 23 | 50 | // Confirm writes question with a [y/N] default and reads one answer line. | |
| 24 | 51 | func (c Confirmer) Confirm(question string) (bool, error) { | |
| 25 | 52 | if c.out != nil { | |
| @@ -2,6 +2,7 @@ package confirm | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | + | "context" | |||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "io" | 8 | "io" | ||
| @@ -20,6 +21,32 @@ func NewConfirmer(in io.Reader, out io.Writer) Confirmer { | |||||
| 20 | return Confirmer{in: in, out: out} | 21 | return Confirmer{in: in, out: out} | ||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| 24 | + | // ConfirmContext writes question with a [y/N] default and reads one answer | |||
| 25 | + | // line, releasing the caller with the cancellation error if ctx ends first. | |||
| 26 | + | // The reader goroutine may stay blocked, so use this only where the process | |||
| 27 | + | // exits (or detaches from the console) afterward rather than continuing to | |||
| 28 | + | // interact on the same stream. | |||
| 29 | + | func (c Confirmer) ConfirmContext(ctx context.Context, question string) (bool, error) { | |||
| 30 | + | if ctx == nil { | |||
| 31 | + | return c.Confirm(question) | |||
| 32 | + | } | |||
| 33 | + | type outcome struct { | |||
| 34 | + | ok bool | |||
| 35 | + | err error | |||
| 36 | + | } | |||
| 37 | + | result := make(chan outcome, 1) | |||
| 38 | + | go func() { | |||
| 39 | + | ok, err := c.Confirm(question) | |||
| 40 | + | result <- outcome{ok: ok, err: err} | |||
| 41 | + | }() | |||
| 42 | + | select { | |||
| 43 | + | case <-ctx.Done(): | |||
| 44 | + | return false, ctx.Err() | |||
| 45 | + | case reply := <-result: | |||
| 46 | + | return reply.ok, reply.err | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 23 | // Confirm writes question with a [y/N] default and reads one answer line. | 50 | // Confirm writes question with a [y/N] default and reads one answer line. | ||
| 24 | func (c Confirmer) Confirm(question string) (bool, error) { | 51 | func (c Confirmer) Confirm(question string) (bool, error) { | ||
| 25 | if c.out != nil { | 52 | if c.out != nil { | ||
M internal/usecase/confirm/confirm_test.go +45 -0
| @@ -2,8 +2,12 @@ package confirm | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 5 | 8 | "strings" | |
| 6 | 9 | "testing" | |
| 10 | + | "time" | |
| 7 | 11 | ) | |
| 8 | 12 | ||
| 9 | 13 | func TestConfirmerAcceptsYes(t *testing.T) { | |
| @@ -49,3 +53,44 @@ func TestConfirmerRejectsUnknownAnswer(t *testing.T) { | |||
| 49 | 53 | t.Fatal("Confirm returned true for unknown answer, want false") | |
| 50 | 54 | } | |
| 51 | 55 | } | |
| 56 | + | ||
| 57 | + | func TestConfirmerReleasesOnCancel(t *testing.T) { | |
| 58 | + | reader, writer := io.Pipe() | |
| 59 | + | defer func() { _ = reader.Close() }() | |
| 60 | + | defer func() { _ = writer.Close() }() | |
| 61 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 62 | + | defer cancel() | |
| 63 | + | wrote := make(chan struct{}, 1) | |
| 64 | + | out := writeFunc(func(p []byte) (int, error) { | |
| 65 | + | select { | |
| 66 | + | case wrote <- struct{}{}: | |
| 67 | + | default: | |
| 68 | + | } | |
| 69 | + | return io.Discard.Write(p) | |
| 70 | + | }) | |
| 71 | + | done := make(chan error, 1) | |
| 72 | + | go func() { | |
| 73 | + | _, err := NewConfirmer(reader, out).ConfirmContext(ctx, "create?") | |
| 74 | + | done <- err | |
| 75 | + | }() | |
| 76 | + | // Wait until the prompt write lands before cancelling, so the test | |
| 77 | + | // exercises release from a blocked read rather than startup. | |
| 78 | + | select { | |
| 79 | + | case <-wrote: | |
| 80 | + | case <-time.After(5 * time.Second): | |
| 81 | + | t.Fatal("confirm did not write its prompt") | |
| 82 | + | } | |
| 83 | + | cancel() | |
| 84 | + | select { | |
| 85 | + | case err := <-done: | |
| 86 | + | if !errors.Is(err, context.Canceled) { | |
| 87 | + | t.Fatalf("ConfirmContext error = %v, want context.Canceled", err) | |
| 88 | + | } | |
| 89 | + | case <-time.After(5 * time.Second): | |
| 90 | + | t.Fatal("confirm did not release on context cancellation") | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | type writeFunc func([]byte) (int, error) | |
| 95 | + | ||
| 96 | + | func (f writeFunc) Write(p []byte) (int, error) { return f(p) } | |
| @@ -2,8 +2,12 @@ package confirm | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 5 | "strings" | 8 | "strings" | ||
| 6 | "testing" | 9 | "testing" | ||
| 10 | + | "time" | |||
| 7 | ) | 11 | ) | ||
| 8 | 12 | ||||
| 9 | func TestConfirmerAcceptsYes(t *testing.T) { | 13 | func TestConfirmerAcceptsYes(t *testing.T) { | ||
| @@ -49,3 +53,44 @@ func TestConfirmerRejectsUnknownAnswer(t *testing.T) { | |||||
| 49 | t.Fatal("Confirm returned true for unknown answer, want false") | 53 | t.Fatal("Confirm returned true for unknown answer, want false") | ||
| 50 | } | 54 | } | ||
| 51 | } | 55 | } | ||
| 56 | + | ||||
| 57 | + | func TestConfirmerReleasesOnCancel(t *testing.T) { | |||
| 58 | + | reader, writer := io.Pipe() | |||
| 59 | + | defer func() { _ = reader.Close() }() | |||
| 60 | + | defer func() { _ = writer.Close() }() | |||
| 61 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 62 | + | defer cancel() | |||
| 63 | + | wrote := make(chan struct{}, 1) | |||
| 64 | + | out := writeFunc(func(p []byte) (int, error) { | |||
| 65 | + | select { | |||
| 66 | + | case wrote <- struct{}{}: | |||
| 67 | + | default: | |||
| 68 | + | } | |||
| 69 | + | return io.Discard.Write(p) | |||
| 70 | + | }) | |||
| 71 | + | done := make(chan error, 1) | |||
| 72 | + | go func() { | |||
| 73 | + | _, err := NewConfirmer(reader, out).ConfirmContext(ctx, "create?") | |||
| 74 | + | done <- err | |||
| 75 | + | }() | |||
| 76 | + | // Wait until the prompt write lands before cancelling, so the test | |||
| 77 | + | // exercises release from a blocked read rather than startup. | |||
| 78 | + | select { | |||
| 79 | + | case <-wrote: | |||
| 80 | + | case <-time.After(5 * time.Second): | |||
| 81 | + | t.Fatal("confirm did not write its prompt") | |||
| 82 | + | } | |||
| 83 | + | cancel() | |||
| 84 | + | select { | |||
| 85 | + | case err := <-done: | |||
| 86 | + | if !errors.Is(err, context.Canceled) { | |||
| 87 | + | t.Fatalf("ConfirmContext error = %v, want context.Canceled", err) | |||
| 88 | + | } | |||
| 89 | + | case <-time.After(5 * time.Second): | |||
| 90 | + | t.Fatal("confirm did not release on context cancellation") | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | type writeFunc func([]byte) (int, error) | |||
| 95 | + | ||||
| 96 | + | func (f writeFunc) Write(p []byte) (int, error) { return f(p) } | |||