detached 0dec724

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 UnverifiedB5690EEEBB952194 The 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 {
900900 runtimeObserver ports.SerializedRuntimeObserver
901901 ui *client.UI
902902 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
907910 // clipboard reads a clipboard image on a remote route's Ctrl+V.
908911 // The client retains it across local-to-remote handoffs and only enables
909912 // interception while the active route is remote.
@@ -974,8 +977,6 @@ func remoteDiscoveryDaemonOption(stateDir, transport string, clk ports.Clock, lo
974977 }, nil
975978 }
976979
977-const maxAttachTargetHandoffs = 32
978-
979980 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
980981 if activeSession != "" {
981982 if remoteTarget == "" && intent == protocol.IntentNew {
@@ -984,6 +985,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
984985 return errors.New("vev: sessions should be nested with care; unset VEV to force")
985986 }
986987
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+
987996 runClient := deps.runClient
988997 if runClient == nil {
989998 runClient = runClientWithDeps
@@ -1609,7 +1618,18 @@ func runList(ctx context.Context, cmd command) (retErr error) {
16091618 }
16101619
16111620 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)
16131633 if err != nil {
16141634 return nil, fmt.Errorf("vev: waiting for durable session state: %w", err)
16151635 }
@@ -1632,16 +1652,44 @@ func listLocalSessions(ctx context.Context) (_ []protocol.SessionInfo, retErr er
16321652 }
16331653 return infos, nil
16341654 }
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()
16351677 defer func() { _ = transport.Close() }()
16361678
16371679 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)
16391684 }
16401685 reply, err := transport.Recv()
16411686 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)
16431691 }
1644- return decodeSessionListReply(reply)
1692+ return reply, nil
16451693 }
16461694
16471695 func decodeSessionListReply(reply wire.Frame) ([]protocol.SessionInfo, error) {
@@ -900,10 +900,13 @@ type runAttachDeps struct {
900 runtimeObserver ports.SerializedRuntimeObserver900 runtimeObserver ports.SerializedRuntimeObserver
901 ui *client.UI901 ui *client.UI
902 terminal func() ports.Terminal902 terminal func() ports.Terminal
903- clock func() ports.Clock903+ // interactiveConsole decides whether the missing-session prompt may read
904- disableCapabilityProbe bool904+ // its answer from a console. Nil probes the client terminal's input.
905- localEnvironment []string905+ interactiveConsole func(ports.Terminal) bool
906- remoteEnvironment func(string) []string906+ 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 enables911 // 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 }, nil977 }, 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.runClient996 runClient := deps.runClient
988 if runClient == nil {997 if runClient == nil {
989 runClient = runClientWithDeps998 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, nil1653 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 (
2424 "github.com/bnema/vev/internal/domain"
2525 "github.com/bnema/vev/internal/persist"
2626 "github.com/bnema/vev/internal/ports"
27+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
2728 "github.com/bnema/vev/internal/protocol"
2829 "github.com/bnema/vev/internal/protocol/catalogue"
2930 "github.com/bnema/vev/internal/protocol/wire"
@@ -900,11 +901,6 @@ func requireNamedClientDialer(t *testing.T, ctx context.Context, dialer ports.Cl
900901 require.EqualError(t, err, "not used: "+name)
901902 }
902903
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-
908904 func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
909905 tests := []struct {
910906 name string
@@ -920,7 +916,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
920916 var gotDialer string
921917 var gotRemote bool
922918 var gotClipboard ports.ClipboardReader
923- clip := &fakeClipboardReader{}
919+ clip := portsmocks.NewMockClipboardReader(t)
924920 factory := newRemoteDialerFactoryMock(t)
925921 factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
926922
@@ -1162,7 +1158,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
11621158 var gotDialer string
11631159 gotRemote := true
11641160 var gotClipboard ports.ClipboardReader
1165- clip := &fakeClipboardReader{}
1161+ clip := portsmocks.NewMockClipboardReader(t)
11661162 factory := newRemoteDialerFactoryMock(t)
11671163 err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{
11681164 localDialer: func() wire.Dialer { return namedDialer{name: "local"} },
@@ -1431,3 +1427,24 @@ func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
14311427 _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial", clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)))
14321428 require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
14331429 }
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 string906 name string
@@ -920,7 +916,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
920 var gotDialer string916 var gotDialer string
921 var gotRemote bool917 var gotRemote bool
922 var gotClipboard ports.ClipboardReader918 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 string1158 var gotDialer string
1163 gotRemote := true1159 gotRemote := true
1164 var gotClipboard ports.ClipboardReader1160 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
22
33 import (
44 "bufio"
5+ "context"
56 "errors"
67 "fmt"
78 "io"
@@ -20,6 +21,32 @@ func NewConfirmer(in io.Reader, out io.Writer) Confirmer {
2021 return Confirmer{in: in, out: out}
2122 }
2223
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+
2350 // Confirm writes question with a [y/N] default and reads one answer line.
2451 func (c Confirmer) Confirm(question string) (bool, error) {
2552 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
22
33 import (
44 "bytes"
5+ "context"
6+ "errors"
7+ "io"
58 "strings"
69 "testing"
10+ "time"
711 )
812
913 func TestConfirmerAcceptsYes(t *testing.T) {
@@ -49,3 +53,44 @@ func TestConfirmerRejectsUnknownAnswer(t *testing.T) {
4953 t.Fatal("Confirm returned true for unknown answer, want false")
5054 }
5155 }
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) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close