feat(client): attach over broker logical streams
A committed picker selection now opens one broker logical stream and runs the typed session protocol on it through the supervisor's existing foreground grant. Offline and unactivated: nothing in production composes this path. - The supervisor resolves the committed key to one exact ports.BrokerOpenStreamRequest, opens it on the broker service, and hands the returned ports.BrokerLogicalConnection to the attachment worker. The picker never opens, writes, or retargets a session, and the supervisor keeps the terminal, the single input lifetime, and the close authority. - One absolute deadline bounds stream open, the preamble, Hello/Welcome, and the committed initial publication. Folding in the admitted stream's accepted deadline keeps the earlier of the two, so queue time is consumed rather than repaid with a second budget, and the deadline is retired at the committed publication: attached always implies the handshake budget can no longer cancel the run. Expiry is lock-free, so retiring a timer can never deadlock against it. - Attached means committed publication: the initial frame is validated as a full first frame with a real context publication, written, flushed, and committed through the UI output transaction, and only then is the attached transition recorded. Welcome alone never attaches. The UI observation channel is not the commit authority, so an unavailable or closed capture does not fail a frame that was written, while any other publication error does. - The committed identity is revalidated against the resolved request before anything is written or marked attached, so the peer is never the only authority on which session the user committed. - EOF, refusal, typed post-Welcome errors, deadline expiry, and stream loss each end the attempt with a typed event before or after attachment, and always return to the picker in the same process. Cancelling a run retires its generation: a retired worker cannot publish output, mark attached, or consume input. - Hello carries the terminal environment supplied by composition (SupervisorConfig.AttachmentEnvironment); the use case reads no environment.
- commit
- 7655e2e5efc3ba76221a2a5ada68cda6971f1de6
- author
- brice · · 2026-09-19 10:35 UTC
- parent
- 5d9061b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +2472 -38
A internal/usecase/client/attachment_session.go +380 -0
| @@ -0,0 +1,380 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Real attachment worker (Plan 001 P5.3b, offline and unactivated). | |
| 15 | + | // | |
| 16 | + | // sessionAttachmentWorker drives the existing typed session protocol on the | |
| 17 | + | // broker logical stream the supervisor opened for one resolved request. It | |
| 18 | + | // sends Hello, awaits Welcome, then awaits and commits the initial full output | |
| 19 | + | // publication. Welcome alone is never attachment: the worker marks attached | |
| 20 | + | // only after the validated frame was written, flushed, and committed through | |
| 21 | + | // the foreground's UI output transaction. After attachment it keeps consuming | |
| 22 | + | // output until the stream ends. | |
| 23 | + | // | |
| 24 | + | // The worker owns no endpoint, route, raw mode, terminal writer, or second | |
| 25 | + | // reader. It reaches the terminal only through the supervisor-granted | |
| 26 | + | // AttachmentForeground. Terminal input pumping into the session and UI action | |
| 27 | + | // admission are deliberately left to P5.4, exactly as the P5.1b host documents, | |
| 28 | + | // so this slice adds no path from picker input to a session. | |
| 29 | + | ||
| 30 | + | var ( | |
| 31 | + | // errAttachmentDeadline is the stable local sentinel of an expired | |
| 32 | + | // attachment budget. The supervisor wraps it in a typed timeout. | |
| 33 | + | errAttachmentDeadline = errors.New("client: attachment deadline elapsed") | |
| 34 | + | // errAttachmentNoStream reports a foreground granted without a stream. | |
| 35 | + | errAttachmentNoStream = errors.New("client: attachment stream is missing") | |
| 36 | + | // errAttachmentPublication reports a publication frame that violates the | |
| 37 | + | // output contract before it was written. | |
| 38 | + | errAttachmentPublication = errors.New("client: invalid initial publication") | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // AttachmentIdentityError reports a server commitment that disagrees with the | |
| 42 | + | // broker request resolved from the selected picker row. | |
| 43 | + | type AttachmentIdentityError struct { | |
| 44 | + | Stage string | |
| 45 | + | Want protocol.ExactSessionTarget | |
| 46 | + | Got protocol.ExactSessionTarget | |
| 47 | + | Name string | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (e *AttachmentIdentityError) Error() string { | |
| 51 | + | if e.Name != "" { | |
| 52 | + | return fmt.Sprintf("client: %s committed session name %q, want %q", e.Stage, e.Got.SessionName, e.Name) | |
| 53 | + | } | |
| 54 | + | return fmt.Sprintf("client: %s committed target %+v, want %+v", e.Stage, e.Got, e.Want) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // sessionAttachmentConfig supplies the typed-session inputs of one worker run. | |
| 58 | + | // The request is the exact request the supervisor opened the stream with; the | |
| 59 | + | // rest is client-local presentation data the worker mirrors into Hello. | |
| 60 | + | type sessionAttachmentConfig struct { | |
| 61 | + | Request ports.BrokerOpenStreamRequest | |
| 62 | + | ClientID [16]byte | |
| 63 | + | Geometry domain.Geometry | |
| 64 | + | TermEnv string | |
| 65 | + | Cwd string | |
| 66 | + | TrueColor bool | |
| 67 | + | // BeforeAttached runs after the initial frame commits but before attached | |
| 68 | + | // presentation becomes observable. A failure keeps the run unattached. | |
| 69 | + | BeforeAttached func() error | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |
| 73 | + | // stream. It holds no stream or terminal of its own: everything arrives through | |
| 74 | + | // Run's foreground for the duration of one run. | |
| 75 | + | type sessionAttachmentWorker struct { | |
| 76 | + | cfg sessionAttachmentConfig | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // newSessionAttachmentWorker builds the real typed-session attachment worker. | |
| 80 | + | func newSessionAttachmentWorker(cfg sessionAttachmentConfig) *sessionAttachmentWorker { | |
| 81 | + | return &sessionAttachmentWorker{cfg: cfg} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // serverReceive is one server message or the terminal receive error. | |
| 85 | + | type serverReceive struct { | |
| 86 | + | message protocol.ServerMessage | |
| 87 | + | err error | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // startServerReceive owns the single reader of the logical stream. It is | |
| 91 | + | // bounded: it buffers one message and blocks on the next read, and the stream's | |
| 92 | + | // Close contract unblocks the read. The returned stop channel releases the | |
| 93 | + | // reader when the run ends, so a message the worker no longer reads can never | |
| 94 | + | // pin it. | |
| 95 | + | func startServerReceive(stream ports.BrokerLogicalConnection) (<-chan serverReceive, chan struct{}) { | |
| 96 | + | out := make(chan serverReceive, 1) | |
| 97 | + | stop := make(chan struct{}) | |
| 98 | + | go func() { | |
| 99 | + | for { | |
| 100 | + | message, err := stream.ReceiveServer() | |
| 101 | + | select { | |
| 102 | + | case out <- serverReceive{message: message, err: err}: | |
| 103 | + | case <-stop: | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | if err != nil { | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | }() | |
| 111 | + | return out, stop | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Run drives Hello -> Welcome -> committed initial publication -> attached | |
| 115 | + | // output until the stream ends or the context/deadline, the supervisor finalize | |
| 116 | + | // signal, or the stream itself stops it. Every return path is a typed event; | |
| 117 | + | // the supervisor stamps the granted token onto it. | |
| 118 | + | func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 119 | + | token := fg.Token() | |
| 120 | + | if ctx == nil { | |
| 121 | + | ctx = context.Background() | |
| 122 | + | } | |
| 123 | + | stream := fg.Stream() | |
| 124 | + | if supervisorNil(stream) { | |
| 125 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentNoStream} | |
| 126 | + | } | |
| 127 | + | incoming, stopReceive := startServerReceive(stream) | |
| 128 | + | defer close(stopReceive) | |
| 129 | + | ||
| 130 | + | if err := w.sendHello(ctx, fg, stream); err != nil { | |
| 131 | + | return w.settle(ctx, fg, stream, token, err) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | welcome, err := w.awaitServer(ctx, fg, incoming) | |
| 135 | + | if err != nil { | |
| 136 | + | return w.settle(ctx, fg, stream, token, err) | |
| 137 | + | } | |
| 138 | + | switch message := welcome.(type) { | |
| 139 | + | case protocol.Welcome: | |
| 140 | + | // Welcome is a promise, not attachment: the daemon still owes the | |
| 141 | + | // committed initial publication. | |
| 142 | + | if err := w.validateWelcome(message); err != nil { | |
| 143 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 144 | + | } | |
| 145 | + | case protocol.ErrorMsg: | |
| 146 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: message.Code, Text: message.Text}} | |
| 147 | + | default: | |
| 148 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: unexpected %T before welcome", welcome)} | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) | |
| 152 | + | if event != nil { | |
| 153 | + | return *event | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | return w.pumpAttached(ctx, fg, stream, token, state, incoming) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // awaitInitialPublication consumes server messages until the first valid full | |
| 160 | + | // output frame is committed. It returns the output state to continue with, or | |
| 161 | + | // a typed terminal event. | |
| 162 | + | func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) { | |
| 163 | + | state := outputApplyState{} | |
| 164 | + | for { | |
| 165 | + | message, err := w.awaitServer(ctx, fg, incoming) | |
| 166 | + | if err != nil { | |
| 167 | + | event := w.settle(ctx, fg, stream, token, err) | |
| 168 | + | return state, &event | |
| 169 | + | } | |
| 170 | + | switch typed := message.(type) { | |
| 171 | + | case protocol.Output: | |
| 172 | + | next, ok := state.next(typed) | |
| 173 | + | if !ok { | |
| 174 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication} | |
| 175 | + | return state, &event | |
| 176 | + | } | |
| 177 | + | if err := w.validatePublication(typed); err != nil { | |
| 178 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 179 | + | return state, &event | |
| 180 | + | } | |
| 181 | + | state = next | |
| 182 | + | if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusTransitioning), typed.Data); err != nil { | |
| 183 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing initial output: %w", err)} | |
| 184 | + | return state, &event | |
| 185 | + | } | |
| 186 | + | // Attachment is the committed publication. Complete the deadline | |
| 187 | + | // boundary before presenting attached, so attached always implies the | |
| 188 | + | // handshake budget can no longer cancel the run. | |
| 189 | + | if w.cfg.BeforeAttached != nil { | |
| 190 | + | if err := w.cfg.BeforeAttached(); err != nil { | |
| 191 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 192 | + | return state, &event | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | // A refused marker means the grant was revoked underneath us, so the | |
| 196 | + | // run ends as a failure. | |
| 197 | + | if !fg.MarkAttached() { | |
| 198 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked} | |
| 199 | + | return state, &event | |
| 200 | + | } | |
| 201 | + | return state, nil | |
| 202 | + | case protocol.ErrorMsg: | |
| 203 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |
| 204 | + | return state, &event | |
| 205 | + | case protocol.Detached: | |
| 206 | + | // The daemon ended the attempt before publishing anything: it | |
| 207 | + | // never attached. | |
| 208 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |
| 209 | + | return state, &event | |
| 210 | + | default: | |
| 211 | + | // Everything else the daemon may publish before the initial frame | |
| 212 | + | // stays outside this slice's scope and is ignored. | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // pumpAttached publishes accepted output after attachment until the stream | |
| 218 | + | // ends. It never unmarks attachment: a stream failure ends the run as | |
| 219 | + | // lost/ended, so the supervisor returns to the picker and never keeps the | |
| 220 | + | // attached presentation showing. | |
| 221 | + | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent { | |
| 222 | + | for { | |
| 223 | + | message, err := w.awaitServer(ctx, fg, incoming) | |
| 224 | + | if err != nil { | |
| 225 | + | return w.settle(ctx, fg, stream, token, err) | |
| 226 | + | } | |
| 227 | + | switch typed := message.(type) { | |
| 228 | + | case protocol.Output: | |
| 229 | + | next, ok := state.next(typed) | |
| 230 | + | if !ok { | |
| 231 | + | // A frame outside the output chain after attachment is ignored | |
| 232 | + | // rather than replaying stale cells. | |
| 233 | + | continue | |
| 234 | + | } | |
| 235 | + | state = next | |
| 236 | + | if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 237 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | |
| 238 | + | } | |
| 239 | + | case protocol.ErrorMsg: | |
| 240 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |
| 241 | + | case protocol.Detached: | |
| 242 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |
| 243 | + | default: | |
| 244 | + | // Messages outside the attached output flow are consumed and | |
| 245 | + | // ignored until the stream ends; this slice adds no other handler. | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error { | |
| 251 | + | if welcome.CommittedIdentity != nil { | |
| 252 | + | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | |
| 253 | + | } | |
| 254 | + | if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name { | |
| 255 | + | return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name} | |
| 256 | + | } | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (w *sessionAttachmentWorker) validatePublication(output protocol.Output) error { | |
| 261 | + | if output.Context == nil { | |
| 262 | + | return errAttachmentPublication | |
| 263 | + | } | |
| 264 | + | return w.validateIdentity("initial publication", output.Context.Route.Target) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.ExactSessionTarget) error { | |
| 268 | + | request := w.cfg.Request | |
| 269 | + | switch request.Admission { | |
| 270 | + | case ports.BrokerAdmissionExact: | |
| 271 | + | if got != request.Target { | |
| 272 | + | return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got} | |
| 273 | + | } | |
| 274 | + | case ports.BrokerAdmissionCreateNamed: | |
| 275 | + | if got.SessionName != request.Name { | |
| 276 | + | return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name} | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | return nil | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // sendHello sends the typed Hello derived from the exact opened request. | |
| 283 | + | func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error { | |
| 284 | + | return w.send(ctx, fg, stream, w.hello(stream)) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // hello builds the Hello the daemon expects for exactly one opened request. No | |
| 288 | + | // endpoint, route, or resume token is invented: an exact request attaches, a | |
| 289 | + | // named request creates, and an ephemeral request is ephemeral. | |
| 290 | + | func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) protocol.Hello { | |
| 291 | + | request := w.cfg.Request | |
| 292 | + | geometry := w.cfg.Geometry.NormalizePixels() | |
| 293 | + | hello := protocol.Hello{ | |
| 294 | + | Version: protocol.Version, | |
| 295 | + | Intent: protocol.IntentAttach, | |
| 296 | + | ClientID: w.cfg.ClientID, | |
| 297 | + | Size: geometry.Size, | |
| 298 | + | PixelWidth: geometry.PixelWidth, | |
| 299 | + | PixelHeight: geometry.PixelHeight, | |
| 300 | + | TermEnv: w.cfg.TermEnv, | |
| 301 | + | Cwd: w.cfg.Cwd, | |
| 302 | + | TrueColor: w.cfg.TrueColor, | |
| 303 | + | MaxOutputInFlight: requestedOutputWindow(stream), | |
| 304 | + | Env: append([]string(nil), request.Env...), | |
| 305 | + | EnvironmentPolicy: request.Policy.EnvironmentPolicy, | |
| 306 | + | Remote: !request.Local, | |
| 307 | + | } | |
| 308 | + | switch request.Admission { | |
| 309 | + | case ports.BrokerAdmissionExact: | |
| 310 | + | target := request.Target | |
| 311 | + | hello.Intent = protocol.IntentAttach | |
| 312 | + | hello.ExactTarget = &target | |
| 313 | + | case ports.BrokerAdmissionCreateNamed: | |
| 314 | + | hello.Intent = protocol.IntentNew | |
| 315 | + | hello.Name = request.Name | |
| 316 | + | case ports.BrokerAdmissionCreateEphemeral: | |
| 317 | + | hello.Intent = protocol.IntentEphemeral | |
| 318 | + | } | |
| 319 | + | return hello | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // send sends one client message while the run is still live. The send runs on | |
| 323 | + | // its own goroutine so a cancellation or finalize wakes the worker without | |
| 324 | + | // waiting for the stream's Close, which is the supervisor's close authority. | |
| 325 | + | func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, message protocol.ClientMessage) error { | |
| 326 | + | sent := make(chan error, 1) | |
| 327 | + | go func() { sent <- stream.SendClient(message) }() | |
| 328 | + | select { | |
| 329 | + | case err := <-sent: | |
| 330 | + | return err | |
| 331 | + | case <-ctx.Done(): | |
| 332 | + | return ctx.Err() | |
| 333 | + | case <-fg.Done(): | |
| 334 | + | return errAttachmentForegroundRevoked | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // awaitServer waits for the next server message while the run is live. | |
| 339 | + | func (w *sessionAttachmentWorker) awaitServer(ctx context.Context, fg AttachmentForeground, incoming <-chan serverReceive) (protocol.ServerMessage, error) { | |
| 340 | + | select { | |
| 341 | + | case result := <-incoming: | |
| 342 | + | if result.err != nil { | |
| 343 | + | return nil, result.err | |
| 344 | + | } | |
| 345 | + | return result.message, nil | |
| 346 | + | case <-ctx.Done(): | |
| 347 | + | return nil, ctx.Err() | |
| 348 | + | case <-fg.Done(): | |
| 349 | + | return nil, errAttachmentForegroundRevoked | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // settle classifies a stopping cause into the correct typed event. A cancelled | |
| 354 | + | // context is the supervisor's own deadline/teardown, a published stream cause | |
| 355 | + | // is a loss, a bare EOF is an orderly end, and anything else is a protocol or | |
| 356 | + | // application failure on the stream. | |
| 357 | + | func (w *sessionAttachmentWorker) settle(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, err error) AttachmentEvent { | |
| 358 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 359 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errors.Join(ctxErr, err)} | |
| 360 | + | } | |
| 361 | + | if cause := streamErr(stream); cause != nil { | |
| 362 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventLost, Err: cause} | |
| 363 | + | } | |
| 364 | + | if errors.Is(err, io.EOF) { | |
| 365 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err} | |
| 366 | + | } | |
| 367 | + | if err != nil { | |
| 368 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 369 | + | } | |
| 370 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // streamErr reads the logical stream's stable terminal cause without treating a | |
| 374 | + | // nil stream as a failure. | |
| 375 | + | func streamErr(stream ports.BrokerLogicalConnection) error { | |
| 376 | + | if supervisorNil(stream) { | |
| 377 | + | return nil | |
| 378 | + | } | |
| 379 | + | return stream.Err() | |
| 380 | + | } | |
| @@ -0,0 +1,380 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Real attachment worker (Plan 001 P5.3b, offline and unactivated). | |||
| 15 | + | // | |||
| 16 | + | // sessionAttachmentWorker drives the existing typed session protocol on the | |||
| 17 | + | // broker logical stream the supervisor opened for one resolved request. It | |||
| 18 | + | // sends Hello, awaits Welcome, then awaits and commits the initial full output | |||
| 19 | + | // publication. Welcome alone is never attachment: the worker marks attached | |||
| 20 | + | // only after the validated frame was written, flushed, and committed through | |||
| 21 | + | // the foreground's UI output transaction. After attachment it keeps consuming | |||
| 22 | + | // output until the stream ends. | |||
| 23 | + | // | |||
| 24 | + | // The worker owns no endpoint, route, raw mode, terminal writer, or second | |||
| 25 | + | // reader. It reaches the terminal only through the supervisor-granted | |||
| 26 | + | // AttachmentForeground. Terminal input pumping into the session and UI action | |||
| 27 | + | // admission are deliberately left to P5.4, exactly as the P5.1b host documents, | |||
| 28 | + | // so this slice adds no path from picker input to a session. | |||
| 29 | + | ||||
| 30 | + | var ( | |||
| 31 | + | // errAttachmentDeadline is the stable local sentinel of an expired | |||
| 32 | + | // attachment budget. The supervisor wraps it in a typed timeout. | |||
| 33 | + | errAttachmentDeadline = errors.New("client: attachment deadline elapsed") | |||
| 34 | + | // errAttachmentNoStream reports a foreground granted without a stream. | |||
| 35 | + | errAttachmentNoStream = errors.New("client: attachment stream is missing") | |||
| 36 | + | // errAttachmentPublication reports a publication frame that violates the | |||
| 37 | + | // output contract before it was written. | |||
| 38 | + | errAttachmentPublication = errors.New("client: invalid initial publication") | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // AttachmentIdentityError reports a server commitment that disagrees with the | |||
| 42 | + | // broker request resolved from the selected picker row. | |||
| 43 | + | type AttachmentIdentityError struct { | |||
| 44 | + | Stage string | |||
| 45 | + | Want protocol.ExactSessionTarget | |||
| 46 | + | Got protocol.ExactSessionTarget | |||
| 47 | + | Name string | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (e *AttachmentIdentityError) Error() string { | |||
| 51 | + | if e.Name != "" { | |||
| 52 | + | return fmt.Sprintf("client: %s committed session name %q, want %q", e.Stage, e.Got.SessionName, e.Name) | |||
| 53 | + | } | |||
| 54 | + | return fmt.Sprintf("client: %s committed target %+v, want %+v", e.Stage, e.Got, e.Want) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // sessionAttachmentConfig supplies the typed-session inputs of one worker run. | |||
| 58 | + | // The request is the exact request the supervisor opened the stream with; the | |||
| 59 | + | // rest is client-local presentation data the worker mirrors into Hello. | |||
| 60 | + | type sessionAttachmentConfig struct { | |||
| 61 | + | Request ports.BrokerOpenStreamRequest | |||
| 62 | + | ClientID [16]byte | |||
| 63 | + | Geometry domain.Geometry | |||
| 64 | + | TermEnv string | |||
| 65 | + | Cwd string | |||
| 66 | + | TrueColor bool | |||
| 67 | + | // BeforeAttached runs after the initial frame commits but before attached | |||
| 68 | + | // presentation becomes observable. A failure keeps the run unattached. | |||
| 69 | + | BeforeAttached func() error | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |||
| 73 | + | // stream. It holds no stream or terminal of its own: everything arrives through | |||
| 74 | + | // Run's foreground for the duration of one run. | |||
| 75 | + | type sessionAttachmentWorker struct { | |||
| 76 | + | cfg sessionAttachmentConfig | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // newSessionAttachmentWorker builds the real typed-session attachment worker. | |||
| 80 | + | func newSessionAttachmentWorker(cfg sessionAttachmentConfig) *sessionAttachmentWorker { | |||
| 81 | + | return &sessionAttachmentWorker{cfg: cfg} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // serverReceive is one server message or the terminal receive error. | |||
| 85 | + | type serverReceive struct { | |||
| 86 | + | message protocol.ServerMessage | |||
| 87 | + | err error | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // startServerReceive owns the single reader of the logical stream. It is | |||
| 91 | + | // bounded: it buffers one message and blocks on the next read, and the stream's | |||
| 92 | + | // Close contract unblocks the read. The returned stop channel releases the | |||
| 93 | + | // reader when the run ends, so a message the worker no longer reads can never | |||
| 94 | + | // pin it. | |||
| 95 | + | func startServerReceive(stream ports.BrokerLogicalConnection) (<-chan serverReceive, chan struct{}) { | |||
| 96 | + | out := make(chan serverReceive, 1) | |||
| 97 | + | stop := make(chan struct{}) | |||
| 98 | + | go func() { | |||
| 99 | + | for { | |||
| 100 | + | message, err := stream.ReceiveServer() | |||
| 101 | + | select { | |||
| 102 | + | case out <- serverReceive{message: message, err: err}: | |||
| 103 | + | case <-stop: | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | if err != nil { | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | }() | |||
| 111 | + | return out, stop | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Run drives Hello -> Welcome -> committed initial publication -> attached | |||
| 115 | + | // output until the stream ends or the context/deadline, the supervisor finalize | |||
| 116 | + | // signal, or the stream itself stops it. Every return path is a typed event; | |||
| 117 | + | // the supervisor stamps the granted token onto it. | |||
| 118 | + | func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 119 | + | token := fg.Token() | |||
| 120 | + | if ctx == nil { | |||
| 121 | + | ctx = context.Background() | |||
| 122 | + | } | |||
| 123 | + | stream := fg.Stream() | |||
| 124 | + | if supervisorNil(stream) { | |||
| 125 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentNoStream} | |||
| 126 | + | } | |||
| 127 | + | incoming, stopReceive := startServerReceive(stream) | |||
| 128 | + | defer close(stopReceive) | |||
| 129 | + | ||||
| 130 | + | if err := w.sendHello(ctx, fg, stream); err != nil { | |||
| 131 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | welcome, err := w.awaitServer(ctx, fg, incoming) | |||
| 135 | + | if err != nil { | |||
| 136 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 137 | + | } | |||
| 138 | + | switch message := welcome.(type) { | |||
| 139 | + | case protocol.Welcome: | |||
| 140 | + | // Welcome is a promise, not attachment: the daemon still owes the | |||
| 141 | + | // committed initial publication. | |||
| 142 | + | if err := w.validateWelcome(message); err != nil { | |||
| 143 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |||
| 144 | + | } | |||
| 145 | + | case protocol.ErrorMsg: | |||
| 146 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: message.Code, Text: message.Text}} | |||
| 147 | + | default: | |||
| 148 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: unexpected %T before welcome", welcome)} | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) | |||
| 152 | + | if event != nil { | |||
| 153 | + | return *event | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | return w.pumpAttached(ctx, fg, stream, token, state, incoming) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // awaitInitialPublication consumes server messages until the first valid full | |||
| 160 | + | // output frame is committed. It returns the output state to continue with, or | |||
| 161 | + | // a typed terminal event. | |||
| 162 | + | func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) { | |||
| 163 | + | state := outputApplyState{} | |||
| 164 | + | for { | |||
| 165 | + | message, err := w.awaitServer(ctx, fg, incoming) | |||
| 166 | + | if err != nil { | |||
| 167 | + | event := w.settle(ctx, fg, stream, token, err) | |||
| 168 | + | return state, &event | |||
| 169 | + | } | |||
| 170 | + | switch typed := message.(type) { | |||
| 171 | + | case protocol.Output: | |||
| 172 | + | next, ok := state.next(typed) | |||
| 173 | + | if !ok { | |||
| 174 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication} | |||
| 175 | + | return state, &event | |||
| 176 | + | } | |||
| 177 | + | if err := w.validatePublication(typed); err != nil { | |||
| 178 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |||
| 179 | + | return state, &event | |||
| 180 | + | } | |||
| 181 | + | state = next | |||
| 182 | + | if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusTransitioning), typed.Data); err != nil { | |||
| 183 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing initial output: %w", err)} | |||
| 184 | + | return state, &event | |||
| 185 | + | } | |||
| 186 | + | // Attachment is the committed publication. Complete the deadline | |||
| 187 | + | // boundary before presenting attached, so attached always implies the | |||
| 188 | + | // handshake budget can no longer cancel the run. | |||
| 189 | + | if w.cfg.BeforeAttached != nil { | |||
| 190 | + | if err := w.cfg.BeforeAttached(); err != nil { | |||
| 191 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |||
| 192 | + | return state, &event | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | // A refused marker means the grant was revoked underneath us, so the | |||
| 196 | + | // run ends as a failure. | |||
| 197 | + | if !fg.MarkAttached() { | |||
| 198 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked} | |||
| 199 | + | return state, &event | |||
| 200 | + | } | |||
| 201 | + | return state, nil | |||
| 202 | + | case protocol.ErrorMsg: | |||
| 203 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |||
| 204 | + | return state, &event | |||
| 205 | + | case protocol.Detached: | |||
| 206 | + | // The daemon ended the attempt before publishing anything: it | |||
| 207 | + | // never attached. | |||
| 208 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |||
| 209 | + | return state, &event | |||
| 210 | + | default: | |||
| 211 | + | // Everything else the daemon may publish before the initial frame | |||
| 212 | + | // stays outside this slice's scope and is ignored. | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // pumpAttached publishes accepted output after attachment until the stream | |||
| 218 | + | // ends. It never unmarks attachment: a stream failure ends the run as | |||
| 219 | + | // lost/ended, so the supervisor returns to the picker and never keeps the | |||
| 220 | + | // attached presentation showing. | |||
| 221 | + | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent { | |||
| 222 | + | for { | |||
| 223 | + | message, err := w.awaitServer(ctx, fg, incoming) | |||
| 224 | + | if err != nil { | |||
| 225 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 226 | + | } | |||
| 227 | + | switch typed := message.(type) { | |||
| 228 | + | case protocol.Output: | |||
| 229 | + | next, ok := state.next(typed) | |||
| 230 | + | if !ok { | |||
| 231 | + | // A frame outside the output chain after attachment is ignored | |||
| 232 | + | // rather than replaying stale cells. | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | state = next | |||
| 236 | + | if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil { | |||
| 237 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | |||
| 238 | + | } | |||
| 239 | + | case protocol.ErrorMsg: | |||
| 240 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |||
| 241 | + | case protocol.Detached: | |||
| 242 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |||
| 243 | + | default: | |||
| 244 | + | // Messages outside the attached output flow are consumed and | |||
| 245 | + | // ignored until the stream ends; this slice adds no other handler. | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error { | |||
| 251 | + | if welcome.CommittedIdentity != nil { | |||
| 252 | + | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | |||
| 253 | + | } | |||
| 254 | + | if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name { | |||
| 255 | + | return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name} | |||
| 256 | + | } | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func (w *sessionAttachmentWorker) validatePublication(output protocol.Output) error { | |||
| 261 | + | if output.Context == nil { | |||
| 262 | + | return errAttachmentPublication | |||
| 263 | + | } | |||
| 264 | + | return w.validateIdentity("initial publication", output.Context.Route.Target) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.ExactSessionTarget) error { | |||
| 268 | + | request := w.cfg.Request | |||
| 269 | + | switch request.Admission { | |||
| 270 | + | case ports.BrokerAdmissionExact: | |||
| 271 | + | if got != request.Target { | |||
| 272 | + | return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got} | |||
| 273 | + | } | |||
| 274 | + | case ports.BrokerAdmissionCreateNamed: | |||
| 275 | + | if got.SessionName != request.Name { | |||
| 276 | + | return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name} | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | return nil | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // sendHello sends the typed Hello derived from the exact opened request. | |||
| 283 | + | func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error { | |||
| 284 | + | return w.send(ctx, fg, stream, w.hello(stream)) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // hello builds the Hello the daemon expects for exactly one opened request. No | |||
| 288 | + | // endpoint, route, or resume token is invented: an exact request attaches, a | |||
| 289 | + | // named request creates, and an ephemeral request is ephemeral. | |||
| 290 | + | func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) protocol.Hello { | |||
| 291 | + | request := w.cfg.Request | |||
| 292 | + | geometry := w.cfg.Geometry.NormalizePixels() | |||
| 293 | + | hello := protocol.Hello{ | |||
| 294 | + | Version: protocol.Version, | |||
| 295 | + | Intent: protocol.IntentAttach, | |||
| 296 | + | ClientID: w.cfg.ClientID, | |||
| 297 | + | Size: geometry.Size, | |||
| 298 | + | PixelWidth: geometry.PixelWidth, | |||
| 299 | + | PixelHeight: geometry.PixelHeight, | |||
| 300 | + | TermEnv: w.cfg.TermEnv, | |||
| 301 | + | Cwd: w.cfg.Cwd, | |||
| 302 | + | TrueColor: w.cfg.TrueColor, | |||
| 303 | + | MaxOutputInFlight: requestedOutputWindow(stream), | |||
| 304 | + | Env: append([]string(nil), request.Env...), | |||
| 305 | + | EnvironmentPolicy: request.Policy.EnvironmentPolicy, | |||
| 306 | + | Remote: !request.Local, | |||
| 307 | + | } | |||
| 308 | + | switch request.Admission { | |||
| 309 | + | case ports.BrokerAdmissionExact: | |||
| 310 | + | target := request.Target | |||
| 311 | + | hello.Intent = protocol.IntentAttach | |||
| 312 | + | hello.ExactTarget = &target | |||
| 313 | + | case ports.BrokerAdmissionCreateNamed: | |||
| 314 | + | hello.Intent = protocol.IntentNew | |||
| 315 | + | hello.Name = request.Name | |||
| 316 | + | case ports.BrokerAdmissionCreateEphemeral: | |||
| 317 | + | hello.Intent = protocol.IntentEphemeral | |||
| 318 | + | } | |||
| 319 | + | return hello | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // send sends one client message while the run is still live. The send runs on | |||
| 323 | + | // its own goroutine so a cancellation or finalize wakes the worker without | |||
| 324 | + | // waiting for the stream's Close, which is the supervisor's close authority. | |||
| 325 | + | func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, message protocol.ClientMessage) error { | |||
| 326 | + | sent := make(chan error, 1) | |||
| 327 | + | go func() { sent <- stream.SendClient(message) }() | |||
| 328 | + | select { | |||
| 329 | + | case err := <-sent: | |||
| 330 | + | return err | |||
| 331 | + | case <-ctx.Done(): | |||
| 332 | + | return ctx.Err() | |||
| 333 | + | case <-fg.Done(): | |||
| 334 | + | return errAttachmentForegroundRevoked | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // awaitServer waits for the next server message while the run is live. | |||
| 339 | + | func (w *sessionAttachmentWorker) awaitServer(ctx context.Context, fg AttachmentForeground, incoming <-chan serverReceive) (protocol.ServerMessage, error) { | |||
| 340 | + | select { | |||
| 341 | + | case result := <-incoming: | |||
| 342 | + | if result.err != nil { | |||
| 343 | + | return nil, result.err | |||
| 344 | + | } | |||
| 345 | + | return result.message, nil | |||
| 346 | + | case <-ctx.Done(): | |||
| 347 | + | return nil, ctx.Err() | |||
| 348 | + | case <-fg.Done(): | |||
| 349 | + | return nil, errAttachmentForegroundRevoked | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // settle classifies a stopping cause into the correct typed event. A cancelled | |||
| 354 | + | // context is the supervisor's own deadline/teardown, a published stream cause | |||
| 355 | + | // is a loss, a bare EOF is an orderly end, and anything else is a protocol or | |||
| 356 | + | // application failure on the stream. | |||
| 357 | + | func (w *sessionAttachmentWorker) settle(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, err error) AttachmentEvent { | |||
| 358 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 359 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errors.Join(ctxErr, err)} | |||
| 360 | + | } | |||
| 361 | + | if cause := streamErr(stream); cause != nil { | |||
| 362 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventLost, Err: cause} | |||
| 363 | + | } | |||
| 364 | + | if errors.Is(err, io.EOF) { | |||
| 365 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err} | |||
| 366 | + | } | |||
| 367 | + | if err != nil { | |||
| 368 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |||
| 369 | + | } | |||
| 370 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // streamErr reads the logical stream's stable terminal cause without treating a | |||
| 374 | + | // nil stream as a failure. | |||
| 375 | + | func streamErr(stream ports.BrokerLogicalConnection) error { | |||
| 376 | + | if supervisorNil(stream) { | |||
| 377 | + | return nil | |||
| 378 | + | } | |||
| 379 | + | return stream.Err() | |||
| 380 | + | } | |||
A internal/usecase/client/attachment_session_test.go +608 -0
| @@ -0,0 +1,608 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/internal/protocol" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Real attachment worker tests (Plan 001 P5.3b). They are deterministic and | |
| 20 | + | // drive the typed session protocol directly: no wall-clock sleeps, no real | |
| 21 | + | // terminal, and no broker process. | |
| 22 | + | ||
| 23 | + | var errSessionTestClosed = errors.New("session test stream is closed") | |
| 24 | + | ||
| 25 | + | // sessionTestStream is one scripted broker logical connection. It records every | |
| 26 | + | // typed client message, delivers scripted server messages, and unblocks | |
| 27 | + | // ReceiveServer on Close exactly as the ports.BrokerLogicalConnection contract | |
| 28 | + | // requires. | |
| 29 | + | type sessionTestStream struct { | |
| 30 | + | mu sync.Mutex | |
| 31 | + | sent []protocol.ClientMessage | |
| 32 | + | incoming chan protocol.ServerMessage | |
| 33 | + | closed chan struct{} | |
| 34 | + | closeOnce sync.Once | |
| 35 | + | err error | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func newSessionTestStream() *sessionTestStream { | |
| 39 | + | return &sessionTestStream{ | |
| 40 | + | incoming: make(chan protocol.ServerMessage, 8), | |
| 41 | + | closed: make(chan struct{}), | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (s *sessionTestStream) SendClient(message protocol.ClientMessage) error { | |
| 46 | + | select { | |
| 47 | + | case <-s.closed: | |
| 48 | + | return errSessionTestClosed | |
| 49 | + | default: | |
| 50 | + | } | |
| 51 | + | s.mu.Lock() | |
| 52 | + | s.sent = append(s.sent, message) | |
| 53 | + | s.mu.Unlock() | |
| 54 | + | return nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (s *sessionTestStream) ReceiveServer() (protocol.ServerMessage, error) { | |
| 58 | + | select { | |
| 59 | + | case message := <-s.incoming: | |
| 60 | + | return message, nil | |
| 61 | + | case <-s.closed: | |
| 62 | + | s.mu.Lock() | |
| 63 | + | err := s.err | |
| 64 | + | s.mu.Unlock() | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, errors.Join(io.EOF, err) | |
| 67 | + | } | |
| 68 | + | return nil, io.EOF | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (s *sessionTestStream) Capabilities() protocol.ConnectionCapabilities { | |
| 73 | + | return protocol.ConnectionCapabilities{} | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (s *sessionTestStream) LinkState() ports.LinkState { return ports.LinkStateConnected } | |
| 77 | + | ||
| 78 | + | func (s *sessionTestStream) LinkEvents() <-chan ports.LinkEvent { return nil } | |
| 79 | + | ||
| 80 | + | func (s *sessionTestStream) Close() error { | |
| 81 | + | s.closeOnce.Do(func() { close(s.closed) }) | |
| 82 | + | return nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (s *sessionTestStream) Done() <-chan struct{} { return s.closed } | |
| 86 | + | ||
| 87 | + | func (s *sessionTestStream) Err() error { | |
| 88 | + | s.mu.Lock() | |
| 89 | + | defer s.mu.Unlock() | |
| 90 | + | return s.err | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (s *sessionTestStream) deliver(message protocol.ServerMessage) { | |
| 94 | + | select { | |
| 95 | + | case s.incoming <- message: | |
| 96 | + | case <-s.closed: | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // fail publishes the stable stream cause and closes, exactly as a transported | |
| 101 | + | // logical stream does on loss. | |
| 102 | + | func (s *sessionTestStream) fail(err error) { | |
| 103 | + | s.mu.Lock() | |
| 104 | + | if s.err == nil { | |
| 105 | + | s.err = err | |
| 106 | + | } | |
| 107 | + | s.mu.Unlock() | |
| 108 | + | _ = s.Close() | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (s *sessionTestStream) closedNow() bool { | |
| 112 | + | select { | |
| 113 | + | case <-s.closed: | |
| 114 | + | return true | |
| 115 | + | default: | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (s *sessionTestStream) messages() []protocol.ClientMessage { | |
| 121 | + | s.mu.Lock() | |
| 122 | + | defer s.mu.Unlock() | |
| 123 | + | return append([]protocol.ClientMessage(nil), s.sent...) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // sessionTestStreamWithDeadline additionally implements | |
| 127 | + | // ports.HandshakeDeadlineProvider, so the supervisor-level deadline tests can | |
| 128 | + | // prove an accepted absolute deadline is adopted verbatim. | |
| 129 | + | type sessionTestStreamWithDeadline struct { | |
| 130 | + | *sessionTestStream | |
| 131 | + | deadline time.Time | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (s *sessionTestStreamWithDeadline) HandshakeDeadline() time.Time { return s.deadline } | |
| 135 | + | ||
| 136 | + | func sessionTestOutput(publication uint64, data string) protocol.Output { | |
| 137 | + | return protocol.Output{ | |
| 138 | + | Epoch: 1, New: 1, Full: true, | |
| 139 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 140 | + | Context: &protocol.ViewContext{ | |
| 141 | + | Publication: publication, | |
| 142 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{ | |
| 143 | + | LifecycleID: domain.SessionLifecycleID{1}, | |
| 144 | + | SessionName: "alpha", | |
| 145 | + | }}, | |
| 146 | + | TabID: "t_abc123", | |
| 147 | + | FocusedPaneID: "p_def456", | |
| 148 | + | }, | |
| 149 | + | Data: []byte(data), | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func sessionTestRequest(local bool) ports.BrokerOpenStreamRequest { | |
| 154 | + | request := ports.BrokerOpenStreamRequest{ | |
| 155 | + | Epoch: 3, | |
| 156 | + | Purpose: ports.BrokerStreamAttachment, | |
| 157 | + | Admission: ports.BrokerAdmissionExact, | |
| 158 | + | Local: local, | |
| 159 | + | Target: protocol.ExactSessionTarget{ | |
| 160 | + | LifecycleID: domain.SessionLifecycleID{1}, | |
| 161 | + | SessionName: "alpha", | |
| 162 | + | }, | |
| 163 | + | Policy: ports.BrokerPolicy{ | |
| 164 | + | ProtocolVersion: protocol.Version, | |
| 165 | + | CatalogSchemaVersion: 1, | |
| 166 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 167 | + | Transport: "transport", | |
| 168 | + | Trust: "trust", | |
| 169 | + | Launch: "launch", | |
| 170 | + | Isolation: "isolation", | |
| 171 | + | }, | |
| 172 | + | } | |
| 173 | + | if !local { | |
| 174 | + | request.Endpoint = "user@example" | |
| 175 | + | request.Registration = domain.RemoteRegistration{ | |
| 176 | + | Endpoint: "user@example", | |
| 177 | + | Incarnation: [16]byte{1}, | |
| 178 | + | Generation: 1, | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | return request | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func sessionTestWorkerConfig(request ports.BrokerOpenStreamRequest) sessionAttachmentConfig { | |
| 185 | + | return sessionAttachmentConfig{ | |
| 186 | + | Request: request, | |
| 187 | + | ClientID: [16]byte{0xAB}, | |
| 188 | + | Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // sessionTestAttachments records every successful MarkAttached without | |
| 193 | + | // draining: a wait observes the transition but never consumes it, so the test | |
| 194 | + | // can assert attachment after a script already synchronized on it. | |
| 195 | + | type sessionTestAttachments struct { | |
| 196 | + | count atomic.Int64 | |
| 197 | + | tokens chan AttachmentToken | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func newSessionTestAttachments() *sessionTestAttachments { | |
| 201 | + | return &sessionTestAttachments{tokens: make(chan AttachmentToken, 8)} | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (a *sessionTestAttachments) record(token AttachmentToken) { | |
| 205 | + | a.count.Add(1) | |
| 206 | + | select { | |
| 207 | + | case a.tokens <- token: | |
| 208 | + | default: | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (a *sessionTestAttachments) attached() bool { return a.count.Load() > 0 } | |
| 213 | + | ||
| 214 | + | func (a *sessionTestAttachments) token(t *testing.T) AttachmentToken { | |
| 215 | + | t.Helper() | |
| 216 | + | select { | |
| 217 | + | case token := <-a.tokens: | |
| 218 | + | return token | |
| 219 | + | case <-time.After(5 * time.Second): | |
| 220 | + | t.Fatal("the worker never attached") | |
| 221 | + | return AttachmentToken{} | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // sessionTestRun boots one worker through the real foreground host. | |
| 226 | + | func sessionTestRun(t *testing.T, stream ports.BrokerLogicalConnection, terminal *workerTestTerminal, cfg sessionAttachmentConfig, attached *sessionTestAttachments) <-chan AttachmentEvent { | |
| 227 | + | t.Helper() | |
| 228 | + | host := newWorkerTestHost(terminal, nil, attached.record) | |
| 229 | + | worker := newSessionAttachmentWorker(cfg) | |
| 230 | + | events := make(chan AttachmentEvent, 1) | |
| 231 | + | go func() { | |
| 232 | + | event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) | |
| 233 | + | events <- event | |
| 234 | + | }() | |
| 235 | + | return events | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // awaitHello blocks until the worker sent its Hello. | |
| 239 | + | func awaitHello(t *testing.T, stream *sessionTestStream) protocol.Hello { | |
| 240 | + | t.Helper() | |
| 241 | + | deadline := time.After(5 * time.Second) | |
| 242 | + | for { | |
| 243 | + | for _, message := range stream.messages() { | |
| 244 | + | if hello, ok := message.(protocol.Hello); ok { | |
| 245 | + | return hello | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | select { | |
| 249 | + | case <-deadline: | |
| 250 | + | t.Fatal("the worker never sent Hello") | |
| 251 | + | return protocol.Hello{} | |
| 252 | + | case <-time.After(time.Millisecond): | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // TestSessionAttachmentWorkerStaysConnectingUntilPublication proves Welcome is | |
| 258 | + | // never attachment: only a validated, committed initial publication marks | |
| 259 | + | // attached, and the frame is written and flushed through the UI transaction. | |
| 260 | + | func TestSessionAttachmentWorkerStaysConnectingUntilPublication(t *testing.T) { | |
| 261 | + | stream := newSessionTestStream() | |
| 262 | + | terminal := newWorkerTestTerminal() | |
| 263 | + | attached := newSessionTestAttachments() | |
| 264 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached) | |
| 265 | + | ||
| 266 | + | hello := awaitHello(t, stream) | |
| 267 | + | require.Equal(t, protocol.Version, hello.Version) | |
| 268 | + | require.Equal(t, protocol.IntentAttach, hello.Intent) | |
| 269 | + | require.NotNil(t, hello.ExactTarget) | |
| 270 | + | require.False(t, hello.Remote) | |
| 271 | + | ||
| 272 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 273 | + | // Welcome alone must not attach, write, or publish. | |
| 274 | + | require.Never(t, attached.attached, 20*time.Millisecond, 2*time.Millisecond, "Welcome alone must never attach") | |
| 275 | + | require.Zero(t, terminal.flushCount(), "Welcome alone writes nothing") | |
| 276 | + | require.Empty(t, terminal.publications(), "Welcome alone publishes nothing") | |
| 277 | + | ||
| 278 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 279 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, attached.token(t)) | |
| 280 | + | require.Equal(t, "\x1b[Hready", terminal.written()) | |
| 281 | + | require.Equal(t, 1, terminal.flushCount()) | |
| 282 | + | require.Len(t, terminal.publications(), 1) | |
| 283 | + | require.Equal(t, ports.UIStatusTransitioning, terminal.publications()[0].Status) | |
| 284 | + | ||
| 285 | + | require.NoError(t, stream.Close()) | |
| 286 | + | select { | |
| 287 | + | case event := <-events: | |
| 288 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |
| 289 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token) | |
| 290 | + | case <-time.After(5 * time.Second): | |
| 291 | + | t.Fatal("the worker never settled after the stream closed") | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // TestSessionAttachmentWorkerFailureStages is the EOF/refusal coverage table: | |
| 296 | + | // before Welcome, after Welcome but before publication, and after attachment, | |
| 297 | + | // plus a protocol refusal and a publication failure. | |
| 298 | + | func TestSessionAttachmentWorkerFailureStages(t *testing.T) { | |
| 299 | + | lossErr := ports.BrokerStreamLost{ | |
| 300 | + | Connection: ports.BrokerConnectionID{1}, | |
| 301 | + | Stream: 1, | |
| 302 | + | Epoch: 3, | |
| 303 | + | Cause: domain.RemoteFailureTransport, | |
| 304 | + | Err: errors.New("transport lost"), | |
| 305 | + | } | |
| 306 | + | publishErr := errors.New("ui publication failed") | |
| 307 | + | ||
| 308 | + | tests := []struct { | |
| 309 | + | name string | |
| 310 | + | script func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments) | |
| 311 | + | wantKind AttachmentEventKind | |
| 312 | + | wantAttached bool | |
| 313 | + | wantErr error | |
| 314 | + | wantProtocolCode uint16 | |
| 315 | + | }{ | |
| 316 | + | { | |
| 317 | + | name: "eof_before_welcome", | |
| 318 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { _ = stream.Close() }, | |
| 319 | + | wantKind: AttachmentEventEnded, | |
| 320 | + | }, | |
| 321 | + | { | |
| 322 | + | name: "refusal_before_welcome", | |
| 323 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |
| 324 | + | stream.deliver(protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such session"}) | |
| 325 | + | }, | |
| 326 | + | wantKind: AttachmentEventFailed, | |
| 327 | + | wantProtocolCode: protocol.ErrNoSuchSession, | |
| 328 | + | }, | |
| 329 | + | { | |
| 330 | + | name: "unexpected_before_welcome", | |
| 331 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |
| 332 | + | stream.deliver(protocol.Sessions{}) | |
| 333 | + | }, | |
| 334 | + | wantKind: AttachmentEventFailed, | |
| 335 | + | }, | |
| 336 | + | { | |
| 337 | + | name: "eof_after_welcome_before_publication", | |
| 338 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |
| 339 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 340 | + | _ = stream.Close() | |
| 341 | + | }, | |
| 342 | + | wantKind: AttachmentEventEnded, | |
| 343 | + | }, | |
| 344 | + | { | |
| 345 | + | name: "invalid_initial_publication", | |
| 346 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |
| 347 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 348 | + | // A full frame with no context never commits. | |
| 349 | + | stream.deliver(protocol.Output{Epoch: 1, New: 1, Full: true}) | |
| 350 | + | }, | |
| 351 | + | wantKind: AttachmentEventFailed, | |
| 352 | + | wantErr: errAttachmentPublication, | |
| 353 | + | }, | |
| 354 | + | { | |
| 355 | + | name: "publication_failure_before_attachment", | |
| 356 | + | script: func(stream *sessionTestStream, terminal *workerTestTerminal, _ *sessionTestAttachments) { | |
| 357 | + | terminal.publishErr = publishErr | |
| 358 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 359 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 360 | + | }, | |
| 361 | + | wantKind: AttachmentEventFailed, | |
| 362 | + | wantErr: publishErr, | |
| 363 | + | }, | |
| 364 | + | { | |
| 365 | + | name: "unavailable_observation_commits_attachment", | |
| 366 | + | script: func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments) { | |
| 367 | + | terminal.publishErr = ports.ErrUIUnavailable | |
| 368 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 369 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 370 | + | attached.token(t) | |
| 371 | + | _ = stream.Close() | |
| 372 | + | }, | |
| 373 | + | wantKind: AttachmentEventEnded, | |
| 374 | + | wantAttached: true, | |
| 375 | + | }, | |
| 376 | + | { | |
| 377 | + | name: "eof_after_attachment", | |
| 378 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) { | |
| 379 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 380 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 381 | + | attached.token(t) | |
| 382 | + | _ = stream.Close() | |
| 383 | + | }, | |
| 384 | + | wantKind: AttachmentEventEnded, | |
| 385 | + | wantAttached: true, | |
| 386 | + | }, | |
| 387 | + | { | |
| 388 | + | name: "loss_after_attachment", | |
| 389 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) { | |
| 390 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 391 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 392 | + | attached.token(t) | |
| 393 | + | stream.fail(lossErr) | |
| 394 | + | }, | |
| 395 | + | wantKind: AttachmentEventLost, | |
| 396 | + | wantAttached: true, | |
| 397 | + | wantErr: lossErr, | |
| 398 | + | }, | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | for _, tt := range tests { | |
| 402 | + | t.Run(tt.name, func(t *testing.T) { | |
| 403 | + | stream := newSessionTestStream() | |
| 404 | + | terminal := newWorkerTestTerminal() | |
| 405 | + | attached := newSessionTestAttachments() | |
| 406 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached) | |
| 407 | + | awaitHello(t, stream) | |
| 408 | + | ||
| 409 | + | tt.script(stream, terminal, attached) | |
| 410 | + | ||
| 411 | + | var event AttachmentEvent | |
| 412 | + | select { | |
| 413 | + | case event = <-events: | |
| 414 | + | case <-time.After(5 * time.Second): | |
| 415 | + | t.Fatal("the worker never settled") | |
| 416 | + | } | |
| 417 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token) | |
| 418 | + | require.Equal(t, tt.wantKind, event.Kind, "event error: %v", event.Err) | |
| 419 | + | if tt.wantErr != nil { | |
| 420 | + | require.ErrorIs(t, event.Err, tt.wantErr) | |
| 421 | + | } | |
| 422 | + | if tt.wantProtocolCode != 0 { | |
| 423 | + | var protocolErr *ProtocolError | |
| 424 | + | require.ErrorAs(t, event.Err, &protocolErr) | |
| 425 | + | require.Equal(t, tt.wantProtocolCode, protocolErr.Code) | |
| 426 | + | } | |
| 427 | + | require.Equal(t, tt.wantAttached, attached.attached()) | |
| 428 | + | if !tt.wantAttached { | |
| 429 | + | // A frame that never committed was never written or flushed; the | |
| 430 | + | // UI transaction still observed its own boundary. | |
| 431 | + | require.Empty(t, terminal.written(), "an uncommitted frame must not be written") | |
| 432 | + | require.Zero(t, terminal.flushCount(), "an uncommitted frame must not be flushed") | |
| 433 | + | } | |
| 434 | + | }) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |
| 439 | + | tests := []struct { | |
| 440 | + | name string | |
| 441 | + | env AttachmentEnvironment | |
| 442 | + | }{ | |
| 443 | + | {name: "unset"}, | |
| 444 | + | {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}}, | |
| 445 | + | } | |
| 446 | + | for _, tt := range tests { | |
| 447 | + | t.Run(tt.name, func(t *testing.T) { | |
| 448 | + | stream := newSessionTestStream() | |
| 449 | + | terminal := newWorkerTestTerminal() | |
| 450 | + | attached := newSessionTestAttachments() | |
| 451 | + | supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}} | |
| 452 | + | worker, err := supervisor.newAttachmentWorker(sessionTestRequest(true), nil) | |
| 453 | + | require.NoError(t, err) | |
| 454 | + | events := make(chan AttachmentEvent, 1) | |
| 455 | + | host := newWorkerTestHost(terminal, nil, attached.record) | |
| 456 | + | go func() { | |
| 457 | + | event, _ := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |
| 458 | + | events <- event | |
| 459 | + | }() | |
| 460 | + | ||
| 461 | + | hello := awaitHello(t, stream) | |
| 462 | + | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | |
| 463 | + | require.Equal(t, tt.env.Cwd, hello.Cwd) | |
| 464 | + | require.Equal(t, tt.env.TrueColor, hello.TrueColor) | |
| 465 | + | require.NoError(t, stream.Close()) | |
| 466 | + | <-events | |
| 467 | + | }) | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func TestSessionAttachmentWorkerHandlesProtocolErrorAfterWelcome(t *testing.T) { | |
| 472 | + | tests := []struct { | |
| 473 | + | name string | |
| 474 | + | attached bool | |
| 475 | + | }{ | |
| 476 | + | {name: "awaiting initial publication"}, | |
| 477 | + | {name: "attached pump", attached: true}, | |
| 478 | + | } | |
| 479 | + | for _, tt := range tests { | |
| 480 | + | t.Run(tt.name, func(t *testing.T) { | |
| 481 | + | stream := newSessionTestStream() | |
| 482 | + | terminal := newWorkerTestTerminal() | |
| 483 | + | attachments := newSessionTestAttachments() | |
| 484 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attachments) | |
| 485 | + | awaitHello(t, stream) | |
| 486 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 487 | + | if tt.attached { | |
| 488 | + | stream.deliver(sessionTestOutput(1, "initial")) | |
| 489 | + | require.Eventually(t, attachments.attached, time.Second, time.Millisecond) | |
| 490 | + | } | |
| 491 | + | stream.deliver(protocol.ErrorMsg{Code: 42, Text: "refused"}) | |
| 492 | + | event := <-events | |
| 493 | + | var protocolErr *ProtocolError | |
| 494 | + | require.ErrorAs(t, event.Err, &protocolErr) | |
| 495 | + | require.Equal(t, uint16(42), protocolErr.Code) | |
| 496 | + | require.Equal(t, "refused", protocolErr.Text) | |
| 497 | + | }) | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func TestSessionAttachmentWorkerValidatesIdentityByAdmission(t *testing.T) { | |
| 502 | + | namedRequest := sessionTestRequest(true) | |
| 503 | + | namedRequest.Admission, namedRequest.Name, namedRequest.Target = ports.BrokerAdmissionCreateNamed, "alpha", protocol.ExactSessionTarget{} | |
| 504 | + | ephemeralRequest := sessionTestRequest(true) | |
| 505 | + | ephemeralRequest.Admission, ephemeralRequest.Target = ports.BrokerAdmissionCreateEphemeral, protocol.ExactSessionTarget{} | |
| 506 | + | tests := []struct { | |
| 507 | + | name string | |
| 508 | + | request ports.BrokerOpenStreamRequest | |
| 509 | + | welcome protocol.Welcome | |
| 510 | + | output protocol.Output | |
| 511 | + | wantErr bool | |
| 512 | + | }{ | |
| 513 | + | {name: "exact welcome mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}}}, wantErr: true}, | |
| 514 | + | {name: "exact publication mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output { | |
| 515 | + | o := sessionTestOutput(1, "wrong") | |
| 516 | + | o.Context.Route.Target.SessionName = "beta" | |
| 517 | + | return o | |
| 518 | + | }(), wantErr: true}, | |
| 519 | + | {name: "exact match", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestRequest(true).Target}}, output: sessionTestOutput(1, "ok")}, | |
| 520 | + | {name: "create named welcome mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "beta"}, wantErr: true}, | |
| 521 | + | {name: "create named publication mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output { | |
| 522 | + | o := sessionTestOutput(1, "wrong") | |
| 523 | + | o.Context.Route.Target.SessionName = "beta" | |
| 524 | + | return o | |
| 525 | + | }(), wantErr: true}, | |
| 526 | + | {name: "create named match", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: sessionTestOutput(1, "ok")}, | |
| 527 | + | {name: "create ephemeral accepts committed identity", request: ephemeralRequest, welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestOutput(1, "").Context.Route.Target}}, output: sessionTestOutput(1, "ok")}, | |
| 528 | + | } | |
| 529 | + | for _, tt := range tests { | |
| 530 | + | t.Run(tt.name, func(t *testing.T) { | |
| 531 | + | stream := newSessionTestStream() | |
| 532 | + | terminal := newWorkerTestTerminal() | |
| 533 | + | attached := newSessionTestAttachments() | |
| 534 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(tt.request), attached) | |
| 535 | + | awaitHello(t, stream) | |
| 536 | + | stream.deliver(tt.welcome) | |
| 537 | + | if tt.output.Context != nil { | |
| 538 | + | stream.deliver(tt.output) | |
| 539 | + | } | |
| 540 | + | if tt.wantErr { | |
| 541 | + | event := <-events | |
| 542 | + | var identityErr *AttachmentIdentityError | |
| 543 | + | require.ErrorAs(t, event.Err, &identityErr) | |
| 544 | + | require.False(t, attached.attached()) | |
| 545 | + | require.Empty(t, terminal.written()) | |
| 546 | + | return | |
| 547 | + | } | |
| 548 | + | require.Eventually(t, attached.attached, time.Second, time.Millisecond) | |
| 549 | + | require.Equal(t, "ok", terminal.written()) | |
| 550 | + | require.NoError(t, stream.Close()) | |
| 551 | + | <-events | |
| 552 | + | }) | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | // TestSessionAttachmentWorkerRemoteHello proves the same worker derives a | |
| 557 | + | // remote Hello from a remote request: the typed-preamble rules stay per | |
| 558 | + | // logical stream, and no endpoint is rewritten. | |
| 559 | + | func TestSessionAttachmentWorkerRemoteHello(t *testing.T) { | |
| 560 | + | stream := newSessionTestStream() | |
| 561 | + | terminal := newWorkerTestTerminal() | |
| 562 | + | attached := newSessionTestAttachments() | |
| 563 | + | request := sessionTestRequest(false) | |
| 564 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(request), attached) | |
| 565 | + | ||
| 566 | + | hello := awaitHello(t, stream) | |
| 567 | + | require.True(t, hello.Remote) | |
| 568 | + | require.Equal(t, protocol.EnvironmentPolicyClientOwned, hello.EnvironmentPolicy) | |
| 569 | + | require.Equal(t, protocol.IntentAttach, hello.Intent) | |
| 570 | + | require.NotNil(t, hello.ExactTarget) | |
| 571 | + | require.Equal(t, "alpha", hello.ExactTarget.SessionName) | |
| 572 | + | ||
| 573 | + | require.NoError(t, stream.Close()) | |
| 574 | + | select { | |
| 575 | + | case event := <-events: | |
| 576 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |
| 577 | + | case <-time.After(5 * time.Second): | |
| 578 | + | t.Fatal("the worker never settled") | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // TestSessionAttachmentWorkerNeverReadsTerminalInput proves the worker has no | |
| 583 | + | // path to terminal input: it consumes no bytes and sends no Input frame while | |
| 584 | + | // the foreground owns no input source. | |
| 585 | + | func TestSessionAttachmentWorkerNeverReadsTerminalInput(t *testing.T) { | |
| 586 | + | stream := newSessionTestStream() | |
| 587 | + | terminal := newWorkerTestTerminal() | |
| 588 | + | attached := newSessionTestAttachments() | |
| 589 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached) | |
| 590 | + | ||
| 591 | + | awaitHello(t, stream) | |
| 592 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 593 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 594 | + | _ = attached.token(t) | |
| 595 | + | ||
| 596 | + | for _, message := range stream.messages() { | |
| 597 | + | _, isInput := message.(protocol.Input) | |
| 598 | + | require.False(t, isInput, "the worker must never send terminal input in P5.3b") | |
| 599 | + | } | |
| 600 | + | require.Equal(t, "\x1b[Hready", terminal.written(), "only session output reaches the terminal") | |
| 601 | + | ||
| 602 | + | require.NoError(t, stream.Close()) | |
| 603 | + | select { | |
| 604 | + | case <-events: | |
| 605 | + | case <-time.After(5 * time.Second): | |
| 606 | + | t.Fatal("the worker never settled") | |
| 607 | + | } | |
| 608 | + | } | |
| @@ -0,0 +1,608 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | "github.com/bnema/vev/internal/protocol" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Real attachment worker tests (Plan 001 P5.3b). They are deterministic and | |||
| 20 | + | // drive the typed session protocol directly: no wall-clock sleeps, no real | |||
| 21 | + | // terminal, and no broker process. | |||
| 22 | + | ||||
| 23 | + | var errSessionTestClosed = errors.New("session test stream is closed") | |||
| 24 | + | ||||
| 25 | + | // sessionTestStream is one scripted broker logical connection. It records every | |||
| 26 | + | // typed client message, delivers scripted server messages, and unblocks | |||
| 27 | + | // ReceiveServer on Close exactly as the ports.BrokerLogicalConnection contract | |||
| 28 | + | // requires. | |||
| 29 | + | type sessionTestStream struct { | |||
| 30 | + | mu sync.Mutex | |||
| 31 | + | sent []protocol.ClientMessage | |||
| 32 | + | incoming chan protocol.ServerMessage | |||
| 33 | + | closed chan struct{} | |||
| 34 | + | closeOnce sync.Once | |||
| 35 | + | err error | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func newSessionTestStream() *sessionTestStream { | |||
| 39 | + | return &sessionTestStream{ | |||
| 40 | + | incoming: make(chan protocol.ServerMessage, 8), | |||
| 41 | + | closed: make(chan struct{}), | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (s *sessionTestStream) SendClient(message protocol.ClientMessage) error { | |||
| 46 | + | select { | |||
| 47 | + | case <-s.closed: | |||
| 48 | + | return errSessionTestClosed | |||
| 49 | + | default: | |||
| 50 | + | } | |||
| 51 | + | s.mu.Lock() | |||
| 52 | + | s.sent = append(s.sent, message) | |||
| 53 | + | s.mu.Unlock() | |||
| 54 | + | return nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (s *sessionTestStream) ReceiveServer() (protocol.ServerMessage, error) { | |||
| 58 | + | select { | |||
| 59 | + | case message := <-s.incoming: | |||
| 60 | + | return message, nil | |||
| 61 | + | case <-s.closed: | |||
| 62 | + | s.mu.Lock() | |||
| 63 | + | err := s.err | |||
| 64 | + | s.mu.Unlock() | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, errors.Join(io.EOF, err) | |||
| 67 | + | } | |||
| 68 | + | return nil, io.EOF | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (s *sessionTestStream) Capabilities() protocol.ConnectionCapabilities { | |||
| 73 | + | return protocol.ConnectionCapabilities{} | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (s *sessionTestStream) LinkState() ports.LinkState { return ports.LinkStateConnected } | |||
| 77 | + | ||||
| 78 | + | func (s *sessionTestStream) LinkEvents() <-chan ports.LinkEvent { return nil } | |||
| 79 | + | ||||
| 80 | + | func (s *sessionTestStream) Close() error { | |||
| 81 | + | s.closeOnce.Do(func() { close(s.closed) }) | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (s *sessionTestStream) Done() <-chan struct{} { return s.closed } | |||
| 86 | + | ||||
| 87 | + | func (s *sessionTestStream) Err() error { | |||
| 88 | + | s.mu.Lock() | |||
| 89 | + | defer s.mu.Unlock() | |||
| 90 | + | return s.err | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (s *sessionTestStream) deliver(message protocol.ServerMessage) { | |||
| 94 | + | select { | |||
| 95 | + | case s.incoming <- message: | |||
| 96 | + | case <-s.closed: | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // fail publishes the stable stream cause and closes, exactly as a transported | |||
| 101 | + | // logical stream does on loss. | |||
| 102 | + | func (s *sessionTestStream) fail(err error) { | |||
| 103 | + | s.mu.Lock() | |||
| 104 | + | if s.err == nil { | |||
| 105 | + | s.err = err | |||
| 106 | + | } | |||
| 107 | + | s.mu.Unlock() | |||
| 108 | + | _ = s.Close() | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (s *sessionTestStream) closedNow() bool { | |||
| 112 | + | select { | |||
| 113 | + | case <-s.closed: | |||
| 114 | + | return true | |||
| 115 | + | default: | |||
| 116 | + | return false | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (s *sessionTestStream) messages() []protocol.ClientMessage { | |||
| 121 | + | s.mu.Lock() | |||
| 122 | + | defer s.mu.Unlock() | |||
| 123 | + | return append([]protocol.ClientMessage(nil), s.sent...) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // sessionTestStreamWithDeadline additionally implements | |||
| 127 | + | // ports.HandshakeDeadlineProvider, so the supervisor-level deadline tests can | |||
| 128 | + | // prove an accepted absolute deadline is adopted verbatim. | |||
| 129 | + | type sessionTestStreamWithDeadline struct { | |||
| 130 | + | *sessionTestStream | |||
| 131 | + | deadline time.Time | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (s *sessionTestStreamWithDeadline) HandshakeDeadline() time.Time { return s.deadline } | |||
| 135 | + | ||||
| 136 | + | func sessionTestOutput(publication uint64, data string) protocol.Output { | |||
| 137 | + | return protocol.Output{ | |||
| 138 | + | Epoch: 1, New: 1, Full: true, | |||
| 139 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 140 | + | Context: &protocol.ViewContext{ | |||
| 141 | + | Publication: publication, | |||
| 142 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{ | |||
| 143 | + | LifecycleID: domain.SessionLifecycleID{1}, | |||
| 144 | + | SessionName: "alpha", | |||
| 145 | + | }}, | |||
| 146 | + | TabID: "t_abc123", | |||
| 147 | + | FocusedPaneID: "p_def456", | |||
| 148 | + | }, | |||
| 149 | + | Data: []byte(data), | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func sessionTestRequest(local bool) ports.BrokerOpenStreamRequest { | |||
| 154 | + | request := ports.BrokerOpenStreamRequest{ | |||
| 155 | + | Epoch: 3, | |||
| 156 | + | Purpose: ports.BrokerStreamAttachment, | |||
| 157 | + | Admission: ports.BrokerAdmissionExact, | |||
| 158 | + | Local: local, | |||
| 159 | + | Target: protocol.ExactSessionTarget{ | |||
| 160 | + | LifecycleID: domain.SessionLifecycleID{1}, | |||
| 161 | + | SessionName: "alpha", | |||
| 162 | + | }, | |||
| 163 | + | Policy: ports.BrokerPolicy{ | |||
| 164 | + | ProtocolVersion: protocol.Version, | |||
| 165 | + | CatalogSchemaVersion: 1, | |||
| 166 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |||
| 167 | + | Transport: "transport", | |||
| 168 | + | Trust: "trust", | |||
| 169 | + | Launch: "launch", | |||
| 170 | + | Isolation: "isolation", | |||
| 171 | + | }, | |||
| 172 | + | } | |||
| 173 | + | if !local { | |||
| 174 | + | request.Endpoint = "user@example" | |||
| 175 | + | request.Registration = domain.RemoteRegistration{ | |||
| 176 | + | Endpoint: "user@example", | |||
| 177 | + | Incarnation: [16]byte{1}, | |||
| 178 | + | Generation: 1, | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return request | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func sessionTestWorkerConfig(request ports.BrokerOpenStreamRequest) sessionAttachmentConfig { | |||
| 185 | + | return sessionAttachmentConfig{ | |||
| 186 | + | Request: request, | |||
| 187 | + | ClientID: [16]byte{0xAB}, | |||
| 188 | + | Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // sessionTestAttachments records every successful MarkAttached without | |||
| 193 | + | // draining: a wait observes the transition but never consumes it, so the test | |||
| 194 | + | // can assert attachment after a script already synchronized on it. | |||
| 195 | + | type sessionTestAttachments struct { | |||
| 196 | + | count atomic.Int64 | |||
| 197 | + | tokens chan AttachmentToken | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func newSessionTestAttachments() *sessionTestAttachments { | |||
| 201 | + | return &sessionTestAttachments{tokens: make(chan AttachmentToken, 8)} | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (a *sessionTestAttachments) record(token AttachmentToken) { | |||
| 205 | + | a.count.Add(1) | |||
| 206 | + | select { | |||
| 207 | + | case a.tokens <- token: | |||
| 208 | + | default: | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (a *sessionTestAttachments) attached() bool { return a.count.Load() > 0 } | |||
| 213 | + | ||||
| 214 | + | func (a *sessionTestAttachments) token(t *testing.T) AttachmentToken { | |||
| 215 | + | t.Helper() | |||
| 216 | + | select { | |||
| 217 | + | case token := <-a.tokens: | |||
| 218 | + | return token | |||
| 219 | + | case <-time.After(5 * time.Second): | |||
| 220 | + | t.Fatal("the worker never attached") | |||
| 221 | + | return AttachmentToken{} | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // sessionTestRun boots one worker through the real foreground host. | |||
| 226 | + | func sessionTestRun(t *testing.T, stream ports.BrokerLogicalConnection, terminal *workerTestTerminal, cfg sessionAttachmentConfig, attached *sessionTestAttachments) <-chan AttachmentEvent { | |||
| 227 | + | t.Helper() | |||
| 228 | + | host := newWorkerTestHost(terminal, nil, attached.record) | |||
| 229 | + | worker := newSessionAttachmentWorker(cfg) | |||
| 230 | + | events := make(chan AttachmentEvent, 1) | |||
| 231 | + | go func() { | |||
| 232 | + | event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) | |||
| 233 | + | events <- event | |||
| 234 | + | }() | |||
| 235 | + | return events | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // awaitHello blocks until the worker sent its Hello. | |||
| 239 | + | func awaitHello(t *testing.T, stream *sessionTestStream) protocol.Hello { | |||
| 240 | + | t.Helper() | |||
| 241 | + | deadline := time.After(5 * time.Second) | |||
| 242 | + | for { | |||
| 243 | + | for _, message := range stream.messages() { | |||
| 244 | + | if hello, ok := message.(protocol.Hello); ok { | |||
| 245 | + | return hello | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | select { | |||
| 249 | + | case <-deadline: | |||
| 250 | + | t.Fatal("the worker never sent Hello") | |||
| 251 | + | return protocol.Hello{} | |||
| 252 | + | case <-time.After(time.Millisecond): | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // TestSessionAttachmentWorkerStaysConnectingUntilPublication proves Welcome is | |||
| 258 | + | // never attachment: only a validated, committed initial publication marks | |||
| 259 | + | // attached, and the frame is written and flushed through the UI transaction. | |||
| 260 | + | func TestSessionAttachmentWorkerStaysConnectingUntilPublication(t *testing.T) { | |||
| 261 | + | stream := newSessionTestStream() | |||
| 262 | + | terminal := newWorkerTestTerminal() | |||
| 263 | + | attached := newSessionTestAttachments() | |||
| 264 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached) | |||
| 265 | + | ||||
| 266 | + | hello := awaitHello(t, stream) | |||
| 267 | + | require.Equal(t, protocol.Version, hello.Version) | |||
| 268 | + | require.Equal(t, protocol.IntentAttach, hello.Intent) | |||
| 269 | + | require.NotNil(t, hello.ExactTarget) | |||
| 270 | + | require.False(t, hello.Remote) | |||
| 271 | + | ||||
| 272 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 273 | + | // Welcome alone must not attach, write, or publish. | |||
| 274 | + | require.Never(t, attached.attached, 20*time.Millisecond, 2*time.Millisecond, "Welcome alone must never attach") | |||
| 275 | + | require.Zero(t, terminal.flushCount(), "Welcome alone writes nothing") | |||
| 276 | + | require.Empty(t, terminal.publications(), "Welcome alone publishes nothing") | |||
| 277 | + | ||||
| 278 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 279 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, attached.token(t)) | |||
| 280 | + | require.Equal(t, "\x1b[Hready", terminal.written()) | |||
| 281 | + | require.Equal(t, 1, terminal.flushCount()) | |||
| 282 | + | require.Len(t, terminal.publications(), 1) | |||
| 283 | + | require.Equal(t, ports.UIStatusTransitioning, terminal.publications()[0].Status) | |||
| 284 | + | ||||
| 285 | + | require.NoError(t, stream.Close()) | |||
| 286 | + | select { | |||
| 287 | + | case event := <-events: | |||
| 288 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |||
| 289 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token) | |||
| 290 | + | case <-time.After(5 * time.Second): | |||
| 291 | + | t.Fatal("the worker never settled after the stream closed") | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // TestSessionAttachmentWorkerFailureStages is the EOF/refusal coverage table: | |||
| 296 | + | // before Welcome, after Welcome but before publication, and after attachment, | |||
| 297 | + | // plus a protocol refusal and a publication failure. | |||
| 298 | + | func TestSessionAttachmentWorkerFailureStages(t *testing.T) { | |||
| 299 | + | lossErr := ports.BrokerStreamLost{ | |||
| 300 | + | Connection: ports.BrokerConnectionID{1}, | |||
| 301 | + | Stream: 1, | |||
| 302 | + | Epoch: 3, | |||
| 303 | + | Cause: domain.RemoteFailureTransport, | |||
| 304 | + | Err: errors.New("transport lost"), | |||
| 305 | + | } | |||
| 306 | + | publishErr := errors.New("ui publication failed") | |||
| 307 | + | ||||
| 308 | + | tests := []struct { | |||
| 309 | + | name string | |||
| 310 | + | script func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments) | |||
| 311 | + | wantKind AttachmentEventKind | |||
| 312 | + | wantAttached bool | |||
| 313 | + | wantErr error | |||
| 314 | + | wantProtocolCode uint16 | |||
| 315 | + | }{ | |||
| 316 | + | { | |||
| 317 | + | name: "eof_before_welcome", | |||
| 318 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { _ = stream.Close() }, | |||
| 319 | + | wantKind: AttachmentEventEnded, | |||
| 320 | + | }, | |||
| 321 | + | { | |||
| 322 | + | name: "refusal_before_welcome", | |||
| 323 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |||
| 324 | + | stream.deliver(protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such session"}) | |||
| 325 | + | }, | |||
| 326 | + | wantKind: AttachmentEventFailed, | |||
| 327 | + | wantProtocolCode: protocol.ErrNoSuchSession, | |||
| 328 | + | }, | |||
| 329 | + | { | |||
| 330 | + | name: "unexpected_before_welcome", | |||
| 331 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |||
| 332 | + | stream.deliver(protocol.Sessions{}) | |||
| 333 | + | }, | |||
| 334 | + | wantKind: AttachmentEventFailed, | |||
| 335 | + | }, | |||
| 336 | + | { | |||
| 337 | + | name: "eof_after_welcome_before_publication", | |||
| 338 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |||
| 339 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 340 | + | _ = stream.Close() | |||
| 341 | + | }, | |||
| 342 | + | wantKind: AttachmentEventEnded, | |||
| 343 | + | }, | |||
| 344 | + | { | |||
| 345 | + | name: "invalid_initial_publication", | |||
| 346 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { | |||
| 347 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 348 | + | // A full frame with no context never commits. | |||
| 349 | + | stream.deliver(protocol.Output{Epoch: 1, New: 1, Full: true}) | |||
| 350 | + | }, | |||
| 351 | + | wantKind: AttachmentEventFailed, | |||
| 352 | + | wantErr: errAttachmentPublication, | |||
| 353 | + | }, | |||
| 354 | + | { | |||
| 355 | + | name: "publication_failure_before_attachment", | |||
| 356 | + | script: func(stream *sessionTestStream, terminal *workerTestTerminal, _ *sessionTestAttachments) { | |||
| 357 | + | terminal.publishErr = publishErr | |||
| 358 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 359 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 360 | + | }, | |||
| 361 | + | wantKind: AttachmentEventFailed, | |||
| 362 | + | wantErr: publishErr, | |||
| 363 | + | }, | |||
| 364 | + | { | |||
| 365 | + | name: "unavailable_observation_commits_attachment", | |||
| 366 | + | script: func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments) { | |||
| 367 | + | terminal.publishErr = ports.ErrUIUnavailable | |||
| 368 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 369 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 370 | + | attached.token(t) | |||
| 371 | + | _ = stream.Close() | |||
| 372 | + | }, | |||
| 373 | + | wantKind: AttachmentEventEnded, | |||
| 374 | + | wantAttached: true, | |||
| 375 | + | }, | |||
| 376 | + | { | |||
| 377 | + | name: "eof_after_attachment", | |||
| 378 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) { | |||
| 379 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 380 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 381 | + | attached.token(t) | |||
| 382 | + | _ = stream.Close() | |||
| 383 | + | }, | |||
| 384 | + | wantKind: AttachmentEventEnded, | |||
| 385 | + | wantAttached: true, | |||
| 386 | + | }, | |||
| 387 | + | { | |||
| 388 | + | name: "loss_after_attachment", | |||
| 389 | + | script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) { | |||
| 390 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 391 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 392 | + | attached.token(t) | |||
| 393 | + | stream.fail(lossErr) | |||
| 394 | + | }, | |||
| 395 | + | wantKind: AttachmentEventLost, | |||
| 396 | + | wantAttached: true, | |||
| 397 | + | wantErr: lossErr, | |||
| 398 | + | }, | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | for _, tt := range tests { | |||
| 402 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 403 | + | stream := newSessionTestStream() | |||
| 404 | + | terminal := newWorkerTestTerminal() | |||
| 405 | + | attached := newSessionTestAttachments() | |||
| 406 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached) | |||
| 407 | + | awaitHello(t, stream) | |||
| 408 | + | ||||
| 409 | + | tt.script(stream, terminal, attached) | |||
| 410 | + | ||||
| 411 | + | var event AttachmentEvent | |||
| 412 | + | select { | |||
| 413 | + | case event = <-events: | |||
| 414 | + | case <-time.After(5 * time.Second): | |||
| 415 | + | t.Fatal("the worker never settled") | |||
| 416 | + | } | |||
| 417 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token) | |||
| 418 | + | require.Equal(t, tt.wantKind, event.Kind, "event error: %v", event.Err) | |||
| 419 | + | if tt.wantErr != nil { | |||
| 420 | + | require.ErrorIs(t, event.Err, tt.wantErr) | |||
| 421 | + | } | |||
| 422 | + | if tt.wantProtocolCode != 0 { | |||
| 423 | + | var protocolErr *ProtocolError | |||
| 424 | + | require.ErrorAs(t, event.Err, &protocolErr) | |||
| 425 | + | require.Equal(t, tt.wantProtocolCode, protocolErr.Code) | |||
| 426 | + | } | |||
| 427 | + | require.Equal(t, tt.wantAttached, attached.attached()) | |||
| 428 | + | if !tt.wantAttached { | |||
| 429 | + | // A frame that never committed was never written or flushed; the | |||
| 430 | + | // UI transaction still observed its own boundary. | |||
| 431 | + | require.Empty(t, terminal.written(), "an uncommitted frame must not be written") | |||
| 432 | + | require.Zero(t, terminal.flushCount(), "an uncommitted frame must not be flushed") | |||
| 433 | + | } | |||
| 434 | + | }) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||
| 439 | + | tests := []struct { | |||
| 440 | + | name string | |||
| 441 | + | env AttachmentEnvironment | |||
| 442 | + | }{ | |||
| 443 | + | {name: "unset"}, | |||
| 444 | + | {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}}, | |||
| 445 | + | } | |||
| 446 | + | for _, tt := range tests { | |||
| 447 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 448 | + | stream := newSessionTestStream() | |||
| 449 | + | terminal := newWorkerTestTerminal() | |||
| 450 | + | attached := newSessionTestAttachments() | |||
| 451 | + | supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}} | |||
| 452 | + | worker, err := supervisor.newAttachmentWorker(sessionTestRequest(true), nil) | |||
| 453 | + | require.NoError(t, err) | |||
| 454 | + | events := make(chan AttachmentEvent, 1) | |||
| 455 | + | host := newWorkerTestHost(terminal, nil, attached.record) | |||
| 456 | + | go func() { | |||
| 457 | + | event, _ := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |||
| 458 | + | events <- event | |||
| 459 | + | }() | |||
| 460 | + | ||||
| 461 | + | hello := awaitHello(t, stream) | |||
| 462 | + | require.Equal(t, tt.env.TermEnv, hello.TermEnv) | |||
| 463 | + | require.Equal(t, tt.env.Cwd, hello.Cwd) | |||
| 464 | + | require.Equal(t, tt.env.TrueColor, hello.TrueColor) | |||
| 465 | + | require.NoError(t, stream.Close()) | |||
| 466 | + | <-events | |||
| 467 | + | }) | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func TestSessionAttachmentWorkerHandlesProtocolErrorAfterWelcome(t *testing.T) { | |||
| 472 | + | tests := []struct { | |||
| 473 | + | name string | |||
| 474 | + | attached bool | |||
| 475 | + | }{ | |||
| 476 | + | {name: "awaiting initial publication"}, | |||
| 477 | + | {name: "attached pump", attached: true}, | |||
| 478 | + | } | |||
| 479 | + | for _, tt := range tests { | |||
| 480 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 481 | + | stream := newSessionTestStream() | |||
| 482 | + | terminal := newWorkerTestTerminal() | |||
| 483 | + | attachments := newSessionTestAttachments() | |||
| 484 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attachments) | |||
| 485 | + | awaitHello(t, stream) | |||
| 486 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 487 | + | if tt.attached { | |||
| 488 | + | stream.deliver(sessionTestOutput(1, "initial")) | |||
| 489 | + | require.Eventually(t, attachments.attached, time.Second, time.Millisecond) | |||
| 490 | + | } | |||
| 491 | + | stream.deliver(protocol.ErrorMsg{Code: 42, Text: "refused"}) | |||
| 492 | + | event := <-events | |||
| 493 | + | var protocolErr *ProtocolError | |||
| 494 | + | require.ErrorAs(t, event.Err, &protocolErr) | |||
| 495 | + | require.Equal(t, uint16(42), protocolErr.Code) | |||
| 496 | + | require.Equal(t, "refused", protocolErr.Text) | |||
| 497 | + | }) | |||
| 498 | + | } | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | func TestSessionAttachmentWorkerValidatesIdentityByAdmission(t *testing.T) { | |||
| 502 | + | namedRequest := sessionTestRequest(true) | |||
| 503 | + | namedRequest.Admission, namedRequest.Name, namedRequest.Target = ports.BrokerAdmissionCreateNamed, "alpha", protocol.ExactSessionTarget{} | |||
| 504 | + | ephemeralRequest := sessionTestRequest(true) | |||
| 505 | + | ephemeralRequest.Admission, ephemeralRequest.Target = ports.BrokerAdmissionCreateEphemeral, protocol.ExactSessionTarget{} | |||
| 506 | + | tests := []struct { | |||
| 507 | + | name string | |||
| 508 | + | request ports.BrokerOpenStreamRequest | |||
| 509 | + | welcome protocol.Welcome | |||
| 510 | + | output protocol.Output | |||
| 511 | + | wantErr bool | |||
| 512 | + | }{ | |||
| 513 | + | {name: "exact welcome mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}}}, wantErr: true}, | |||
| 514 | + | {name: "exact publication mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output { | |||
| 515 | + | o := sessionTestOutput(1, "wrong") | |||
| 516 | + | o.Context.Route.Target.SessionName = "beta" | |||
| 517 | + | return o | |||
| 518 | + | }(), wantErr: true}, | |||
| 519 | + | {name: "exact match", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestRequest(true).Target}}, output: sessionTestOutput(1, "ok")}, | |||
| 520 | + | {name: "create named welcome mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "beta"}, wantErr: true}, | |||
| 521 | + | {name: "create named publication mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output { | |||
| 522 | + | o := sessionTestOutput(1, "wrong") | |||
| 523 | + | o.Context.Route.Target.SessionName = "beta" | |||
| 524 | + | return o | |||
| 525 | + | }(), wantErr: true}, | |||
| 526 | + | {name: "create named match", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: sessionTestOutput(1, "ok")}, | |||
| 527 | + | {name: "create ephemeral accepts committed identity", request: ephemeralRequest, welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestOutput(1, "").Context.Route.Target}}, output: sessionTestOutput(1, "ok")}, | |||
| 528 | + | } | |||
| 529 | + | for _, tt := range tests { | |||
| 530 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 531 | + | stream := newSessionTestStream() | |||
| 532 | + | terminal := newWorkerTestTerminal() | |||
| 533 | + | attached := newSessionTestAttachments() | |||
| 534 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(tt.request), attached) | |||
| 535 | + | awaitHello(t, stream) | |||
| 536 | + | stream.deliver(tt.welcome) | |||
| 537 | + | if tt.output.Context != nil { | |||
| 538 | + | stream.deliver(tt.output) | |||
| 539 | + | } | |||
| 540 | + | if tt.wantErr { | |||
| 541 | + | event := <-events | |||
| 542 | + | var identityErr *AttachmentIdentityError | |||
| 543 | + | require.ErrorAs(t, event.Err, &identityErr) | |||
| 544 | + | require.False(t, attached.attached()) | |||
| 545 | + | require.Empty(t, terminal.written()) | |||
| 546 | + | return | |||
| 547 | + | } | |||
| 548 | + | require.Eventually(t, attached.attached, time.Second, time.Millisecond) | |||
| 549 | + | require.Equal(t, "ok", terminal.written()) | |||
| 550 | + | require.NoError(t, stream.Close()) | |||
| 551 | + | <-events | |||
| 552 | + | }) | |||
| 553 | + | } | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | // TestSessionAttachmentWorkerRemoteHello proves the same worker derives a | |||
| 557 | + | // remote Hello from a remote request: the typed-preamble rules stay per | |||
| 558 | + | // logical stream, and no endpoint is rewritten. | |||
| 559 | + | func TestSessionAttachmentWorkerRemoteHello(t *testing.T) { | |||
| 560 | + | stream := newSessionTestStream() | |||
| 561 | + | terminal := newWorkerTestTerminal() | |||
| 562 | + | attached := newSessionTestAttachments() | |||
| 563 | + | request := sessionTestRequest(false) | |||
| 564 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(request), attached) | |||
| 565 | + | ||||
| 566 | + | hello := awaitHello(t, stream) | |||
| 567 | + | require.True(t, hello.Remote) | |||
| 568 | + | require.Equal(t, protocol.EnvironmentPolicyClientOwned, hello.EnvironmentPolicy) | |||
| 569 | + | require.Equal(t, protocol.IntentAttach, hello.Intent) | |||
| 570 | + | require.NotNil(t, hello.ExactTarget) | |||
| 571 | + | require.Equal(t, "alpha", hello.ExactTarget.SessionName) | |||
| 572 | + | ||||
| 573 | + | require.NoError(t, stream.Close()) | |||
| 574 | + | select { | |||
| 575 | + | case event := <-events: | |||
| 576 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |||
| 577 | + | case <-time.After(5 * time.Second): | |||
| 578 | + | t.Fatal("the worker never settled") | |||
| 579 | + | } | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | // TestSessionAttachmentWorkerNeverReadsTerminalInput proves the worker has no | |||
| 583 | + | // path to terminal input: it consumes no bytes and sends no Input frame while | |||
| 584 | + | // the foreground owns no input source. | |||
| 585 | + | func TestSessionAttachmentWorkerNeverReadsTerminalInput(t *testing.T) { | |||
| 586 | + | stream := newSessionTestStream() | |||
| 587 | + | terminal := newWorkerTestTerminal() | |||
| 588 | + | attached := newSessionTestAttachments() | |||
| 589 | + | events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached) | |||
| 590 | + | ||||
| 591 | + | awaitHello(t, stream) | |||
| 592 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 593 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 594 | + | _ = attached.token(t) | |||
| 595 | + | ||||
| 596 | + | for _, message := range stream.messages() { | |||
| 597 | + | _, isInput := message.(protocol.Input) | |||
| 598 | + | require.False(t, isInput, "the worker must never send terminal input in P5.3b") | |||
| 599 | + | } | |||
| 600 | + | require.Equal(t, "\x1b[Hready", terminal.written(), "only session output reaches the terminal") | |||
| 601 | + | ||||
| 602 | + | require.NoError(t, stream.Close()) | |||
| 603 | + | select { | |||
| 604 | + | case <-events: | |||
| 605 | + | case <-time.After(5 * time.Second): | |||
| 606 | + | t.Fatal("the worker never settled") | |||
| 607 | + | } | |||
| 608 | + | } | |||
M internal/usecase/client/attachment_worker.go +8 -8
| @@ -745,11 +745,11 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo | |||
| 745 | 745 | } | |
| 746 | 746 | ||
| 747 | 747 | // Output holds the same send lease across the entire UI observation boundary. | |
| 748 | - | // It mirrors the legacy attach output path: a UI capture that is merely | |
| 749 | - | // unavailable (ports.ErrUIUnavailable) never stops the frame, so the write and | |
| 750 | - | // flush still proceed; any other publication failure aborts. EndOutput success | |
| 751 | - | // follows the frame write and flush, so a failed publication, short write, or | |
| 752 | - | // failed write or flush leaves EndOutput(false). | |
| 748 | + | // UIOutputTransaction is an observation channel, not attachment commit | |
| 749 | + | // authority: an unavailable or closed observation channel does not stop the | |
| 750 | + | // terminal frame. Other publication failures abort. Successful terminal write, | |
| 751 | + | // flush, and EndOutput(true) commit the frame; any failed or short write or | |
| 752 | + | // failed flush leaves EndOutput(false). | |
| 753 | 753 | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | |
| 754 | 754 | if f == nil { | |
| 755 | 755 | return errAttachmentForegroundRevoked | |
| @@ -772,9 +772,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er | |||
| 772 | 772 | f.ui.BeginOutput(uiContext) | |
| 773 | 773 | success := false | |
| 774 | 774 | defer func() { f.ui.EndOutput(success) }() | |
| 775 | - | // An unavailable UI capture is tolerated, exactly like the legacy attach | |
| 776 | - | // path: the frame is still written and flushed. Any other publication | |
| 777 | - | // error aborts before the frame is written. | |
| 775 | + | // An unavailable capture means the optional UI observation channel is | |
| 776 | + | // disabled or closed, so the terminal frame still writes and flushes. | |
| 777 | + | // Any other publication error aborts before the frame is written. | |
| 778 | 778 | if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 779 | 779 | outputErr = err | |
| 780 | 780 | return true | |
| @@ -745,11 +745,11 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo | |||||
| 745 | } | 745 | } | ||
| 746 | 746 | ||||
| 747 | // Output holds the same send lease across the entire UI observation boundary. | 747 | // Output holds the same send lease across the entire UI observation boundary. | ||
| 748 | - | // It mirrors the legacy attach output path: a UI capture that is merely | 748 | + | // UIOutputTransaction is an observation channel, not attachment commit |
| 749 | - | // unavailable (ports.ErrUIUnavailable) never stops the frame, so the write and | 749 | + | // authority: an unavailable or closed observation channel does not stop the |
| 750 | - | // flush still proceed; any other publication failure aborts. EndOutput success | 750 | + | // terminal frame. Other publication failures abort. Successful terminal write, |
| 751 | - | // follows the frame write and flush, so a failed publication, short write, or | 751 | + | // flush, and EndOutput(true) commit the frame; any failed or short write or |
| 752 | - | // failed write or flush leaves EndOutput(false). | 752 | + | // failed flush leaves EndOutput(false). |
| 753 | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | 753 | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | ||
| 754 | if f == nil { | 754 | if f == nil { | ||
| 755 | return errAttachmentForegroundRevoked | 755 | return errAttachmentForegroundRevoked | ||
| @@ -772,9 +772,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er | |||||
| 772 | f.ui.BeginOutput(uiContext) | 772 | f.ui.BeginOutput(uiContext) | ||
| 773 | success := false | 773 | success := false | ||
| 774 | defer func() { f.ui.EndOutput(success) }() | 774 | defer func() { f.ui.EndOutput(success) }() | ||
| 775 | - | // An unavailable UI capture is tolerated, exactly like the legacy attach | 775 | + | // An unavailable capture means the optional UI observation channel is |
| 776 | - | // path: the frame is still written and flushed. Any other publication | 776 | + | // disabled or closed, so the terminal frame still writes and flushes. |
| 777 | - | // error aborts before the frame is written. | 777 | + | // Any other publication error aborts before the frame is written. |
| 778 | if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | 778 | if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | ||
| 779 | outputErr = err | 779 | outputErr = err | ||
| 780 | return true | 780 | return true | ||
M internal/usecase/client/picker_catalogue.go +9 -10
| @@ -799,12 +799,11 @@ func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports | |||
| 799 | 799 | return merged | |
| 800 | 800 | } | |
| 801 | 801 | ||
| 802 | - | // pickerObservationStatus classifies one observation into a row badge. It | |
| 803 | - | // mirrors the legacy daemon-served classifier (directorySessionReason): | |
| 804 | - | // availability is decided first, so an unobserved (Availability Unknown) or | |
| 805 | - | // unreachable daemon is never reported as a version mismatch. A version | |
| 806 | - | // mismatch is derived only for a reachable observation whose observed | |
| 807 | - | // ProtocolVersion differs from its policy's required version. | |
| 802 | + | // pickerObservationStatus classifies one observation into a row badge using | |
| 803 | + | // the shared domain.RemoteReason taxonomy: availability is decided first, so | |
| 804 | + | // an unobserved (Availability Unknown) or unreachable daemon is never reported | |
| 805 | + | // as a version mismatch. A version mismatch is derived only for a reachable | |
| 806 | + | // observation whose observed ProtocolVersion differs from policy. | |
| 808 | 807 | func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus { | |
| 809 | 808 | switch observation.Availability { | |
| 810 | 809 | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed: | |
| @@ -829,10 +828,10 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo | |||
| 829 | 828 | } | |
| 830 | 829 | ||
| 831 | 830 | // pickerObservationReason classifies one observation into a bounded display | |
| 832 | - | // reason. As with the badge, availability is decided first; the version branch | |
| 833 | - | // applies only to a reachable observation whose observed version differs from | |
| 834 | - | // policy. Unobserved and unknown availability render as refreshing, matching | |
| 835 | - | // the legacy daemon-served classifier. | |
| 831 | + | // reason from the shared domain.RemoteReason taxonomy. Availability is decided | |
| 832 | + | // first; the version branch applies only to a reachable observation whose | |
| 833 | + | // observed version differs from policy. Unobserved and unknown availability | |
| 834 | + | // render as refreshing. | |
| 836 | 835 | func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string { | |
| 837 | 836 | switch observation.Availability { | |
| 838 | 837 | case domain.RemoteAvailabilityUnreachable: | |
| @@ -799,12 +799,11 @@ func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports | |||||
| 799 | return merged | 799 | return merged | ||
| 800 | } | 800 | } | ||
| 801 | 801 | ||||
| 802 | - | // pickerObservationStatus classifies one observation into a row badge. It | 802 | + | // pickerObservationStatus classifies one observation into a row badge using |
| 803 | - | // mirrors the legacy daemon-served classifier (directorySessionReason): | 803 | + | // the shared domain.RemoteReason taxonomy: availability is decided first, so |
| 804 | - | // availability is decided first, so an unobserved (Availability Unknown) or | 804 | + | // an unobserved (Availability Unknown) or unreachable daemon is never reported |
| 805 | - | // unreachable daemon is never reported as a version mismatch. A version | 805 | + | // as a version mismatch. A version mismatch is derived only for a reachable |
| 806 | - | // mismatch is derived only for a reachable observation whose observed | 806 | + | // observation whose observed ProtocolVersion differs from policy. |
| 807 | - | // ProtocolVersion differs from its policy's required version. | |||
| 808 | func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus { | 807 | func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus { | ||
| 809 | switch observation.Availability { | 808 | switch observation.Availability { | ||
| 810 | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed: | 809 | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed: | ||
| @@ -829,10 +828,10 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo | |||||
| 829 | } | 828 | } | ||
| 830 | 829 | ||||
| 831 | // pickerObservationReason classifies one observation into a bounded display | 830 | // pickerObservationReason classifies one observation into a bounded display | ||
| 832 | - | // reason. As with the badge, availability is decided first; the version branch | 831 | + | // reason from the shared domain.RemoteReason taxonomy. Availability is decided |
| 833 | - | // applies only to a reachable observation whose observed version differs from | 832 | + | // first; the version branch applies only to a reachable observation whose |
| 834 | - | // policy. Unobserved and unknown availability render as refreshing, matching | 833 | + | // observed version differs from policy. Unobserved and unknown availability |
| 835 | - | // the legacy daemon-served classifier. | 834 | + | // render as refreshing. |
| 836 | func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string { | 835 | func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string { | ||
| 837 | switch observation.Availability { | 836 | switch observation.Availability { | ||
| 838 | case domain.RemoteAvailabilityUnreachable: | 837 | case domain.RemoteAvailabilityUnreachable: | ||
M internal/usecase/client/picker_controller.go +40 -0
| @@ -37,11 +37,31 @@ import ( | |||
| 37 | 37 | // pickerHost is the supervisor's view of the client-owned picker. The | |
| 38 | 38 | // supervisor applies each broker publication and hands it every terminal read | |
| 39 | 39 | // from its single input lifetime; the picker decides ownership. | |
| 40 | + | // | |
| 41 | + | // The commit seam (Plan 001 P5.3b) is what lets the supervisor turn a user | |
| 42 | + | // commit into one exact broker stream without ever reading the presentation | |
| 43 | + | // model itself: the picker records the decision and the key it committed | |
| 44 | + | // atomically, the supervisor wakes on OpsReady, and ResolveKey revalidates | |
| 45 | + | // exactly that key. The picker never opens a stream, and the supervisor never | |
| 46 | + | // reconstructs a selection from a display label. | |
| 40 | 47 | type pickerHost interface { | |
| 41 | 48 | pickerInputConsumer | |
| 42 | 49 | // ApplySnapshot folds one broker publication into the catalogue and | |
| 43 | 50 | // refreshes the presentation immediately. | |
| 44 | 51 | ApplySnapshot(ports.BrokerSnapshot) | |
| 52 | + | // TakeOp returns and clears the accumulated presentation decision together | |
| 53 | + | // with the catalogue key captured with a commit decision. | |
| 54 | + | TakeOp() (pickerOp, string) | |
| 55 | + | // ResolveKey revalidates exactly one committed catalogue key into the | |
| 56 | + | // exact broker stream request the user committed. | |
| 57 | + | ResolveKey(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, error) | |
| 58 | + | // SetOwnsInput releases or re-acquires picker input ownership at an attach | |
| 59 | + | // boundary, so exactly one owner consumes the shared terminal reader. | |
| 60 | + | SetOwnsInput(bool) | |
| 61 | + | // OpsReady wakes the supervisor when a presentation decision was recorded. | |
| 62 | + | // It is a capacity-one coalescing signal, never closed while the picker | |
| 63 | + | // lives, so a driver that ignores it only misses a wakeup, never blocks. | |
| 64 | + | OpsReady() <-chan struct{} | |
| 45 | 65 | } | |
| 46 | 66 | ||
| 47 | 67 | // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead | |
| @@ -74,6 +94,7 @@ type pickerController struct { | |||
| 74 | 94 | notices ui.ToastManager | |
| 75 | 95 | interaction uint64 | |
| 76 | 96 | ownsInput bool | |
| 97 | + | opsReady chan struct{} | |
| 77 | 98 | lastOp pickerOp | |
| 78 | 99 | // lastCommitKey is the catalogue key captured with the pending commit | |
| 79 | 100 | // decision, so TakeOp can hand a driver the exact committed row. | |
| @@ -92,6 +113,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration) *pickerCont | |||
| 92 | 113 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | |
| 93 | 114 | renderer: newPickerRenderer(), | |
| 94 | 115 | ownsInput: true, | |
| 116 | + | opsReady: make(chan struct{}, 1), | |
| 95 | 117 | } | |
| 96 | 118 | controller.consumer.setOwned(pickerGeneration, pickerGeneration) | |
| 97 | 119 | return controller | |
| @@ -167,6 +189,24 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||
| 167 | 189 | p.lastCommitKey = p.commitKeyLocked() | |
| 168 | 190 | } | |
| 169 | 191 | p.lastOp = mergePickerOps(p.lastOp, op) | |
| 192 | + | if p.lastOp.commit { | |
| 193 | + | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | |
| 194 | + | // re-reads the accumulated decision, so a dropped signal is harmless. | |
| 195 | + | select { | |
| 196 | + | case p.opsReady <- struct{}{}: | |
| 197 | + | default: | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // OpsReady wakes the supervisor when a commit decision was recorded. It is a | |
| 203 | + | // capacity-one coalescing signal; TakeOp remains the authority on what was | |
| 204 | + | // decided, so a driver never infers the decision from the wakeup alone. | |
| 205 | + | func (p *pickerController) OpsReady() <-chan struct{} { | |
| 206 | + | if p == nil { | |
| 207 | + | return nil | |
| 208 | + | } | |
| 209 | + | return p.opsReady | |
| 170 | 210 | } | |
| 171 | 211 | ||
| 172 | 212 | // commitKeyLocked returns the opaque catalogue key of the row under the cursor | |
| @@ -37,11 +37,31 @@ import ( | |||||
| 37 | // pickerHost is the supervisor's view of the client-owned picker. The | 37 | // pickerHost is the supervisor's view of the client-owned picker. The | ||
| 38 | // supervisor applies each broker publication and hands it every terminal read | 38 | // supervisor applies each broker publication and hands it every terminal read | ||
| 39 | // from its single input lifetime; the picker decides ownership. | 39 | // from its single input lifetime; the picker decides ownership. | ||
| 40 | + | // | |||
| 41 | + | // The commit seam (Plan 001 P5.3b) is what lets the supervisor turn a user | |||
| 42 | + | // commit into one exact broker stream without ever reading the presentation | |||
| 43 | + | // model itself: the picker records the decision and the key it committed | |||
| 44 | + | // atomically, the supervisor wakes on OpsReady, and ResolveKey revalidates | |||
| 45 | + | // exactly that key. The picker never opens a stream, and the supervisor never | |||
| 46 | + | // reconstructs a selection from a display label. | |||
| 40 | type pickerHost interface { | 47 | type pickerHost interface { | ||
| 41 | pickerInputConsumer | 48 | pickerInputConsumer | ||
| 42 | // ApplySnapshot folds one broker publication into the catalogue and | 49 | // ApplySnapshot folds one broker publication into the catalogue and | ||
| 43 | // refreshes the presentation immediately. | 50 | // refreshes the presentation immediately. | ||
| 44 | ApplySnapshot(ports.BrokerSnapshot) | 51 | ApplySnapshot(ports.BrokerSnapshot) | ||
| 52 | + | // TakeOp returns and clears the accumulated presentation decision together | |||
| 53 | + | // with the catalogue key captured with a commit decision. | |||
| 54 | + | TakeOp() (pickerOp, string) | |||
| 55 | + | // ResolveKey revalidates exactly one committed catalogue key into the | |||
| 56 | + | // exact broker stream request the user committed. | |||
| 57 | + | ResolveKey(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, error) | |||
| 58 | + | // SetOwnsInput releases or re-acquires picker input ownership at an attach | |||
| 59 | + | // boundary, so exactly one owner consumes the shared terminal reader. | |||
| 60 | + | SetOwnsInput(bool) | |||
| 61 | + | // OpsReady wakes the supervisor when a presentation decision was recorded. | |||
| 62 | + | // It is a capacity-one coalescing signal, never closed while the picker | |||
| 63 | + | // lives, so a driver that ignores it only misses a wakeup, never blocks. | |||
| 64 | + | OpsReady() <-chan struct{} | |||
| 45 | } | 65 | } | ||
| 46 | 66 | ||||
| 47 | // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead | 67 | // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead | ||
| @@ -74,6 +94,7 @@ type pickerController struct { | |||||
| 74 | notices ui.ToastManager | 94 | notices ui.ToastManager | ||
| 75 | interaction uint64 | 95 | interaction uint64 | ||
| 76 | ownsInput bool | 96 | ownsInput bool | ||
| 97 | + | opsReady chan struct{} | |||
| 77 | lastOp pickerOp | 98 | lastOp pickerOp | ||
| 78 | // lastCommitKey is the catalogue key captured with the pending commit | 99 | // lastCommitKey is the catalogue key captured with the pending commit | ||
| 79 | // decision, so TakeOp can hand a driver the exact committed row. | 100 | // decision, so TakeOp can hand a driver the exact committed row. | ||
| @@ -92,6 +113,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration) *pickerCont | |||||
| 92 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | 113 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | ||
| 93 | renderer: newPickerRenderer(), | 114 | renderer: newPickerRenderer(), | ||
| 94 | ownsInput: true, | 115 | ownsInput: true, | ||
| 116 | + | opsReady: make(chan struct{}, 1), | |||
| 95 | } | 117 | } | ||
| 96 | controller.consumer.setOwned(pickerGeneration, pickerGeneration) | 118 | controller.consumer.setOwned(pickerGeneration, pickerGeneration) | ||
| 97 | return controller | 119 | return controller | ||
| @@ -167,6 +189,24 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||||
| 167 | p.lastCommitKey = p.commitKeyLocked() | 189 | p.lastCommitKey = p.commitKeyLocked() | ||
| 168 | } | 190 | } | ||
| 169 | p.lastOp = mergePickerOps(p.lastOp, op) | 191 | p.lastOp = mergePickerOps(p.lastOp, op) | ||
| 192 | + | if p.lastOp.commit { | |||
| 193 | + | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | |||
| 194 | + | // re-reads the accumulated decision, so a dropped signal is harmless. | |||
| 195 | + | select { | |||
| 196 | + | case p.opsReady <- struct{}{}: | |||
| 197 | + | default: | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // OpsReady wakes the supervisor when a commit decision was recorded. It is a | |||
| 203 | + | // capacity-one coalescing signal; TakeOp remains the authority on what was | |||
| 204 | + | // decided, so a driver never infers the decision from the wakeup alone. | |||
| 205 | + | func (p *pickerController) OpsReady() <-chan struct{} { | |||
| 206 | + | if p == nil { | |||
| 207 | + | return nil | |||
| 208 | + | } | |||
| 209 | + | return p.opsReady | |||
| 170 | } | 210 | } | ||
| 171 | 211 | ||||
| 172 | // commitKeyLocked returns the opaque catalogue key of the row under the cursor | 212 | // commitKeyLocked returns the opaque catalogue key of the row under the cursor | ||
M internal/usecase/client/supervisor.go +145 -18
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "math/rand/v2" | |
| 9 | 9 | "reflect" | |
| 10 | 10 | "sync" | |
| 11 | + | "sync/atomic" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| @@ -153,6 +154,17 @@ const ( | |||
| 153 | 154 | // supervisorBrokerLoss is the loss of an established connection observed | |
| 154 | 155 | // through BrokerService.Done/Err while no logical stream is held. | |
| 155 | 156 | supervisorBrokerLoss | |
| 157 | + | // supervisorAttachBegin marks the admission of one committed attachment | |
| 158 | + | // stream: the picker released input and the supervisor is presenting the | |
| 159 | + | // connecting state until the initial publication is committed. | |
| 160 | + | supervisorAttachBegin | |
| 161 | + | // supervisorAttached marks a committed initial publication (Plan 001 | |
| 162 | + | // P5.3b): the worker proved the full output frame was written, flushed, | |
| 163 | + | // and UI-committed. Welcome alone never produces it. | |
| 164 | + | supervisorAttached | |
| 165 | + | // supervisorAttachEnded returns a settled attachment to the picker without | |
| 166 | + | // restarting the process. event.err carries the typed failure, if any. | |
| 167 | + | supervisorAttachEnded | |
| 156 | 168 | // supervisorNonRetryable is a failure that retrying can never fix. | |
| 157 | 169 | supervisorNonRetryable | |
| 158 | 170 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | |
| @@ -183,6 +195,21 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||
| 183 | 195 | state.Connectivity = ConnectivityRetryWait | |
| 184 | 196 | state.Attempt++ | |
| 185 | 197 | state.Err = event.err | |
| 198 | + | case supervisorAttachBegin: | |
| 199 | + | // Connecting is the honest presentation until the committed initial | |
| 200 | + | // publication: the broker connection is still ready and the attempt | |
| 201 | + | // cadence is untouched. | |
| 202 | + | state.Presentation = PresentConnecting | |
| 203 | + | state.Err = nil | |
| 204 | + | case supervisorAttached: | |
| 205 | + | state.Presentation = PresentAttached | |
| 206 | + | state.Err = nil | |
| 207 | + | case supervisorAttachEnded: | |
| 208 | + | // A settled attachment always returns to the picker in the same | |
| 209 | + | // process, attached or not; a stream that fails after attachment must | |
| 210 | + | // never leave the attached presentation showing. | |
| 211 | + | state.Presentation = PresentPicker | |
| 212 | + | state.Err = event.err | |
| 186 | 213 | case supervisorNonRetryable: | |
| 187 | 214 | state.Connectivity = ConnectivityDisconnected | |
| 188 | 215 | state.Err = event.err | |
| @@ -222,6 +249,18 @@ type SupervisorConfig struct { | |||
| 222 | 249 | // so the picker never starts a second reader. When nil the supervisor drops | |
| 223 | 250 | // terminal input exactly as P5.1a does. Optional. | |
| 224 | 251 | Picker pickerHost | |
| 252 | + | // AttachmentEnvironment is composition-owned process context copied into | |
| 253 | + | // each attachment Hello. The use case deliberately does not inspect the | |
| 254 | + | // process environment; omitted fields retain their protocol zero values. | |
| 255 | + | AttachmentEnvironment AttachmentEnvironment | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // AttachmentEnvironment is the composition seam for client environment data | |
| 259 | + | // carried by attachment Hello messages. | |
| 260 | + | type AttachmentEnvironment struct { | |
| 261 | + | TermEnv string | |
| 262 | + | Cwd string | |
| 263 | + | TrueColor bool | |
| 225 | 264 | } | |
| 226 | 265 | ||
| 227 | 266 | // Supervisor owns one autonomous client process: raw mode, one terminal input | |
| @@ -231,6 +270,21 @@ type Supervisor struct { | |||
| 231 | 270 | ||
| 232 | 271 | mu sync.Mutex | |
| 233 | 272 | state State | |
| 273 | + | ||
| 274 | + | // attachments is the single foreground host every committed attachment | |
| 275 | + | // runs through (Plan 001 P5.3b). The supervisor owns it for the whole run | |
| 276 | + | // and retains close authority over each stream it admits. | |
| 277 | + | attachments *attachmentHost | |
| 278 | + | // nextStream is the strictly increasing logical stream ID allocator for | |
| 279 | + | // the current broker connection. Failed admitted opens consume their ID, | |
| 280 | + | // exactly as the pool contract requires. | |
| 281 | + | nextStream atomic.Uint64 | |
| 282 | + | // nextAttachment identifies one attachment run within the connection | |
| 283 | + | // attempt generation. | |
| 284 | + | nextAttachment atomic.Uint64 | |
| 285 | + | // clientID is the stable client identity carried in every Hello this | |
| 286 | + | // supervisor sends, across reconnects and attachments. | |
| 287 | + | clientID [16]byte | |
| 234 | 288 | } | |
| 235 | 289 | ||
| 236 | 290 | // NewSupervisor validates the required dependencies and returns a supervisor | |
| @@ -257,10 +311,22 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||
| 257 | 311 | if cfg.Jitter == nil { | |
| 258 | 312 | cfg.Jitter = rand.Float64 | |
| 259 | 313 | } | |
| 260 | - | return &Supervisor{ | |
| 261 | - | cfg: cfg, | |
| 262 | - | state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected}, | |
| 263 | - | }, nil | |
| 314 | + | supervisor := &Supervisor{ | |
| 315 | + | cfg: cfg, | |
| 316 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected}, | |
| 317 | + | clientID: newClientID(), | |
| 318 | + | } | |
| 319 | + | // The host is the supervisor's existing foreground grant. It owns no raw | |
| 320 | + | // mode and starts no reader: the supervisor keeps its one terminal input | |
| 321 | + | // lifetime, so the attachment path adds neither a second reader nor a | |
| 322 | + | // second writer. Terminal input pumping into a session and UI actions are | |
| 323 | + | // deliberately left to P5.4 (see the P5.1b integration note). | |
| 324 | + | supervisor.attachments = newAttachmentHost(attachmentHostConfig{ | |
| 325 | + | Terminal: cfg.Terminal, | |
| 326 | + | Clock: cfg.Clock, | |
| 327 | + | OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) }, | |
| 328 | + | }) | |
| 329 | + | return supervisor, nil | |
| 264 | 330 | } | |
| 265 | 331 | ||
| 266 | 332 | // State returns the current immutable projection. | |
| @@ -375,12 +441,30 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 375 | 441 | } | |
| 376 | 442 | s.transition(supervisorEvent{kind: supervisorReady}) | |
| 377 | 443 | ||
| 378 | - | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub) | |
| 379 | - | retire() | |
| 380 | - | if terminated { | |
| 381 | - | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| 382 | - | return termErr | |
| 444 | + | // Ready phase: fold publications, admit committed attachments one at a | |
| 445 | + | // time, and return to the picker after each. A committed attachment | |
| 446 | + | // never replaces the broker connection; only a broker loss leaves this | |
| 447 | + | // loop, and cancellation or terminal EOF ends the run. | |
| 448 | + | var lossErr error | |
| 449 | + | ready: | |
| 450 | + | for { | |
| 451 | + | ready := s.awaitReady(ctx, input, service, sub) | |
| 452 | + | if ready.terminated { | |
| 453 | + | retire() | |
| 454 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: ready.termErr}) | |
| 455 | + | return ready.termErr | |
| 456 | + | } | |
| 457 | + | if ready.commitKey == "" { | |
| 458 | + | lossErr = ready.lossErr | |
| 459 | + | break ready | |
| 460 | + | } | |
| 461 | + | if terminated, termErr := s.runCommittedAttachment(ctx, input, service, ready.commitKey); terminated { | |
| 462 | + | retire() | |
| 463 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| 464 | + | return termErr | |
| 465 | + | } | |
| 383 | 466 | } | |
| 467 | + | retire() | |
| 384 | 468 | if cause := ctx.Err(); cause != nil { | |
| 385 | 469 | // Parent cancellation wins over a loss that settled at the same instant, | |
| 386 | 470 | // exactly as it does at the attempt settle. The connection was already | |
| @@ -525,28 +609,71 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||
| 525 | 609 | } | |
| 526 | 610 | } | |
| 527 | 611 | ||
| 528 | - | // awaitLoss waits for an established connection to be lost, or for the run to | |
| 529 | - | // be terminated first. A loss is reported with the service's stable terminal | |
| 530 | - | // cause. | |
| 531 | - | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) { | |
| 612 | + | // readyOutcome is the result of one wait inside the established-connection | |
| 613 | + | // phase: either the broker connection was lost, the run was terminated, or a | |
| 614 | + | // committed picker selection is ready to be attached. | |
| 615 | + | type readyOutcome struct { | |
| 616 | + | // commitKey is the exact catalogue key captured with a commit decision. A | |
| 617 | + | // non-empty value names one attachment to admit; the ready loop never | |
| 618 | + | // resolves anything else. | |
| 619 | + | commitKey string | |
| 620 | + | // lossErr is the broker service's stable terminal cause when the | |
| 621 | + | // established connection was lost. | |
| 622 | + | lossErr error | |
| 623 | + | // terminated reports that the run itself must end, with termErr carrying | |
| 624 | + | // the process cancellation or terminal-read cause. | |
| 625 | + | terminated bool | |
| 626 | + | termErr error | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | // awaitReady waits for an established connection to be lost, for a committed | |
| 630 | + | // picker selection, or for the run to be terminated first. Publications are | |
| 631 | + | // folded into the picker as they arrive; a commit decision wakes the loop so | |
| 632 | + | // the supervisor can admit exactly the row the user committed. It opens no | |
| 633 | + | // stream and starts no terminal work itself. | |
| 634 | + | func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome { | |
| 532 | 635 | var changed <-chan struct{} | |
| 533 | - | if s.cfg.Picker != nil && !supervisorNil(sub) { | |
| 534 | - | changed = sub.Changed() | |
| 636 | + | var ops <-chan struct{} | |
| 637 | + | if s.cfg.Picker != nil { | |
| 638 | + | if !supervisorNil(sub) { | |
| 639 | + | changed = sub.Changed() | |
| 640 | + | } | |
| 641 | + | ops = s.cfg.Picker.OpsReady() | |
| 535 | 642 | } | |
| 536 | 643 | for { | |
| 537 | 644 | select { | |
| 538 | 645 | case <-service.Done(): | |
| 539 | - | return service.Err(), false, nil | |
| 646 | + | return readyOutcome{lossErr: service.Err()} | |
| 540 | 647 | case <-changed: | |
| 541 | 648 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | |
| 649 | + | case <-ops: | |
| 650 | + | if key, ok := s.takeCommittedKey(); ok { | |
| 651 | + | return readyOutcome{commitKey: key} | |
| 652 | + | } | |
| 542 | 653 | case <-ctx.Done(): | |
| 543 | - | return nil, true, ctx.Err() | |
| 654 | + | return readyOutcome{terminated: true, termErr: ctx.Err()} | |
| 544 | 655 | case err := <-input.EOF(): | |
| 545 | - | return nil, true, terminalReadCause(err) | |
| 656 | + | return readyOutcome{terminated: true, termErr: terminalReadCause(err)} | |
| 546 | 657 | } | |
| 547 | 658 | } | |
| 548 | 659 | } | |
| 549 | 660 | ||
| 661 | + | // takeCommittedKey drains exactly one committed presentation decision. Only a | |
| 662 | + | // commit with a captured catalogue key admits an attachment; every other | |
| 663 | + | // decision (cursor moves, search, kill, close) is consumed and dropped here, | |
| 664 | + | // so picker input alone can never open, write, or retarget a session. | |
| 665 | + | func (s *Supervisor) takeCommittedKey() (string, bool) { | |
| 666 | + | picker := s.cfg.Picker | |
| 667 | + | if picker == nil { | |
| 668 | + | return "", false | |
| 669 | + | } | |
| 670 | + | op, key := picker.TakeOp() | |
| 671 | + | if !op.commit || key == "" { | |
| 672 | + | return "", false | |
| 673 | + | } | |
| 674 | + | return key, true | |
| 675 | + | } | |
| 676 | + | ||
| 550 | 677 | // awaitTermination parks the supervisor on the picker after a non-retryable | |
| 551 | 678 | // failure until the terminal ends or the process is cancelled. A clean terminal | |
| 552 | 679 | // EOF returns nil; every other cause is reported. | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "math/rand/v2" | 8 | "math/rand/v2" | ||
| 9 | "reflect" | 9 | "reflect" | ||
| 10 | "sync" | 10 | "sync" | ||
| 11 | + | "sync/atomic" | |||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| @@ -153,6 +154,17 @@ const ( | |||||
| 153 | // supervisorBrokerLoss is the loss of an established connection observed | 154 | // supervisorBrokerLoss is the loss of an established connection observed | ||
| 154 | // through BrokerService.Done/Err while no logical stream is held. | 155 | // through BrokerService.Done/Err while no logical stream is held. | ||
| 155 | supervisorBrokerLoss | 156 | supervisorBrokerLoss | ||
| 157 | + | // supervisorAttachBegin marks the admission of one committed attachment | |||
| 158 | + | // stream: the picker released input and the supervisor is presenting the | |||
| 159 | + | // connecting state until the initial publication is committed. | |||
| 160 | + | supervisorAttachBegin | |||
| 161 | + | // supervisorAttached marks a committed initial publication (Plan 001 | |||
| 162 | + | // P5.3b): the worker proved the full output frame was written, flushed, | |||
| 163 | + | // and UI-committed. Welcome alone never produces it. | |||
| 164 | + | supervisorAttached | |||
| 165 | + | // supervisorAttachEnded returns a settled attachment to the picker without | |||
| 166 | + | // restarting the process. event.err carries the typed failure, if any. | |||
| 167 | + | supervisorAttachEnded | |||
| 156 | // supervisorNonRetryable is a failure that retrying can never fix. | 168 | // supervisorNonRetryable is a failure that retrying can never fix. | ||
| 157 | supervisorNonRetryable | 169 | supervisorNonRetryable | ||
| 158 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | 170 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | ||
| @@ -183,6 +195,21 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||||
| 183 | state.Connectivity = ConnectivityRetryWait | 195 | state.Connectivity = ConnectivityRetryWait | ||
| 184 | state.Attempt++ | 196 | state.Attempt++ | ||
| 185 | state.Err = event.err | 197 | state.Err = event.err | ||
| 198 | + | case supervisorAttachBegin: | |||
| 199 | + | // Connecting is the honest presentation until the committed initial | |||
| 200 | + | // publication: the broker connection is still ready and the attempt | |||
| 201 | + | // cadence is untouched. | |||
| 202 | + | state.Presentation = PresentConnecting | |||
| 203 | + | state.Err = nil | |||
| 204 | + | case supervisorAttached: | |||
| 205 | + | state.Presentation = PresentAttached | |||
| 206 | + | state.Err = nil | |||
| 207 | + | case supervisorAttachEnded: | |||
| 208 | + | // A settled attachment always returns to the picker in the same | |||
| 209 | + | // process, attached or not; a stream that fails after attachment must | |||
| 210 | + | // never leave the attached presentation showing. | |||
| 211 | + | state.Presentation = PresentPicker | |||
| 212 | + | state.Err = event.err | |||
| 186 | case supervisorNonRetryable: | 213 | case supervisorNonRetryable: | ||
| 187 | state.Connectivity = ConnectivityDisconnected | 214 | state.Connectivity = ConnectivityDisconnected | ||
| 188 | state.Err = event.err | 215 | state.Err = event.err | ||
| @@ -222,6 +249,18 @@ type SupervisorConfig struct { | |||||
| 222 | // so the picker never starts a second reader. When nil the supervisor drops | 249 | // so the picker never starts a second reader. When nil the supervisor drops | ||
| 223 | // terminal input exactly as P5.1a does. Optional. | 250 | // terminal input exactly as P5.1a does. Optional. | ||
| 224 | Picker pickerHost | 251 | Picker pickerHost | ||
| 252 | + | // AttachmentEnvironment is composition-owned process context copied into | |||
| 253 | + | // each attachment Hello. The use case deliberately does not inspect the | |||
| 254 | + | // process environment; omitted fields retain their protocol zero values. | |||
| 255 | + | AttachmentEnvironment AttachmentEnvironment | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // AttachmentEnvironment is the composition seam for client environment data | |||
| 259 | + | // carried by attachment Hello messages. | |||
| 260 | + | type AttachmentEnvironment struct { | |||
| 261 | + | TermEnv string | |||
| 262 | + | Cwd string | |||
| 263 | + | TrueColor bool | |||
| 225 | } | 264 | } | ||
| 226 | 265 | ||||
| 227 | // Supervisor owns one autonomous client process: raw mode, one terminal input | 266 | // Supervisor owns one autonomous client process: raw mode, one terminal input | ||
| @@ -231,6 +270,21 @@ type Supervisor struct { | |||||
| 231 | 270 | ||||
| 232 | mu sync.Mutex | 271 | mu sync.Mutex | ||
| 233 | state State | 272 | state State | ||
| 273 | + | ||||
| 274 | + | // attachments is the single foreground host every committed attachment | |||
| 275 | + | // runs through (Plan 001 P5.3b). The supervisor owns it for the whole run | |||
| 276 | + | // and retains close authority over each stream it admits. | |||
| 277 | + | attachments *attachmentHost | |||
| 278 | + | // nextStream is the strictly increasing logical stream ID allocator for | |||
| 279 | + | // the current broker connection. Failed admitted opens consume their ID, | |||
| 280 | + | // exactly as the pool contract requires. | |||
| 281 | + | nextStream atomic.Uint64 | |||
| 282 | + | // nextAttachment identifies one attachment run within the connection | |||
| 283 | + | // attempt generation. | |||
| 284 | + | nextAttachment atomic.Uint64 | |||
| 285 | + | // clientID is the stable client identity carried in every Hello this | |||
| 286 | + | // supervisor sends, across reconnects and attachments. | |||
| 287 | + | clientID [16]byte | |||
| 234 | } | 288 | } | ||
| 235 | 289 | ||||
| 236 | // NewSupervisor validates the required dependencies and returns a supervisor | 290 | // NewSupervisor validates the required dependencies and returns a supervisor | ||
| @@ -257,10 +311,22 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||||
| 257 | if cfg.Jitter == nil { | 311 | if cfg.Jitter == nil { | ||
| 258 | cfg.Jitter = rand.Float64 | 312 | cfg.Jitter = rand.Float64 | ||
| 259 | } | 313 | } | ||
| 260 | - | return &Supervisor{ | 314 | + | supervisor := &Supervisor{ |
| 261 | - | cfg: cfg, | 315 | + | cfg: cfg, |
| 262 | - | state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected}, | 316 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected}, |
| 263 | - | }, nil | 317 | + | clientID: newClientID(), |
| 318 | + | } | |||
| 319 | + | // The host is the supervisor's existing foreground grant. It owns no raw | |||
| 320 | + | // mode and starts no reader: the supervisor keeps its one terminal input | |||
| 321 | + | // lifetime, so the attachment path adds neither a second reader nor a | |||
| 322 | + | // second writer. Terminal input pumping into a session and UI actions are | |||
| 323 | + | // deliberately left to P5.4 (see the P5.1b integration note). | |||
| 324 | + | supervisor.attachments = newAttachmentHost(attachmentHostConfig{ | |||
| 325 | + | Terminal: cfg.Terminal, | |||
| 326 | + | Clock: cfg.Clock, | |||
| 327 | + | OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) }, | |||
| 328 | + | }) | |||
| 329 | + | return supervisor, nil | |||
| 264 | } | 330 | } | ||
| 265 | 331 | ||||
| 266 | // State returns the current immutable projection. | 332 | // State returns the current immutable projection. | ||
| @@ -375,12 +441,30 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 375 | } | 441 | } | ||
| 376 | s.transition(supervisorEvent{kind: supervisorReady}) | 442 | s.transition(supervisorEvent{kind: supervisorReady}) | ||
| 377 | 443 | ||||
| 378 | - | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub) | 444 | + | // Ready phase: fold publications, admit committed attachments one at a |
| 379 | - | retire() | 445 | + | // time, and return to the picker after each. A committed attachment |
| 380 | - | if terminated { | 446 | + | // never replaces the broker connection; only a broker loss leaves this |
| 381 | - | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | 447 | + | // loop, and cancellation or terminal EOF ends the run. |
| 382 | - | return termErr | 448 | + | var lossErr error |
| 449 | + | ready: | |||
| 450 | + | for { | |||
| 451 | + | ready := s.awaitReady(ctx, input, service, sub) | |||
| 452 | + | if ready.terminated { | |||
| 453 | + | retire() | |||
| 454 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: ready.termErr}) | |||
| 455 | + | return ready.termErr | |||
| 456 | + | } | |||
| 457 | + | if ready.commitKey == "" { | |||
| 458 | + | lossErr = ready.lossErr | |||
| 459 | + | break ready | |||
| 460 | + | } | |||
| 461 | + | if terminated, termErr := s.runCommittedAttachment(ctx, input, service, ready.commitKey); terminated { | |||
| 462 | + | retire() | |||
| 463 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |||
| 464 | + | return termErr | |||
| 465 | + | } | |||
| 383 | } | 466 | } | ||
| 467 | + | retire() | |||
| 384 | if cause := ctx.Err(); cause != nil { | 468 | if cause := ctx.Err(); cause != nil { | ||
| 385 | // Parent cancellation wins over a loss that settled at the same instant, | 469 | // Parent cancellation wins over a loss that settled at the same instant, | ||
| 386 | // exactly as it does at the attempt settle. The connection was already | 470 | // exactly as it does at the attempt settle. The connection was already | ||
| @@ -525,28 +609,71 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||||
| 525 | } | 609 | } | ||
| 526 | } | 610 | } | ||
| 527 | 611 | ||||
| 528 | - | // awaitLoss waits for an established connection to be lost, or for the run to | 612 | + | // readyOutcome is the result of one wait inside the established-connection |
| 529 | - | // be terminated first. A loss is reported with the service's stable terminal | 613 | + | // phase: either the broker connection was lost, the run was terminated, or a |
| 530 | - | // cause. | 614 | + | // committed picker selection is ready to be attached. |
| 531 | - | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) { | 615 | + | type readyOutcome struct { |
| 616 | + | // commitKey is the exact catalogue key captured with a commit decision. A | |||
| 617 | + | // non-empty value names one attachment to admit; the ready loop never | |||
| 618 | + | // resolves anything else. | |||
| 619 | + | commitKey string | |||
| 620 | + | // lossErr is the broker service's stable terminal cause when the | |||
| 621 | + | // established connection was lost. | |||
| 622 | + | lossErr error | |||
| 623 | + | // terminated reports that the run itself must end, with termErr carrying | |||
| 624 | + | // the process cancellation or terminal-read cause. | |||
| 625 | + | terminated bool | |||
| 626 | + | termErr error | |||
| 627 | + | } | |||
| 628 | + | ||||
| 629 | + | // awaitReady waits for an established connection to be lost, for a committed | |||
| 630 | + | // picker selection, or for the run to be terminated first. Publications are | |||
| 631 | + | // folded into the picker as they arrive; a commit decision wakes the loop so | |||
| 632 | + | // the supervisor can admit exactly the row the user committed. It opens no | |||
| 633 | + | // stream and starts no terminal work itself. | |||
| 634 | + | func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome { | |||
| 532 | var changed <-chan struct{} | 635 | var changed <-chan struct{} | ||
| 533 | - | if s.cfg.Picker != nil && !supervisorNil(sub) { | 636 | + | var ops <-chan struct{} |
| 534 | - | changed = sub.Changed() | 637 | + | if s.cfg.Picker != nil { |
| 638 | + | if !supervisorNil(sub) { | |||
| 639 | + | changed = sub.Changed() | |||
| 640 | + | } | |||
| 641 | + | ops = s.cfg.Picker.OpsReady() | |||
| 535 | } | 642 | } | ||
| 536 | for { | 643 | for { | ||
| 537 | select { | 644 | select { | ||
| 538 | case <-service.Done(): | 645 | case <-service.Done(): | ||
| 539 | - | return service.Err(), false, nil | 646 | + | return readyOutcome{lossErr: service.Err()} |
| 540 | case <-changed: | 647 | case <-changed: | ||
| 541 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | 648 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | ||
| 649 | + | case <-ops: | |||
| 650 | + | if key, ok := s.takeCommittedKey(); ok { | |||
| 651 | + | return readyOutcome{commitKey: key} | |||
| 652 | + | } | |||
| 542 | case <-ctx.Done(): | 653 | case <-ctx.Done(): | ||
| 543 | - | return nil, true, ctx.Err() | 654 | + | return readyOutcome{terminated: true, termErr: ctx.Err()} |
| 544 | case err := <-input.EOF(): | 655 | case err := <-input.EOF(): | ||
| 545 | - | return nil, true, terminalReadCause(err) | 656 | + | return readyOutcome{terminated: true, termErr: terminalReadCause(err)} |
| 546 | } | 657 | } | ||
| 547 | } | 658 | } | ||
| 548 | } | 659 | } | ||
| 549 | 660 | ||||
| 661 | + | // takeCommittedKey drains exactly one committed presentation decision. Only a | |||
| 662 | + | // commit with a captured catalogue key admits an attachment; every other | |||
| 663 | + | // decision (cursor moves, search, kill, close) is consumed and dropped here, | |||
| 664 | + | // so picker input alone can never open, write, or retarget a session. | |||
| 665 | + | func (s *Supervisor) takeCommittedKey() (string, bool) { | |||
| 666 | + | picker := s.cfg.Picker | |||
| 667 | + | if picker == nil { | |||
| 668 | + | return "", false | |||
| 669 | + | } | |||
| 670 | + | op, key := picker.TakeOp() | |||
| 671 | + | if !op.commit || key == "" { | |||
| 672 | + | return "", false | |||
| 673 | + | } | |||
| 674 | + | return key, true | |||
| 675 | + | } | |||
| 676 | + | ||||
| 550 | // awaitTermination parks the supervisor on the picker after a non-retryable | 677 | // awaitTermination parks the supervisor on the picker after a non-retryable | ||
| 551 | // failure until the terminal ends or the process is cancelled. A clean terminal | 678 | // failure until the terminal ends or the process is cancelled. A clean terminal | ||
| 552 | // EOF returns nil; every other cause is reported. | 679 | // EOF returns nil; every other cause is reported. | ||
A internal/usecase/client/supervisor_attach.go +407 -0
| @@ -0,0 +1,407 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Committed attachment orchestration (Plan 001 P5.3b, offline and unactivated). | |
| 16 | + | // | |
| 17 | + | // After the picker resolves a committed selection to one exact | |
| 18 | + | // ports.BrokerOpenStreamRequest, the supervisor opens that logical stream on | |
| 19 | + | // the broker and hands the typed connection to the real attachment worker | |
| 20 | + | // through its existing foreground grant. The supervisor still owns the | |
| 21 | + | // terminal, raw mode, the single input lifetime, and the close authority over | |
| 22 | + | // the stream; the picker never opens, writes, or retargets a session. | |
| 23 | + | // | |
| 24 | + | // One absolute deadline bounds the whole attachment: stream open, the session | |
| 25 | + | // preamble, Hello/Welcome, and the committed initial publication. It is never | |
| 26 | + | // restarted and never extended. When the admitted stream exposes an accepted | |
| 27 | + | // absolute deadline (ports.HandshakeDeadlineProvider) the earlier of the two is | |
| 28 | + | // kept, so queue time spent before the stream reached this client is consumed | |
| 29 | + | // rather than repaid with a second budget; an already-elapsed deadline fails the | |
| 30 | + | // attachment immediately. The deadline is driven by ports.Clock/ports.Timer, | |
| 31 | + | // never the wall clock and never a sleep. | |
| 32 | + | ||
| 33 | + | // attachmentTimeoutError is the one typed failure a deadline ends an attachment | |
| 34 | + | // with. The cause stays local for errors.Is/As and is never display text. | |
| 35 | + | func attachmentTimeoutError(cause error) error { | |
| 36 | + | return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // attachmentDeadline owns the one absolute deadline of a whole attachment. It | |
| 40 | + | // is safe for concurrent use: its timer goroutine expires it while the | |
| 41 | + | // supervisor's run goroutine may adopt a later deadline after the stream opens. | |
| 42 | + | type attachmentDeadline struct { | |
| 43 | + | clock ports.Clock | |
| 44 | + | ctx context.Context | |
| 45 | + | cancel context.CancelFunc | |
| 46 | + | timedOut chan struct{} | |
| 47 | + | ||
| 48 | + | mu sync.Mutex | |
| 49 | + | deadline time.Time | |
| 50 | + | timer ports.Timer | |
| 51 | + | stop chan struct{} | |
| 52 | + | done chan struct{} | |
| 53 | + | expired atomic.Bool | |
| 54 | + | expireOnce sync.Once | |
| 55 | + | // finishOnce makes finish idempotent across the deferred teardown and any | |
| 56 | + | // early failure path. | |
| 57 | + | finishOnce sync.Once | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // startAttachmentDeadline arms the pre-open budget of one attachment. The | |
| 61 | + | // returned deadline already bounds the OpenStream call; adopt may fold the | |
| 62 | + | // stream's own accepted absolute deadline into it, but the earlier deadline | |
| 63 | + | // always stays in force. | |
| 64 | + | func startAttachmentDeadline(parent context.Context, clock ports.Clock) *attachmentDeadline { | |
| 65 | + | if parent == nil { | |
| 66 | + | parent = context.Background() | |
| 67 | + | } | |
| 68 | + | if supervisorNil(clock) { | |
| 69 | + | clock = systemClock{} | |
| 70 | + | } | |
| 71 | + | ctx, cancel := context.WithCancel(parent) | |
| 72 | + | deadline := &attachmentDeadline{ | |
| 73 | + | clock: clock, | |
| 74 | + | ctx: ctx, | |
| 75 | + | cancel: cancel, | |
| 76 | + | timedOut: make(chan struct{}), | |
| 77 | + | } | |
| 78 | + | deadline.mu.Lock() | |
| 79 | + | deadline.deadline = clock.Now().Add(protocol.HandshakeTimeout) | |
| 80 | + | deadline.armLocked(protocol.HandshakeTimeout) | |
| 81 | + | deadline.mu.Unlock() | |
| 82 | + | return deadline | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // armLocked starts a fresh timer for exactly duration. Callers hold d.mu and | |
| 86 | + | // must have stopped any previous timer. It never derives duration from | |
| 87 | + | // protocol.HandshakeTimeout itself, so folding in an absolute deadline can only | |
| 88 | + | // shrink the remaining budget, never restart it. | |
| 89 | + | func (d *attachmentDeadline) armLocked(duration time.Duration) { | |
| 90 | + | if duration <= 0 { | |
| 91 | + | d.expire() | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | timer := d.clock.NewTimer(duration) | |
| 95 | + | if supervisorNil(timer) { | |
| 96 | + | timer = systemClock{}.NewTimer(duration) | |
| 97 | + | } | |
| 98 | + | stop := make(chan struct{}) | |
| 99 | + | done := make(chan struct{}) | |
| 100 | + | d.timer = timer | |
| 101 | + | d.stop = stop | |
| 102 | + | d.done = done | |
| 103 | + | go func() { | |
| 104 | + | defer close(done) | |
| 105 | + | select { | |
| 106 | + | case <-timer.C(): | |
| 107 | + | d.expire() | |
| 108 | + | case <-stop: | |
| 109 | + | } | |
| 110 | + | }() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // stopTimerLocked stops and joins the current timer goroutine. Callers hold | |
| 114 | + | // d.mu. It is a no-op when no timer is armed. | |
| 115 | + | func (d *attachmentDeadline) stopTimerLocked() { | |
| 116 | + | if d.timer == nil { | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | close(d.stop) | |
| 120 | + | <-d.done | |
| 121 | + | d.timer.Stop() | |
| 122 | + | d.timer, d.stop, d.done = nil, nil, nil | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // expire closes the timeout signal and cancels the attachment context exactly | |
| 126 | + | // once without taking d.mu. Timer retirement may join a goroutine that has | |
| 127 | + | // already selected the timer channel, so expiry must never need that mutex. | |
| 128 | + | func (d *attachmentDeadline) expire() { | |
| 129 | + | d.expireOnce.Do(func() { | |
| 130 | + | d.expired.Store(true) | |
| 131 | + | close(d.timedOut) | |
| 132 | + | d.cancel() | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // adopt folds the admitted stream's accepted absolute deadline into the one | |
| 137 | + | // attachment deadline, keeping the EARLIER of the two. It reports | |
| 138 | + | // errAttachmentDeadline when the effective deadline already elapsed (including | |
| 139 | + | // during stream open), which fails the attachment instead of starting a second | |
| 140 | + | // budget. A stream without the optional seam keeps the pre-open deadline | |
| 141 | + | // unchanged. | |
| 142 | + | // | |
| 143 | + | // Adoption never extends the deadline. The accepted deadline is anchored | |
| 144 | + | // before this client's attempt began, so it is normally later than the client's | |
| 145 | + | // own budget once the stream is admitted; adopting it verbatim would repay the | |
| 146 | + | // open stage with a second full budget and let the whole attachment outlive the | |
| 147 | + | // one absolute deadline. Keeping the earlier value still consumes the time | |
| 148 | + | // already spent before this client saw the stream, because that anchor is | |
| 149 | + | // earlier. | |
| 150 | + | func (d *attachmentDeadline) adopt(stream ports.BrokerLogicalConnection) error { | |
| 151 | + | provider, ok := stream.(ports.HandshakeDeadlineProvider) | |
| 152 | + | if !ok { | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | accepted := provider.HandshakeDeadline() | |
| 156 | + | if accepted.IsZero() { | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | d.mu.Lock() | |
| 160 | + | defer d.mu.Unlock() | |
| 161 | + | d.stopTimerLocked() | |
| 162 | + | // Expiry is lock-free and may win while the old timer is being joined. | |
| 163 | + | // The post-join flag is authoritative; never re-arm a cancelled context. | |
| 164 | + | if d.expired.Load() { | |
| 165 | + | return errAttachmentDeadline | |
| 166 | + | } | |
| 167 | + | if accepted.Before(d.deadline) { | |
| 168 | + | d.deadline = accepted | |
| 169 | + | } | |
| 170 | + | remaining := d.deadline.Sub(d.clock.Now()) | |
| 171 | + | if remaining <= 0 { | |
| 172 | + | d.expire() | |
| 173 | + | return errAttachmentDeadline | |
| 174 | + | } | |
| 175 | + | if d.expired.Load() { | |
| 176 | + | return errAttachmentDeadline | |
| 177 | + | } | |
| 178 | + | d.armLocked(remaining) | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Context bounds every stage of the attachment (stream open, preamble, | |
| 183 | + | // Hello/Welcome, initial publication) with the one absolute deadline. | |
| 184 | + | func (d *attachmentDeadline) Context() context.Context { return d.ctx } | |
| 185 | + | ||
| 186 | + | // TimedOut reports whether the absolute deadline fired. | |
| 187 | + | func (d *attachmentDeadline) TimedOut() bool { | |
| 188 | + | select { | |
| 189 | + | case <-d.timedOut: | |
| 190 | + | return true | |
| 191 | + | default: | |
| 192 | + | return false | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // cause maps an open-stream failure to the typed timeout when the deadline | |
| 197 | + | // fired, so an expired budget is never reported as an ordinary unavailability. | |
| 198 | + | func (d *attachmentDeadline) cause(err error) error { | |
| 199 | + | if d.TimedOut() { | |
| 200 | + | return attachmentTimeoutError(errors.Join(errAttachmentDeadline, err)) | |
| 201 | + | } | |
| 202 | + | return normalizeUnavailable(err) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // disarm stops the deadline without cancelling the worker context. It is safe | |
| 206 | + | // to race with expiry and with the deferred finish. | |
| 207 | + | func (d *attachmentDeadline) disarm() { | |
| 208 | + | if d == nil { | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | d.mu.Lock() | |
| 212 | + | d.stopTimerLocked() | |
| 213 | + | d.mu.Unlock() | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // finish stops the deadline timer and cancels the attachment context. It is | |
| 217 | + | // idempotent and is the caller's deferred teardown for one attachment. | |
| 218 | + | func (d *attachmentDeadline) finish() { | |
| 219 | + | if d == nil { | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | d.finishOnce.Do(func() { | |
| 223 | + | d.mu.Lock() | |
| 224 | + | d.stopTimerLocked() | |
| 225 | + | d.mu.Unlock() | |
| 226 | + | d.cancel() | |
| 227 | + | }) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // attachmentSettlement is one worker run's outcome as observed by the | |
| 231 | + | // supervisor: whether the supervisor adopted the event or retired the run. | |
| 232 | + | type attachmentSettlement struct { | |
| 233 | + | event AttachmentEvent | |
| 234 | + | adopted bool | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // resolveCommittedStreamRequest turns exactly one committed catalogue key into | |
| 238 | + | // the exact broker stream request the user committed. The stream ID is | |
| 239 | + | // allocated strictly increasing per connection and is consumed even when | |
| 240 | + | // resolution refuses, matching the pool's never-reused ID contract. | |
| 241 | + | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) { | |
| 242 | + | base := pickerResolveBase{ | |
| 243 | + | Connection: service.ConnectionID(), | |
| 244 | + | Stream: ports.BrokerStreamID(s.nextStream.Add(1)), | |
| 245 | + | } | |
| 246 | + | return s.cfg.Picker.ResolveKey(key, base) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // newAttachmentWorker builds the real typed-session worker for one admitted | |
| 250 | + | // request. The worker receives no endpoint, route, or raw-mode authority: it | |
| 251 | + | // only drives the typed session protocol on the stream the supervisor opened. | |
| 252 | + | func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error) (AttachmentWorker, error) { | |
| 253 | + | geometry, err := s.cfg.Terminal.Geometry() | |
| 254 | + | if err != nil { | |
| 255 | + | return nil, fmt.Errorf("vev: reading terminal geometry: %w", err) | |
| 256 | + | } | |
| 257 | + | environment := s.cfg.AttachmentEnvironment | |
| 258 | + | return newSessionAttachmentWorker(sessionAttachmentConfig{ | |
| 259 | + | Request: request, | |
| 260 | + | ClientID: s.clientID, | |
| 261 | + | Geometry: geometry, | |
| 262 | + | TermEnv: environment.TermEnv, | |
| 263 | + | Cwd: environment.Cwd, | |
| 264 | + | TrueColor: environment.TrueColor, | |
| 265 | + | BeforeAttached: beforeAttached, | |
| 266 | + | }), nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // runCommittedAttachment admits one committed selection end to end: resolve, | |
| 270 | + | // open the exact stream under the one absolute deadline, run the real | |
| 271 | + | // attachment worker through the supervisor's foreground grant, and return to | |
| 272 | + | // the picker. It reports terminated=true only when the run itself must end | |
| 273 | + | // (process cancellation or terminal EOF); every attachment outcome returns to | |
| 274 | + | // the picker in the same process. | |
| 275 | + | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | |
| 276 | + | picker := s.cfg.Picker | |
| 277 | + | if picker == nil { | |
| 278 | + | return false, nil | |
| 279 | + | } | |
| 280 | + | // Present connecting before releasing picker input, so a slow resolve/open | |
| 281 | + | // never leaves a visible picker whose keystrokes are silently discarded. | |
| 282 | + | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |
| 283 | + | // Release picker input for the whole attachment. Exactly one owner | |
| 284 | + | // consumes the shared terminal reader, so picker input can never reach a | |
| 285 | + | // session and the attachment path starts no second reader. | |
| 286 | + | picker.SetOwnsInput(false) | |
| 287 | + | defer picker.SetOwnsInput(true) | |
| 288 | + | ||
| 289 | + | request, err := s.resolveCommittedStreamRequest(service, key) | |
| 290 | + | if err != nil { | |
| 291 | + | // A refused selection is a typed presentation notice; it never opens a | |
| 292 | + | // stream or becomes a session write. | |
| 293 | + | s.reportAttachmentFailure(err) | |
| 294 | + | return false, nil | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | |
| 298 | + | defer deadline.finish() | |
| 299 | + | ||
| 300 | + | stream, err := service.OpenStream(deadline.Context(), request) | |
| 301 | + | if err != nil { | |
| 302 | + | s.reportAttachmentFailure(deadline.cause(err)) | |
| 303 | + | return false, nil | |
| 304 | + | } | |
| 305 | + | if supervisorNil(stream) { | |
| 306 | + | s.reportAttachmentFailure(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker returned no stream"}) | |
| 307 | + | return false, nil | |
| 308 | + | } | |
| 309 | + | if err := deadline.adopt(stream); err != nil { | |
| 310 | + | // The accepted absolute deadline already elapsed before this client | |
| 311 | + | // saw the stream. Fail the attachment instead of restarting a budget. | |
| 312 | + | _ = stream.Close() | |
| 313 | + | s.reportAttachmentFailure(attachmentTimeoutError(err)) | |
| 314 | + | return false, nil | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | worker, err := s.newAttachmentWorker(request, func() error { | |
| 318 | + | deadline.disarm() | |
| 319 | + | if deadline.expired.Load() { | |
| 320 | + | return errAttachmentDeadline | |
| 321 | + | } | |
| 322 | + | return nil | |
| 323 | + | }) | |
| 324 | + | if err != nil { | |
| 325 | + | _ = stream.Close() | |
| 326 | + | s.reportAttachmentFailure(err) | |
| 327 | + | return false, nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)} | |
| 331 | + | run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream) | |
| 332 | + | if !ok { | |
| 333 | + | // Another foreground owns the terminal, or the shared reader is | |
| 334 | + | // claimed. Begin never closes the stream, so the supervisor does. | |
| 335 | + | _ = stream.Close() | |
| 336 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 337 | + | return false, nil | |
| 338 | + | } | |
| 339 | + | return s.settleAttachment(ctx, input, service, deadline, run) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // settleAttachment joins one admitted attachment while watching the parent | |
| 343 | + | // context, terminal EOF, and broker-connection loss. The deadline is the only | |
| 344 | + | // other stop: when it fires before the worker settled, the attachment ends as | |
| 345 | + | // a typed timeout without claiming attachment. | |
| 346 | + | func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) { | |
| 347 | + | settled := make(chan attachmentSettlement, 1) | |
| 348 | + | go func() { | |
| 349 | + | event, adopted := run.Wait(deadline.Context()) | |
| 350 | + | settled <- attachmentSettlement{event: event, adopted: adopted} | |
| 351 | + | }() | |
| 352 | + | ||
| 353 | + | var result attachmentSettlement | |
| 354 | + | var terminated bool | |
| 355 | + | var termErr error | |
| 356 | + | select { | |
| 357 | + | case result = <-settled: | |
| 358 | + | case <-service.Done(): | |
| 359 | + | // The broker connection is gone; the supervisor cancels the run and | |
| 360 | + | // lets the ready loop observe the loss and retire the connection. | |
| 361 | + | run.Cancel() | |
| 362 | + | result = <-settled | |
| 363 | + | case <-ctx.Done(): | |
| 364 | + | run.Cancel() | |
| 365 | + | result = <-settled | |
| 366 | + | terminated, termErr = true, ctx.Err() | |
| 367 | + | case err := <-input.EOF(): | |
| 368 | + | run.Cancel() | |
| 369 | + | result = <-settled | |
| 370 | + | terminated, termErr = true, terminalReadCause(err) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | if terminated { | |
| 374 | + | return true, termErr | |
| 375 | + | } | |
| 376 | + | if deadline.TimedOut() && !result.adopted { | |
| 377 | + | timeout := attachmentTimeoutError(errAttachmentDeadline) | |
| 378 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | |
| 379 | + | s.notifyAttachment(timeout) | |
| 380 | + | return false, nil | |
| 381 | + | } | |
| 382 | + | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | |
| 383 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | |
| 384 | + | s.notifyAttachment(result.event.Err) | |
| 385 | + | return false, nil | |
| 386 | + | } | |
| 387 | + | // An orderly end, or a run the supervisor retired for a broker loss, | |
| 388 | + | // returns to the picker with no attachment failure reported. A stream that | |
| 389 | + | // failed after attachment was lost/failed above, so it can never leave the | |
| 390 | + | // attached presentation showing. | |
| 391 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 392 | + | return false, nil | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // reportAttachmentFailure returns to the picker with a typed, visible failure. | |
| 396 | + | func (s *Supervisor) reportAttachmentFailure(err error) { | |
| 397 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | |
| 398 | + | s.notifyAttachment(err) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // notifyAttachment publishes the typed failure to the optional observer. | |
| 402 | + | func (s *Supervisor) notifyAttachment(err error) { | |
| 403 | + | if s.cfg.Notify == nil || err == nil { | |
| 404 | + | return | |
| 405 | + | } | |
| 406 | + | s.cfg.Notify(s.State(), err) | |
| 407 | + | } | |
| @@ -0,0 +1,407 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Committed attachment orchestration (Plan 001 P5.3b, offline and unactivated). | |||
| 16 | + | // | |||
| 17 | + | // After the picker resolves a committed selection to one exact | |||
| 18 | + | // ports.BrokerOpenStreamRequest, the supervisor opens that logical stream on | |||
| 19 | + | // the broker and hands the typed connection to the real attachment worker | |||
| 20 | + | // through its existing foreground grant. The supervisor still owns the | |||
| 21 | + | // terminal, raw mode, the single input lifetime, and the close authority over | |||
| 22 | + | // the stream; the picker never opens, writes, or retargets a session. | |||
| 23 | + | // | |||
| 24 | + | // One absolute deadline bounds the whole attachment: stream open, the session | |||
| 25 | + | // preamble, Hello/Welcome, and the committed initial publication. It is never | |||
| 26 | + | // restarted and never extended. When the admitted stream exposes an accepted | |||
| 27 | + | // absolute deadline (ports.HandshakeDeadlineProvider) the earlier of the two is | |||
| 28 | + | // kept, so queue time spent before the stream reached this client is consumed | |||
| 29 | + | // rather than repaid with a second budget; an already-elapsed deadline fails the | |||
| 30 | + | // attachment immediately. The deadline is driven by ports.Clock/ports.Timer, | |||
| 31 | + | // never the wall clock and never a sleep. | |||
| 32 | + | ||||
| 33 | + | // attachmentTimeoutError is the one typed failure a deadline ends an attachment | |||
| 34 | + | // with. The cause stays local for errors.Is/As and is never display text. | |||
| 35 | + | func attachmentTimeoutError(cause error) error { | |||
| 36 | + | return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // attachmentDeadline owns the one absolute deadline of a whole attachment. It | |||
| 40 | + | // is safe for concurrent use: its timer goroutine expires it while the | |||
| 41 | + | // supervisor's run goroutine may adopt a later deadline after the stream opens. | |||
| 42 | + | type attachmentDeadline struct { | |||
| 43 | + | clock ports.Clock | |||
| 44 | + | ctx context.Context | |||
| 45 | + | cancel context.CancelFunc | |||
| 46 | + | timedOut chan struct{} | |||
| 47 | + | ||||
| 48 | + | mu sync.Mutex | |||
| 49 | + | deadline time.Time | |||
| 50 | + | timer ports.Timer | |||
| 51 | + | stop chan struct{} | |||
| 52 | + | done chan struct{} | |||
| 53 | + | expired atomic.Bool | |||
| 54 | + | expireOnce sync.Once | |||
| 55 | + | // finishOnce makes finish idempotent across the deferred teardown and any | |||
| 56 | + | // early failure path. | |||
| 57 | + | finishOnce sync.Once | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // startAttachmentDeadline arms the pre-open budget of one attachment. The | |||
| 61 | + | // returned deadline already bounds the OpenStream call; adopt may fold the | |||
| 62 | + | // stream's own accepted absolute deadline into it, but the earlier deadline | |||
| 63 | + | // always stays in force. | |||
| 64 | + | func startAttachmentDeadline(parent context.Context, clock ports.Clock) *attachmentDeadline { | |||
| 65 | + | if parent == nil { | |||
| 66 | + | parent = context.Background() | |||
| 67 | + | } | |||
| 68 | + | if supervisorNil(clock) { | |||
| 69 | + | clock = systemClock{} | |||
| 70 | + | } | |||
| 71 | + | ctx, cancel := context.WithCancel(parent) | |||
| 72 | + | deadline := &attachmentDeadline{ | |||
| 73 | + | clock: clock, | |||
| 74 | + | ctx: ctx, | |||
| 75 | + | cancel: cancel, | |||
| 76 | + | timedOut: make(chan struct{}), | |||
| 77 | + | } | |||
| 78 | + | deadline.mu.Lock() | |||
| 79 | + | deadline.deadline = clock.Now().Add(protocol.HandshakeTimeout) | |||
| 80 | + | deadline.armLocked(protocol.HandshakeTimeout) | |||
| 81 | + | deadline.mu.Unlock() | |||
| 82 | + | return deadline | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // armLocked starts a fresh timer for exactly duration. Callers hold d.mu and | |||
| 86 | + | // must have stopped any previous timer. It never derives duration from | |||
| 87 | + | // protocol.HandshakeTimeout itself, so folding in an absolute deadline can only | |||
| 88 | + | // shrink the remaining budget, never restart it. | |||
| 89 | + | func (d *attachmentDeadline) armLocked(duration time.Duration) { | |||
| 90 | + | if duration <= 0 { | |||
| 91 | + | d.expire() | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | timer := d.clock.NewTimer(duration) | |||
| 95 | + | if supervisorNil(timer) { | |||
| 96 | + | timer = systemClock{}.NewTimer(duration) | |||
| 97 | + | } | |||
| 98 | + | stop := make(chan struct{}) | |||
| 99 | + | done := make(chan struct{}) | |||
| 100 | + | d.timer = timer | |||
| 101 | + | d.stop = stop | |||
| 102 | + | d.done = done | |||
| 103 | + | go func() { | |||
| 104 | + | defer close(done) | |||
| 105 | + | select { | |||
| 106 | + | case <-timer.C(): | |||
| 107 | + | d.expire() | |||
| 108 | + | case <-stop: | |||
| 109 | + | } | |||
| 110 | + | }() | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // stopTimerLocked stops and joins the current timer goroutine. Callers hold | |||
| 114 | + | // d.mu. It is a no-op when no timer is armed. | |||
| 115 | + | func (d *attachmentDeadline) stopTimerLocked() { | |||
| 116 | + | if d.timer == nil { | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | close(d.stop) | |||
| 120 | + | <-d.done | |||
| 121 | + | d.timer.Stop() | |||
| 122 | + | d.timer, d.stop, d.done = nil, nil, nil | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // expire closes the timeout signal and cancels the attachment context exactly | |||
| 126 | + | // once without taking d.mu. Timer retirement may join a goroutine that has | |||
| 127 | + | // already selected the timer channel, so expiry must never need that mutex. | |||
| 128 | + | func (d *attachmentDeadline) expire() { | |||
| 129 | + | d.expireOnce.Do(func() { | |||
| 130 | + | d.expired.Store(true) | |||
| 131 | + | close(d.timedOut) | |||
| 132 | + | d.cancel() | |||
| 133 | + | }) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // adopt folds the admitted stream's accepted absolute deadline into the one | |||
| 137 | + | // attachment deadline, keeping the EARLIER of the two. It reports | |||
| 138 | + | // errAttachmentDeadline when the effective deadline already elapsed (including | |||
| 139 | + | // during stream open), which fails the attachment instead of starting a second | |||
| 140 | + | // budget. A stream without the optional seam keeps the pre-open deadline | |||
| 141 | + | // unchanged. | |||
| 142 | + | // | |||
| 143 | + | // Adoption never extends the deadline. The accepted deadline is anchored | |||
| 144 | + | // before this client's attempt began, so it is normally later than the client's | |||
| 145 | + | // own budget once the stream is admitted; adopting it verbatim would repay the | |||
| 146 | + | // open stage with a second full budget and let the whole attachment outlive the | |||
| 147 | + | // one absolute deadline. Keeping the earlier value still consumes the time | |||
| 148 | + | // already spent before this client saw the stream, because that anchor is | |||
| 149 | + | // earlier. | |||
| 150 | + | func (d *attachmentDeadline) adopt(stream ports.BrokerLogicalConnection) error { | |||
| 151 | + | provider, ok := stream.(ports.HandshakeDeadlineProvider) | |||
| 152 | + | if !ok { | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
| 155 | + | accepted := provider.HandshakeDeadline() | |||
| 156 | + | if accepted.IsZero() { | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 159 | + | d.mu.Lock() | |||
| 160 | + | defer d.mu.Unlock() | |||
| 161 | + | d.stopTimerLocked() | |||
| 162 | + | // Expiry is lock-free and may win while the old timer is being joined. | |||
| 163 | + | // The post-join flag is authoritative; never re-arm a cancelled context. | |||
| 164 | + | if d.expired.Load() { | |||
| 165 | + | return errAttachmentDeadline | |||
| 166 | + | } | |||
| 167 | + | if accepted.Before(d.deadline) { | |||
| 168 | + | d.deadline = accepted | |||
| 169 | + | } | |||
| 170 | + | remaining := d.deadline.Sub(d.clock.Now()) | |||
| 171 | + | if remaining <= 0 { | |||
| 172 | + | d.expire() | |||
| 173 | + | return errAttachmentDeadline | |||
| 174 | + | } | |||
| 175 | + | if d.expired.Load() { | |||
| 176 | + | return errAttachmentDeadline | |||
| 177 | + | } | |||
| 178 | + | d.armLocked(remaining) | |||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // Context bounds every stage of the attachment (stream open, preamble, | |||
| 183 | + | // Hello/Welcome, initial publication) with the one absolute deadline. | |||
| 184 | + | func (d *attachmentDeadline) Context() context.Context { return d.ctx } | |||
| 185 | + | ||||
| 186 | + | // TimedOut reports whether the absolute deadline fired. | |||
| 187 | + | func (d *attachmentDeadline) TimedOut() bool { | |||
| 188 | + | select { | |||
| 189 | + | case <-d.timedOut: | |||
| 190 | + | return true | |||
| 191 | + | default: | |||
| 192 | + | return false | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // cause maps an open-stream failure to the typed timeout when the deadline | |||
| 197 | + | // fired, so an expired budget is never reported as an ordinary unavailability. | |||
| 198 | + | func (d *attachmentDeadline) cause(err error) error { | |||
| 199 | + | if d.TimedOut() { | |||
| 200 | + | return attachmentTimeoutError(errors.Join(errAttachmentDeadline, err)) | |||
| 201 | + | } | |||
| 202 | + | return normalizeUnavailable(err) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // disarm stops the deadline without cancelling the worker context. It is safe | |||
| 206 | + | // to race with expiry and with the deferred finish. | |||
| 207 | + | func (d *attachmentDeadline) disarm() { | |||
| 208 | + | if d == nil { | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | d.mu.Lock() | |||
| 212 | + | d.stopTimerLocked() | |||
| 213 | + | d.mu.Unlock() | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // finish stops the deadline timer and cancels the attachment context. It is | |||
| 217 | + | // idempotent and is the caller's deferred teardown for one attachment. | |||
| 218 | + | func (d *attachmentDeadline) finish() { | |||
| 219 | + | if d == nil { | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | d.finishOnce.Do(func() { | |||
| 223 | + | d.mu.Lock() | |||
| 224 | + | d.stopTimerLocked() | |||
| 225 | + | d.mu.Unlock() | |||
| 226 | + | d.cancel() | |||
| 227 | + | }) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // attachmentSettlement is one worker run's outcome as observed by the | |||
| 231 | + | // supervisor: whether the supervisor adopted the event or retired the run. | |||
| 232 | + | type attachmentSettlement struct { | |||
| 233 | + | event AttachmentEvent | |||
| 234 | + | adopted bool | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // resolveCommittedStreamRequest turns exactly one committed catalogue key into | |||
| 238 | + | // the exact broker stream request the user committed. The stream ID is | |||
| 239 | + | // allocated strictly increasing per connection and is consumed even when | |||
| 240 | + | // resolution refuses, matching the pool's never-reused ID contract. | |||
| 241 | + | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) { | |||
| 242 | + | base := pickerResolveBase{ | |||
| 243 | + | Connection: service.ConnectionID(), | |||
| 244 | + | Stream: ports.BrokerStreamID(s.nextStream.Add(1)), | |||
| 245 | + | } | |||
| 246 | + | return s.cfg.Picker.ResolveKey(key, base) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // newAttachmentWorker builds the real typed-session worker for one admitted | |||
| 250 | + | // request. The worker receives no endpoint, route, or raw-mode authority: it | |||
| 251 | + | // only drives the typed session protocol on the stream the supervisor opened. | |||
| 252 | + | func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error) (AttachmentWorker, error) { | |||
| 253 | + | geometry, err := s.cfg.Terminal.Geometry() | |||
| 254 | + | if err != nil { | |||
| 255 | + | return nil, fmt.Errorf("vev: reading terminal geometry: %w", err) | |||
| 256 | + | } | |||
| 257 | + | environment := s.cfg.AttachmentEnvironment | |||
| 258 | + | return newSessionAttachmentWorker(sessionAttachmentConfig{ | |||
| 259 | + | Request: request, | |||
| 260 | + | ClientID: s.clientID, | |||
| 261 | + | Geometry: geometry, | |||
| 262 | + | TermEnv: environment.TermEnv, | |||
| 263 | + | Cwd: environment.Cwd, | |||
| 264 | + | TrueColor: environment.TrueColor, | |||
| 265 | + | BeforeAttached: beforeAttached, | |||
| 266 | + | }), nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // runCommittedAttachment admits one committed selection end to end: resolve, | |||
| 270 | + | // open the exact stream under the one absolute deadline, run the real | |||
| 271 | + | // attachment worker through the supervisor's foreground grant, and return to | |||
| 272 | + | // the picker. It reports terminated=true only when the run itself must end | |||
| 273 | + | // (process cancellation or terminal EOF); every attachment outcome returns to | |||
| 274 | + | // the picker in the same process. | |||
| 275 | + | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | |||
| 276 | + | picker := s.cfg.Picker | |||
| 277 | + | if picker == nil { | |||
| 278 | + | return false, nil | |||
| 279 | + | } | |||
| 280 | + | // Present connecting before releasing picker input, so a slow resolve/open | |||
| 281 | + | // never leaves a visible picker whose keystrokes are silently discarded. | |||
| 282 | + | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |||
| 283 | + | // Release picker input for the whole attachment. Exactly one owner | |||
| 284 | + | // consumes the shared terminal reader, so picker input can never reach a | |||
| 285 | + | // session and the attachment path starts no second reader. | |||
| 286 | + | picker.SetOwnsInput(false) | |||
| 287 | + | defer picker.SetOwnsInput(true) | |||
| 288 | + | ||||
| 289 | + | request, err := s.resolveCommittedStreamRequest(service, key) | |||
| 290 | + | if err != nil { | |||
| 291 | + | // A refused selection is a typed presentation notice; it never opens a | |||
| 292 | + | // stream or becomes a session write. | |||
| 293 | + | s.reportAttachmentFailure(err) | |||
| 294 | + | return false, nil | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | |||
| 298 | + | defer deadline.finish() | |||
| 299 | + | ||||
| 300 | + | stream, err := service.OpenStream(deadline.Context(), request) | |||
| 301 | + | if err != nil { | |||
| 302 | + | s.reportAttachmentFailure(deadline.cause(err)) | |||
| 303 | + | return false, nil | |||
| 304 | + | } | |||
| 305 | + | if supervisorNil(stream) { | |||
| 306 | + | s.reportAttachmentFailure(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker returned no stream"}) | |||
| 307 | + | return false, nil | |||
| 308 | + | } | |||
| 309 | + | if err := deadline.adopt(stream); err != nil { | |||
| 310 | + | // The accepted absolute deadline already elapsed before this client | |||
| 311 | + | // saw the stream. Fail the attachment instead of restarting a budget. | |||
| 312 | + | _ = stream.Close() | |||
| 313 | + | s.reportAttachmentFailure(attachmentTimeoutError(err)) | |||
| 314 | + | return false, nil | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | worker, err := s.newAttachmentWorker(request, func() error { | |||
| 318 | + | deadline.disarm() | |||
| 319 | + | if deadline.expired.Load() { | |||
| 320 | + | return errAttachmentDeadline | |||
| 321 | + | } | |||
| 322 | + | return nil | |||
| 323 | + | }) | |||
| 324 | + | if err != nil { | |||
| 325 | + | _ = stream.Close() | |||
| 326 | + | s.reportAttachmentFailure(err) | |||
| 327 | + | return false, nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)} | |||
| 331 | + | run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream) | |||
| 332 | + | if !ok { | |||
| 333 | + | // Another foreground owns the terminal, or the shared reader is | |||
| 334 | + | // claimed. Begin never closes the stream, so the supervisor does. | |||
| 335 | + | _ = stream.Close() | |||
| 336 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |||
| 337 | + | return false, nil | |||
| 338 | + | } | |||
| 339 | + | return s.settleAttachment(ctx, input, service, deadline, run) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // settleAttachment joins one admitted attachment while watching the parent | |||
| 343 | + | // context, terminal EOF, and broker-connection loss. The deadline is the only | |||
| 344 | + | // other stop: when it fires before the worker settled, the attachment ends as | |||
| 345 | + | // a typed timeout without claiming attachment. | |||
| 346 | + | func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) { | |||
| 347 | + | settled := make(chan attachmentSettlement, 1) | |||
| 348 | + | go func() { | |||
| 349 | + | event, adopted := run.Wait(deadline.Context()) | |||
| 350 | + | settled <- attachmentSettlement{event: event, adopted: adopted} | |||
| 351 | + | }() | |||
| 352 | + | ||||
| 353 | + | var result attachmentSettlement | |||
| 354 | + | var terminated bool | |||
| 355 | + | var termErr error | |||
| 356 | + | select { | |||
| 357 | + | case result = <-settled: | |||
| 358 | + | case <-service.Done(): | |||
| 359 | + | // The broker connection is gone; the supervisor cancels the run and | |||
| 360 | + | // lets the ready loop observe the loss and retire the connection. | |||
| 361 | + | run.Cancel() | |||
| 362 | + | result = <-settled | |||
| 363 | + | case <-ctx.Done(): | |||
| 364 | + | run.Cancel() | |||
| 365 | + | result = <-settled | |||
| 366 | + | terminated, termErr = true, ctx.Err() | |||
| 367 | + | case err := <-input.EOF(): | |||
| 368 | + | run.Cancel() | |||
| 369 | + | result = <-settled | |||
| 370 | + | terminated, termErr = true, terminalReadCause(err) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | if terminated { | |||
| 374 | + | return true, termErr | |||
| 375 | + | } | |||
| 376 | + | if deadline.TimedOut() && !result.adopted { | |||
| 377 | + | timeout := attachmentTimeoutError(errAttachmentDeadline) | |||
| 378 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | |||
| 379 | + | s.notifyAttachment(timeout) | |||
| 380 | + | return false, nil | |||
| 381 | + | } | |||
| 382 | + | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | |||
| 383 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | |||
| 384 | + | s.notifyAttachment(result.event.Err) | |||
| 385 | + | return false, nil | |||
| 386 | + | } | |||
| 387 | + | // An orderly end, or a run the supervisor retired for a broker loss, | |||
| 388 | + | // returns to the picker with no attachment failure reported. A stream that | |||
| 389 | + | // failed after attachment was lost/failed above, so it can never leave the | |||
| 390 | + | // attached presentation showing. | |||
| 391 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |||
| 392 | + | return false, nil | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | // reportAttachmentFailure returns to the picker with a typed, visible failure. | |||
| 396 | + | func (s *Supervisor) reportAttachmentFailure(err error) { | |||
| 397 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | |||
| 398 | + | s.notifyAttachment(err) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | // notifyAttachment publishes the typed failure to the optional observer. | |||
| 402 | + | func (s *Supervisor) notifyAttachment(err error) { | |||
| 403 | + | if s.cfg.Notify == nil || err == nil { | |||
| 404 | + | return | |||
| 405 | + | } | |||
| 406 | + | s.cfg.Notify(s.State(), err) | |||
| 407 | + | } | |||
A internal/usecase/client/supervisor_attach_test.go +846 -0
| @@ -0,0 +1,846 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/internal/protocol" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Committed attachment supervisor tests (Plan 001 P5.3b). They drive the real | |
| 20 | + | // Run loop over the in-package broker fakes, with a fake clock, no sleeps, and | |
| 21 | + | // no production wiring. | |
| 22 | + | ||
| 23 | + | // attachTestTerminal is the supervisor-owned terminal double: it records output, | |
| 24 | + | // flushes, raw entrances/restores, and UI publication boundaries so a test can | |
| 25 | + | // prove exactly what a committed attachment wrote. | |
| 26 | + | type attachTestTerminal struct { | |
| 27 | + | in io.Reader | |
| 28 | + | ||
| 29 | + | mu sync.Mutex | |
| 30 | + | out bytes.Buffer | |
| 31 | + | flushes int | |
| 32 | + | restores int | |
| 33 | + | publications []ports.UIContext | |
| 34 | + | successes []bool | |
| 35 | + | publishEntered chan struct{} | |
| 36 | + | publishRelease <-chan struct{} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (t *attachTestTerminal) EnterRaw() (func() error, error) { | |
| 40 | + | return func() error { | |
| 41 | + | t.mu.Lock() | |
| 42 | + | t.restores++ | |
| 43 | + | t.mu.Unlock() | |
| 44 | + | return nil | |
| 45 | + | }, nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (t *attachTestTerminal) Geometry() (domain.Geometry, error) { | |
| 49 | + | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (t *attachTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |
| 53 | + | ||
| 54 | + | func (t *attachTestTerminal) In() io.Reader { return t.in } | |
| 55 | + | ||
| 56 | + | func (t *attachTestTerminal) Out() io.Writer { return &attachTestWriter{term: t} } | |
| 57 | + | ||
| 58 | + | func (t *attachTestTerminal) Flush() error { | |
| 59 | + | t.mu.Lock() | |
| 60 | + | defer t.mu.Unlock() | |
| 61 | + | t.flushes++ | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (t *attachTestTerminal) BeginOutput(ports.UIContext) {} | |
| 66 | + | ||
| 67 | + | func (t *attachTestTerminal) EndOutput(success bool) { | |
| 68 | + | t.mu.Lock() | |
| 69 | + | defer t.mu.Unlock() | |
| 70 | + | t.successes = append(t.successes, success) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (t *attachTestTerminal) PublishContext(uiContext ports.UIContext) error { | |
| 74 | + | if t.publishEntered != nil { | |
| 75 | + | select { | |
| 76 | + | case t.publishEntered <- struct{}{}: | |
| 77 | + | default: | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | if t.publishRelease != nil { | |
| 81 | + | <-t.publishRelease | |
| 82 | + | } | |
| 83 | + | t.mu.Lock() | |
| 84 | + | defer t.mu.Unlock() | |
| 85 | + | t.publications = append(t.publications, uiContext) | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (t *attachTestTerminal) written() string { | |
| 90 | + | t.mu.Lock() | |
| 91 | + | defer t.mu.Unlock() | |
| 92 | + | return t.out.String() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (t *attachTestTerminal) counts() (writes int, flushes int, restores int) { | |
| 96 | + | t.mu.Lock() | |
| 97 | + | defer t.mu.Unlock() | |
| 98 | + | return len(t.out.Bytes()), t.flushes, t.restores | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | type attachTestWriter struct{ term *attachTestTerminal } | |
| 102 | + | ||
| 103 | + | func (w *attachTestWriter) Write(data []byte) (int, error) { | |
| 104 | + | w.term.mu.Lock() | |
| 105 | + | defer w.term.mu.Unlock() | |
| 106 | + | return w.term.out.Write(data) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // attachTestReader blocks until a chunk is pushed, then returns EOF once | |
| 110 | + | // closed. It is the supervisor's single terminal reader. | |
| 111 | + | type attachTestReader struct { | |
| 112 | + | chunks chan []byte | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func newAttachTestReader() *attachTestReader { | |
| 116 | + | return &attachTestReader{chunks: make(chan []byte, 8)} | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (r *attachTestReader) Read(buffer []byte) (int, error) { | |
| 120 | + | chunk, ok := <-r.chunks | |
| 121 | + | if !ok { | |
| 122 | + | return 0, io.EOF | |
| 123 | + | } | |
| 124 | + | return copy(buffer, chunk), nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (r *attachTestReader) push(data []byte) { r.chunks <- data } | |
| 128 | + | ||
| 129 | + | func (r *attachTestReader) close() { close(r.chunks) } | |
| 130 | + | ||
| 131 | + | // attachTestPicker is the supervisor's picker seam double. It records input | |
| 132 | + | // ownership transitions, resolves exactly the committed key to one canned | |
| 133 | + | // request, and coalesces commit wakeups exactly like the real controller. | |
| 134 | + | type attachTestPicker struct { | |
| 135 | + | mu sync.Mutex | |
| 136 | + | opsReady chan struct{} | |
| 137 | + | ownsInput bool | |
| 138 | + | consumed int | |
| 139 | + | op pickerOp | |
| 140 | + | key string | |
| 141 | + | request ports.BrokerOpenStreamRequest | |
| 142 | + | resolved int | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func newAttachTestPicker() *attachTestPicker { | |
| 146 | + | return &attachTestPicker{opsReady: make(chan struct{}, 1), ownsInput: true} | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (p *attachTestPicker) ApplySnapshot(ports.BrokerSnapshot) {} | |
| 150 | + | ||
| 151 | + | func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool { | |
| 152 | + | p.mu.Lock() | |
| 153 | + | defer p.mu.Unlock() | |
| 154 | + | if !p.ownsInput { | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | p.consumed++ | |
| 158 | + | return true | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (p *attachTestPicker) TakeOp() (pickerOp, string) { | |
| 162 | + | p.mu.Lock() | |
| 163 | + | defer p.mu.Unlock() | |
| 164 | + | op, key := p.op, p.key | |
| 165 | + | p.op, p.key = pickerOp{}, "" | |
| 166 | + | return op, key | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 170 | + | p.mu.Lock() | |
| 171 | + | defer p.mu.Unlock() | |
| 172 | + | p.resolved++ | |
| 173 | + | request := p.request | |
| 174 | + | request.Connection = base.Connection | |
| 175 | + | request.Stream = base.Stream | |
| 176 | + | return request, nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (p *attachTestPicker) SetOwnsInput(owns bool) { | |
| 180 | + | p.mu.Lock() | |
| 181 | + | defer p.mu.Unlock() | |
| 182 | + | p.ownsInput = owns | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (p *attachTestPicker) OpsReady() <-chan struct{} { return p.opsReady } | |
| 186 | + | ||
| 187 | + | func (p *attachTestPicker) commit(request ports.BrokerOpenStreamRequest) { | |
| 188 | + | p.mu.Lock() | |
| 189 | + | p.op.commit = true | |
| 190 | + | p.key = "row" | |
| 191 | + | p.request = request | |
| 192 | + | p.mu.Unlock() | |
| 193 | + | select { | |
| 194 | + | case p.opsReady <- struct{}{}: | |
| 195 | + | default: | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (p *attachTestPicker) owns() bool { | |
| 200 | + | p.mu.Lock() | |
| 201 | + | defer p.mu.Unlock() | |
| 202 | + | return p.ownsInput | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (p *attachTestPicker) consumedCount() int { | |
| 206 | + | p.mu.Lock() | |
| 207 | + | defer p.mu.Unlock() | |
| 208 | + | return p.consumed | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (p *attachTestPicker) resolveCount() int { | |
| 212 | + | p.mu.Lock() | |
| 213 | + | defer p.mu.Unlock() | |
| 214 | + | return p.resolved | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // attachTestHarness is one running supervisor with its scripted broker. | |
| 218 | + | type attachTestHarness struct { | |
| 219 | + | sup *Supervisor | |
| 220 | + | service *supervisorTestService | |
| 221 | + | clock *supervisorTestClock | |
| 222 | + | terminal *attachTestTerminal | |
| 223 | + | reader *attachTestReader | |
| 224 | + | picker *attachTestPicker | |
| 225 | + | cancel context.CancelFunc | |
| 226 | + | runDone chan struct{} | |
| 227 | + | ||
| 228 | + | runMu sync.Mutex | |
| 229 | + | runErr error | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // startAttachHarness runs one supervisor over a scripted picker and service. | |
| 233 | + | // The caller installs service.setOpenStream before committing a selection. | |
| 234 | + | func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness { | |
| 235 | + | t.Helper() | |
| 236 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 237 | + | clock := newSupervisorTestClock() | |
| 238 | + | reader := newAttachTestReader() | |
| 239 | + | terminal := &attachTestTerminal{in: reader} | |
| 240 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 241 | + | service.publish(3, 1) | |
| 242 | + | ||
| 243 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 244 | + | return service, nil | |
| 245 | + | }) | |
| 246 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 247 | + | Connector: connector, | |
| 248 | + | Terminal: terminal, | |
| 249 | + | Clock: clock, | |
| 250 | + | Jitter: func() float64 { return 0 }, | |
| 251 | + | Picker: picker, | |
| 252 | + | }) | |
| 253 | + | ||
| 254 | + | harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})} | |
| 255 | + | go func() { | |
| 256 | + | err := sup.Run(ctx) | |
| 257 | + | harness.runMu.Lock() | |
| 258 | + | harness.runErr = err | |
| 259 | + | harness.runMu.Unlock() | |
| 260 | + | close(harness.runDone) | |
| 261 | + | }() | |
| 262 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 263 | + | ||
| 264 | + | t.Cleanup(func() { | |
| 265 | + | cancel() | |
| 266 | + | select { | |
| 267 | + | case <-harness.runDone: | |
| 268 | + | case <-time.After(5 * time.Second): | |
| 269 | + | t.Error("the supervisor did not stop") | |
| 270 | + | } | |
| 271 | + | }) | |
| 272 | + | return harness | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // waitRun joins the supervisor and returns its terminal error. | |
| 276 | + | func (h *attachTestHarness) waitRun(t *testing.T) error { | |
| 277 | + | t.Helper() | |
| 278 | + | select { | |
| 279 | + | case <-h.runDone: | |
| 280 | + | case <-time.After(5 * time.Second): | |
| 281 | + | t.Fatal("the supervisor did not stop") | |
| 282 | + | } | |
| 283 | + | h.runMu.Lock() | |
| 284 | + | defer h.runMu.Unlock() | |
| 285 | + | return h.runErr | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // snapshotTimers returns the armed timers for assertions without racing the | |
| 289 | + | // clock's own bookkeeping. | |
| 290 | + | func (c *supervisorTestClock) snapshotTimers() []*supervisorTestTimer { | |
| 291 | + | c.mu.Lock() | |
| 292 | + | defer c.mu.Unlock() | |
| 293 | + | return append([]*supervisorTestTimer(nil), c.timers...) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // awaitAttachedState waits until the supervisor presents the attached state. | |
| 297 | + | func awaitAttachedState(t *testing.T, sup *Supervisor) { | |
| 298 | + | t.Helper() | |
| 299 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentAttached }, 5*time.Second, time.Millisecond) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // awaitPickerState waits until the supervisor is back in the picker. | |
| 303 | + | func awaitPickerState(t *testing.T, sup *Supervisor) { | |
| 304 | + | t.Helper() | |
| 305 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // awaitStreamHello waits until the admitted stream has sent its first client | |
| 309 | + | // message, polling the guarded stream pointer rather than a stale snapshot. | |
| 310 | + | func awaitStreamHello(t *testing.T, mu *sync.Mutex, stream **sessionTestStream) { | |
| 311 | + | t.Helper() | |
| 312 | + | require.Eventually(t, func() bool { | |
| 313 | + | mu.Lock() | |
| 314 | + | defer mu.Unlock() | |
| 315 | + | return *stream != nil && len((*stream).messages()) >= 1 | |
| 316 | + | }, 5*time.Second, time.Millisecond) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // deliverReadyStream scripts Welcome plus one committed initial publication. | |
| 320 | + | func deliverReadyStream(t *testing.T, stream *sessionTestStream) { | |
| 321 | + | t.Helper() | |
| 322 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 323 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // TestSupervisorReducerAttachmentPresentation pins the presentation | |
| 327 | + | // transitions of one attachment: connecting until the committed publication, | |
| 328 | + | // attached, then back to the picker without disturbing connectivity. | |
| 329 | + | func TestSupervisorReducerAttachmentPresentation(t *testing.T) { | |
| 330 | + | ready := State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 2} | |
| 331 | + | connecting := reduceSupervisor(ready, supervisorEvent{kind: supervisorAttachBegin}) | |
| 332 | + | require.Equal(t, PresentConnecting, connecting.Presentation) | |
| 333 | + | require.Equal(t, ConnectivityReady, connecting.Connectivity) | |
| 334 | + | require.Zero(t, connecting.Attempt) | |
| 335 | + | ||
| 336 | + | attached := reduceSupervisor(connecting, supervisorEvent{kind: supervisorAttached}) | |
| 337 | + | require.Equal(t, PresentAttached, attached.Presentation) | |
| 338 | + | require.Equal(t, ConnectivityReady, attached.Connectivity) | |
| 339 | + | ||
| 340 | + | returned := reduceSupervisor(attached, supervisorEvent{kind: supervisorAttachEnded, err: errAttachmentDeadline}) | |
| 341 | + | require.Equal(t, PresentPicker, returned.Presentation) | |
| 342 | + | require.Equal(t, ConnectivityReady, returned.Connectivity) | |
| 343 | + | require.ErrorIs(t, returned.Err, errAttachmentDeadline) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // TestSupervisorAttachmentLocalRemoteParity proves the same supervisor path | |
| 347 | + | // opens the exact resolved request for a local and a remote selection, with no | |
| 348 | + | // dialer choice in the client. | |
| 349 | + | func TestSupervisorAttachmentLocalRemoteParity(t *testing.T) { | |
| 350 | + | for _, local := range []bool{true, false} { | |
| 351 | + | name := "remote" | |
| 352 | + | if local { | |
| 353 | + | name = "local" | |
| 354 | + | } | |
| 355 | + | t.Run(name, func(t *testing.T) { | |
| 356 | + | picker := newAttachTestPicker() | |
| 357 | + | harness := startAttachHarness(t, picker) | |
| 358 | + | var ( | |
| 359 | + | streamMu sync.Mutex | |
| 360 | + | stream *sessionTestStream | |
| 361 | + | ) | |
| 362 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 363 | + | admitted := newSessionTestStream() | |
| 364 | + | streamMu.Lock() | |
| 365 | + | stream = admitted | |
| 366 | + | streamMu.Unlock() | |
| 367 | + | return admitted, nil | |
| 368 | + | }) | |
| 369 | + | picker.commit(sessionTestRequest(local)) | |
| 370 | + | ||
| 371 | + | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |
| 372 | + | opened := harness.service.openedRequests()[0] | |
| 373 | + | require.Equal(t, local, opened.Local) | |
| 374 | + | require.Equal(t, ports.BrokerStreamAttachment, opened.Purpose) | |
| 375 | + | require.Equal(t, ports.BrokerAdmissionExact, opened.Admission) | |
| 376 | + | require.Equal(t, "alpha", opened.Target.SessionName) | |
| 377 | + | require.Equal(t, ports.BrokerConnectionID{1}, opened.Connection) | |
| 378 | + | require.Equal(t, ports.BrokerStreamID(1), opened.Stream) | |
| 379 | + | if !local { | |
| 380 | + | require.Equal(t, "user@example", opened.Endpoint) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | // The stream now runs the typed protocol and commits the initial | |
| 384 | + | // publication before attachment is reported. | |
| 385 | + | streamMu.Lock() | |
| 386 | + | admitted := stream | |
| 387 | + | streamMu.Unlock() | |
| 388 | + | require.NotNil(t, admitted) | |
| 389 | + | require.Eventually(t, func() bool { return len(admitted.messages()) >= 1 }, 5*time.Second, time.Millisecond) | |
| 390 | + | require.Equal(t, PresentConnecting, harness.sup.State().Presentation, "connecting until the publication commits") | |
| 391 | + | require.False(t, picker.owns(), "the picker released input for the attachment") | |
| 392 | + | deliverReadyStream(t, admitted) | |
| 393 | + | awaitAttachedState(t, harness.sup) | |
| 394 | + | require.Equal(t, "\x1b[Hready", harness.terminal.written()) | |
| 395 | + | require.Equal(t, []bool{true}, harness.terminal.successes, "the frame committed through the UI transaction") | |
| 396 | + | }) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart proves a | |
| 401 | + | // deadline that passed during stream open fails the attachment immediately | |
| 402 | + | // instead of starting a second budget. | |
| 403 | + | func TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart(t *testing.T) { | |
| 404 | + | picker := newAttachTestPicker() | |
| 405 | + | var ( | |
| 406 | + | streamMu sync.Mutex | |
| 407 | + | stream *sessionTestStream | |
| 408 | + | ) | |
| 409 | + | harness := startAttachHarness(t, picker) | |
| 410 | + | clock := harness.clock | |
| 411 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 412 | + | // The accepted absolute deadline elapsed before this client saw it. | |
| 413 | + | admitted := &sessionTestStreamWithDeadline{ | |
| 414 | + | sessionTestStream: newSessionTestStream(), | |
| 415 | + | deadline: clock.Now().Add(-time.Second), | |
| 416 | + | } | |
| 417 | + | streamMu.Lock() | |
| 418 | + | stream = admitted.sessionTestStream | |
| 419 | + | streamMu.Unlock() | |
| 420 | + | return admitted, nil | |
| 421 | + | }) | |
| 422 | + | picker.commit(sessionTestRequest(true)) | |
| 423 | + | ||
| 424 | + | awaitPickerState(t, harness.sup) | |
| 425 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |
| 426 | + | var typed ports.BrokerError | |
| 427 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |
| 428 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |
| 429 | + | ||
| 430 | + | streamMu.Lock() | |
| 431 | + | require.NotNil(t, stream) | |
| 432 | + | require.True(t, stream.closedNow(), "an expired stream is closed") | |
| 433 | + | streamMu.Unlock() | |
| 434 | + | ||
| 435 | + | // Exactly one deadline timer was ever armed, and it was stopped: the | |
| 436 | + | // elapsed accepted deadline never restarted a fresh budget. | |
| 437 | + | require.Equal(t, 1, harness.clock.timerCount(), "the elapsed deadline must not restart a budget") | |
| 438 | + | require.True(t, harness.clock.allStopped(), "the pre-open budget is retired") | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // TestSupervisorAttachmentDeadlineNotExtendedAcrossStages proves one absolute | |
| 442 | + | // deadline spans the whole attachment: adopting the accepted deadline arms | |
| 443 | + | // exactly its remaining time, and crossing Welcome never restarts it. | |
| 444 | + | func TestSupervisorAttachmentDeadlineNotExtendedAcrossStages(t *testing.T) { | |
| 445 | + | picker := newAttachTestPicker() | |
| 446 | + | var ( | |
| 447 | + | streamMu sync.Mutex | |
| 448 | + | stream *sessionTestStream | |
| 449 | + | ) | |
| 450 | + | harness := startAttachHarness(t, picker) | |
| 451 | + | clock := harness.clock | |
| 452 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 453 | + | admitted := &sessionTestStreamWithDeadline{ | |
| 454 | + | sessionTestStream: newSessionTestStream(), | |
| 455 | + | deadline: clock.Now().Add(5 * time.Second), | |
| 456 | + | } | |
| 457 | + | streamMu.Lock() | |
| 458 | + | stream = admitted.sessionTestStream | |
| 459 | + | streamMu.Unlock() | |
| 460 | + | return admitted, nil | |
| 461 | + | }) | |
| 462 | + | picker.commit(sessionTestRequest(true)) | |
| 463 | + | ||
| 464 | + | awaitStreamHello(t, &streamMu, &stream) | |
| 465 | + | ||
| 466 | + | streamMu.Lock() | |
| 467 | + | admitted := stream | |
| 468 | + | streamMu.Unlock() | |
| 469 | + | ||
| 470 | + | // Wait until adoption armed the second timer. | |
| 471 | + | require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond) | |
| 472 | + | timers := harness.clock.snapshotTimers() | |
| 473 | + | require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open") | |
| 474 | + | require.Equal(t, 5*time.Second, timers[1].delay, "the accepted deadline is adopted verbatim, never restarted") | |
| 475 | + | require.True(t, timers[0].stopped(), "the pre-open budget is replaced by the accepted deadline") | |
| 476 | + | require.False(t, timers[1].stopped()) | |
| 477 | + | ||
| 478 | + | // Crossing Welcome does not arm another deadline. | |
| 479 | + | admitted.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 480 | + | require.Never(t, func() bool { return harness.clock.timerCount() > 2 }, 20*time.Millisecond, 2*time.Millisecond, "no stage may restart the deadline") | |
| 481 | + | ||
| 482 | + | // Firing the one adopted deadline ends the attachment as a typed timeout | |
| 483 | + | // before publication, without claiming attachment. | |
| 484 | + | timers[1].fire() | |
| 485 | + | awaitPickerState(t, harness.sup) | |
| 486 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |
| 487 | + | var typed ports.BrokerError | |
| 488 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |
| 489 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |
| 490 | + | require.Empty(t, harness.terminal.publications, "a timed-out attachment never published") | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | // TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget proves the | |
| 494 | + | // one absolute deadline is the earlier of the client's own pre-open budget and | |
| 495 | + | // the admitted stream's accepted deadline. The accepted deadline is anchored | |
| 496 | + | // before this client's attempt began, so it is normally the later of the two; | |
| 497 | + | // folding it in verbatim would repay the open stage with a fresh budget and let | |
| 498 | + | // the attachment outlive the deadline. | |
| 499 | + | func TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget(t *testing.T) { | |
| 500 | + | picker := newAttachTestPicker() | |
| 501 | + | var ( | |
| 502 | + | streamMu sync.Mutex | |
| 503 | + | stream *sessionTestStream | |
| 504 | + | ) | |
| 505 | + | harness := startAttachHarness(t, picker) | |
| 506 | + | clock := harness.clock | |
| 507 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 508 | + | admitted := &sessionTestStreamWithDeadline{ | |
| 509 | + | sessionTestStream: newSessionTestStream(), | |
| 510 | + | deadline: clock.Now().Add(4 * protocol.HandshakeTimeout), | |
| 511 | + | } | |
| 512 | + | streamMu.Lock() | |
| 513 | + | stream = admitted.sessionTestStream | |
| 514 | + | streamMu.Unlock() | |
| 515 | + | return admitted, nil | |
| 516 | + | }) | |
| 517 | + | picker.commit(sessionTestRequest(true)) | |
| 518 | + | ||
| 519 | + | awaitStreamHello(t, &streamMu, &stream) | |
| 520 | + | ||
| 521 | + | require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond) | |
| 522 | + | timers := harness.clock.snapshotTimers() | |
| 523 | + | require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open") | |
| 524 | + | require.Equal(t, protocol.HandshakeTimeout, timers[1].delay, | |
| 525 | + | "a later accepted deadline never repays the open stage with a second budget") | |
| 526 | + | require.True(t, timers[0].stopped()) | |
| 527 | + | require.False(t, timers[1].stopped()) | |
| 528 | + | ||
| 529 | + | // The one deadline still ends the attachment as a typed timeout before | |
| 530 | + | // publication, and it does so no later than the client's own budget. | |
| 531 | + | timers[1].fire() | |
| 532 | + | awaitPickerState(t, harness.sup) | |
| 533 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |
| 534 | + | var typed ports.BrokerError | |
| 535 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |
| 536 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |
| 537 | + | require.Empty(t, harness.terminal.publications, "a timed-out attachment never published") | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | // TestSupervisorAttachmentPreOpenBudgetWithoutProvider proves a stream without | |
| 541 | + | // the optional seam keeps the one pre-open budget: no second timer, no restart. | |
| 542 | + | func TestSupervisorAttachmentPreOpenBudgetWithoutProvider(t *testing.T) { | |
| 543 | + | picker := newAttachTestPicker() | |
| 544 | + | var ( | |
| 545 | + | streamMu sync.Mutex | |
| 546 | + | stream *sessionTestStream | |
| 547 | + | ) | |
| 548 | + | harness := startAttachHarness(t, picker) | |
| 549 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 550 | + | admitted := newSessionTestStream() | |
| 551 | + | streamMu.Lock() | |
| 552 | + | stream = admitted | |
| 553 | + | streamMu.Unlock() | |
| 554 | + | return admitted, nil | |
| 555 | + | }) | |
| 556 | + | picker.commit(sessionTestRequest(true)) | |
| 557 | + | ||
| 558 | + | awaitStreamHello(t, &streamMu, &stream) | |
| 559 | + | ||
| 560 | + | streamMu.Lock() | |
| 561 | + | admitted := stream | |
| 562 | + | streamMu.Unlock() | |
| 563 | + | require.Equal(t, 1, harness.clock.timerCount()) | |
| 564 | + | timers := harness.clock.snapshotTimers() | |
| 565 | + | require.Equal(t, protocol.HandshakeTimeout, timers[0].delay) | |
| 566 | + | require.False(t, timers[0].stopped()) | |
| 567 | + | ||
| 568 | + | timers[0].fire() | |
| 569 | + | awaitPickerState(t, harness.sup) | |
| 570 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |
| 571 | + | var typed ports.BrokerError | |
| 572 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |
| 573 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |
| 574 | + | require.True(t, admitted.closedNow(), "the expired stream is closed") | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | func TestSupervisorAttachmentDeadlineDisarmedAfterPublication(t *testing.T) { | |
| 578 | + | picker := newAttachTestPicker() | |
| 579 | + | var stream *sessionTestStream | |
| 580 | + | var streamMu sync.Mutex | |
| 581 | + | harness := startAttachHarness(t, picker) | |
| 582 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 583 | + | admitted := newSessionTestStream() | |
| 584 | + | streamMu.Lock() | |
| 585 | + | stream = admitted | |
| 586 | + | streamMu.Unlock() | |
| 587 | + | return admitted, nil | |
| 588 | + | }) | |
| 589 | + | picker.commit(sessionTestRequest(true)) | |
| 590 | + | awaitStreamHello(t, &streamMu, &stream) | |
| 591 | + | streamMu.Lock() | |
| 592 | + | admitted := stream | |
| 593 | + | streamMu.Unlock() | |
| 594 | + | deliverReadyStream(t, admitted) | |
| 595 | + | awaitAttachedState(t, harness.sup) | |
| 596 | + | ||
| 597 | + | timer := harness.clock.snapshotTimers()[0] | |
| 598 | + | require.True(t, timer.stopped(), "the initial-publication commit disarms the budget") | |
| 599 | + | timer.fire() | |
| 600 | + | // This bounded negative window gives the retired deadline goroutine an | |
| 601 | + | // opportunity to mishandle the late fire; no sleep is used for ordering. | |
| 602 | + | require.Never(t, func() bool { | |
| 603 | + | return harness.sup.State().Presentation != PresentAttached || admitted.closedNow() || harness.sup.State().Err != nil | |
| 604 | + | }, 50*time.Millisecond, time.Millisecond) | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func TestAttachmentDeadlineConcurrentExpiryAndRetirement(t *testing.T) { | |
| 608 | + | tests := []struct { | |
| 609 | + | name string | |
| 610 | + | retire func(*attachmentDeadline) | |
| 611 | + | }{ | |
| 612 | + | {name: "disarm", retire: (*attachmentDeadline).disarm}, | |
| 613 | + | {name: "adopt", retire: func(deadline *attachmentDeadline) { | |
| 614 | + | _ = deadline.adopt(&sessionTestStreamWithDeadline{sessionTestStream: newSessionTestStream(), deadline: time.Unix(2000, 0)}) | |
| 615 | + | }}, | |
| 616 | + | } | |
| 617 | + | for _, tt := range tests { | |
| 618 | + | t.Run(tt.name, func(t *testing.T) { | |
| 619 | + | clock := newSupervisorTestClock() | |
| 620 | + | deadline := startAttachmentDeadline(context.Background(), clock) | |
| 621 | + | timer := clock.snapshotTimers()[0] | |
| 622 | + | done := make(chan struct{}) | |
| 623 | + | go func() { | |
| 624 | + | timer.fire() | |
| 625 | + | tt.retire(deadline) | |
| 626 | + | close(done) | |
| 627 | + | }() | |
| 628 | + | select { | |
| 629 | + | case <-done: | |
| 630 | + | case <-time.After(time.Second): | |
| 631 | + | t.Fatal("concurrent expiry and retirement hung") | |
| 632 | + | } | |
| 633 | + | deadline.finish() | |
| 634 | + | require.True(t, clock.allStopped()) | |
| 635 | + | }) | |
| 636 | + | } | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | // TestSupervisorAttachmentPostAttachmentLossReturnsToPicker proves a stream | |
| 640 | + | // that fails after attachment never keeps the attached presentation: the | |
| 641 | + | // supervisor returns to the picker with the typed loss. | |
| 642 | + | func TestSupervisorAttachmentPostAttachmentLossReturnsToPicker(t *testing.T) { | |
| 643 | + | picker := newAttachTestPicker() | |
| 644 | + | var ( | |
| 645 | + | streamMu sync.Mutex | |
| 646 | + | stream *sessionTestStream | |
| 647 | + | ) | |
| 648 | + | harness := startAttachHarness(t, picker) | |
| 649 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 650 | + | admitted := newSessionTestStream() | |
| 651 | + | streamMu.Lock() | |
| 652 | + | stream = admitted | |
| 653 | + | streamMu.Unlock() | |
| 654 | + | return admitted, nil | |
| 655 | + | }) | |
| 656 | + | picker.commit(sessionTestRequest(true)) | |
| 657 | + | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |
| 658 | + | ||
| 659 | + | streamMu.Lock() | |
| 660 | + | admitted := stream | |
| 661 | + | streamMu.Unlock() | |
| 662 | + | deliverReadyStream(t, admitted) | |
| 663 | + | awaitAttachedState(t, harness.sup) | |
| 664 | + | ||
| 665 | + | admitted.fail(ports.BrokerStreamLost{ | |
| 666 | + | Connection: ports.BrokerConnectionID{1}, | |
| 667 | + | Stream: 1, | |
| 668 | + | Epoch: 3, | |
| 669 | + | Cause: domain.RemoteFailureTransport, | |
| 670 | + | Err: errors.New("attachment lost"), | |
| 671 | + | }) | |
| 672 | + | awaitPickerState(t, harness.sup) | |
| 673 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |
| 674 | + | require.NotEqual(t, PresentAttached, harness.sup.State().Presentation, "a failed stream is never still attached") | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | // TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation | |
| 678 | + | // mid-attach closes the stream, ends the run, and never reports attachment. | |
| 679 | + | func TestSupervisorAttachmentCancellationRetiresGeneration(t *testing.T) { | |
| 680 | + | picker := newAttachTestPicker() | |
| 681 | + | var ( | |
| 682 | + | streamMu sync.Mutex | |
| 683 | + | stream *sessionTestStream | |
| 684 | + | ) | |
| 685 | + | harness := startAttachHarness(t, picker) | |
| 686 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 687 | + | admitted := newSessionTestStream() | |
| 688 | + | streamMu.Lock() | |
| 689 | + | stream = admitted | |
| 690 | + | streamMu.Unlock() | |
| 691 | + | return admitted, nil | |
| 692 | + | }) | |
| 693 | + | picker.commit(sessionTestRequest(true)) | |
| 694 | + | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |
| 695 | + | ||
| 696 | + | harness.cancel() | |
| 697 | + | require.ErrorIs(t, harness.waitRun(t), context.Canceled) | |
| 698 | + | streamMu.Lock() | |
| 699 | + | admitted := stream | |
| 700 | + | streamMu.Unlock() | |
| 701 | + | require.True(t, admitted.closedNow(), "the supervisor close authority released the stream") | |
| 702 | + | require.Equal(t, PresentTerminating, harness.sup.State().Presentation) | |
| 703 | + | require.Empty(t, harness.terminal.publications, "a cancelled attachment never published") | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | // TestSupervisorPickerInputNeverReachesSession proves picker bytes produce no | |
| 707 | + | // stream open, no session write, and no terminal write. | |
| 708 | + | func TestSupervisorPickerInputNeverReachesSession(t *testing.T) { | |
| 709 | + | picker := newAttachTestPicker() | |
| 710 | + | harness := startAttachHarness(t, picker) | |
| 711 | + | opened := false | |
| 712 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 713 | + | opened = true | |
| 714 | + | return newSessionTestStream(), nil | |
| 715 | + | }) | |
| 716 | + | ||
| 717 | + | harness.reader.push([]byte("j")) | |
| 718 | + | require.Eventually(t, func() bool { return picker.consumedCount() >= 1 }, 5*time.Second, time.Millisecond) | |
| 719 | + | require.False(t, opened, "picker input must never open a stream") | |
| 720 | + | require.Zero(t, picker.resolveCount(), "picker input must never resolve a selection") | |
| 721 | + | require.Empty(t, harness.service.openedRequests()) | |
| 722 | + | _, flushes, _ := harness.terminal.counts() | |
| 723 | + | require.Zero(t, flushes, "picker input must never flush session output") | |
| 724 | + | require.Empty(t, harness.terminal.written(), "picker input must never write session bytes") | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | // TestSupervisorAttachmentReachableFromRealPicker drives the real client-owned | |
| 728 | + | // picker: a committed row resolves to one exact stream request and its | |
| 729 | + | // publication attaches. | |
| 730 | + | func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) { | |
| 731 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 732 | + | defer cancel() | |
| 733 | + | ||
| 734 | + | clock := newSupervisorTestClock() | |
| 735 | + | reader := newPickerChunkReader() | |
| 736 | + | t.Cleanup(reader.close) | |
| 737 | + | terminal := &attachTestTerminal{in: reader} | |
| 738 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 739 | + | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 740 | + | admittedStreams := make(chan *sessionTestStream, 1) | |
| 741 | + | service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 742 | + | admitted := newSessionTestStream() | |
| 743 | + | admittedStreams <- admitted | |
| 744 | + | return admitted, nil | |
| 745 | + | }) | |
| 746 | + | ||
| 747 | + | controller := newPickerController(clock, pickerTestFreshness) | |
| 748 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }) | |
| 749 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 750 | + | Connector: connector, | |
| 751 | + | Terminal: terminal, | |
| 752 | + | Clock: clock, | |
| 753 | + | Jitter: func() float64 { return 0 }, | |
| 754 | + | Picker: controller, | |
| 755 | + | }) | |
| 756 | + | runErr := make(chan error, 1) | |
| 757 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 758 | + | ||
| 759 | + | require.Eventually(t, func() bool { return controller.Catalogue().Revision() == 1 }, 5*time.Second, time.Millisecond) | |
| 760 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines())) | |
| 761 | + | ||
| 762 | + | // The user commits the row under the cursor through the shared reader. | |
| 763 | + | reader.push([]byte("\r")) | |
| 764 | + | require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |
| 765 | + | opened := service.openedRequests()[0] | |
| 766 | + | require.Equal(t, "alpha", opened.Target.SessionName) | |
| 767 | + | require.True(t, opened.Local) | |
| 768 | + | require.Equal(t, ports.BrokerAdmissionExact, opened.Admission) | |
| 769 | + | require.Equal(t, ports.BrokerStreamID(1), opened.Stream) | |
| 770 | + | ||
| 771 | + | var stream *sessionTestStream | |
| 772 | + | select { | |
| 773 | + | case stream = <-admittedStreams: | |
| 774 | + | case <-time.After(5 * time.Second): | |
| 775 | + | t.Fatal("the resolved stream was never admitted") | |
| 776 | + | } | |
| 777 | + | deliverReadyStream(t, stream) | |
| 778 | + | awaitAttachedState(t, sup) | |
| 779 | + | ||
| 780 | + | cancel() | |
| 781 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 782 | + | require.True(t, stream.closedNow()) | |
| 783 | + | require.Equal(t, 1, terminal.restores, "the supervisor restored raw mode exactly once") | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | // TestSupervisorAttachmentConcurrentCommitAndCancellation deterministically | |
| 787 | + | // retires attachment authority at each blocking boundary under review. | |
| 788 | + | func TestSupervisorAttachmentConcurrentCommitAndCancellation(t *testing.T) { | |
| 789 | + | tests := []struct { | |
| 790 | + | name string | |
| 791 | + | run func(*testing.T, *attachTestHarness, *attachTestPicker) *sessionTestStream | |
| 792 | + | }{ | |
| 793 | + | {name: "open in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream { | |
| 794 | + | entered := make(chan struct{}) | |
| 795 | + | release := make(chan struct{}) | |
| 796 | + | stream := newSessionTestStream() | |
| 797 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 798 | + | close(entered) | |
| 799 | + | <-release | |
| 800 | + | return stream, nil | |
| 801 | + | }) | |
| 802 | + | picker.commit(sessionTestRequest(true)) | |
| 803 | + | <-entered | |
| 804 | + | harness.cancel() | |
| 805 | + | close(release) | |
| 806 | + | return stream | |
| 807 | + | }}, | |
| 808 | + | {name: "publication commit in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream { | |
| 809 | + | stream := newSessionTestStream() | |
| 810 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 811 | + | return stream, nil | |
| 812 | + | }) | |
| 813 | + | entered := make(chan struct{}, 1) | |
| 814 | + | release := make(chan struct{}) | |
| 815 | + | harness.terminal.publishEntered = entered | |
| 816 | + | harness.terminal.publishRelease = release | |
| 817 | + | picker.commit(sessionTestRequest(true)) | |
| 818 | + | awaitHello(t, stream) | |
| 819 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 820 | + | stream.deliver(sessionTestOutput(1, "initial")) | |
| 821 | + | <-entered | |
| 822 | + | harness.cancel() | |
| 823 | + | close(release) | |
| 824 | + | return stream | |
| 825 | + | }}, | |
| 826 | + | } | |
| 827 | + | for _, tt := range tests { | |
| 828 | + | t.Run(tt.name, func(t *testing.T) { | |
| 829 | + | picker := newAttachTestPicker() | |
| 830 | + | harness := startAttachHarness(t, picker) | |
| 831 | + | stream := tt.run(t, harness, picker) | |
| 832 | + | // Cancellation revokes picker authority immediately, before teardown | |
| 833 | + | // completes. Inject work in that teardown window and prove neither path | |
| 834 | + | // can consume it. | |
| 835 | + | resolved, consumed := picker.resolveCount(), picker.consumedCount() | |
| 836 | + | picker.commit(sessionTestRequest(true)) | |
| 837 | + | harness.reader.push([]byte("retired")) | |
| 838 | + | require.False(t, picker.owns()) | |
| 839 | + | require.Equal(t, resolved, picker.resolveCount(), "revoked picker operations must not resolve") | |
| 840 | + | require.Equal(t, consumed, picker.consumedCount(), "revoked input must not be consumed") | |
| 841 | + | require.ErrorIs(t, harness.waitRun(t), context.Canceled) | |
| 842 | + | require.True(t, stream.closedNow(), "the admitted stream must be released") | |
| 843 | + | require.NotEqual(t, PresentAttached, harness.sup.State().Presentation) | |
| 844 | + | }) | |
| 845 | + | } | |
| 846 | + | } | |
| @@ -0,0 +1,846 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | "github.com/bnema/vev/internal/protocol" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Committed attachment supervisor tests (Plan 001 P5.3b). They drive the real | |||
| 20 | + | // Run loop over the in-package broker fakes, with a fake clock, no sleeps, and | |||
| 21 | + | // no production wiring. | |||
| 22 | + | ||||
| 23 | + | // attachTestTerminal is the supervisor-owned terminal double: it records output, | |||
| 24 | + | // flushes, raw entrances/restores, and UI publication boundaries so a test can | |||
| 25 | + | // prove exactly what a committed attachment wrote. | |||
| 26 | + | type attachTestTerminal struct { | |||
| 27 | + | in io.Reader | |||
| 28 | + | ||||
| 29 | + | mu sync.Mutex | |||
| 30 | + | out bytes.Buffer | |||
| 31 | + | flushes int | |||
| 32 | + | restores int | |||
| 33 | + | publications []ports.UIContext | |||
| 34 | + | successes []bool | |||
| 35 | + | publishEntered chan struct{} | |||
| 36 | + | publishRelease <-chan struct{} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (t *attachTestTerminal) EnterRaw() (func() error, error) { | |||
| 40 | + | return func() error { | |||
| 41 | + | t.mu.Lock() | |||
| 42 | + | t.restores++ | |||
| 43 | + | t.mu.Unlock() | |||
| 44 | + | return nil | |||
| 45 | + | }, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (t *attachTestTerminal) Geometry() (domain.Geometry, error) { | |||
| 49 | + | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (t *attachTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |||
| 53 | + | ||||
| 54 | + | func (t *attachTestTerminal) In() io.Reader { return t.in } | |||
| 55 | + | ||||
| 56 | + | func (t *attachTestTerminal) Out() io.Writer { return &attachTestWriter{term: t} } | |||
| 57 | + | ||||
| 58 | + | func (t *attachTestTerminal) Flush() error { | |||
| 59 | + | t.mu.Lock() | |||
| 60 | + | defer t.mu.Unlock() | |||
| 61 | + | t.flushes++ | |||
| 62 | + | return nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (t *attachTestTerminal) BeginOutput(ports.UIContext) {} | |||
| 66 | + | ||||
| 67 | + | func (t *attachTestTerminal) EndOutput(success bool) { | |||
| 68 | + | t.mu.Lock() | |||
| 69 | + | defer t.mu.Unlock() | |||
| 70 | + | t.successes = append(t.successes, success) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (t *attachTestTerminal) PublishContext(uiContext ports.UIContext) error { | |||
| 74 | + | if t.publishEntered != nil { | |||
| 75 | + | select { | |||
| 76 | + | case t.publishEntered <- struct{}{}: | |||
| 77 | + | default: | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | if t.publishRelease != nil { | |||
| 81 | + | <-t.publishRelease | |||
| 82 | + | } | |||
| 83 | + | t.mu.Lock() | |||
| 84 | + | defer t.mu.Unlock() | |||
| 85 | + | t.publications = append(t.publications, uiContext) | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (t *attachTestTerminal) written() string { | |||
| 90 | + | t.mu.Lock() | |||
| 91 | + | defer t.mu.Unlock() | |||
| 92 | + | return t.out.String() | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (t *attachTestTerminal) counts() (writes int, flushes int, restores int) { | |||
| 96 | + | t.mu.Lock() | |||
| 97 | + | defer t.mu.Unlock() | |||
| 98 | + | return len(t.out.Bytes()), t.flushes, t.restores | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | type attachTestWriter struct{ term *attachTestTerminal } | |||
| 102 | + | ||||
| 103 | + | func (w *attachTestWriter) Write(data []byte) (int, error) { | |||
| 104 | + | w.term.mu.Lock() | |||
| 105 | + | defer w.term.mu.Unlock() | |||
| 106 | + | return w.term.out.Write(data) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // attachTestReader blocks until a chunk is pushed, then returns EOF once | |||
| 110 | + | // closed. It is the supervisor's single terminal reader. | |||
| 111 | + | type attachTestReader struct { | |||
| 112 | + | chunks chan []byte | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func newAttachTestReader() *attachTestReader { | |||
| 116 | + | return &attachTestReader{chunks: make(chan []byte, 8)} | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (r *attachTestReader) Read(buffer []byte) (int, error) { | |||
| 120 | + | chunk, ok := <-r.chunks | |||
| 121 | + | if !ok { | |||
| 122 | + | return 0, io.EOF | |||
| 123 | + | } | |||
| 124 | + | return copy(buffer, chunk), nil | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (r *attachTestReader) push(data []byte) { r.chunks <- data } | |||
| 128 | + | ||||
| 129 | + | func (r *attachTestReader) close() { close(r.chunks) } | |||
| 130 | + | ||||
| 131 | + | // attachTestPicker is the supervisor's picker seam double. It records input | |||
| 132 | + | // ownership transitions, resolves exactly the committed key to one canned | |||
| 133 | + | // request, and coalesces commit wakeups exactly like the real controller. | |||
| 134 | + | type attachTestPicker struct { | |||
| 135 | + | mu sync.Mutex | |||
| 136 | + | opsReady chan struct{} | |||
| 137 | + | ownsInput bool | |||
| 138 | + | consumed int | |||
| 139 | + | op pickerOp | |||
| 140 | + | key string | |||
| 141 | + | request ports.BrokerOpenStreamRequest | |||
| 142 | + | resolved int | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func newAttachTestPicker() *attachTestPicker { | |||
| 146 | + | return &attachTestPicker{opsReady: make(chan struct{}, 1), ownsInput: true} | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (p *attachTestPicker) ApplySnapshot(ports.BrokerSnapshot) {} | |||
| 150 | + | ||||
| 151 | + | func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool { | |||
| 152 | + | p.mu.Lock() | |||
| 153 | + | defer p.mu.Unlock() | |||
| 154 | + | if !p.ownsInput { | |||
| 155 | + | return false | |||
| 156 | + | } | |||
| 157 | + | p.consumed++ | |||
| 158 | + | return true | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (p *attachTestPicker) TakeOp() (pickerOp, string) { | |||
| 162 | + | p.mu.Lock() | |||
| 163 | + | defer p.mu.Unlock() | |||
| 164 | + | op, key := p.op, p.key | |||
| 165 | + | p.op, p.key = pickerOp{}, "" | |||
| 166 | + | return op, key | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 170 | + | p.mu.Lock() | |||
| 171 | + | defer p.mu.Unlock() | |||
| 172 | + | p.resolved++ | |||
| 173 | + | request := p.request | |||
| 174 | + | request.Connection = base.Connection | |||
| 175 | + | request.Stream = base.Stream | |||
| 176 | + | return request, nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (p *attachTestPicker) SetOwnsInput(owns bool) { | |||
| 180 | + | p.mu.Lock() | |||
| 181 | + | defer p.mu.Unlock() | |||
| 182 | + | p.ownsInput = owns | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (p *attachTestPicker) OpsReady() <-chan struct{} { return p.opsReady } | |||
| 186 | + | ||||
| 187 | + | func (p *attachTestPicker) commit(request ports.BrokerOpenStreamRequest) { | |||
| 188 | + | p.mu.Lock() | |||
| 189 | + | p.op.commit = true | |||
| 190 | + | p.key = "row" | |||
| 191 | + | p.request = request | |||
| 192 | + | p.mu.Unlock() | |||
| 193 | + | select { | |||
| 194 | + | case p.opsReady <- struct{}{}: | |||
| 195 | + | default: | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (p *attachTestPicker) owns() bool { | |||
| 200 | + | p.mu.Lock() | |||
| 201 | + | defer p.mu.Unlock() | |||
| 202 | + | return p.ownsInput | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (p *attachTestPicker) consumedCount() int { | |||
| 206 | + | p.mu.Lock() | |||
| 207 | + | defer p.mu.Unlock() | |||
| 208 | + | return p.consumed | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (p *attachTestPicker) resolveCount() int { | |||
| 212 | + | p.mu.Lock() | |||
| 213 | + | defer p.mu.Unlock() | |||
| 214 | + | return p.resolved | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // attachTestHarness is one running supervisor with its scripted broker. | |||
| 218 | + | type attachTestHarness struct { | |||
| 219 | + | sup *Supervisor | |||
| 220 | + | service *supervisorTestService | |||
| 221 | + | clock *supervisorTestClock | |||
| 222 | + | terminal *attachTestTerminal | |||
| 223 | + | reader *attachTestReader | |||
| 224 | + | picker *attachTestPicker | |||
| 225 | + | cancel context.CancelFunc | |||
| 226 | + | runDone chan struct{} | |||
| 227 | + | ||||
| 228 | + | runMu sync.Mutex | |||
| 229 | + | runErr error | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // startAttachHarness runs one supervisor over a scripted picker and service. | |||
| 233 | + | // The caller installs service.setOpenStream before committing a selection. | |||
| 234 | + | func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness { | |||
| 235 | + | t.Helper() | |||
| 236 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 237 | + | clock := newSupervisorTestClock() | |||
| 238 | + | reader := newAttachTestReader() | |||
| 239 | + | terminal := &attachTestTerminal{in: reader} | |||
| 240 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 241 | + | service.publish(3, 1) | |||
| 242 | + | ||||
| 243 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 244 | + | return service, nil | |||
| 245 | + | }) | |||
| 246 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 247 | + | Connector: connector, | |||
| 248 | + | Terminal: terminal, | |||
| 249 | + | Clock: clock, | |||
| 250 | + | Jitter: func() float64 { return 0 }, | |||
| 251 | + | Picker: picker, | |||
| 252 | + | }) | |||
| 253 | + | ||||
| 254 | + | harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})} | |||
| 255 | + | go func() { | |||
| 256 | + | err := sup.Run(ctx) | |||
| 257 | + | harness.runMu.Lock() | |||
| 258 | + | harness.runErr = err | |||
| 259 | + | harness.runMu.Unlock() | |||
| 260 | + | close(harness.runDone) | |||
| 261 | + | }() | |||
| 262 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 263 | + | ||||
| 264 | + | t.Cleanup(func() { | |||
| 265 | + | cancel() | |||
| 266 | + | select { | |||
| 267 | + | case <-harness.runDone: | |||
| 268 | + | case <-time.After(5 * time.Second): | |||
| 269 | + | t.Error("the supervisor did not stop") | |||
| 270 | + | } | |||
| 271 | + | }) | |||
| 272 | + | return harness | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // waitRun joins the supervisor and returns its terminal error. | |||
| 276 | + | func (h *attachTestHarness) waitRun(t *testing.T) error { | |||
| 277 | + | t.Helper() | |||
| 278 | + | select { | |||
| 279 | + | case <-h.runDone: | |||
| 280 | + | case <-time.After(5 * time.Second): | |||
| 281 | + | t.Fatal("the supervisor did not stop") | |||
| 282 | + | } | |||
| 283 | + | h.runMu.Lock() | |||
| 284 | + | defer h.runMu.Unlock() | |||
| 285 | + | return h.runErr | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // snapshotTimers returns the armed timers for assertions without racing the | |||
| 289 | + | // clock's own bookkeeping. | |||
| 290 | + | func (c *supervisorTestClock) snapshotTimers() []*supervisorTestTimer { | |||
| 291 | + | c.mu.Lock() | |||
| 292 | + | defer c.mu.Unlock() | |||
| 293 | + | return append([]*supervisorTestTimer(nil), c.timers...) | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | // awaitAttachedState waits until the supervisor presents the attached state. | |||
| 297 | + | func awaitAttachedState(t *testing.T, sup *Supervisor) { | |||
| 298 | + | t.Helper() | |||
| 299 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentAttached }, 5*time.Second, time.Millisecond) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // awaitPickerState waits until the supervisor is back in the picker. | |||
| 303 | + | func awaitPickerState(t *testing.T, sup *Supervisor) { | |||
| 304 | + | t.Helper() | |||
| 305 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // awaitStreamHello waits until the admitted stream has sent its first client | |||
| 309 | + | // message, polling the guarded stream pointer rather than a stale snapshot. | |||
| 310 | + | func awaitStreamHello(t *testing.T, mu *sync.Mutex, stream **sessionTestStream) { | |||
| 311 | + | t.Helper() | |||
| 312 | + | require.Eventually(t, func() bool { | |||
| 313 | + | mu.Lock() | |||
| 314 | + | defer mu.Unlock() | |||
| 315 | + | return *stream != nil && len((*stream).messages()) >= 1 | |||
| 316 | + | }, 5*time.Second, time.Millisecond) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // deliverReadyStream scripts Welcome plus one committed initial publication. | |||
| 320 | + | func deliverReadyStream(t *testing.T, stream *sessionTestStream) { | |||
| 321 | + | t.Helper() | |||
| 322 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 323 | + | stream.deliver(sessionTestOutput(1, "\x1b[Hready")) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // TestSupervisorReducerAttachmentPresentation pins the presentation | |||
| 327 | + | // transitions of one attachment: connecting until the committed publication, | |||
| 328 | + | // attached, then back to the picker without disturbing connectivity. | |||
| 329 | + | func TestSupervisorReducerAttachmentPresentation(t *testing.T) { | |||
| 330 | + | ready := State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 2} | |||
| 331 | + | connecting := reduceSupervisor(ready, supervisorEvent{kind: supervisorAttachBegin}) | |||
| 332 | + | require.Equal(t, PresentConnecting, connecting.Presentation) | |||
| 333 | + | require.Equal(t, ConnectivityReady, connecting.Connectivity) | |||
| 334 | + | require.Zero(t, connecting.Attempt) | |||
| 335 | + | ||||
| 336 | + | attached := reduceSupervisor(connecting, supervisorEvent{kind: supervisorAttached}) | |||
| 337 | + | require.Equal(t, PresentAttached, attached.Presentation) | |||
| 338 | + | require.Equal(t, ConnectivityReady, attached.Connectivity) | |||
| 339 | + | ||||
| 340 | + | returned := reduceSupervisor(attached, supervisorEvent{kind: supervisorAttachEnded, err: errAttachmentDeadline}) | |||
| 341 | + | require.Equal(t, PresentPicker, returned.Presentation) | |||
| 342 | + | require.Equal(t, ConnectivityReady, returned.Connectivity) | |||
| 343 | + | require.ErrorIs(t, returned.Err, errAttachmentDeadline) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // TestSupervisorAttachmentLocalRemoteParity proves the same supervisor path | |||
| 347 | + | // opens the exact resolved request for a local and a remote selection, with no | |||
| 348 | + | // dialer choice in the client. | |||
| 349 | + | func TestSupervisorAttachmentLocalRemoteParity(t *testing.T) { | |||
| 350 | + | for _, local := range []bool{true, false} { | |||
| 351 | + | name := "remote" | |||
| 352 | + | if local { | |||
| 353 | + | name = "local" | |||
| 354 | + | } | |||
| 355 | + | t.Run(name, func(t *testing.T) { | |||
| 356 | + | picker := newAttachTestPicker() | |||
| 357 | + | harness := startAttachHarness(t, picker) | |||
| 358 | + | var ( | |||
| 359 | + | streamMu sync.Mutex | |||
| 360 | + | stream *sessionTestStream | |||
| 361 | + | ) | |||
| 362 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 363 | + | admitted := newSessionTestStream() | |||
| 364 | + | streamMu.Lock() | |||
| 365 | + | stream = admitted | |||
| 366 | + | streamMu.Unlock() | |||
| 367 | + | return admitted, nil | |||
| 368 | + | }) | |||
| 369 | + | picker.commit(sessionTestRequest(local)) | |||
| 370 | + | ||||
| 371 | + | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |||
| 372 | + | opened := harness.service.openedRequests()[0] | |||
| 373 | + | require.Equal(t, local, opened.Local) | |||
| 374 | + | require.Equal(t, ports.BrokerStreamAttachment, opened.Purpose) | |||
| 375 | + | require.Equal(t, ports.BrokerAdmissionExact, opened.Admission) | |||
| 376 | + | require.Equal(t, "alpha", opened.Target.SessionName) | |||
| 377 | + | require.Equal(t, ports.BrokerConnectionID{1}, opened.Connection) | |||
| 378 | + | require.Equal(t, ports.BrokerStreamID(1), opened.Stream) | |||
| 379 | + | if !local { | |||
| 380 | + | require.Equal(t, "user@example", opened.Endpoint) | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | // The stream now runs the typed protocol and commits the initial | |||
| 384 | + | // publication before attachment is reported. | |||
| 385 | + | streamMu.Lock() | |||
| 386 | + | admitted := stream | |||
| 387 | + | streamMu.Unlock() | |||
| 388 | + | require.NotNil(t, admitted) | |||
| 389 | + | require.Eventually(t, func() bool { return len(admitted.messages()) >= 1 }, 5*time.Second, time.Millisecond) | |||
| 390 | + | require.Equal(t, PresentConnecting, harness.sup.State().Presentation, "connecting until the publication commits") | |||
| 391 | + | require.False(t, picker.owns(), "the picker released input for the attachment") | |||
| 392 | + | deliverReadyStream(t, admitted) | |||
| 393 | + | awaitAttachedState(t, harness.sup) | |||
| 394 | + | require.Equal(t, "\x1b[Hready", harness.terminal.written()) | |||
| 395 | + | require.Equal(t, []bool{true}, harness.terminal.successes, "the frame committed through the UI transaction") | |||
| 396 | + | }) | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart proves a | |||
| 401 | + | // deadline that passed during stream open fails the attachment immediately | |||
| 402 | + | // instead of starting a second budget. | |||
| 403 | + | func TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart(t *testing.T) { | |||
| 404 | + | picker := newAttachTestPicker() | |||
| 405 | + | var ( | |||
| 406 | + | streamMu sync.Mutex | |||
| 407 | + | stream *sessionTestStream | |||
| 408 | + | ) | |||
| 409 | + | harness := startAttachHarness(t, picker) | |||
| 410 | + | clock := harness.clock | |||
| 411 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 412 | + | // The accepted absolute deadline elapsed before this client saw it. | |||
| 413 | + | admitted := &sessionTestStreamWithDeadline{ | |||
| 414 | + | sessionTestStream: newSessionTestStream(), | |||
| 415 | + | deadline: clock.Now().Add(-time.Second), | |||
| 416 | + | } | |||
| 417 | + | streamMu.Lock() | |||
| 418 | + | stream = admitted.sessionTestStream | |||
| 419 | + | streamMu.Unlock() | |||
| 420 | + | return admitted, nil | |||
| 421 | + | }) | |||
| 422 | + | picker.commit(sessionTestRequest(true)) | |||
| 423 | + | ||||
| 424 | + | awaitPickerState(t, harness.sup) | |||
| 425 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |||
| 426 | + | var typed ports.BrokerError | |||
| 427 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |||
| 428 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |||
| 429 | + | ||||
| 430 | + | streamMu.Lock() | |||
| 431 | + | require.NotNil(t, stream) | |||
| 432 | + | require.True(t, stream.closedNow(), "an expired stream is closed") | |||
| 433 | + | streamMu.Unlock() | |||
| 434 | + | ||||
| 435 | + | // Exactly one deadline timer was ever armed, and it was stopped: the | |||
| 436 | + | // elapsed accepted deadline never restarted a fresh budget. | |||
| 437 | + | require.Equal(t, 1, harness.clock.timerCount(), "the elapsed deadline must not restart a budget") | |||
| 438 | + | require.True(t, harness.clock.allStopped(), "the pre-open budget is retired") | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // TestSupervisorAttachmentDeadlineNotExtendedAcrossStages proves one absolute | |||
| 442 | + | // deadline spans the whole attachment: adopting the accepted deadline arms | |||
| 443 | + | // exactly its remaining time, and crossing Welcome never restarts it. | |||
| 444 | + | func TestSupervisorAttachmentDeadlineNotExtendedAcrossStages(t *testing.T) { | |||
| 445 | + | picker := newAttachTestPicker() | |||
| 446 | + | var ( | |||
| 447 | + | streamMu sync.Mutex | |||
| 448 | + | stream *sessionTestStream | |||
| 449 | + | ) | |||
| 450 | + | harness := startAttachHarness(t, picker) | |||
| 451 | + | clock := harness.clock | |||
| 452 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 453 | + | admitted := &sessionTestStreamWithDeadline{ | |||
| 454 | + | sessionTestStream: newSessionTestStream(), | |||
| 455 | + | deadline: clock.Now().Add(5 * time.Second), | |||
| 456 | + | } | |||
| 457 | + | streamMu.Lock() | |||
| 458 | + | stream = admitted.sessionTestStream | |||
| 459 | + | streamMu.Unlock() | |||
| 460 | + | return admitted, nil | |||
| 461 | + | }) | |||
| 462 | + | picker.commit(sessionTestRequest(true)) | |||
| 463 | + | ||||
| 464 | + | awaitStreamHello(t, &streamMu, &stream) | |||
| 465 | + | ||||
| 466 | + | streamMu.Lock() | |||
| 467 | + | admitted := stream | |||
| 468 | + | streamMu.Unlock() | |||
| 469 | + | ||||
| 470 | + | // Wait until adoption armed the second timer. | |||
| 471 | + | require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond) | |||
| 472 | + | timers := harness.clock.snapshotTimers() | |||
| 473 | + | require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open") | |||
| 474 | + | require.Equal(t, 5*time.Second, timers[1].delay, "the accepted deadline is adopted verbatim, never restarted") | |||
| 475 | + | require.True(t, timers[0].stopped(), "the pre-open budget is replaced by the accepted deadline") | |||
| 476 | + | require.False(t, timers[1].stopped()) | |||
| 477 | + | ||||
| 478 | + | // Crossing Welcome does not arm another deadline. | |||
| 479 | + | admitted.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 480 | + | require.Never(t, func() bool { return harness.clock.timerCount() > 2 }, 20*time.Millisecond, 2*time.Millisecond, "no stage may restart the deadline") | |||
| 481 | + | ||||
| 482 | + | // Firing the one adopted deadline ends the attachment as a typed timeout | |||
| 483 | + | // before publication, without claiming attachment. | |||
| 484 | + | timers[1].fire() | |||
| 485 | + | awaitPickerState(t, harness.sup) | |||
| 486 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |||
| 487 | + | var typed ports.BrokerError | |||
| 488 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |||
| 489 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |||
| 490 | + | require.Empty(t, harness.terminal.publications, "a timed-out attachment never published") | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | // TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget proves the | |||
| 494 | + | // one absolute deadline is the earlier of the client's own pre-open budget and | |||
| 495 | + | // the admitted stream's accepted deadline. The accepted deadline is anchored | |||
| 496 | + | // before this client's attempt began, so it is normally the later of the two; | |||
| 497 | + | // folding it in verbatim would repay the open stage with a fresh budget and let | |||
| 498 | + | // the attachment outlive the deadline. | |||
| 499 | + | func TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget(t *testing.T) { | |||
| 500 | + | picker := newAttachTestPicker() | |||
| 501 | + | var ( | |||
| 502 | + | streamMu sync.Mutex | |||
| 503 | + | stream *sessionTestStream | |||
| 504 | + | ) | |||
| 505 | + | harness := startAttachHarness(t, picker) | |||
| 506 | + | clock := harness.clock | |||
| 507 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 508 | + | admitted := &sessionTestStreamWithDeadline{ | |||
| 509 | + | sessionTestStream: newSessionTestStream(), | |||
| 510 | + | deadline: clock.Now().Add(4 * protocol.HandshakeTimeout), | |||
| 511 | + | } | |||
| 512 | + | streamMu.Lock() | |||
| 513 | + | stream = admitted.sessionTestStream | |||
| 514 | + | streamMu.Unlock() | |||
| 515 | + | return admitted, nil | |||
| 516 | + | }) | |||
| 517 | + | picker.commit(sessionTestRequest(true)) | |||
| 518 | + | ||||
| 519 | + | awaitStreamHello(t, &streamMu, &stream) | |||
| 520 | + | ||||
| 521 | + | require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond) | |||
| 522 | + | timers := harness.clock.snapshotTimers() | |||
| 523 | + | require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open") | |||
| 524 | + | require.Equal(t, protocol.HandshakeTimeout, timers[1].delay, | |||
| 525 | + | "a later accepted deadline never repays the open stage with a second budget") | |||
| 526 | + | require.True(t, timers[0].stopped()) | |||
| 527 | + | require.False(t, timers[1].stopped()) | |||
| 528 | + | ||||
| 529 | + | // The one deadline still ends the attachment as a typed timeout before | |||
| 530 | + | // publication, and it does so no later than the client's own budget. | |||
| 531 | + | timers[1].fire() | |||
| 532 | + | awaitPickerState(t, harness.sup) | |||
| 533 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |||
| 534 | + | var typed ports.BrokerError | |||
| 535 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |||
| 536 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |||
| 537 | + | require.Empty(t, harness.terminal.publications, "a timed-out attachment never published") | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | // TestSupervisorAttachmentPreOpenBudgetWithoutProvider proves a stream without | |||
| 541 | + | // the optional seam keeps the one pre-open budget: no second timer, no restart. | |||
| 542 | + | func TestSupervisorAttachmentPreOpenBudgetWithoutProvider(t *testing.T) { | |||
| 543 | + | picker := newAttachTestPicker() | |||
| 544 | + | var ( | |||
| 545 | + | streamMu sync.Mutex | |||
| 546 | + | stream *sessionTestStream | |||
| 547 | + | ) | |||
| 548 | + | harness := startAttachHarness(t, picker) | |||
| 549 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 550 | + | admitted := newSessionTestStream() | |||
| 551 | + | streamMu.Lock() | |||
| 552 | + | stream = admitted | |||
| 553 | + | streamMu.Unlock() | |||
| 554 | + | return admitted, nil | |||
| 555 | + | }) | |||
| 556 | + | picker.commit(sessionTestRequest(true)) | |||
| 557 | + | ||||
| 558 | + | awaitStreamHello(t, &streamMu, &stream) | |||
| 559 | + | ||||
| 560 | + | streamMu.Lock() | |||
| 561 | + | admitted := stream | |||
| 562 | + | streamMu.Unlock() | |||
| 563 | + | require.Equal(t, 1, harness.clock.timerCount()) | |||
| 564 | + | timers := harness.clock.snapshotTimers() | |||
| 565 | + | require.Equal(t, protocol.HandshakeTimeout, timers[0].delay) | |||
| 566 | + | require.False(t, timers[0].stopped()) | |||
| 567 | + | ||||
| 568 | + | timers[0].fire() | |||
| 569 | + | awaitPickerState(t, harness.sup) | |||
| 570 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |||
| 571 | + | var typed ports.BrokerError | |||
| 572 | + | require.ErrorAs(t, harness.sup.State().Err, &typed) | |||
| 573 | + | require.Equal(t, ports.BrokerErrorTimeout, typed.Code) | |||
| 574 | + | require.True(t, admitted.closedNow(), "the expired stream is closed") | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | func TestSupervisorAttachmentDeadlineDisarmedAfterPublication(t *testing.T) { | |||
| 578 | + | picker := newAttachTestPicker() | |||
| 579 | + | var stream *sessionTestStream | |||
| 580 | + | var streamMu sync.Mutex | |||
| 581 | + | harness := startAttachHarness(t, picker) | |||
| 582 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 583 | + | admitted := newSessionTestStream() | |||
| 584 | + | streamMu.Lock() | |||
| 585 | + | stream = admitted | |||
| 586 | + | streamMu.Unlock() | |||
| 587 | + | return admitted, nil | |||
| 588 | + | }) | |||
| 589 | + | picker.commit(sessionTestRequest(true)) | |||
| 590 | + | awaitStreamHello(t, &streamMu, &stream) | |||
| 591 | + | streamMu.Lock() | |||
| 592 | + | admitted := stream | |||
| 593 | + | streamMu.Unlock() | |||
| 594 | + | deliverReadyStream(t, admitted) | |||
| 595 | + | awaitAttachedState(t, harness.sup) | |||
| 596 | + | ||||
| 597 | + | timer := harness.clock.snapshotTimers()[0] | |||
| 598 | + | require.True(t, timer.stopped(), "the initial-publication commit disarms the budget") | |||
| 599 | + | timer.fire() | |||
| 600 | + | // This bounded negative window gives the retired deadline goroutine an | |||
| 601 | + | // opportunity to mishandle the late fire; no sleep is used for ordering. | |||
| 602 | + | require.Never(t, func() bool { | |||
| 603 | + | return harness.sup.State().Presentation != PresentAttached || admitted.closedNow() || harness.sup.State().Err != nil | |||
| 604 | + | }, 50*time.Millisecond, time.Millisecond) | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | func TestAttachmentDeadlineConcurrentExpiryAndRetirement(t *testing.T) { | |||
| 608 | + | tests := []struct { | |||
| 609 | + | name string | |||
| 610 | + | retire func(*attachmentDeadline) | |||
| 611 | + | }{ | |||
| 612 | + | {name: "disarm", retire: (*attachmentDeadline).disarm}, | |||
| 613 | + | {name: "adopt", retire: func(deadline *attachmentDeadline) { | |||
| 614 | + | _ = deadline.adopt(&sessionTestStreamWithDeadline{sessionTestStream: newSessionTestStream(), deadline: time.Unix(2000, 0)}) | |||
| 615 | + | }}, | |||
| 616 | + | } | |||
| 617 | + | for _, tt := range tests { | |||
| 618 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 619 | + | clock := newSupervisorTestClock() | |||
| 620 | + | deadline := startAttachmentDeadline(context.Background(), clock) | |||
| 621 | + | timer := clock.snapshotTimers()[0] | |||
| 622 | + | done := make(chan struct{}) | |||
| 623 | + | go func() { | |||
| 624 | + | timer.fire() | |||
| 625 | + | tt.retire(deadline) | |||
| 626 | + | close(done) | |||
| 627 | + | }() | |||
| 628 | + | select { | |||
| 629 | + | case <-done: | |||
| 630 | + | case <-time.After(time.Second): | |||
| 631 | + | t.Fatal("concurrent expiry and retirement hung") | |||
| 632 | + | } | |||
| 633 | + | deadline.finish() | |||
| 634 | + | require.True(t, clock.allStopped()) | |||
| 635 | + | }) | |||
| 636 | + | } | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | // TestSupervisorAttachmentPostAttachmentLossReturnsToPicker proves a stream | |||
| 640 | + | // that fails after attachment never keeps the attached presentation: the | |||
| 641 | + | // supervisor returns to the picker with the typed loss. | |||
| 642 | + | func TestSupervisorAttachmentPostAttachmentLossReturnsToPicker(t *testing.T) { | |||
| 643 | + | picker := newAttachTestPicker() | |||
| 644 | + | var ( | |||
| 645 | + | streamMu sync.Mutex | |||
| 646 | + | stream *sessionTestStream | |||
| 647 | + | ) | |||
| 648 | + | harness := startAttachHarness(t, picker) | |||
| 649 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 650 | + | admitted := newSessionTestStream() | |||
| 651 | + | streamMu.Lock() | |||
| 652 | + | stream = admitted | |||
| 653 | + | streamMu.Unlock() | |||
| 654 | + | return admitted, nil | |||
| 655 | + | }) | |||
| 656 | + | picker.commit(sessionTestRequest(true)) | |||
| 657 | + | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |||
| 658 | + | ||||
| 659 | + | streamMu.Lock() | |||
| 660 | + | admitted := stream | |||
| 661 | + | streamMu.Unlock() | |||
| 662 | + | deliverReadyStream(t, admitted) | |||
| 663 | + | awaitAttachedState(t, harness.sup) | |||
| 664 | + | ||||
| 665 | + | admitted.fail(ports.BrokerStreamLost{ | |||
| 666 | + | Connection: ports.BrokerConnectionID{1}, | |||
| 667 | + | Stream: 1, | |||
| 668 | + | Epoch: 3, | |||
| 669 | + | Cause: domain.RemoteFailureTransport, | |||
| 670 | + | Err: errors.New("attachment lost"), | |||
| 671 | + | }) | |||
| 672 | + | awaitPickerState(t, harness.sup) | |||
| 673 | + | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |||
| 674 | + | require.NotEqual(t, PresentAttached, harness.sup.State().Presentation, "a failed stream is never still attached") | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | // TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation | |||
| 678 | + | // mid-attach closes the stream, ends the run, and never reports attachment. | |||
| 679 | + | func TestSupervisorAttachmentCancellationRetiresGeneration(t *testing.T) { | |||
| 680 | + | picker := newAttachTestPicker() | |||
| 681 | + | var ( | |||
| 682 | + | streamMu sync.Mutex | |||
| 683 | + | stream *sessionTestStream | |||
| 684 | + | ) | |||
| 685 | + | harness := startAttachHarness(t, picker) | |||
| 686 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 687 | + | admitted := newSessionTestStream() | |||
| 688 | + | streamMu.Lock() | |||
| 689 | + | stream = admitted | |||
| 690 | + | streamMu.Unlock() | |||
| 691 | + | return admitted, nil | |||
| 692 | + | }) | |||
| 693 | + | picker.commit(sessionTestRequest(true)) | |||
| 694 | + | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |||
| 695 | + | ||||
| 696 | + | harness.cancel() | |||
| 697 | + | require.ErrorIs(t, harness.waitRun(t), context.Canceled) | |||
| 698 | + | streamMu.Lock() | |||
| 699 | + | admitted := stream | |||
| 700 | + | streamMu.Unlock() | |||
| 701 | + | require.True(t, admitted.closedNow(), "the supervisor close authority released the stream") | |||
| 702 | + | require.Equal(t, PresentTerminating, harness.sup.State().Presentation) | |||
| 703 | + | require.Empty(t, harness.terminal.publications, "a cancelled attachment never published") | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | // TestSupervisorPickerInputNeverReachesSession proves picker bytes produce no | |||
| 707 | + | // stream open, no session write, and no terminal write. | |||
| 708 | + | func TestSupervisorPickerInputNeverReachesSession(t *testing.T) { | |||
| 709 | + | picker := newAttachTestPicker() | |||
| 710 | + | harness := startAttachHarness(t, picker) | |||
| 711 | + | opened := false | |||
| 712 | + | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 713 | + | opened = true | |||
| 714 | + | return newSessionTestStream(), nil | |||
| 715 | + | }) | |||
| 716 | + | ||||
| 717 | + | harness.reader.push([]byte("j")) | |||
| 718 | + | require.Eventually(t, func() bool { return picker.consumedCount() >= 1 }, 5*time.Second, time.Millisecond) | |||
| 719 | + | require.False(t, opened, "picker input must never open a stream") | |||
| 720 | + | require.Zero(t, picker.resolveCount(), "picker input must never resolve a selection") | |||
| 721 | + | require.Empty(t, harness.service.openedRequests()) | |||
| 722 | + | _, flushes, _ := harness.terminal.counts() | |||
| 723 | + | require.Zero(t, flushes, "picker input must never flush session output") | |||
| 724 | + | require.Empty(t, harness.terminal.written(), "picker input must never write session bytes") | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | // TestSupervisorAttachmentReachableFromRealPicker drives the real client-owned | |||
| 728 | + | // picker: a committed row resolves to one exact stream request and its | |||
| 729 | + | // publication attaches. | |||
| 730 | + | func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) { | |||
| 731 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 732 | + | defer cancel() | |||
| 733 | + | ||||
| 734 | + | clock := newSupervisorTestClock() | |||
| 735 | + | reader := newPickerChunkReader() | |||
| 736 | + | t.Cleanup(reader.close) | |||
| 737 | + | terminal := &attachTestTerminal{in: reader} | |||
| 738 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 739 | + | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |||
| 740 | + | admittedStreams := make(chan *sessionTestStream, 1) | |||
| 741 | + | service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 742 | + | admitted := newSessionTestStream() | |||
| 743 | + | admittedStreams <- admitted | |||
| 744 | + | return admitted, nil | |||
| 745 | + | }) | |||
| 746 | + | ||||
| 747 | + | controller := newPickerController(clock, pickerTestFreshness) | |||
| 748 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }) | |||
| 749 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 750 | + | Connector: connector, | |||
| 751 | + | Terminal: terminal, | |||
| 752 | + | Clock: clock, | |||
| 753 | + | Jitter: func() float64 { return 0 }, | |||
| 754 | + | Picker: controller, | |||
| 755 | + | }) | |||
| 756 | + | runErr := make(chan error, 1) | |||
| 757 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 758 | + | ||||
| 759 | + | require.Eventually(t, func() bool { return controller.Catalogue().Revision() == 1 }, 5*time.Second, time.Millisecond) | |||
| 760 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines())) | |||
| 761 | + | ||||
| 762 | + | // The user commits the row under the cursor through the shared reader. | |||
| 763 | + | reader.push([]byte("\r")) | |||
| 764 | + | require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |||
| 765 | + | opened := service.openedRequests()[0] | |||
| 766 | + | require.Equal(t, "alpha", opened.Target.SessionName) | |||
| 767 | + | require.True(t, opened.Local) | |||
| 768 | + | require.Equal(t, ports.BrokerAdmissionExact, opened.Admission) | |||
| 769 | + | require.Equal(t, ports.BrokerStreamID(1), opened.Stream) | |||
| 770 | + | ||||
| 771 | + | var stream *sessionTestStream | |||
| 772 | + | select { | |||
| 773 | + | case stream = <-admittedStreams: | |||
| 774 | + | case <-time.After(5 * time.Second): | |||
| 775 | + | t.Fatal("the resolved stream was never admitted") | |||
| 776 | + | } | |||
| 777 | + | deliverReadyStream(t, stream) | |||
| 778 | + | awaitAttachedState(t, sup) | |||
| 779 | + | ||||
| 780 | + | cancel() | |||
| 781 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 782 | + | require.True(t, stream.closedNow()) | |||
| 783 | + | require.Equal(t, 1, terminal.restores, "the supervisor restored raw mode exactly once") | |||
| 784 | + | } | |||
| 785 | + | ||||
| 786 | + | // TestSupervisorAttachmentConcurrentCommitAndCancellation deterministically | |||
| 787 | + | // retires attachment authority at each blocking boundary under review. | |||
| 788 | + | func TestSupervisorAttachmentConcurrentCommitAndCancellation(t *testing.T) { | |||
| 789 | + | tests := []struct { | |||
| 790 | + | name string | |||
| 791 | + | run func(*testing.T, *attachTestHarness, *attachTestPicker) *sessionTestStream | |||
| 792 | + | }{ | |||
| 793 | + | {name: "open in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream { | |||
| 794 | + | entered := make(chan struct{}) | |||
| 795 | + | release := make(chan struct{}) | |||
| 796 | + | stream := newSessionTestStream() | |||
| 797 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 798 | + | close(entered) | |||
| 799 | + | <-release | |||
| 800 | + | return stream, nil | |||
| 801 | + | }) | |||
| 802 | + | picker.commit(sessionTestRequest(true)) | |||
| 803 | + | <-entered | |||
| 804 | + | harness.cancel() | |||
| 805 | + | close(release) | |||
| 806 | + | return stream | |||
| 807 | + | }}, | |||
| 808 | + | {name: "publication commit in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream { | |||
| 809 | + | stream := newSessionTestStream() | |||
| 810 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 811 | + | return stream, nil | |||
| 812 | + | }) | |||
| 813 | + | entered := make(chan struct{}, 1) | |||
| 814 | + | release := make(chan struct{}) | |||
| 815 | + | harness.terminal.publishEntered = entered | |||
| 816 | + | harness.terminal.publishRelease = release | |||
| 817 | + | picker.commit(sessionTestRequest(true)) | |||
| 818 | + | awaitHello(t, stream) | |||
| 819 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 820 | + | stream.deliver(sessionTestOutput(1, "initial")) | |||
| 821 | + | <-entered | |||
| 822 | + | harness.cancel() | |||
| 823 | + | close(release) | |||
| 824 | + | return stream | |||
| 825 | + | }}, | |||
| 826 | + | } | |||
| 827 | + | for _, tt := range tests { | |||
| 828 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 829 | + | picker := newAttachTestPicker() | |||
| 830 | + | harness := startAttachHarness(t, picker) | |||
| 831 | + | stream := tt.run(t, harness, picker) | |||
| 832 | + | // Cancellation revokes picker authority immediately, before teardown | |||
| 833 | + | // completes. Inject work in that teardown window and prove neither path | |||
| 834 | + | // can consume it. | |||
| 835 | + | resolved, consumed := picker.resolveCount(), picker.consumedCount() | |||
| 836 | + | picker.commit(sessionTestRequest(true)) | |||
| 837 | + | harness.reader.push([]byte("retired")) | |||
| 838 | + | require.False(t, picker.owns()) | |||
| 839 | + | require.Equal(t, resolved, picker.resolveCount(), "revoked picker operations must not resolve") | |||
| 840 | + | require.Equal(t, consumed, picker.consumedCount(), "revoked input must not be consumed") | |||
| 841 | + | require.ErrorIs(t, harness.waitRun(t), context.Canceled) | |||
| 842 | + | require.True(t, stream.closedNow(), "the admitted stream must be released") | |||
| 843 | + | require.NotEqual(t, PresentAttached, harness.sup.State().Presentation) | |||
| 844 | + | }) | |||
| 845 | + | } | |||
| 846 | + | } | |||
M internal/usecase/client/supervisor_test.go +29 -2
| @@ -322,6 +322,12 @@ type supervisorTestService struct { | |||
| 322 | 322 | // closeEntered reports that Close began waiting on it. | |
| 323 | 323 | closeGate chan struct{} | |
| 324 | 324 | closeEntered chan struct{} | |
| 325 | + | ||
| 326 | + | // openStream scripts logical-stream admission for the P5.3b attachment | |
| 327 | + | // path. When nil, OpenStream keeps the historical refusal. openCalls | |
| 328 | + | // records every exact request the supervisor admitted. | |
| 329 | + | openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) | |
| 330 | + | openCalls []ports.BrokerOpenStreamRequest | |
| 325 | 331 | } | |
| 326 | 332 | ||
| 327 | 333 | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | |
| @@ -355,8 +361,29 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) { | |||
| 355 | 361 | }), nil | |
| 356 | 362 | } | |
| 357 | 363 | ||
| 358 | - | func (s *supervisorTestService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 359 | - | return nil, errors.New("supervisor test service does not support streams") | |
| 364 | + | func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 365 | + | s.mu.Lock() | |
| 366 | + | s.openCalls = append(s.openCalls, request) | |
| 367 | + | open := s.openStream | |
| 368 | + | s.mu.Unlock() | |
| 369 | + | if open == nil { | |
| 370 | + | return nil, errors.New("supervisor test service does not support streams") | |
| 371 | + | } | |
| 372 | + | return open(ctx, request) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // setOpenStream installs the logical-stream admission all P5.3b tests drive. | |
| 376 | + | func (s *supervisorTestService) setOpenStream(open func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)) { | |
| 377 | + | s.mu.Lock() | |
| 378 | + | defer s.mu.Unlock() | |
| 379 | + | s.openStream = open | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // openedRequests returns a copy of every exact stream request admitted so far. | |
| 383 | + | func (s *supervisorTestService) openedRequests() []ports.BrokerOpenStreamRequest { | |
| 384 | + | s.mu.Lock() | |
| 385 | + | defer s.mu.Unlock() | |
| 386 | + | return append([]ports.BrokerOpenStreamRequest(nil), s.openCalls...) | |
| 360 | 387 | } | |
| 361 | 388 | ||
| 362 | 389 | func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |
| @@ -322,6 +322,12 @@ type supervisorTestService struct { | |||||
| 322 | // closeEntered reports that Close began waiting on it. | 322 | // closeEntered reports that Close began waiting on it. | ||
| 323 | closeGate chan struct{} | 323 | closeGate chan struct{} | ||
| 324 | closeEntered chan struct{} | 324 | closeEntered chan struct{} | ||
| 325 | + | ||||
| 326 | + | // openStream scripts logical-stream admission for the P5.3b attachment | |||
| 327 | + | // path. When nil, OpenStream keeps the historical refusal. openCalls | |||
| 328 | + | // records every exact request the supervisor admitted. | |||
| 329 | + | openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) | |||
| 330 | + | openCalls []ports.BrokerOpenStreamRequest | |||
| 325 | } | 331 | } | ||
| 326 | 332 | ||||
| 327 | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | 333 | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | ||
| @@ -355,8 +361,29 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) { | |||||
| 355 | }), nil | 361 | }), nil | ||
| 356 | } | 362 | } | ||
| 357 | 363 | ||||
| 358 | - | func (s *supervisorTestService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | 364 | + | func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { |
| 359 | - | return nil, errors.New("supervisor test service does not support streams") | 365 | + | s.mu.Lock() |
| 366 | + | s.openCalls = append(s.openCalls, request) | |||
| 367 | + | open := s.openStream | |||
| 368 | + | s.mu.Unlock() | |||
| 369 | + | if open == nil { | |||
| 370 | + | return nil, errors.New("supervisor test service does not support streams") | |||
| 371 | + | } | |||
| 372 | + | return open(ctx, request) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // setOpenStream installs the logical-stream admission all P5.3b tests drive. | |||
| 376 | + | func (s *supervisorTestService) setOpenStream(open func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)) { | |||
| 377 | + | s.mu.Lock() | |||
| 378 | + | defer s.mu.Unlock() | |||
| 379 | + | s.openStream = open | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | // openedRequests returns a copy of every exact stream request admitted so far. | |||
| 383 | + | func (s *supervisorTestService) openedRequests() []ports.BrokerOpenStreamRequest { | |||
| 384 | + | s.mu.Lock() | |||
| 385 | + | defer s.mu.Unlock() | |||
| 386 | + | return append([]ports.BrokerOpenStreamRequest(nil), s.openCalls...) | |||
| 360 | } | 387 | } | ||
| 361 | 388 | ||||
| 362 | func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | 389 | func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | ||