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}