v0.7.0 4c16dd8

attach_missing_prompt.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"fmt"
6	"io"
7	"os"
8	"sync"
9
10	"github.com/bnema/vev/internal/adapters/clock"
11	"github.com/bnema/vev/internal/ports"
12	"github.com/bnema/vev/internal/protocol"
13	"github.com/bnema/vev/internal/usecase/client"
14	"github.com/bnema/vev/internal/usecase/confirm"
15	"github.com/bnema/vev/pkg/rawterm"
16)
17
18// terminalIsInteractive reports whether the prompt can read its answer from a
19// console. The client terminal's input has to be a real terminal file: pipes,
20// redirected files, and detached readers are not consoles, so the prompt
21// never blocks on a stream nobody reads from the keyboard.
22func terminalIsInteractive(terminal ports.Terminal) bool {
23	if terminal == nil {
24		return false
25	}
26	file, ok := terminal.In().(*os.File)
27	return ok && rawterm.IsTerminal(int(file.Fd()))
28}
29
30// attachInteractiveConsole is the composition-root seam for the console
31// decision. Production probes the real terminal file; tests inject the
32// decision so the prompt matrix runs without owning a real TTY.
33var attachInteractiveConsole = terminalIsInteractive
34
35// flushingWriter pushes every prompt write to the console before the
36// confirmer blocks on the answer. Terminal output is buffered, so without
37// this flush the user waits in front of a blank console until the client
38// repaints.
39type flushingWriter struct {
40	out   io.Writer
41	flush func() error
42}
43
44func (w flushingWriter) Write(data []byte) (int, error) {
45	written, err := w.out.Write(data)
46	if err != nil {
47		return written, err
48	}
49	if err := w.flush(); err != nil {
50		return written, err
51	}
52	return written, nil
53}
54
55// resolveAttachCreationIntent decides the attach intent for one direct
56// `attach <name>` target (local when remoteTarget is empty, otherwise a
57// configured remote host) before the client owns any console reader or raw
58// mode. Existence is asked of the destination daemon itself through a list
59// that may start it, never read from the broker catalogue: right after
60// `kill --all` the catalogue is empty while the daemon still restores the
61// session from disk.
62//
63// When the session exists, the host is not configured, or the daemon cannot
64// answer, it keeps IntentAttach; the named attach then lets the daemon decide
65// and report its own refusal. Only a daemon listing without the name offers
66// creation on an interactive console, returning IntentNew on confirmation; a
67// declined answer or a non-interactive console keeps IntentAttach.
68//
69// The check runs before Supervisor.Run enters raw mode, so the create prompt
70// is always the sole console reader: no attachment worker can leave a
71// blocked input-pump read behind to steal the answer.
72//
73// When the peek connection succeeds, it is returned so the caller can hand it
74// to a preconnectedBrokerConnector instead of dialing the broker a second
75// time: an ordinary attach, confirmed or declined, still opens exactly one
76// broker connection.
77func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remoteTarget string, terminal ports.Terminal) (uint8, ports.BrokerService, error) {
78	if intent != protocol.IntentAttach || name == "" {
79		return intent, nil, nil
80	}
81	service, err := connectProductionClientBroker(ctx)
82	if err != nil {
83		// Existence could not be determined: keep the plain attach path and let
84		// the resolver's own refusal (or the ordinary connector's own retry
85		// cadence) decide, exactly as if this preflight had never run.
86		return protocol.IntentAttach, nil, nil
87	}
88	if !daemonReportsSessionMissing(ctx, service, remoteTarget, name) {
89		return protocol.IntentAttach, service, nil
90	}
91	resolved, err := confirmMissingSessionCreate(ctx, name, terminal)
92	if err != nil {
93		_ = service.Close()
94		return protocol.IntentAttach, nil, err
95	}
96	return resolved, service, nil
97}
98
99// attachSessionLister is the daemon listing seam; production starts the
100// destination daemon if needed so restored sessions are included.
101var attachSessionLister = func(ctx context.Context, service ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) {
102	operations, err := client.NewBrokerOperations(service, clock.New())
103	if err != nil {
104		return nil, err
105	}
106	return operations.ListStarting(ctx, route)
107}
108
109// daemonReportsSessionMissing reports true only when the destination daemon
110// answered a listing that does not carry name. An unconfigured host or a
111// failed listing proves nothing, so it reports false.
112func daemonReportsSessionMissing(ctx context.Context, service ports.BrokerService, remoteTarget, name string) bool {
113	snapshot := service.Snapshot()
114	route := client.BrokerOperationRoute{Epoch: snapshot.Epoch, Destination: ports.BrokerEndpointFence{Local: true}}
115	if remoteTarget != "" {
116		observation, ok := snapshot.Find(remoteTarget)
117		if !ok {
118			return false
119		}
120		route.Destination = ports.BrokerEndpointFence{Registration: observation.Registration}
121	}
122	sessions, err := attachSessionLister(ctx, service, route)
123	if err != nil {
124		return false
125	}
126	for _, session := range sessions {
127		if session.Name == name {
128			return false
129		}
130	}
131	return true
132}
133
134// confirmMissingSessionCreate prompts to create an absent session and maps
135// the answer to the attach intent: IntentNew on confirmation, IntentAttach on
136// decline. A cancelled wait surfaces the cancellation error. Non-interactive
137// consoles, a declined answer, or unreadable input keep IntentAttach. Only
138// y/yes answers confirm; empty, no, and unknown answers decline.
139func confirmMissingSessionCreate(ctx context.Context, name string, terminal ports.Terminal) (uint8, error) {
140	if err := ctx.Err(); err != nil {
141		return protocol.IntentAttach, err
142	}
143	if name == "" {
144		return protocol.IntentAttach, nil
145	}
146	if !attachInteractiveConsole(terminal) {
147		return protocol.IntentAttach, nil
148	}
149	in, out := terminal.In(), terminal.Out()
150	if in == nil || out == nil {
151		return protocol.IntentAttach, nil
152	}
153	prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush})
154	create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name))
155	if err != nil {
156		if ctxErr := ctx.Err(); ctxErr != nil {
157			return protocol.IntentAttach, ctxErr
158		}
159		// A console that cannot show the question must not consume an answer:
160		// keep the plain attach path and let the resolver decide.
161		return protocol.IntentAttach, nil
162	}
163	if !create {
164		return protocol.IntentAttach, nil
165	}
166	if err := ctx.Err(); err != nil {
167		return protocol.IntentAttach, err
168	}
169	return protocol.IntentNew, nil
170}
171
172// preconnectedBrokerConnector hands one already-connected broker service to
173// the caller's first Connect call, then falls back to the wrapped connector
174// for every later attempt (the supervisor's own reconnect cadence). It exists
175// only to avoid wasting the connection the attach-creation preflight already
176// established: the supervisor still owns and closes whatever it adopts.
177type preconnectedBrokerConnector struct {
178	first    ports.BrokerService
179	fallback ports.BrokerConnector
180
181	mu   sync.Mutex
182	used bool
183}
184
185func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
186	c.mu.Lock()
187	if !c.used {
188		c.used = true
189		first := c.first
190		c.mu.Unlock()
191		if first != nil {
192			return first, nil
193		}
194	} else {
195		c.mu.Unlock()
196	}
197	return c.fallback.Connect(ctx)
198}
199
200var _ ports.BrokerConnector = (*preconnectedBrokerConnector)(nil)
201
202// withPreconnected wraps connector to serve preconnected as its first
203// connection, or returns connector unchanged when preconnected is nil.
204func withPreconnected(connector ports.BrokerConnector, preconnected ports.BrokerService) ports.BrokerConnector {
205	if preconnected == nil {
206		return connector
207	}
208	return &preconnectedBrokerConnector{first: preconnected, fallback: connector}
209}

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