feat(app): prompt before creating missing named sessions
- commit
- 0be98f18aabaaff06e3fe66cc3922716cf65c1df
- author
- brice · · 2026-09-22 20:51 UTC
- parent
- 4895225
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +695 -3
A internal/app/attach_missing_prompt.go +196 -0
| @@ -0,0 +1,196 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "sync" | |
| 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 | + | // terminalIsInteractive reports whether the prompt can read its answer from a | |
| 17 | + | // console. The client terminal's input has to be a real terminal file: pipes, | |
| 18 | + | // redirected files, and detached readers are not consoles, so the prompt | |
| 19 | + | // never blocks on a stream nobody reads from the keyboard. | |
| 20 | + | func terminalIsInteractive(terminal ports.Terminal) bool { | |
| 21 | + | if terminal == nil { | |
| 22 | + | return false | |
| 23 | + | } | |
| 24 | + | file, ok := terminal.In().(*os.File) | |
| 25 | + | return ok && rawterm.IsTerminal(int(file.Fd())) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // attachInteractiveConsole is the composition-root seam for the console | |
| 29 | + | // decision. Production probes the real terminal file; tests inject the | |
| 30 | + | // decision so the prompt matrix runs without owning a real TTY. | |
| 31 | + | var attachInteractiveConsole = terminalIsInteractive | |
| 32 | + | ||
| 33 | + | // flushingWriter pushes every prompt write to the console before the | |
| 34 | + | // confirmer blocks on the answer. Terminal output is buffered, so without | |
| 35 | + | // this flush the user waits in front of a blank console until the client | |
| 36 | + | // repaints. | |
| 37 | + | type flushingWriter struct { | |
| 38 | + | out io.Writer | |
| 39 | + | flush func() error | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (w flushingWriter) Write(data []byte) (int, error) { | |
| 43 | + | written, err := w.out.Write(data) | |
| 44 | + | if err != nil { | |
| 45 | + | return written, err | |
| 46 | + | } | |
| 47 | + | if err := w.flush(); err != nil { | |
| 48 | + | return written, err | |
| 49 | + | } | |
| 50 | + | return written, nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // resolveAttachCreationIntent decides the attach intent for one direct exact | |
| 54 | + | // `attach <name>` target (local when remoteTarget is empty, otherwise a | |
| 55 | + | // configured remote host) before the client owns any console reader or raw | |
| 56 | + | // mode. It reuses the broker's own attach resolution (the same exact-target | |
| 57 | + | // lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so | |
| 58 | + | // existence is decided from one committed broker publication, never from | |
| 59 | + | // error-text matching. | |
| 60 | + | // | |
| 61 | + | // When the session exists, the host is not configured, or existence cannot be | |
| 62 | + | // determined (the broker is unreachable), it keeps IntentAttach unchanged and | |
| 63 | + | // the resolver's own rejection, if any, surfaces unchanged later. When the | |
| 64 | + | // session is confirmed absent on a known host, it offers creation on an | |
| 65 | + | // interactive console and returns IntentNew on confirmation; a declined | |
| 66 | + | // answer, an unusable console, or a non-interactive console all keep | |
| 67 | + | // IntentAttach so the existing resolver reports its own clear refusal. | |
| 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. | |
| 77 | + | func 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 | + | exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name) | |
| 89 | + | if exists || !hostKnown { | |
| 90 | + | return protocol.IntentAttach, service, nil | |
| 91 | + | } | |
| 92 | + | resolved, err := confirmMissingSessionCreate(ctx, name, terminal) | |
| 93 | + | if err != nil { | |
| 94 | + | _ = service.Close() | |
| 95 | + | return protocol.IntentAttach, nil, err | |
| 96 | + | } | |
| 97 | + | return resolved, service, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // attachTargetSessionExists reports whether one exact-name attach target is | |
| 101 | + | // present in the committed publication, and whether the target's daemon (the | |
| 102 | + | // local daemon, or the named remote host) is even a configured observation. | |
| 103 | + | // hostKnown is false only when the daemon itself is absent from the snapshot; | |
| 104 | + | // a known daemon with no matching session name reports exists=false, | |
| 105 | + | // hostKnown=true. | |
| 106 | + | func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) { | |
| 107 | + | var observation ports.BrokerDaemonObservation | |
| 108 | + | var ok bool | |
| 109 | + | if remoteTarget == "" { | |
| 110 | + | observation, ok = localBrokerObservation(snapshot) | |
| 111 | + | } else { | |
| 112 | + | observation, ok = snapshot.Find(remoteTarget) | |
| 113 | + | } | |
| 114 | + | if !ok { | |
| 115 | + | return false, false | |
| 116 | + | } | |
| 117 | + | _, found := brokerObservationExactTarget(observation, name) | |
| 118 | + | return found, true | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // confirmMissingSessionCreate prompts to create an absent session and maps | |
| 122 | + | // the answer to the attach intent: IntentNew on confirmation, IntentAttach on | |
| 123 | + | // decline. A cancelled wait surfaces the cancellation error. Non-interactive | |
| 124 | + | // consoles, a declined answer, or unreadable input keep IntentAttach. Only | |
| 125 | + | // y/yes answers confirm; empty, no, and unknown answers decline. | |
| 126 | + | func confirmMissingSessionCreate(ctx context.Context, name string, terminal ports.Terminal) (uint8, error) { | |
| 127 | + | if err := ctx.Err(); err != nil { | |
| 128 | + | return protocol.IntentAttach, err | |
| 129 | + | } | |
| 130 | + | if name == "" { | |
| 131 | + | return protocol.IntentAttach, nil | |
| 132 | + | } | |
| 133 | + | if !attachInteractiveConsole(terminal) { | |
| 134 | + | return protocol.IntentAttach, nil | |
| 135 | + | } | |
| 136 | + | in, out := terminal.In(), terminal.Out() | |
| 137 | + | if in == nil || out == nil { | |
| 138 | + | return protocol.IntentAttach, nil | |
| 139 | + | } | |
| 140 | + | prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush}) | |
| 141 | + | create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name)) | |
| 142 | + | if err != nil { | |
| 143 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 144 | + | return protocol.IntentAttach, ctxErr | |
| 145 | + | } | |
| 146 | + | // A console that cannot show the question must not consume an answer: | |
| 147 | + | // keep the plain attach path and let the resolver decide. | |
| 148 | + | return protocol.IntentAttach, nil | |
| 149 | + | } | |
| 150 | + | if !create { | |
| 151 | + | return protocol.IntentAttach, nil | |
| 152 | + | } | |
| 153 | + | if err := ctx.Err(); err != nil { | |
| 154 | + | return protocol.IntentAttach, err | |
| 155 | + | } | |
| 156 | + | return protocol.IntentNew, nil | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // preconnectedBrokerConnector hands one already-connected broker service to | |
| 160 | + | // the caller's first Connect call, then falls back to the wrapped connector | |
| 161 | + | // for every later attempt (the supervisor's own reconnect cadence). It exists | |
| 162 | + | // only to avoid wasting the connection the attach-creation preflight already | |
| 163 | + | // established: the supervisor still owns and closes whatever it adopts. | |
| 164 | + | type preconnectedBrokerConnector struct { | |
| 165 | + | first ports.BrokerService | |
| 166 | + | fallback ports.BrokerConnector | |
| 167 | + | ||
| 168 | + | mu sync.Mutex | |
| 169 | + | used bool | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) { | |
| 173 | + | c.mu.Lock() | |
| 174 | + | if !c.used { | |
| 175 | + | c.used = true | |
| 176 | + | first := c.first | |
| 177 | + | c.mu.Unlock() | |
| 178 | + | if first != nil { | |
| 179 | + | return first, nil | |
| 180 | + | } | |
| 181 | + | } else { | |
| 182 | + | c.mu.Unlock() | |
| 183 | + | } | |
| 184 | + | return c.fallback.Connect(ctx) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | var _ ports.BrokerConnector = (*preconnectedBrokerConnector)(nil) | |
| 188 | + | ||
| 189 | + | // withPreconnected wraps connector to serve preconnected as its first | |
| 190 | + | // connection, or returns connector unchanged when preconnected is nil. | |
| 191 | + | func withPreconnected(connector ports.BrokerConnector, preconnected ports.BrokerService) ports.BrokerConnector { | |
| 192 | + | if preconnected == nil { | |
| 193 | + | return connector | |
| 194 | + | } | |
| 195 | + | return &preconnectedBrokerConnector{first: preconnected, fallback: connector} | |
| 196 | + | } | |
| @@ -0,0 +1,196 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "sync" | |||
| 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 | + | // terminalIsInteractive reports whether the prompt can read its answer from a | |||
| 17 | + | // console. The client terminal's input has to be a real terminal file: pipes, | |||
| 18 | + | // redirected files, and detached readers are not consoles, so the prompt | |||
| 19 | + | // never blocks on a stream nobody reads from the keyboard. | |||
| 20 | + | func terminalIsInteractive(terminal ports.Terminal) bool { | |||
| 21 | + | if terminal == nil { | |||
| 22 | + | return false | |||
| 23 | + | } | |||
| 24 | + | file, ok := terminal.In().(*os.File) | |||
| 25 | + | return ok && rawterm.IsTerminal(int(file.Fd())) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // attachInteractiveConsole is the composition-root seam for the console | |||
| 29 | + | // decision. Production probes the real terminal file; tests inject the | |||
| 30 | + | // decision so the prompt matrix runs without owning a real TTY. | |||
| 31 | + | var attachInteractiveConsole = terminalIsInteractive | |||
| 32 | + | ||||
| 33 | + | // flushingWriter pushes every prompt write to the console before the | |||
| 34 | + | // confirmer blocks on the answer. Terminal output is buffered, so without | |||
| 35 | + | // this flush the user waits in front of a blank console until the client | |||
| 36 | + | // repaints. | |||
| 37 | + | type flushingWriter struct { | |||
| 38 | + | out io.Writer | |||
| 39 | + | flush func() error | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (w flushingWriter) Write(data []byte) (int, error) { | |||
| 43 | + | written, err := w.out.Write(data) | |||
| 44 | + | if err != nil { | |||
| 45 | + | return written, err | |||
| 46 | + | } | |||
| 47 | + | if err := w.flush(); err != nil { | |||
| 48 | + | return written, err | |||
| 49 | + | } | |||
| 50 | + | return written, nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // resolveAttachCreationIntent decides the attach intent for one direct exact | |||
| 54 | + | // `attach <name>` target (local when remoteTarget is empty, otherwise a | |||
| 55 | + | // configured remote host) before the client owns any console reader or raw | |||
| 56 | + | // mode. It reuses the broker's own attach resolution (the same exact-target | |||
| 57 | + | // lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so | |||
| 58 | + | // existence is decided from one committed broker publication, never from | |||
| 59 | + | // error-text matching. | |||
| 60 | + | // | |||
| 61 | + | // When the session exists, the host is not configured, or existence cannot be | |||
| 62 | + | // determined (the broker is unreachable), it keeps IntentAttach unchanged and | |||
| 63 | + | // the resolver's own rejection, if any, surfaces unchanged later. When the | |||
| 64 | + | // session is confirmed absent on a known host, it offers creation on an | |||
| 65 | + | // interactive console and returns IntentNew on confirmation; a declined | |||
| 66 | + | // answer, an unusable console, or a non-interactive console all keep | |||
| 67 | + | // IntentAttach so the existing resolver reports its own clear refusal. | |||
| 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. | |||
| 77 | + | func 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 | + | exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name) | |||
| 89 | + | if exists || !hostKnown { | |||
| 90 | + | return protocol.IntentAttach, service, nil | |||
| 91 | + | } | |||
| 92 | + | resolved, err := confirmMissingSessionCreate(ctx, name, terminal) | |||
| 93 | + | if err != nil { | |||
| 94 | + | _ = service.Close() | |||
| 95 | + | return protocol.IntentAttach, nil, err | |||
| 96 | + | } | |||
| 97 | + | return resolved, service, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // attachTargetSessionExists reports whether one exact-name attach target is | |||
| 101 | + | // present in the committed publication, and whether the target's daemon (the | |||
| 102 | + | // local daemon, or the named remote host) is even a configured observation. | |||
| 103 | + | // hostKnown is false only when the daemon itself is absent from the snapshot; | |||
| 104 | + | // a known daemon with no matching session name reports exists=false, | |||
| 105 | + | // hostKnown=true. | |||
| 106 | + | func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) { | |||
| 107 | + | var observation ports.BrokerDaemonObservation | |||
| 108 | + | var ok bool | |||
| 109 | + | if remoteTarget == "" { | |||
| 110 | + | observation, ok = localBrokerObservation(snapshot) | |||
| 111 | + | } else { | |||
| 112 | + | observation, ok = snapshot.Find(remoteTarget) | |||
| 113 | + | } | |||
| 114 | + | if !ok { | |||
| 115 | + | return false, false | |||
| 116 | + | } | |||
| 117 | + | _, found := brokerObservationExactTarget(observation, name) | |||
| 118 | + | return found, true | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // confirmMissingSessionCreate prompts to create an absent session and maps | |||
| 122 | + | // the answer to the attach intent: IntentNew on confirmation, IntentAttach on | |||
| 123 | + | // decline. A cancelled wait surfaces the cancellation error. Non-interactive | |||
| 124 | + | // consoles, a declined answer, or unreadable input keep IntentAttach. Only | |||
| 125 | + | // y/yes answers confirm; empty, no, and unknown answers decline. | |||
| 126 | + | func confirmMissingSessionCreate(ctx context.Context, name string, terminal ports.Terminal) (uint8, error) { | |||
| 127 | + | if err := ctx.Err(); err != nil { | |||
| 128 | + | return protocol.IntentAttach, err | |||
| 129 | + | } | |||
| 130 | + | if name == "" { | |||
| 131 | + | return protocol.IntentAttach, nil | |||
| 132 | + | } | |||
| 133 | + | if !attachInteractiveConsole(terminal) { | |||
| 134 | + | return protocol.IntentAttach, nil | |||
| 135 | + | } | |||
| 136 | + | in, out := terminal.In(), terminal.Out() | |||
| 137 | + | if in == nil || out == nil { | |||
| 138 | + | return protocol.IntentAttach, nil | |||
| 139 | + | } | |||
| 140 | + | prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush}) | |||
| 141 | + | create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name)) | |||
| 142 | + | if err != nil { | |||
| 143 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 144 | + | return protocol.IntentAttach, ctxErr | |||
| 145 | + | } | |||
| 146 | + | // A console that cannot show the question must not consume an answer: | |||
| 147 | + | // keep the plain attach path and let the resolver decide. | |||
| 148 | + | return protocol.IntentAttach, nil | |||
| 149 | + | } | |||
| 150 | + | if !create { | |||
| 151 | + | return protocol.IntentAttach, nil | |||
| 152 | + | } | |||
| 153 | + | if err := ctx.Err(); err != nil { | |||
| 154 | + | return protocol.IntentAttach, err | |||
| 155 | + | } | |||
| 156 | + | return protocol.IntentNew, nil | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // preconnectedBrokerConnector hands one already-connected broker service to | |||
| 160 | + | // the caller's first Connect call, then falls back to the wrapped connector | |||
| 161 | + | // for every later attempt (the supervisor's own reconnect cadence). It exists | |||
| 162 | + | // only to avoid wasting the connection the attach-creation preflight already | |||
| 163 | + | // established: the supervisor still owns and closes whatever it adopts. | |||
| 164 | + | type preconnectedBrokerConnector struct { | |||
| 165 | + | first ports.BrokerService | |||
| 166 | + | fallback ports.BrokerConnector | |||
| 167 | + | ||||
| 168 | + | mu sync.Mutex | |||
| 169 | + | used bool | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) { | |||
| 173 | + | c.mu.Lock() | |||
| 174 | + | if !c.used { | |||
| 175 | + | c.used = true | |||
| 176 | + | first := c.first | |||
| 177 | + | c.mu.Unlock() | |||
| 178 | + | if first != nil { | |||
| 179 | + | return first, nil | |||
| 180 | + | } | |||
| 181 | + | } else { | |||
| 182 | + | c.mu.Unlock() | |||
| 183 | + | } | |||
| 184 | + | return c.fallback.Connect(ctx) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | var _ ports.BrokerConnector = (*preconnectedBrokerConnector)(nil) | |||
| 188 | + | ||||
| 189 | + | // withPreconnected wraps connector to serve preconnected as its first | |||
| 190 | + | // connection, or returns connector unchanged when preconnected is nil. | |||
| 191 | + | func withPreconnected(connector ports.BrokerConnector, preconnected ports.BrokerService) ports.BrokerConnector { | |||
| 192 | + | if preconnected == nil { | |||
| 193 | + | return connector | |||
| 194 | + | } | |||
| 195 | + | return &preconnectedBrokerConnector{first: preconnected, fallback: connector} | |||
| 196 | + | } | |||
A internal/app/attach_missing_prompt_test.go +480 -0
| @@ -0,0 +1,480 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | // Attach-missing create prompt (Plan 003 E4). | |
| 4 | + | // | |
| 5 | + | // resolveAttachCreationIntent restores the `vev attach <missing>` create | |
| 6 | + | // prompt main once ran through the direct-dial preflight, but through the | |
| 7 | + | // broker: it peeks the first committed publication over the same | |
| 8 | + | // connectProductionClientBroker seam the ordinary terminal composition uses, | |
| 9 | + | // decides existence with the exact lookup the later resolver also runs | |
| 10 | + | // (attachTargetSessionExists / brokerObservationExactTarget), and on a | |
| 11 | + | // confirmed create hands its already-open connection back so the run still | |
| 12 | + | // opens exactly one broker connection. | |
| 13 | + | ||
| 14 | + | import ( | |
| 15 | + | "context" | |
| 16 | + | "errors" | |
| 17 | + | "io" | |
| 18 | + | "os" | |
| 19 | + | "strings" | |
| 20 | + | "syscall" | |
| 21 | + | "testing" | |
| 22 | + | "time" | |
| 23 | + | ||
| 24 | + | "github.com/stretchr/testify/require" | |
| 25 | + | ||
| 26 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 27 | + | "github.com/bnema/vev/internal/ports" | |
| 28 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 29 | + | "github.com/bnema/vev/internal/protocol" | |
| 30 | + | "github.com/bnema/vev/pkg/rawterm" | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // interactiveProbe injects the console decision, standing in for the | |
| 34 | + | // production probe so the prompt matrix runs without owning a real TTY. | |
| 35 | + | func interactiveProbe(interactive bool) func(ports.Terminal) bool { | |
| 36 | + | return func(ports.Terminal) bool { return interactive } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // withAttachInteractiveConsole installs a scripted console decision for the | |
| 40 | + | // duration of a test. | |
| 41 | + | func withAttachInteractiveConsole(t *testing.T, interactive bool) { | |
| 42 | + | t.Helper() | |
| 43 | + | previous := attachInteractiveConsole | |
| 44 | + | attachInteractiveConsole = interactiveProbe(interactive) | |
| 45 | + | t.Cleanup(func() { attachInteractiveConsole = previous }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // withPreflightBroker installs a scripted connectProductionClientBroker seam | |
| 49 | + | // for the duration of a test, exactly like the terminal composition tests in | |
| 50 | + | // broker_client_test.go, so the preflight peek never reaches a production | |
| 51 | + | // broker. | |
| 52 | + | func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.BrokerService, error)) { | |
| 53 | + | t.Helper() | |
| 54 | + | previous := connectProductionClientBroker | |
| 55 | + | connectProductionClientBroker = connect | |
| 56 | + | t.Cleanup(func() { connectProductionClientBroker = previous }) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestAttachTargetSessionExists(t *testing.T) { | |
| 60 | + | tests := []struct { | |
| 61 | + | name string | |
| 62 | + | snapshot ports.BrokerSnapshot | |
| 63 | + | remote string | |
| 64 | + | target string | |
| 65 | + | wantExists bool | |
| 66 | + | wantHostKnow bool | |
| 67 | + | }{ | |
| 68 | + | { | |
| 69 | + | name: "local session present", | |
| 70 | + | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |
| 71 | + | target: "work", | |
| 72 | + | wantExists: true, | |
| 73 | + | wantHostKnow: true, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "local session absent", | |
| 77 | + | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |
| 78 | + | target: "gone", | |
| 79 | + | wantExists: false, | |
| 80 | + | wantHostKnow: true, | |
| 81 | + | }, | |
| 82 | + | { | |
| 83 | + | name: "local daemon absent from snapshot", | |
| 84 | + | snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1}, | |
| 85 | + | target: "gone", | |
| 86 | + | wantExists: false, | |
| 87 | + | wantHostKnow: false, | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "remote session present", | |
| 91 | + | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |
| 92 | + | remote: "user@example.test", | |
| 93 | + | target: "work", | |
| 94 | + | wantExists: true, | |
| 95 | + | wantHostKnow: true, | |
| 96 | + | }, | |
| 97 | + | { | |
| 98 | + | name: "remote session absent on known host", | |
| 99 | + | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |
| 100 | + | remote: "user@example.test", | |
| 101 | + | target: "gone", | |
| 102 | + | wantExists: false, | |
| 103 | + | wantHostKnow: true, | |
| 104 | + | }, | |
| 105 | + | { | |
| 106 | + | name: "remote host not configured", | |
| 107 | + | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |
| 108 | + | remote: "user@elsewhere.test", | |
| 109 | + | target: "work", | |
| 110 | + | wantExists: false, | |
| 111 | + | wantHostKnow: false, | |
| 112 | + | }, | |
| 113 | + | } | |
| 114 | + | for _, tc := range tests { | |
| 115 | + | t.Run(tc.name, func(t *testing.T) { | |
| 116 | + | exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target) | |
| 117 | + | require.Equal(t, tc.wantExists, exists) | |
| 118 | + | require.Equal(t, tc.wantHostKnow, hostKnown) | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestResolveAttachCreationIntentSkipsNonAttach(t *testing.T) { | |
| 124 | + | for _, tc := range []struct { | |
| 125 | + | name string | |
| 126 | + | intent uint8 | |
| 127 | + | target string | |
| 128 | + | }{ | |
| 129 | + | {name: "create", intent: protocol.IntentNew, target: "scratch"}, | |
| 130 | + | {name: "ephemeral", intent: protocol.IntentEphemeral, target: ""}, | |
| 131 | + | {name: "attach with no name", intent: protocol.IntentAttach, target: ""}, | |
| 132 | + | } { | |
| 133 | + | t.Run(tc.name, func(t *testing.T) { | |
| 134 | + | connected := false | |
| 135 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { | |
| 136 | + | connected = true | |
| 137 | + | return nil, errors.New("must not connect") | |
| 138 | + | }) | |
| 139 | + | intent, service, err := resolveAttachCreationIntent(context.Background(), tc.intent, tc.target, "", nil) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | require.Equal(t, tc.intent, intent) | |
| 142 | + | require.Nil(t, service) | |
| 143 | + | require.False(t, connected, "a non-attach or nameless intent never peeks the broker") | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) { | |
| 149 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { | |
| 150 | + | return nil, errors.New("broker unreachable") | |
| 151 | + | }) | |
| 152 | + | intent, service, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "scratch", "", nil) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | require.Equal(t, protocol.IntentAttach, intent) | |
| 155 | + | require.Nil(t, service, "an undeterminable preflight must not hand back a connection") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) { | |
| 159 | + | service := portsmocks.NewMockBrokerService(t) | |
| 160 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |
| 161 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |
| 162 | + | withAttachInteractiveConsole(t, true) // must not even be consulted | |
| 163 | + | ||
| 164 | + | intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "", nil) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.Equal(t, protocol.IntentAttach, intent) | |
| 167 | + | require.Same(t, service, got, "the peeked connection is handed back for reuse") | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestResolveAttachCreationIntentUnknownHostAttachesWithoutPrompt(t *testing.T) { | |
| 171 | + | service := portsmocks.NewMockBrokerService(t) | |
| 172 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |
| 173 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |
| 174 | + | withAttachInteractiveConsole(t, true) | |
| 175 | + | ||
| 176 | + | intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "user@elsewhere.test", nil) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | require.Equal(t, protocol.IntentAttach, intent) | |
| 179 | + | require.Same(t, service, got) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) { | |
| 183 | + | tests := []struct { | |
| 184 | + | name string | |
| 185 | + | remote string | |
| 186 | + | answer string | |
| 187 | + | interactive bool | |
| 188 | + | wantIntent uint8 | |
| 189 | + | wantPromptPart string | |
| 190 | + | wantNoPrompt bool | |
| 191 | + | }{ | |
| 192 | + | {name: "local confirm creates", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "gone" doesn't exist, want to create and attach to it? [y/N]`}, | |
| 193 | + | {name: "local decline attaches", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "gone"}, | |
| 194 | + | {name: "local empty answer attaches", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach}, | |
| 195 | + | {name: "local unknown answer attaches", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"}, | |
| 196 | + | {name: "non-interactive console attaches without prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |
| 197 | + | {name: "remote confirm creates", remote: "user@example.test", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "gone" doesn't exist`}, | |
| 198 | + | } | |
| 199 | + | for _, tc := range tests { | |
| 200 | + | t.Run(tc.name, func(t *testing.T) { | |
| 201 | + | var snapshot ports.BrokerSnapshot | |
| 202 | + | if tc.remote == "" { | |
| 203 | + | snapshot = terminalCompositionSnapshot([]string{"work"}, nil) | |
| 204 | + | } else { | |
| 205 | + | snapshot = terminalCompositionSnapshot(nil, map[string][]string{tc.remote: {"work"}}) | |
| 206 | + | } | |
| 207 | + | service := portsmocks.NewMockBrokerService(t) | |
| 208 | + | service.EXPECT().Snapshot().Return(snapshot).Once() | |
| 209 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |
| 210 | + | withAttachInteractiveConsole(t, tc.interactive) | |
| 211 | + | ||
| 212 | + | terminal := portsmocks.NewMockTerminal(t) | |
| 213 | + | var promptOut strings.Builder | |
| 214 | + | terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe() | |
| 215 | + | terminal.EXPECT().Out().Return(&promptOut).Maybe() | |
| 216 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |
| 217 | + | ||
| 218 | + | intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "gone", tc.remote, terminal) | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | require.Equal(t, tc.wantIntent, intent) | |
| 221 | + | require.Same(t, service, got, "the peeked connection is always handed back on a definite answer") | |
| 222 | + | if tc.wantPromptPart != "" { | |
| 223 | + | require.Contains(t, promptOut.String(), tc.wantPromptPart) | |
| 224 | + | } | |
| 225 | + | if tc.wantNoPrompt { | |
| 226 | + | require.Empty(t, promptOut.String()) | |
| 227 | + | } | |
| 228 | + | }) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) { | |
| 233 | + | service := portsmocks.NewMockBrokerService(t) | |
| 234 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |
| 235 | + | service.EXPECT().Close().Return(nil).Once() | |
| 236 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |
| 237 | + | withAttachInteractiveConsole(t, true) | |
| 238 | + | ||
| 239 | + | terminal := portsmocks.NewMockTerminal(t) | |
| 240 | + | reader, writer := io.Pipe() | |
| 241 | + | t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() }) | |
| 242 | + | terminal.EXPECT().In().Return(reader).Maybe() | |
| 243 | + | terminal.EXPECT().Out().Return(io.Discard).Maybe() | |
| 244 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |
| 245 | + | ||
| 246 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 247 | + | done := make(chan struct { | |
| 248 | + | intent uint8 | |
| 249 | + | service ports.BrokerService | |
| 250 | + | err error | |
| 251 | + | }, 1) | |
| 252 | + | go func() { | |
| 253 | + | intent, got, err := resolveAttachCreationIntent(ctx, protocol.IntentAttach, "gone", "", terminal) | |
| 254 | + | done <- struct { | |
| 255 | + | intent uint8 | |
| 256 | + | service ports.BrokerService | |
| 257 | + | err error | |
| 258 | + | }{intent, got, err} | |
| 259 | + | }() | |
| 260 | + | cancel() | |
| 261 | + | select { | |
| 262 | + | case result := <-done: | |
| 263 | + | require.ErrorIs(t, result.err, context.Canceled) | |
| 264 | + | require.Nil(t, result.service, "a cancelled prompt closes and drops the peeked connection") | |
| 265 | + | case <-time.After(5 * time.Second): | |
| 266 | + | t.Fatal("resolveAttachCreationIntent did not release on cancellation") | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestConfirmMissingSessionCreate(t *testing.T) { | |
| 271 | + | tests := []struct { | |
| 272 | + | name string | |
| 273 | + | answer string | |
| 274 | + | interactive bool | |
| 275 | + | wantIntent uint8 | |
| 276 | + | wantPromptPart string | |
| 277 | + | wantNoPrompt bool | |
| 278 | + | }{ | |
| 279 | + | {name: "accepts yes", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "scratch" doesn't exist, want to create and attach to it? [y/N]`}, | |
| 280 | + | {name: "accepts yes word", answer: "yes\n", interactive: true, wantIntent: protocol.IntentNew}, | |
| 281 | + | {name: "declines no", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach}, | |
| 282 | + | {name: "defaults no on empty answer", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach}, | |
| 283 | + | {name: "defaults no on unknown answer", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach}, | |
| 284 | + | {name: "non-interactive console skips the prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |
| 285 | + | } | |
| 286 | + | for _, tc := range tests { | |
| 287 | + | t.Run(tc.name, func(t *testing.T) { | |
| 288 | + | withAttachInteractiveConsole(t, tc.interactive) | |
| 289 | + | terminal := portsmocks.NewMockTerminal(t) | |
| 290 | + | var out strings.Builder | |
| 291 | + | terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe() | |
| 292 | + | terminal.EXPECT().Out().Return(&out).Maybe() | |
| 293 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |
| 294 | + | ||
| 295 | + | intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | require.Equal(t, tc.wantIntent, intent) | |
| 298 | + | if tc.wantPromptPart != "" { | |
| 299 | + | require.Contains(t, out.String(), tc.wantPromptPart) | |
| 300 | + | } | |
| 301 | + | if tc.wantNoPrompt { | |
| 302 | + | require.Empty(t, out.String()) | |
| 303 | + | } | |
| 304 | + | }) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // TestConfirmMissingSessionCreateFailureAttaches covers a console that cannot | |
| 309 | + | // show the question: the prompt must keep the plain attach path instead of | |
| 310 | + | // consuming an answer nobody was asked for. The input blocks forever, so a | |
| 311 | + | // prompt that reads it would only return on context expiry. | |
| 312 | + | func TestConfirmMissingSessionCreateFailureAttaches(t *testing.T) { | |
| 313 | + | closedOut, err := os.CreateTemp(t.TempDir(), "vev-console") | |
| 314 | + | require.NoError(t, err) | |
| 315 | + | require.NoError(t, closedOut.Close()) | |
| 316 | + | ||
| 317 | + | for _, tc := range []struct { | |
| 318 | + | name string | |
| 319 | + | out io.Writer | |
| 320 | + | flushErr error | |
| 321 | + | }{ | |
| 322 | + | {name: "write fails", out: closedOut}, | |
| 323 | + | {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")}, | |
| 324 | + | } { | |
| 325 | + | t.Run(tc.name, func(t *testing.T) { | |
| 326 | + | withAttachInteractiveConsole(t, true) | |
| 327 | + | consoleIn, consoleOut, err := os.Pipe() | |
| 328 | + | require.NoError(t, err) | |
| 329 | + | t.Cleanup(func() { _ = consoleIn.Close(); _ = consoleOut.Close() }) | |
| 330 | + | ||
| 331 | + | terminal := portsmocks.NewMockTerminal(t) | |
| 332 | + | terminal.EXPECT().In().Return(consoleIn).Maybe() | |
| 333 | + | terminal.EXPECT().Out().Return(tc.out).Maybe() | |
| 334 | + | terminal.EXPECT().Flush().Return(tc.flushErr).Maybe() | |
| 335 | + | ||
| 336 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 337 | + | defer cancel() | |
| 338 | + | intent, err := confirmMissingSessionCreate(ctx, "scratch", terminal) | |
| 339 | + | require.NoError(t, err, "an unusable console must not fail the attach") | |
| 340 | + | require.Equal(t, protocol.IntentAttach, intent) | |
| 341 | + | }) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // TestConfirmMissingSessionCreateFlushesQuestion drives the real terminal | |
| 346 | + | // adapter, whose output is buffered: the prompt has to flush the question | |
| 347 | + | // before it blocks on the answer, or the user waits in front of a blank | |
| 348 | + | // console and the question only shows up after the client repaints. | |
| 349 | + | func TestConfirmMissingSessionCreateFlushesQuestion(t *testing.T) { | |
| 350 | + | withAttachInteractiveConsole(t, true) | |
| 351 | + | consoleIn, answers, err := os.Pipe() | |
| 352 | + | require.NoError(t, err) | |
| 353 | + | questions, consoleOut, err := os.Pipe() | |
| 354 | + | require.NoError(t, err) | |
| 355 | + | t.Cleanup(func() { | |
| 356 | + | _ = consoleIn.Close() | |
| 357 | + | _ = answers.Close() | |
| 358 | + | _ = questions.Close() | |
| 359 | + | _ = consoleOut.Close() | |
| 360 | + | }) | |
| 361 | + | ||
| 362 | + | terminal := term.NewWithFiles(consoleIn, consoleOut) | |
| 363 | + | done := make(chan struct { | |
| 364 | + | intent uint8 | |
| 365 | + | err error | |
| 366 | + | }, 1) | |
| 367 | + | go func() { | |
| 368 | + | intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal) | |
| 369 | + | done <- struct { | |
| 370 | + | intent uint8 | |
| 371 | + | err error | |
| 372 | + | }{intent, err} | |
| 373 | + | }() | |
| 374 | + | ||
| 375 | + | question := readAttachPrompt(t, questions) | |
| 376 | + | require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the prompt reads an answer") | |
| 377 | + | ||
| 378 | + | _, err = answers.WriteString("y\n") | |
| 379 | + | require.NoError(t, err) | |
| 380 | + | select { | |
| 381 | + | case result := <-done: | |
| 382 | + | require.NoError(t, result.err) | |
| 383 | + | require.Equal(t, protocol.IntentNew, result.intent) | |
| 384 | + | case <-time.After(5 * time.Second): | |
| 385 | + | t.Fatal("the prompt did not release after the answer") | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // readAttachPrompt returns the console output that ends the create question, | |
| 390 | + | // failing the test instead of hanging when nothing arrives. | |
| 391 | + | func readAttachPrompt(t *testing.T, console *os.File) string { | |
| 392 | + | t.Helper() | |
| 393 | + | require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second))) | |
| 394 | + | var output []byte | |
| 395 | + | frame := make([]byte, 64) | |
| 396 | + | for { | |
| 397 | + | read, err := console.Read(frame) | |
| 398 | + | output = append(output, frame[:read]...) | |
| 399 | + | if strings.Contains(string(output), "[y/N] ") { | |
| 400 | + | return string(output) | |
| 401 | + | } | |
| 402 | + | if err != nil { | |
| 403 | + | t.Fatalf("prompt never reached the console (read %q: %v)", output, err) | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // TestTerminalIsInteractive covers the production probe on both sides of the | |
| 409 | + | // console check: a real terminal file and the streams that are not consoles. | |
| 410 | + | func TestTerminalIsInteractive(t *testing.T) { | |
| 411 | + | require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt") | |
| 412 | + | pty := openAttachPtySlave(t) | |
| 413 | + | require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console") | |
| 414 | + | ||
| 415 | + | console, writer, err := os.Pipe() | |
| 416 | + | require.NoError(t, err) | |
| 417 | + | t.Cleanup(func() { _ = console.Close(); _ = writer.Close() }) | |
| 418 | + | ||
| 419 | + | for _, tc := range []struct { | |
| 420 | + | name string | |
| 421 | + | in io.Reader | |
| 422 | + | }{ | |
| 423 | + | {name: "detached reader", in: strings.NewReader("y\n")}, | |
| 424 | + | {name: "non-terminal file", in: console}, | |
| 425 | + | } { | |
| 426 | + | t.Run(tc.name, func(t *testing.T) { | |
| 427 | + | terminal := portsmocks.NewMockTerminal(t) | |
| 428 | + | terminal.EXPECT().In().Return(tc.in).Maybe() | |
| 429 | + | require.False(t, terminalIsInteractive(terminal)) | |
| 430 | + | }) | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // openAttachPtySlave returns the slave side of a real PTY pair: the only way | |
| 435 | + | // to hand the probe an *os.File the kernel reports as a terminal. | |
| 436 | + | func openAttachPtySlave(t *testing.T) *os.File { | |
| 437 | + | t.Helper() | |
| 438 | + | master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |
| 439 | + | if err != nil { | |
| 440 | + | t.Skipf("open /dev/ptmx: %v", err) | |
| 441 | + | } | |
| 442 | + | t.Cleanup(func() { _ = master.Close() }) | |
| 443 | + | slave, err := rawterm.PreparePty(int(master.Fd())) | |
| 444 | + | require.NoError(t, err, "prepare pty") | |
| 445 | + | t.Cleanup(func() { _ = slave.Close() }) | |
| 446 | + | return slave | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func TestPreconnectedBrokerConnectorReusesThenFallsBack(t *testing.T) { | |
| 450 | + | first := portsmocks.NewMockBrokerService(t) | |
| 451 | + | second := portsmocks.NewMockBrokerService(t) | |
| 452 | + | fallbackCalls := 0 | |
| 453 | + | fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) { | |
| 454 | + | fallbackCalls++ | |
| 455 | + | return second, nil | |
| 456 | + | }) | |
| 457 | + | connector := withPreconnected(fallback, first) | |
| 458 | + | ||
| 459 | + | got, err := connector.Connect(context.Background()) | |
| 460 | + | require.NoError(t, err) | |
| 461 | + | require.Same(t, first, got, "the first Connect reuses the preflight's own connection") | |
| 462 | + | require.Zero(t, fallbackCalls) | |
| 463 | + | ||
| 464 | + | got, err = connector.Connect(context.Background()) | |
| 465 | + | require.NoError(t, err) | |
| 466 | + | require.Same(t, second, got, "a later Connect reconnects through the fallback") | |
| 467 | + | require.Equal(t, 1, fallbackCalls) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func TestWithPreconnectedNilPassesThroughUnchanged(t *testing.T) { | |
| 471 | + | fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) { return nil, nil }) | |
| 472 | + | got := withPreconnected(fallback, nil) | |
| 473 | + | service, err := got.Connect(context.Background()) | |
| 474 | + | require.NoError(t, err) | |
| 475 | + | require.Nil(t, service) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | type brokerConnectorFunc func(context.Context) (ports.BrokerService, error) | |
| 479 | + | ||
| 480 | + | func (f brokerConnectorFunc) Connect(ctx context.Context) (ports.BrokerService, error) { return f(ctx) } | |
| @@ -0,0 +1,480 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | // Attach-missing create prompt (Plan 003 E4). | |||
| 4 | + | // | |||
| 5 | + | // resolveAttachCreationIntent restores the `vev attach <missing>` create | |||
| 6 | + | // prompt main once ran through the direct-dial preflight, but through the | |||
| 7 | + | // broker: it peeks the first committed publication over the same | |||
| 8 | + | // connectProductionClientBroker seam the ordinary terminal composition uses, | |||
| 9 | + | // decides existence with the exact lookup the later resolver also runs | |||
| 10 | + | // (attachTargetSessionExists / brokerObservationExactTarget), and on a | |||
| 11 | + | // confirmed create hands its already-open connection back so the run still | |||
| 12 | + | // opens exactly one broker connection. | |||
| 13 | + | ||||
| 14 | + | import ( | |||
| 15 | + | "context" | |||
| 16 | + | "errors" | |||
| 17 | + | "io" | |||
| 18 | + | "os" | |||
| 19 | + | "strings" | |||
| 20 | + | "syscall" | |||
| 21 | + | "testing" | |||
| 22 | + | "time" | |||
| 23 | + | ||||
| 24 | + | "github.com/stretchr/testify/require" | |||
| 25 | + | ||||
| 26 | + | "github.com/bnema/vev/internal/adapters/term" | |||
| 27 | + | "github.com/bnema/vev/internal/ports" | |||
| 28 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 29 | + | "github.com/bnema/vev/internal/protocol" | |||
| 30 | + | "github.com/bnema/vev/pkg/rawterm" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // interactiveProbe injects the console decision, standing in for the | |||
| 34 | + | // production probe so the prompt matrix runs without owning a real TTY. | |||
| 35 | + | func interactiveProbe(interactive bool) func(ports.Terminal) bool { | |||
| 36 | + | return func(ports.Terminal) bool { return interactive } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // withAttachInteractiveConsole installs a scripted console decision for the | |||
| 40 | + | // duration of a test. | |||
| 41 | + | func withAttachInteractiveConsole(t *testing.T, interactive bool) { | |||
| 42 | + | t.Helper() | |||
| 43 | + | previous := attachInteractiveConsole | |||
| 44 | + | attachInteractiveConsole = interactiveProbe(interactive) | |||
| 45 | + | t.Cleanup(func() { attachInteractiveConsole = previous }) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // withPreflightBroker installs a scripted connectProductionClientBroker seam | |||
| 49 | + | // for the duration of a test, exactly like the terminal composition tests in | |||
| 50 | + | // broker_client_test.go, so the preflight peek never reaches a production | |||
| 51 | + | // broker. | |||
| 52 | + | func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.BrokerService, error)) { | |||
| 53 | + | t.Helper() | |||
| 54 | + | previous := connectProductionClientBroker | |||
| 55 | + | connectProductionClientBroker = connect | |||
| 56 | + | t.Cleanup(func() { connectProductionClientBroker = previous }) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestAttachTargetSessionExists(t *testing.T) { | |||
| 60 | + | tests := []struct { | |||
| 61 | + | name string | |||
| 62 | + | snapshot ports.BrokerSnapshot | |||
| 63 | + | remote string | |||
| 64 | + | target string | |||
| 65 | + | wantExists bool | |||
| 66 | + | wantHostKnow bool | |||
| 67 | + | }{ | |||
| 68 | + | { | |||
| 69 | + | name: "local session present", | |||
| 70 | + | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |||
| 71 | + | target: "work", | |||
| 72 | + | wantExists: true, | |||
| 73 | + | wantHostKnow: true, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "local session absent", | |||
| 77 | + | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |||
| 78 | + | target: "gone", | |||
| 79 | + | wantExists: false, | |||
| 80 | + | wantHostKnow: true, | |||
| 81 | + | }, | |||
| 82 | + | { | |||
| 83 | + | name: "local daemon absent from snapshot", | |||
| 84 | + | snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1}, | |||
| 85 | + | target: "gone", | |||
| 86 | + | wantExists: false, | |||
| 87 | + | wantHostKnow: false, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "remote session present", | |||
| 91 | + | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |||
| 92 | + | remote: "user@example.test", | |||
| 93 | + | target: "work", | |||
| 94 | + | wantExists: true, | |||
| 95 | + | wantHostKnow: true, | |||
| 96 | + | }, | |||
| 97 | + | { | |||
| 98 | + | name: "remote session absent on known host", | |||
| 99 | + | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |||
| 100 | + | remote: "user@example.test", | |||
| 101 | + | target: "gone", | |||
| 102 | + | wantExists: false, | |||
| 103 | + | wantHostKnow: true, | |||
| 104 | + | }, | |||
| 105 | + | { | |||
| 106 | + | name: "remote host not configured", | |||
| 107 | + | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |||
| 108 | + | remote: "user@elsewhere.test", | |||
| 109 | + | target: "work", | |||
| 110 | + | wantExists: false, | |||
| 111 | + | wantHostKnow: false, | |||
| 112 | + | }, | |||
| 113 | + | } | |||
| 114 | + | for _, tc := range tests { | |||
| 115 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 116 | + | exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target) | |||
| 117 | + | require.Equal(t, tc.wantExists, exists) | |||
| 118 | + | require.Equal(t, tc.wantHostKnow, hostKnown) | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestResolveAttachCreationIntentSkipsNonAttach(t *testing.T) { | |||
| 124 | + | for _, tc := range []struct { | |||
| 125 | + | name string | |||
| 126 | + | intent uint8 | |||
| 127 | + | target string | |||
| 128 | + | }{ | |||
| 129 | + | {name: "create", intent: protocol.IntentNew, target: "scratch"}, | |||
| 130 | + | {name: "ephemeral", intent: protocol.IntentEphemeral, target: ""}, | |||
| 131 | + | {name: "attach with no name", intent: protocol.IntentAttach, target: ""}, | |||
| 132 | + | } { | |||
| 133 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 134 | + | connected := false | |||
| 135 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { | |||
| 136 | + | connected = true | |||
| 137 | + | return nil, errors.New("must not connect") | |||
| 138 | + | }) | |||
| 139 | + | intent, service, err := resolveAttachCreationIntent(context.Background(), tc.intent, tc.target, "", nil) | |||
| 140 | + | require.NoError(t, err) | |||
| 141 | + | require.Equal(t, tc.intent, intent) | |||
| 142 | + | require.Nil(t, service) | |||
| 143 | + | require.False(t, connected, "a non-attach or nameless intent never peeks the broker") | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) { | |||
| 149 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { | |||
| 150 | + | return nil, errors.New("broker unreachable") | |||
| 151 | + | }) | |||
| 152 | + | intent, service, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "scratch", "", nil) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | require.Equal(t, protocol.IntentAttach, intent) | |||
| 155 | + | require.Nil(t, service, "an undeterminable preflight must not hand back a connection") | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) { | |||
| 159 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 160 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |||
| 161 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |||
| 162 | + | withAttachInteractiveConsole(t, true) // must not even be consulted | |||
| 163 | + | ||||
| 164 | + | intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "", nil) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | require.Equal(t, protocol.IntentAttach, intent) | |||
| 167 | + | require.Same(t, service, got, "the peeked connection is handed back for reuse") | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestResolveAttachCreationIntentUnknownHostAttachesWithoutPrompt(t *testing.T) { | |||
| 171 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 172 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |||
| 173 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |||
| 174 | + | withAttachInteractiveConsole(t, true) | |||
| 175 | + | ||||
| 176 | + | intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "user@elsewhere.test", nil) | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | require.Equal(t, protocol.IntentAttach, intent) | |||
| 179 | + | require.Same(t, service, got) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) { | |||
| 183 | + | tests := []struct { | |||
| 184 | + | name string | |||
| 185 | + | remote string | |||
| 186 | + | answer string | |||
| 187 | + | interactive bool | |||
| 188 | + | wantIntent uint8 | |||
| 189 | + | wantPromptPart string | |||
| 190 | + | wantNoPrompt bool | |||
| 191 | + | }{ | |||
| 192 | + | {name: "local confirm creates", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "gone" doesn't exist, want to create and attach to it? [y/N]`}, | |||
| 193 | + | {name: "local decline attaches", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "gone"}, | |||
| 194 | + | {name: "local empty answer attaches", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach}, | |||
| 195 | + | {name: "local unknown answer attaches", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"}, | |||
| 196 | + | {name: "non-interactive console attaches without prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |||
| 197 | + | {name: "remote confirm creates", remote: "user@example.test", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "gone" doesn't exist`}, | |||
| 198 | + | } | |||
| 199 | + | for _, tc := range tests { | |||
| 200 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 201 | + | var snapshot ports.BrokerSnapshot | |||
| 202 | + | if tc.remote == "" { | |||
| 203 | + | snapshot = terminalCompositionSnapshot([]string{"work"}, nil) | |||
| 204 | + | } else { | |||
| 205 | + | snapshot = terminalCompositionSnapshot(nil, map[string][]string{tc.remote: {"work"}}) | |||
| 206 | + | } | |||
| 207 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 208 | + | service.EXPECT().Snapshot().Return(snapshot).Once() | |||
| 209 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |||
| 210 | + | withAttachInteractiveConsole(t, tc.interactive) | |||
| 211 | + | ||||
| 212 | + | terminal := portsmocks.NewMockTerminal(t) | |||
| 213 | + | var promptOut strings.Builder | |||
| 214 | + | terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe() | |||
| 215 | + | terminal.EXPECT().Out().Return(&promptOut).Maybe() | |||
| 216 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |||
| 217 | + | ||||
| 218 | + | intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "gone", tc.remote, terminal) | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | require.Equal(t, tc.wantIntent, intent) | |||
| 221 | + | require.Same(t, service, got, "the peeked connection is always handed back on a definite answer") | |||
| 222 | + | if tc.wantPromptPart != "" { | |||
| 223 | + | require.Contains(t, promptOut.String(), tc.wantPromptPart) | |||
| 224 | + | } | |||
| 225 | + | if tc.wantNoPrompt { | |||
| 226 | + | require.Empty(t, promptOut.String()) | |||
| 227 | + | } | |||
| 228 | + | }) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) { | |||
| 233 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 234 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |||
| 235 | + | service.EXPECT().Close().Return(nil).Once() | |||
| 236 | + | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |||
| 237 | + | withAttachInteractiveConsole(t, true) | |||
| 238 | + | ||||
| 239 | + | terminal := portsmocks.NewMockTerminal(t) | |||
| 240 | + | reader, writer := io.Pipe() | |||
| 241 | + | t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() }) | |||
| 242 | + | terminal.EXPECT().In().Return(reader).Maybe() | |||
| 243 | + | terminal.EXPECT().Out().Return(io.Discard).Maybe() | |||
| 244 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |||
| 245 | + | ||||
| 246 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 247 | + | done := make(chan struct { | |||
| 248 | + | intent uint8 | |||
| 249 | + | service ports.BrokerService | |||
| 250 | + | err error | |||
| 251 | + | }, 1) | |||
| 252 | + | go func() { | |||
| 253 | + | intent, got, err := resolveAttachCreationIntent(ctx, protocol.IntentAttach, "gone", "", terminal) | |||
| 254 | + | done <- struct { | |||
| 255 | + | intent uint8 | |||
| 256 | + | service ports.BrokerService | |||
| 257 | + | err error | |||
| 258 | + | }{intent, got, err} | |||
| 259 | + | }() | |||
| 260 | + | cancel() | |||
| 261 | + | select { | |||
| 262 | + | case result := <-done: | |||
| 263 | + | require.ErrorIs(t, result.err, context.Canceled) | |||
| 264 | + | require.Nil(t, result.service, "a cancelled prompt closes and drops the peeked connection") | |||
| 265 | + | case <-time.After(5 * time.Second): | |||
| 266 | + | t.Fatal("resolveAttachCreationIntent did not release on cancellation") | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestConfirmMissingSessionCreate(t *testing.T) { | |||
| 271 | + | tests := []struct { | |||
| 272 | + | name string | |||
| 273 | + | answer string | |||
| 274 | + | interactive bool | |||
| 275 | + | wantIntent uint8 | |||
| 276 | + | wantPromptPart string | |||
| 277 | + | wantNoPrompt bool | |||
| 278 | + | }{ | |||
| 279 | + | {name: "accepts yes", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "scratch" doesn't exist, want to create and attach to it? [y/N]`}, | |||
| 280 | + | {name: "accepts yes word", answer: "yes\n", interactive: true, wantIntent: protocol.IntentNew}, | |||
| 281 | + | {name: "declines no", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach}, | |||
| 282 | + | {name: "defaults no on empty answer", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach}, | |||
| 283 | + | {name: "defaults no on unknown answer", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach}, | |||
| 284 | + | {name: "non-interactive console skips the prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |||
| 285 | + | } | |||
| 286 | + | for _, tc := range tests { | |||
| 287 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 288 | + | withAttachInteractiveConsole(t, tc.interactive) | |||
| 289 | + | terminal := portsmocks.NewMockTerminal(t) | |||
| 290 | + | var out strings.Builder | |||
| 291 | + | terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe() | |||
| 292 | + | terminal.EXPECT().Out().Return(&out).Maybe() | |||
| 293 | + | terminal.EXPECT().Flush().Return(nil).Maybe() | |||
| 294 | + | ||||
| 295 | + | intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal) | |||
| 296 | + | require.NoError(t, err) | |||
| 297 | + | require.Equal(t, tc.wantIntent, intent) | |||
| 298 | + | if tc.wantPromptPart != "" { | |||
| 299 | + | require.Contains(t, out.String(), tc.wantPromptPart) | |||
| 300 | + | } | |||
| 301 | + | if tc.wantNoPrompt { | |||
| 302 | + | require.Empty(t, out.String()) | |||
| 303 | + | } | |||
| 304 | + | }) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // TestConfirmMissingSessionCreateFailureAttaches covers a console that cannot | |||
| 309 | + | // show the question: the prompt must keep the plain attach path instead of | |||
| 310 | + | // consuming an answer nobody was asked for. The input blocks forever, so a | |||
| 311 | + | // prompt that reads it would only return on context expiry. | |||
| 312 | + | func TestConfirmMissingSessionCreateFailureAttaches(t *testing.T) { | |||
| 313 | + | closedOut, err := os.CreateTemp(t.TempDir(), "vev-console") | |||
| 314 | + | require.NoError(t, err) | |||
| 315 | + | require.NoError(t, closedOut.Close()) | |||
| 316 | + | ||||
| 317 | + | for _, tc := range []struct { | |||
| 318 | + | name string | |||
| 319 | + | out io.Writer | |||
| 320 | + | flushErr error | |||
| 321 | + | }{ | |||
| 322 | + | {name: "write fails", out: closedOut}, | |||
| 323 | + | {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")}, | |||
| 324 | + | } { | |||
| 325 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 326 | + | withAttachInteractiveConsole(t, true) | |||
| 327 | + | consoleIn, consoleOut, err := os.Pipe() | |||
| 328 | + | require.NoError(t, err) | |||
| 329 | + | t.Cleanup(func() { _ = consoleIn.Close(); _ = consoleOut.Close() }) | |||
| 330 | + | ||||
| 331 | + | terminal := portsmocks.NewMockTerminal(t) | |||
| 332 | + | terminal.EXPECT().In().Return(consoleIn).Maybe() | |||
| 333 | + | terminal.EXPECT().Out().Return(tc.out).Maybe() | |||
| 334 | + | terminal.EXPECT().Flush().Return(tc.flushErr).Maybe() | |||
| 335 | + | ||||
| 336 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 337 | + | defer cancel() | |||
| 338 | + | intent, err := confirmMissingSessionCreate(ctx, "scratch", terminal) | |||
| 339 | + | require.NoError(t, err, "an unusable console must not fail the attach") | |||
| 340 | + | require.Equal(t, protocol.IntentAttach, intent) | |||
| 341 | + | }) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // TestConfirmMissingSessionCreateFlushesQuestion drives the real terminal | |||
| 346 | + | // adapter, whose output is buffered: the prompt has to flush the question | |||
| 347 | + | // before it blocks on the answer, or the user waits in front of a blank | |||
| 348 | + | // console and the question only shows up after the client repaints. | |||
| 349 | + | func TestConfirmMissingSessionCreateFlushesQuestion(t *testing.T) { | |||
| 350 | + | withAttachInteractiveConsole(t, true) | |||
| 351 | + | consoleIn, answers, err := os.Pipe() | |||
| 352 | + | require.NoError(t, err) | |||
| 353 | + | questions, consoleOut, err := os.Pipe() | |||
| 354 | + | require.NoError(t, err) | |||
| 355 | + | t.Cleanup(func() { | |||
| 356 | + | _ = consoleIn.Close() | |||
| 357 | + | _ = answers.Close() | |||
| 358 | + | _ = questions.Close() | |||
| 359 | + | _ = consoleOut.Close() | |||
| 360 | + | }) | |||
| 361 | + | ||||
| 362 | + | terminal := term.NewWithFiles(consoleIn, consoleOut) | |||
| 363 | + | done := make(chan struct { | |||
| 364 | + | intent uint8 | |||
| 365 | + | err error | |||
| 366 | + | }, 1) | |||
| 367 | + | go func() { | |||
| 368 | + | intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal) | |||
| 369 | + | done <- struct { | |||
| 370 | + | intent uint8 | |||
| 371 | + | err error | |||
| 372 | + | }{intent, err} | |||
| 373 | + | }() | |||
| 374 | + | ||||
| 375 | + | question := readAttachPrompt(t, questions) | |||
| 376 | + | require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the prompt reads an answer") | |||
| 377 | + | ||||
| 378 | + | _, err = answers.WriteString("y\n") | |||
| 379 | + | require.NoError(t, err) | |||
| 380 | + | select { | |||
| 381 | + | case result := <-done: | |||
| 382 | + | require.NoError(t, result.err) | |||
| 383 | + | require.Equal(t, protocol.IntentNew, result.intent) | |||
| 384 | + | case <-time.After(5 * time.Second): | |||
| 385 | + | t.Fatal("the prompt did not release after the answer") | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // readAttachPrompt returns the console output that ends the create question, | |||
| 390 | + | // failing the test instead of hanging when nothing arrives. | |||
| 391 | + | func readAttachPrompt(t *testing.T, console *os.File) string { | |||
| 392 | + | t.Helper() | |||
| 393 | + | require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second))) | |||
| 394 | + | var output []byte | |||
| 395 | + | frame := make([]byte, 64) | |||
| 396 | + | for { | |||
| 397 | + | read, err := console.Read(frame) | |||
| 398 | + | output = append(output, frame[:read]...) | |||
| 399 | + | if strings.Contains(string(output), "[y/N] ") { | |||
| 400 | + | return string(output) | |||
| 401 | + | } | |||
| 402 | + | if err != nil { | |||
| 403 | + | t.Fatalf("prompt never reached the console (read %q: %v)", output, err) | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // TestTerminalIsInteractive covers the production probe on both sides of the | |||
| 409 | + | // console check: a real terminal file and the streams that are not consoles. | |||
| 410 | + | func TestTerminalIsInteractive(t *testing.T) { | |||
| 411 | + | require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt") | |||
| 412 | + | pty := openAttachPtySlave(t) | |||
| 413 | + | require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console") | |||
| 414 | + | ||||
| 415 | + | console, writer, err := os.Pipe() | |||
| 416 | + | require.NoError(t, err) | |||
| 417 | + | t.Cleanup(func() { _ = console.Close(); _ = writer.Close() }) | |||
| 418 | + | ||||
| 419 | + | for _, tc := range []struct { | |||
| 420 | + | name string | |||
| 421 | + | in io.Reader | |||
| 422 | + | }{ | |||
| 423 | + | {name: "detached reader", in: strings.NewReader("y\n")}, | |||
| 424 | + | {name: "non-terminal file", in: console}, | |||
| 425 | + | } { | |||
| 426 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 427 | + | terminal := portsmocks.NewMockTerminal(t) | |||
| 428 | + | terminal.EXPECT().In().Return(tc.in).Maybe() | |||
| 429 | + | require.False(t, terminalIsInteractive(terminal)) | |||
| 430 | + | }) | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // openAttachPtySlave returns the slave side of a real PTY pair: the only way | |||
| 435 | + | // to hand the probe an *os.File the kernel reports as a terminal. | |||
| 436 | + | func openAttachPtySlave(t *testing.T) *os.File { | |||
| 437 | + | t.Helper() | |||
| 438 | + | master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |||
| 439 | + | if err != nil { | |||
| 440 | + | t.Skipf("open /dev/ptmx: %v", err) | |||
| 441 | + | } | |||
| 442 | + | t.Cleanup(func() { _ = master.Close() }) | |||
| 443 | + | slave, err := rawterm.PreparePty(int(master.Fd())) | |||
| 444 | + | require.NoError(t, err, "prepare pty") | |||
| 445 | + | t.Cleanup(func() { _ = slave.Close() }) | |||
| 446 | + | return slave | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func TestPreconnectedBrokerConnectorReusesThenFallsBack(t *testing.T) { | |||
| 450 | + | first := portsmocks.NewMockBrokerService(t) | |||
| 451 | + | second := portsmocks.NewMockBrokerService(t) | |||
| 452 | + | fallbackCalls := 0 | |||
| 453 | + | fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) { | |||
| 454 | + | fallbackCalls++ | |||
| 455 | + | return second, nil | |||
| 456 | + | }) | |||
| 457 | + | connector := withPreconnected(fallback, first) | |||
| 458 | + | ||||
| 459 | + | got, err := connector.Connect(context.Background()) | |||
| 460 | + | require.NoError(t, err) | |||
| 461 | + | require.Same(t, first, got, "the first Connect reuses the preflight's own connection") | |||
| 462 | + | require.Zero(t, fallbackCalls) | |||
| 463 | + | ||||
| 464 | + | got, err = connector.Connect(context.Background()) | |||
| 465 | + | require.NoError(t, err) | |||
| 466 | + | require.Same(t, second, got, "a later Connect reconnects through the fallback") | |||
| 467 | + | require.Equal(t, 1, fallbackCalls) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func TestWithPreconnectedNilPassesThroughUnchanged(t *testing.T) { | |||
| 471 | + | fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) { return nil, nil }) | |||
| 472 | + | got := withPreconnected(fallback, nil) | |||
| 473 | + | service, err := got.Connect(context.Background()) | |||
| 474 | + | require.NoError(t, err) | |||
| 475 | + | require.Nil(t, service) | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | type brokerConnectorFunc func(context.Context) (ports.BrokerService, error) | |||
| 479 | + | ||||
| 480 | + | func (f brokerConnectorFunc) Connect(ctx context.Context) (ports.BrokerService, error) { return f(ctx) } | |||
M internal/app/run.go +9 -1
| @@ -940,13 +940,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||
| 940 | 940 | if observer != nil { | |
| 941 | 941 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true)) | |
| 942 | 942 | } | |
| 943 | + | terminal := terminalForAttach() | |
| 944 | + | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |
| 945 | + | if err != nil { | |
| 946 | + | return err | |
| 947 | + | } | |
| 943 | 948 | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | |
| 944 | 949 | if err != nil { | |
| 950 | + | if preconnected != nil { | |
| 951 | + | _ = preconnected.Close() | |
| 952 | + | } | |
| 945 | 953 | return err | |
| 946 | 954 | } | |
| 947 | 955 | callbacks := terminalBrokerCallbacks() | |
| 948 | 956 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 949 | - | Connector: newProductionBrokerConnector(), Terminal: terminalForAttach(), Clock: clk, | |
| 957 | + | Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk, | |
| 950 | 958 | InitialNavigation: navigation, ResolveInitialNavigation: resolver, | |
| 951 | 959 | AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(), | |
| 952 | 960 | OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure, | |
| @@ -940,13 +940,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||||
| 940 | if observer != nil { | 940 | if observer != nil { | ||
| 941 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true)) | 941 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true)) | ||
| 942 | } | 942 | } | ||
| 943 | + | terminal := terminalForAttach() | |||
| 944 | + | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |||
| 945 | + | if err != nil { | |||
| 946 | + | return err | |||
| 947 | + | } | |||
| 943 | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | 948 | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | ||
| 944 | if err != nil { | 949 | if err != nil { | ||
| 950 | + | if preconnected != nil { | |||
| 951 | + | _ = preconnected.Close() | |||
| 952 | + | } | |||
| 945 | return err | 953 | return err | ||
| 946 | } | 954 | } | ||
| 947 | callbacks := terminalBrokerCallbacks() | 955 | callbacks := terminalBrokerCallbacks() | ||
| 948 | return runBrokerClient(ctx, brokerClientConfig{ | 956 | return runBrokerClient(ctx, brokerClientConfig{ | ||
| 949 | - | Connector: newProductionBrokerConnector(), Terminal: terminalForAttach(), Clock: clk, | 957 | + | Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk, |
| 950 | InitialNavigation: navigation, ResolveInitialNavigation: resolver, | 958 | InitialNavigation: navigation, ResolveInitialNavigation: resolver, | ||
| 951 | AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(), | 959 | AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(), | ||
| 952 | OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure, | 960 | OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure, | ||
M internal/app/ui_driver.go +10 -2
| @@ -471,16 +471,24 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget | |||
| 471 | 471 | fmt.Fprintln(os.Stderr, socketPath) | |
| 472 | 472 | defer func() { _ = endpoint.Close() }() | |
| 473 | 473 | } | |
| 474 | + | terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror} | |
| 475 | + | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |
| 476 | + | if err != nil { | |
| 477 | + | return err | |
| 478 | + | } | |
| 474 | 479 | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | |
| 475 | 480 | if err != nil { | |
| 481 | + | if preconnected != nil { | |
| 482 | + | _ = preconnected.Close() | |
| 483 | + | } | |
| 476 | 484 | return err | |
| 477 | 485 | } | |
| 478 | 486 | sessionEnv := terminalSessionEnvironment() | |
| 479 | 487 | attachmentEnv := terminalAttachmentEnvironment() | |
| 480 | 488 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 481 | 489 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 482 | - | Connector: newProductionBrokerConnector(), | |
| 483 | - | Terminal: observedTerminal{Terminal: physical, UIOutputTransaction: mirror}, | |
| 490 | + | Connector: withPreconnected(newProductionBrokerConnector(), preconnected), | |
| 491 | + | Terminal: terminal, | |
| 484 | 492 | Clock: clk, | |
| 485 | 493 | UI: ui, | |
| 486 | 494 | InitialNavigation: navigation, | |
| @@ -471,16 +471,24 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget | |||||
| 471 | fmt.Fprintln(os.Stderr, socketPath) | 471 | fmt.Fprintln(os.Stderr, socketPath) | ||
| 472 | defer func() { _ = endpoint.Close() }() | 472 | defer func() { _ = endpoint.Close() }() | ||
| 473 | } | 473 | } | ||
| 474 | + | terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror} | |||
| 475 | + | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |||
| 476 | + | if err != nil { | |||
| 477 | + | return err | |||
| 478 | + | } | |||
| 474 | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | 479 | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | ||
| 475 | if err != nil { | 480 | if err != nil { | ||
| 481 | + | if preconnected != nil { | |||
| 482 | + | _ = preconnected.Close() | |||
| 483 | + | } | |||
| 476 | return err | 484 | return err | ||
| 477 | } | 485 | } | ||
| 478 | sessionEnv := terminalSessionEnvironment() | 486 | sessionEnv := terminalSessionEnvironment() | ||
| 479 | attachmentEnv := terminalAttachmentEnvironment() | 487 | attachmentEnv := terminalAttachmentEnvironment() | ||
| 480 | attachmentEnv.Cwd = sessionEnv.Cwd | 488 | attachmentEnv.Cwd = sessionEnv.Cwd | ||
| 481 | return runBrokerClient(ctx, brokerClientConfig{ | 489 | return runBrokerClient(ctx, brokerClientConfig{ | ||
| 482 | - | Connector: newProductionBrokerConnector(), | 490 | + | Connector: withPreconnected(newProductionBrokerConnector(), preconnected), |
| 483 | - | Terminal: observedTerminal{Terminal: physical, UIOutputTransaction: mirror}, | 491 | + | Terminal: terminal, |
| 484 | Clock: clk, | 492 | Clock: clk, | ||
| 485 | UI: ui, | 493 | UI: ui, | ||
| 486 | InitialNavigation: navigation, | 494 | InitialNavigation: navigation, | ||