feat(client): own the autonomous client lifecycle
The supervisor now owns the whole lifecycle of an autonomous client: one-shot initial navigation, attached input and geometry, detach actions, and the return to the picker. Offline and unactivated: no production composition uses it. - Initial navigation is consumed exactly once after the first ready publication and resolves through the client-owned catalogue to an ephemeral creation admission, so creating a session with no arguments is broker navigation rather than a special client path. It is never replayed on a reconnect. - Detach-to-picker and detach-and-exit are distinct typed actions: the first returns the terminal to the picker in the same process, the second ends the run. Session termination, session deletion, destination failure, and broker loss each return to the picker with a typed bounded notice. - Broker loss discards attachment authority, reconnects without reattaching, and replays no mutation whose outcome is unknown: no committed key or pending operation survives the generation that lost the connection. - One terminal reader and one writer remain. The supervisor's single input lifetime owns the pump; picker and attachment claims are mutually exclusive; data-ready wakeups are per claim rather than shared, so a stale runner cannot consume a wakeup it cannot use, and a claim with data outstanding is always woken. Readiness channels are pruned when their claim is revoked. - Undecided input never crosses an owner-class boundary: bytes queued while the picker owned input are disposed of at the handoff instead of becoming session input, and an attachment's undecided bytes are dropped at finalize instead of becoming picker input (which could otherwise commit a row). Attachment-to- attachment replacement stays lossless. - Geometry is collected once per run and consumed per attachment, so resize is never coalesced across attachments and a revoked attachment cannot submit. - Lifecycle actions carry the grant token; a stale token is skipped rather than terminating the delivery loop, so a later detach still arrives. Review disposition: four review rounds, each fix independently verified, and the two behavioural fixes confirmed by mutation testing (disabling them fails the tests that claim them). Documented gap: the owner-class drop performed when the picker releases its claim is defence in depth for a handoff window this harness cannot stage deterministically, so no test pins it; removing it leaves the suite green. The invariant is stated at the call site, and the gap is recorded rather than covered by a test that would not fail without the code.
- commit
- 952412b5ef95b3ac3489cc6720fb1ae4f145fb6e
- author
- brice · · 2026-09-19 12:15 UTC
- parent
- 2dfda5e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +1121 -117
M internal/usecase/client/attachment_session.go +116 -28
| @@ -23,9 +23,8 @@ import ( | |||
| 23 | 23 | // | |
| 24 | 24 | // The worker owns no endpoint, route, raw mode, terminal writer, or second | |
| 25 | 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. | |
| 26 | + | // AttachmentForeground. Input, geometry and lifecycle sends therefore retain | |
| 27 | + | // the foreground token and stop when the grant is superseded. | |
| 29 | 28 | ||
| 30 | 29 | var ( | |
| 31 | 30 | // errAttachmentDeadline is the stable local sentinel of an expired | |
| @@ -214,39 +213,128 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 214 | 213 | } | |
| 215 | 214 | } | |
| 216 | 215 | ||
| 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. | |
| 216 | + | type attachmentClientEvent struct { | |
| 217 | + | message protocol.ClientMessage | |
| 218 | + | input *AttachmentInputEvent | |
| 219 | + | action AttachmentLifecycleActionKind | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | var ( | |
| 223 | + | errDetachToPicker = errors.New("client: detach to picker") | |
| 224 | + | errDetachAndExit = errors.New("client: detach and exit") | |
| 225 | + | ) | |
| 226 | + | ||
| 227 | + | // pumpAttached publishes output and concurrently forwards the foreground's | |
| 228 | + | // authorized input, latest geometry and explicit lifecycle decision. | |
| 221 | 229 | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent { | |
| 230 | + | pumpCtx, cancel := context.WithCancel(ctx) | |
| 231 | + | defer cancel() | |
| 232 | + | outgoing := make(chan attachmentClientEvent) | |
| 233 | + | go pumpAttachmentInput(pumpCtx, fg, outgoing) | |
| 234 | + | go pumpAttachmentGeometry(pumpCtx, fg, outgoing) | |
| 235 | + | go pumpAttachmentLifecycle(pumpCtx, fg, outgoing) | |
| 222 | 236 | 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 | |
| 237 | + | select { | |
| 238 | + | case result := <-incoming: | |
| 239 | + | if result.err != nil { | |
| 240 | + | return w.settle(ctx, fg, stream, token, result.err) | |
| 234 | 241 | } | |
| 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)} | |
| 242 | + | switch typed := result.message.(type) { | |
| 243 | + | case protocol.Output: | |
| 244 | + | next, ok := state.next(typed) | |
| 245 | + | if !ok { | |
| 246 | + | continue | |
| 247 | + | } | |
| 248 | + | state = next | |
| 249 | + | if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 250 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | |
| 251 | + | } | |
| 252 | + | case protocol.ErrorMsg: | |
| 253 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |
| 254 | + | case protocol.Detached: | |
| 255 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |
| 238 | 256 | } | |
| 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. | |
| 257 | + | case event := <-outgoing: | |
| 258 | + | if event.input != nil && event.input.Err != nil { | |
| 259 | + | fg.PreserveInput(event.input.Data) | |
| 260 | + | return w.settle(ctx, fg, stream, token, event.input.Err) | |
| 261 | + | } | |
| 262 | + | if err := w.send(ctx, fg, stream, event.message); err != nil { | |
| 263 | + | if event.input != nil { | |
| 264 | + | fg.PreserveInput(event.input.Data) | |
| 265 | + | } | |
| 266 | + | return w.settle(ctx, fg, stream, token, err) | |
| 267 | + | } | |
| 268 | + | if event.input != nil { | |
| 269 | + | fg.AckInput() | |
| 270 | + | } | |
| 271 | + | if event.action == AttachmentDetachToPicker { | |
| 272 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker} | |
| 273 | + | } | |
| 274 | + | if event.action == AttachmentDetachAndExit { | |
| 275 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit} | |
| 276 | + | } | |
| 277 | + | case <-ctx.Done(): | |
| 278 | + | return w.settle(ctx, fg, stream, token, ctx.Err()) | |
| 279 | + | case <-fg.Done(): | |
| 280 | + | return w.settle(ctx, fg, stream, token, errAttachmentForegroundRevoked) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | |
| 286 | + | var sequence uint64 | |
| 287 | + | for { | |
| 288 | + | event, ok := fg.Input(ctx) | |
| 289 | + | if !ok { | |
| 290 | + | return | |
| 291 | + | } | |
| 292 | + | if event.Err != nil { | |
| 293 | + | select { | |
| 294 | + | case out <- attachmentClientEvent{input: &event}: | |
| 295 | + | case <-ctx.Done(): | |
| 296 | + | fg.PreserveInput(event.Data) | |
| 297 | + | } | |
| 298 | + | return | |
| 299 | + | } | |
| 300 | + | sequence++ | |
| 301 | + | copyEvent := event | |
| 302 | + | select { | |
| 303 | + | case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, Data: append([]byte(nil), event.Data...)}, input: ©Event}: | |
| 304 | + | case <-ctx.Done(): | |
| 305 | + | fg.PreserveInput(event.Data) | |
| 306 | + | return | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func pumpAttachmentGeometry(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | |
| 312 | + | for { | |
| 313 | + | geometry, ok := fg.Resize(ctx) | |
| 314 | + | if !ok { | |
| 315 | + | return | |
| 316 | + | } | |
| 317 | + | geometry = geometry.NormalizePixels() | |
| 318 | + | select { | |
| 319 | + | case out <- attachmentClientEvent{message: protocol.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}}: | |
| 320 | + | case <-ctx.Done(): | |
| 321 | + | return | |
| 246 | 322 | } | |
| 247 | 323 | } | |
| 248 | 324 | } | |
| 249 | 325 | ||
| 326 | + | func pumpAttachmentLifecycle(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | |
| 327 | + | action, ok := fg.Lifecycle(ctx) | |
| 328 | + | if !ok { | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | select { | |
| 332 | + | case out <- attachmentClientEvent{message: protocol.Detach{}, action: action}: | |
| 333 | + | case <-ctx.Done(): | |
| 334 | + | return | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 250 | 338 | func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error { | |
| 251 | 339 | if welcome.CommittedIdentity != nil { | |
| 252 | 340 | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | |
| @@ -23,9 +23,8 @@ import ( | |||||
| 23 | // | 23 | // | ||
| 24 | // The worker owns no endpoint, route, raw mode, terminal writer, or second | 24 | // The worker owns no endpoint, route, raw mode, terminal writer, or second | ||
| 25 | // reader. It reaches the terminal only through the supervisor-granted | 25 | // reader. It reaches the terminal only through the supervisor-granted | ||
| 26 | - | // AttachmentForeground. Terminal input pumping into the session and UI action | 26 | + | // AttachmentForeground. Input, geometry and lifecycle sends therefore retain |
| 27 | - | // admission are deliberately left to P5.4, exactly as the P5.1b host documents, | 27 | + | // the foreground token and stop when the grant is superseded. |
| 28 | - | // so this slice adds no path from picker input to a session. | |||
| 29 | 28 | ||||
| 30 | var ( | 29 | var ( | ||
| 31 | // errAttachmentDeadline is the stable local sentinel of an expired | 30 | // errAttachmentDeadline is the stable local sentinel of an expired | ||
| @@ -214,39 +213,128 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 214 | } | 213 | } | ||
| 215 | } | 214 | } | ||
| 216 | 215 | ||||
| 217 | - | // pumpAttached publishes accepted output after attachment until the stream | 216 | + | type attachmentClientEvent struct { |
| 218 | - | // ends. It never unmarks attachment: a stream failure ends the run as | 217 | + | message protocol.ClientMessage |
| 219 | - | // lost/ended, so the supervisor returns to the picker and never keeps the | 218 | + | input *AttachmentInputEvent |
| 220 | - | // attached presentation showing. | 219 | + | action AttachmentLifecycleActionKind |
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | var ( | |||
| 223 | + | errDetachToPicker = errors.New("client: detach to picker") | |||
| 224 | + | errDetachAndExit = errors.New("client: detach and exit") | |||
| 225 | + | ) | |||
| 226 | + | ||||
| 227 | + | // pumpAttached publishes output and concurrently forwards the foreground's | |||
| 228 | + | // authorized input, latest geometry and explicit lifecycle decision. | |||
| 221 | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent { | 229 | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent { | ||
| 230 | + | pumpCtx, cancel := context.WithCancel(ctx) | |||
| 231 | + | defer cancel() | |||
| 232 | + | outgoing := make(chan attachmentClientEvent) | |||
| 233 | + | go pumpAttachmentInput(pumpCtx, fg, outgoing) | |||
| 234 | + | go pumpAttachmentGeometry(pumpCtx, fg, outgoing) | |||
| 235 | + | go pumpAttachmentLifecycle(pumpCtx, fg, outgoing) | |||
| 222 | for { | 236 | for { | ||
| 223 | - | message, err := w.awaitServer(ctx, fg, incoming) | 237 | + | select { |
| 224 | - | if err != nil { | 238 | + | case result := <-incoming: |
| 225 | - | return w.settle(ctx, fg, stream, token, err) | 239 | + | if result.err != nil { |
| 226 | - | } | 240 | + | return w.settle(ctx, fg, stream, token, result.err) |
| 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 | } | 241 | } | ||
| 235 | - | state = next | 242 | + | switch typed := result.message.(type) { |
| 236 | - | if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil { | 243 | + | case protocol.Output: |
| 237 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | 244 | + | next, ok := state.next(typed) |
| 245 | + | if !ok { | |||
| 246 | + | continue | |||
| 247 | + | } | |||
| 248 | + | state = next | |||
| 249 | + | if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil { | |||
| 250 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | |||
| 251 | + | } | |||
| 252 | + | case protocol.ErrorMsg: | |||
| 253 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |||
| 254 | + | case protocol.Detached: | |||
| 255 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |||
| 238 | } | 256 | } | ||
| 239 | - | case protocol.ErrorMsg: | 257 | + | case event := <-outgoing: |
| 240 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | 258 | + | if event.input != nil && event.input.Err != nil { |
| 241 | - | case protocol.Detached: | 259 | + | fg.PreserveInput(event.input.Data) |
| 242 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | 260 | + | return w.settle(ctx, fg, stream, token, event.input.Err) |
| 243 | - | default: | 261 | + | } |
| 244 | - | // Messages outside the attached output flow are consumed and | 262 | + | if err := w.send(ctx, fg, stream, event.message); err != nil { |
| 245 | - | // ignored until the stream ends; this slice adds no other handler. | 263 | + | if event.input != nil { |
| 264 | + | fg.PreserveInput(event.input.Data) | |||
| 265 | + | } | |||
| 266 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 267 | + | } | |||
| 268 | + | if event.input != nil { | |||
| 269 | + | fg.AckInput() | |||
| 270 | + | } | |||
| 271 | + | if event.action == AttachmentDetachToPicker { | |||
| 272 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker} | |||
| 273 | + | } | |||
| 274 | + | if event.action == AttachmentDetachAndExit { | |||
| 275 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit} | |||
| 276 | + | } | |||
| 277 | + | case <-ctx.Done(): | |||
| 278 | + | return w.settle(ctx, fg, stream, token, ctx.Err()) | |||
| 279 | + | case <-fg.Done(): | |||
| 280 | + | return w.settle(ctx, fg, stream, token, errAttachmentForegroundRevoked) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | |||
| 286 | + | var sequence uint64 | |||
| 287 | + | for { | |||
| 288 | + | event, ok := fg.Input(ctx) | |||
| 289 | + | if !ok { | |||
| 290 | + | return | |||
| 291 | + | } | |||
| 292 | + | if event.Err != nil { | |||
| 293 | + | select { | |||
| 294 | + | case out <- attachmentClientEvent{input: &event}: | |||
| 295 | + | case <-ctx.Done(): | |||
| 296 | + | fg.PreserveInput(event.Data) | |||
| 297 | + | } | |||
| 298 | + | return | |||
| 299 | + | } | |||
| 300 | + | sequence++ | |||
| 301 | + | copyEvent := event | |||
| 302 | + | select { | |||
| 303 | + | case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, Data: append([]byte(nil), event.Data...)}, input: ©Event}: | |||
| 304 | + | case <-ctx.Done(): | |||
| 305 | + | fg.PreserveInput(event.Data) | |||
| 306 | + | return | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func pumpAttachmentGeometry(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | |||
| 312 | + | for { | |||
| 313 | + | geometry, ok := fg.Resize(ctx) | |||
| 314 | + | if !ok { | |||
| 315 | + | return | |||
| 316 | + | } | |||
| 317 | + | geometry = geometry.NormalizePixels() | |||
| 318 | + | select { | |||
| 319 | + | case out <- attachmentClientEvent{message: protocol.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}}: | |||
| 320 | + | case <-ctx.Done(): | |||
| 321 | + | return | |||
| 246 | } | 322 | } | ||
| 247 | } | 323 | } | ||
| 248 | } | 324 | } | ||
| 249 | 325 | ||||
| 326 | + | func pumpAttachmentLifecycle(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | |||
| 327 | + | action, ok := fg.Lifecycle(ctx) | |||
| 328 | + | if !ok { | |||
| 329 | + | return | |||
| 330 | + | } | |||
| 331 | + | select { | |||
| 332 | + | case out <- attachmentClientEvent{message: protocol.Detach{}, action: action}: | |||
| 333 | + | case <-ctx.Done(): | |||
| 334 | + | return | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 250 | func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error { | 338 | func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error { | ||
| 251 | if welcome.CommittedIdentity != nil { | 339 | if welcome.CommittedIdentity != nil { | ||
| 252 | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | 340 | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | ||
M internal/usecase/client/attachment_worker.go +185 -26
| @@ -157,10 +157,11 @@ type AttachmentForeground interface { | |||
| 157 | 157 | // so a cancelled worker can still save input; it is a no-op once authority is | |
| 158 | 158 | // revoked. | |
| 159 | 159 | PreserveInput(data []byte) | |
| 160 | - | // Resize blocks until the next authorized terminal resize. It returns false | |
| 161 | - | // once authority is revoked or ctx ends. A nil or closed resize source is | |
| 162 | - | // disabled, not an attachment end; Resize waits for cancellation in that case. | |
| 160 | + | // Resize blocks until the latest authorized terminal resize for this | |
| 161 | + | // attachment. It returns false once authority is revoked or ctx ends. | |
| 163 | 162 | Resize(ctx context.Context) (domain.Geometry, bool) | |
| 163 | + | // Lifecycle blocks for one explicit typed lifecycle action. | |
| 164 | + | Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) | |
| 164 | 165 | // Output publishes context and writes and flushes bytes in one authorized | |
| 165 | 166 | // transaction. Missing UI or terminal returns ports.ErrUIUnavailable. | |
| 166 | 167 | Output(uiContext ports.UIContext, data []byte) error | |
| @@ -212,6 +213,18 @@ type attachmentAuthority struct { | |||
| 212 | 213 | state attachmentAuthorityState | |
| 213 | 214 | } | |
| 214 | 215 | ||
| 216 | + | func (a *attachmentAuthority) currentToken() AttachmentToken { | |
| 217 | + | if a == nil { | |
| 218 | + | return AttachmentToken{} | |
| 219 | + | } | |
| 220 | + | a.mu.Lock() | |
| 221 | + | defer a.mu.Unlock() | |
| 222 | + | if a.current == nil { | |
| 223 | + | return AttachmentToken{} | |
| 224 | + | } | |
| 225 | + | return a.current.token | |
| 226 | + | } | |
| 227 | + | ||
| 215 | 228 | // grant installs fg as the sole foreground in the active state. It fails while | |
| 216 | 229 | // another grant is live or while the shared input pump already has a consumer, | |
| 217 | 230 | // which is what keeps exactly one foreground owner. The host never allocates a | |
| @@ -345,6 +358,8 @@ type attachmentHostConfig struct { | |||
| 345 | 358 | // Input is the supervisor-owned single terminal reader. The host claims one | |
| 346 | 359 | // consumer per foreground and revokes it after finalization. Optional. | |
| 347 | 360 | Input *terminalInputPump | |
| 361 | + | // Actions carries explicit lifecycle decisions for the current attachment. | |
| 362 | + | Actions <-chan AttachmentLifecycleAction | |
| 348 | 363 | // UI is the supervisor-owned UI publication transaction. When nil it is | |
| 349 | 364 | // taken from Terminal when that implements ports.UIOutputTransaction. | |
| 350 | 365 | UI ports.UIOutputTransaction | |
| @@ -363,8 +378,18 @@ type attachmentHost struct { | |||
| 363 | 378 | input *terminalInputPump | |
| 364 | 379 | ui ports.UIOutputTransaction | |
| 365 | 380 | resizes <-chan domain.Geometry | |
| 381 | + | actions <-chan AttachmentLifecycleAction | |
| 366 | 382 | onAttach func(AttachmentToken) | |
| 367 | 383 | authority attachmentAuthority | |
| 384 | + | ||
| 385 | + | geometryMu sync.Mutex | |
| 386 | + | geometry domain.Geometry | |
| 387 | + | geometryValid bool | |
| 388 | + | geometrySeq uint64 | |
| 389 | + | geometryUpdate chan struct{} | |
| 390 | + | geometryCancel context.CancelFunc | |
| 391 | + | geometryDone chan struct{} | |
| 392 | + | ownerBoundary bool | |
| 368 | 393 | } | |
| 369 | 394 | ||
| 370 | 395 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | |
| @@ -384,12 +409,14 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||
| 384 | 409 | resizes = cfg.Terminal.ResizeEvents() | |
| 385 | 410 | } | |
| 386 | 411 | return &attachmentHost{ | |
| 387 | - | term: cfg.Terminal, | |
| 388 | - | clock: clock, | |
| 389 | - | input: cfg.Input, | |
| 390 | - | ui: ui, | |
| 391 | - | resizes: resizes, | |
| 392 | - | onAttach: cfg.OnAttached, | |
| 412 | + | term: cfg.Terminal, | |
| 413 | + | clock: clock, | |
| 414 | + | input: cfg.Input, | |
| 415 | + | ui: ui, | |
| 416 | + | resizes: resizes, | |
| 417 | + | actions: cfg.Actions, | |
| 418 | + | onAttach: cfg.OnAttached, | |
| 419 | + | geometryUpdate: make(chan struct{}, 1), | |
| 393 | 420 | } | |
| 394 | 421 | } | |
| 395 | 422 | ||
| @@ -452,6 +479,82 @@ func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker | |||
| 452 | 479 | } | |
| 453 | 480 | ||
| 454 | 481 | // newForeground allocates a candidate; grant claims input before publishing it. | |
| 482 | + | func (h *attachmentHost) setInput(input *terminalInputPump) { | |
| 483 | + | if h == nil { | |
| 484 | + | return | |
| 485 | + | } | |
| 486 | + | h.input = input | |
| 487 | + | h.ownerBoundary = true | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | func (h *attachmentHost) startGeometry(ctx context.Context) { | |
| 491 | + | if h == nil { | |
| 492 | + | return | |
| 493 | + | } | |
| 494 | + | if h.geometryDone != nil { | |
| 495 | + | select { | |
| 496 | + | case <-h.geometryDone: | |
| 497 | + | h.geometryDone = nil | |
| 498 | + | h.geometryCancel = nil | |
| 499 | + | default: | |
| 500 | + | return | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | geometryCtx, cancel := context.WithCancel(ctx) | |
| 504 | + | h.geometryCancel = cancel | |
| 505 | + | h.geometryDone = make(chan struct{}) | |
| 506 | + | go func() { | |
| 507 | + | defer close(h.geometryDone) | |
| 508 | + | for { | |
| 509 | + | select { | |
| 510 | + | case geometry, ok := <-h.resizes: | |
| 511 | + | if !ok { | |
| 512 | + | return | |
| 513 | + | } | |
| 514 | + | if !geometry.Valid() { | |
| 515 | + | continue | |
| 516 | + | } | |
| 517 | + | h.geometryMu.Lock() | |
| 518 | + | h.geometry = geometry.NormalizePixels() | |
| 519 | + | h.geometryValid = true | |
| 520 | + | h.geometrySeq++ | |
| 521 | + | h.geometryMu.Unlock() | |
| 522 | + | select { | |
| 523 | + | case h.geometryUpdate <- struct{}{}: | |
| 524 | + | default: | |
| 525 | + | } | |
| 526 | + | case <-geometryCtx.Done(): | |
| 527 | + | return | |
| 528 | + | } | |
| 529 | + | } | |
| 530 | + | }() | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | func (h *attachmentHost) stopGeometry() { | |
| 534 | + | if h == nil || h.geometryCancel == nil { | |
| 535 | + | return | |
| 536 | + | } | |
| 537 | + | h.geometryCancel() | |
| 538 | + | <-h.geometryDone | |
| 539 | + | h.geometryCancel = nil | |
| 540 | + | h.geometryDone = nil | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | func (h *attachmentHost) latestGeometry() (domain.Geometry, bool) { | |
| 544 | + | if h == nil { | |
| 545 | + | return domain.Geometry{}, false | |
| 546 | + | } | |
| 547 | + | h.geometryMu.Lock() | |
| 548 | + | defer h.geometryMu.Unlock() | |
| 549 | + | return h.geometry, h.geometryValid | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func (h *attachmentHost) geometryAfter(sequence uint64) (domain.Geometry, uint64, bool) { | |
| 553 | + | h.geometryMu.Lock() | |
| 554 | + | defer h.geometryMu.Unlock() | |
| 555 | + | return h.geometry, h.geometrySeq, h.geometryValid && h.geometrySeq > sequence | |
| 556 | + | } | |
| 557 | + | ||
| 455 | 558 | func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground { | |
| 456 | 559 | return &attachmentForeground{ | |
| 457 | 560 | authority: &h.authority, | |
| @@ -462,6 +565,8 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke | |||
| 462 | 565 | term: h.term, | |
| 463 | 566 | ui: h.ui, | |
| 464 | 567 | resizes: h.resizes, | |
| 568 | + | actions: h.actions, | |
| 569 | + | host: h, | |
| 465 | 570 | done: make(chan struct{}), | |
| 466 | 571 | decided: true, | |
| 467 | 572 | onAttached: h.onAttach, | |
| @@ -578,6 +683,8 @@ type attachmentForeground struct { | |||
| 578 | 683 | term ports.Terminal | |
| 579 | 684 | ui ports.UIOutputTransaction | |
| 580 | 685 | resizes <-chan domain.Geometry | |
| 686 | + | actions <-chan AttachmentLifecycleAction | |
| 687 | + | host *attachmentHost | |
| 581 | 688 | done chan struct{} | |
| 582 | 689 | // finalizedOnce and finishedOnce split the single teardown into the | |
| 583 | 690 | // deterministic final input window: finalize closes Done and stops output | |
| @@ -588,10 +695,11 @@ type attachmentForeground struct { | |||
| 588 | 695 | // by marking a freshly received delivery undecided; AckInput or PreserveInput | |
| 589 | 696 | // decides it at most once. decided starts true, so a decision with no | |
| 590 | 697 | // outstanding delivery is refused instead of silently committing nothing. | |
| 591 | - | deliveryMu sync.Mutex | |
| 592 | - | decided bool | |
| 593 | - | attached atomic.Bool | |
| 594 | - | onAttached func(AttachmentToken) | |
| 698 | + | deliveryMu sync.Mutex | |
| 699 | + | decided bool | |
| 700 | + | geometrySeq uint64 | |
| 701 | + | attached atomic.Bool | |
| 702 | + | onAttached func(AttachmentToken) | |
| 595 | 703 | } | |
| 596 | 704 | ||
| 597 | 705 | // Token is the generation/attempt identity of this grant. | |
| @@ -633,7 +741,15 @@ func (f *attachmentForeground) finish() { | |||
| 633 | 741 | if f == nil { | |
| 634 | 742 | return | |
| 635 | 743 | } | |
| 636 | - | f.finishedOnce.Do(func() { f.authority.revoke(f) }) | |
| 744 | + | f.finishedOnce.Do(func() { | |
| 745 | + | // Autonomous supervisor foregrounds have a host-lifetime geometry | |
| 746 | + | // collector. At that owner-class boundary, no attachment byte may reach | |
| 747 | + | // the picker. Isolated legacy worker replacement remains lossless. | |
| 748 | + | if f.input != nil && f.host != nil && f.host.ownerBoundary { | |
| 749 | + | f.input.dropOwned(f.consumer) | |
| 750 | + | } | |
| 751 | + | f.authority.revoke(f) | |
| 752 | + | }) | |
| 637 | 753 | } | |
| 638 | 754 | ||
| 639 | 755 | // beginDelivery opens the decision window for the delivery the worker just | |
| @@ -682,7 +798,7 @@ func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent, | |||
| 682 | 798 | return AttachmentInputEvent{}, false | |
| 683 | 799 | case <-f.done: | |
| 684 | 800 | return AttachmentInputEvent{}, false | |
| 685 | - | case <-f.input.ready: | |
| 801 | + | case <-f.input.readyFor(f.consumer): | |
| 686 | 802 | } | |
| 687 | 803 | } | |
| 688 | 804 | } | |
| @@ -717,29 +833,72 @@ func (f *attachmentForeground) PreserveInput(data []byte) { | |||
| 717 | 833 | } | |
| 718 | 834 | ||
| 719 | 835 | // Resize waits for the next authorized terminal resize. | |
| 720 | - | func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) { | |
| 836 | + | func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) { | |
| 721 | 837 | if ctx == nil { | |
| 722 | 838 | ctx = context.Background() | |
| 723 | 839 | } | |
| 724 | - | if !f.authority.actionAuthorized(f) { | |
| 725 | - | return domain.Geometry{}, false | |
| 726 | - | } | |
| 727 | - | resizes := f.resizes | |
| 728 | 840 | for { | |
| 841 | + | if !f.authority.actionAuthorized(f) { | |
| 842 | + | return 0, false | |
| 843 | + | } | |
| 729 | 844 | select { | |
| 730 | - | case geometry, ok := <-resizes: | |
| 731 | - | if !ok { | |
| 732 | - | resizes = nil | |
| 845 | + | case action, ok := <-f.actions: | |
| 846 | + | if !ok || !f.authority.actionAuthorized(f) { | |
| 847 | + | return 0, false | |
| 848 | + | } | |
| 849 | + | if action.Token != f.token { | |
| 733 | 850 | continue | |
| 734 | - | } // uiterm has no resize source | |
| 735 | - | if !f.authority.actionAuthorized(f) { | |
| 851 | + | } | |
| 852 | + | return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit | |
| 853 | + | case <-ctx.Done(): | |
| 854 | + | return 0, false | |
| 855 | + | case <-f.done: | |
| 856 | + | return 0, false | |
| 857 | + | } | |
| 858 | + | } | |
| 859 | + | } | |
| 860 | + | ||
| 861 | + | func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) { | |
| 862 | + | if ctx == nil { | |
| 863 | + | ctx = context.Background() | |
| 864 | + | } | |
| 865 | + | if !f.authority.actionAuthorized(f) || f.host == nil { | |
| 866 | + | return domain.Geometry{}, false | |
| 867 | + | } | |
| 868 | + | if !f.host.ownerBoundary { | |
| 869 | + | resizes := f.resizes | |
| 870 | + | for { | |
| 871 | + | select { | |
| 872 | + | case geometry, ok := <-resizes: | |
| 873 | + | if !ok { | |
| 874 | + | resizes = nil | |
| 875 | + | continue | |
| 876 | + | } | |
| 877 | + | if !f.authority.actionAuthorized(f) { | |
| 878 | + | return domain.Geometry{}, false | |
| 879 | + | } | |
| 880 | + | return geometry, true | |
| 881 | + | case <-ctx.Done(): | |
| 882 | + | return domain.Geometry{}, false | |
| 883 | + | case <-f.done: | |
| 736 | 884 | return domain.Geometry{}, false | |
| 737 | 885 | } | |
| 738 | - | return geometry, true | |
| 886 | + | } | |
| 887 | + | } | |
| 888 | + | for { | |
| 889 | + | if geometry, next, ok := f.host.geometryAfter(f.geometrySeq); ok { | |
| 890 | + | f.geometrySeq = next | |
| 891 | + | if f.authority.actionAuthorized(f) { | |
| 892 | + | return geometry, true | |
| 893 | + | } | |
| 894 | + | return domain.Geometry{}, false | |
| 895 | + | } | |
| 896 | + | select { | |
| 739 | 897 | case <-ctx.Done(): | |
| 740 | 898 | return domain.Geometry{}, false | |
| 741 | 899 | case <-f.done: | |
| 742 | 900 | return domain.Geometry{}, false | |
| 901 | + | case <-f.host.geometryUpdate: | |
| 743 | 902 | } | |
| 744 | 903 | } | |
| 745 | 904 | } | |
| @@ -157,10 +157,11 @@ type AttachmentForeground interface { | |||||
| 157 | // so a cancelled worker can still save input; it is a no-op once authority is | 157 | // so a cancelled worker can still save input; it is a no-op once authority is | ||
| 158 | // revoked. | 158 | // revoked. | ||
| 159 | PreserveInput(data []byte) | 159 | PreserveInput(data []byte) | ||
| 160 | - | // Resize blocks until the next authorized terminal resize. It returns false | 160 | + | // Resize blocks until the latest authorized terminal resize for this |
| 161 | - | // once authority is revoked or ctx ends. A nil or closed resize source is | 161 | + | // attachment. It returns false once authority is revoked or ctx ends. |
| 162 | - | // disabled, not an attachment end; Resize waits for cancellation in that case. | |||
| 163 | Resize(ctx context.Context) (domain.Geometry, bool) | 162 | Resize(ctx context.Context) (domain.Geometry, bool) | ||
| 163 | + | // Lifecycle blocks for one explicit typed lifecycle action. | |||
| 164 | + | Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) | |||
| 164 | // Output publishes context and writes and flushes bytes in one authorized | 165 | // Output publishes context and writes and flushes bytes in one authorized | ||
| 165 | // transaction. Missing UI or terminal returns ports.ErrUIUnavailable. | 166 | // transaction. Missing UI or terminal returns ports.ErrUIUnavailable. | ||
| 166 | Output(uiContext ports.UIContext, data []byte) error | 167 | Output(uiContext ports.UIContext, data []byte) error | ||
| @@ -212,6 +213,18 @@ type attachmentAuthority struct { | |||||
| 212 | state attachmentAuthorityState | 213 | state attachmentAuthorityState | ||
| 213 | } | 214 | } | ||
| 214 | 215 | ||||
| 216 | + | func (a *attachmentAuthority) currentToken() AttachmentToken { | |||
| 217 | + | if a == nil { | |||
| 218 | + | return AttachmentToken{} | |||
| 219 | + | } | |||
| 220 | + | a.mu.Lock() | |||
| 221 | + | defer a.mu.Unlock() | |||
| 222 | + | if a.current == nil { | |||
| 223 | + | return AttachmentToken{} | |||
| 224 | + | } | |||
| 225 | + | return a.current.token | |||
| 226 | + | } | |||
| 227 | + | ||||
| 215 | // grant installs fg as the sole foreground in the active state. It fails while | 228 | // grant installs fg as the sole foreground in the active state. It fails while | ||
| 216 | // another grant is live or while the shared input pump already has a consumer, | 229 | // another grant is live or while the shared input pump already has a consumer, | ||
| 217 | // which is what keeps exactly one foreground owner. The host never allocates a | 230 | // which is what keeps exactly one foreground owner. The host never allocates a | ||
| @@ -345,6 +358,8 @@ type attachmentHostConfig struct { | |||||
| 345 | // Input is the supervisor-owned single terminal reader. The host claims one | 358 | // Input is the supervisor-owned single terminal reader. The host claims one | ||
| 346 | // consumer per foreground and revokes it after finalization. Optional. | 359 | // consumer per foreground and revokes it after finalization. Optional. | ||
| 347 | Input *terminalInputPump | 360 | Input *terminalInputPump | ||
| 361 | + | // Actions carries explicit lifecycle decisions for the current attachment. | |||
| 362 | + | Actions <-chan AttachmentLifecycleAction | |||
| 348 | // UI is the supervisor-owned UI publication transaction. When nil it is | 363 | // UI is the supervisor-owned UI publication transaction. When nil it is | ||
| 349 | // taken from Terminal when that implements ports.UIOutputTransaction. | 364 | // taken from Terminal when that implements ports.UIOutputTransaction. | ||
| 350 | UI ports.UIOutputTransaction | 365 | UI ports.UIOutputTransaction | ||
| @@ -363,8 +378,18 @@ type attachmentHost struct { | |||||
| 363 | input *terminalInputPump | 378 | input *terminalInputPump | ||
| 364 | ui ports.UIOutputTransaction | 379 | ui ports.UIOutputTransaction | ||
| 365 | resizes <-chan domain.Geometry | 380 | resizes <-chan domain.Geometry | ||
| 381 | + | actions <-chan AttachmentLifecycleAction | |||
| 366 | onAttach func(AttachmentToken) | 382 | onAttach func(AttachmentToken) | ||
| 367 | authority attachmentAuthority | 383 | authority attachmentAuthority | ||
| 384 | + | ||||
| 385 | + | geometryMu sync.Mutex | |||
| 386 | + | geometry domain.Geometry | |||
| 387 | + | geometryValid bool | |||
| 388 | + | geometrySeq uint64 | |||
| 389 | + | geometryUpdate chan struct{} | |||
| 390 | + | geometryCancel context.CancelFunc | |||
| 391 | + | geometryDone chan struct{} | |||
| 392 | + | ownerBoundary bool | |||
| 368 | } | 393 | } | ||
| 369 | 394 | ||||
| 370 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | 395 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | ||
| @@ -384,12 +409,14 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||||
| 384 | resizes = cfg.Terminal.ResizeEvents() | 409 | resizes = cfg.Terminal.ResizeEvents() | ||
| 385 | } | 410 | } | ||
| 386 | return &attachmentHost{ | 411 | return &attachmentHost{ | ||
| 387 | - | term: cfg.Terminal, | 412 | + | term: cfg.Terminal, |
| 388 | - | clock: clock, | 413 | + | clock: clock, |
| 389 | - | input: cfg.Input, | 414 | + | input: cfg.Input, |
| 390 | - | ui: ui, | 415 | + | ui: ui, |
| 391 | - | resizes: resizes, | 416 | + | resizes: resizes, |
| 392 | - | onAttach: cfg.OnAttached, | 417 | + | actions: cfg.Actions, |
| 418 | + | onAttach: cfg.OnAttached, | |||
| 419 | + | geometryUpdate: make(chan struct{}, 1), | |||
| 393 | } | 420 | } | ||
| 394 | } | 421 | } | ||
| 395 | 422 | ||||
| @@ -452,6 +479,82 @@ func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker | |||||
| 452 | } | 479 | } | ||
| 453 | 480 | ||||
| 454 | // newForeground allocates a candidate; grant claims input before publishing it. | 481 | // newForeground allocates a candidate; grant claims input before publishing it. | ||
| 482 | + | func (h *attachmentHost) setInput(input *terminalInputPump) { | |||
| 483 | + | if h == nil { | |||
| 484 | + | return | |||
| 485 | + | } | |||
| 486 | + | h.input = input | |||
| 487 | + | h.ownerBoundary = true | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | func (h *attachmentHost) startGeometry(ctx context.Context) { | |||
| 491 | + | if h == nil { | |||
| 492 | + | return | |||
| 493 | + | } | |||
| 494 | + | if h.geometryDone != nil { | |||
| 495 | + | select { | |||
| 496 | + | case <-h.geometryDone: | |||
| 497 | + | h.geometryDone = nil | |||
| 498 | + | h.geometryCancel = nil | |||
| 499 | + | default: | |||
| 500 | + | return | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | geometryCtx, cancel := context.WithCancel(ctx) | |||
| 504 | + | h.geometryCancel = cancel | |||
| 505 | + | h.geometryDone = make(chan struct{}) | |||
| 506 | + | go func() { | |||
| 507 | + | defer close(h.geometryDone) | |||
| 508 | + | for { | |||
| 509 | + | select { | |||
| 510 | + | case geometry, ok := <-h.resizes: | |||
| 511 | + | if !ok { | |||
| 512 | + | return | |||
| 513 | + | } | |||
| 514 | + | if !geometry.Valid() { | |||
| 515 | + | continue | |||
| 516 | + | } | |||
| 517 | + | h.geometryMu.Lock() | |||
| 518 | + | h.geometry = geometry.NormalizePixels() | |||
| 519 | + | h.geometryValid = true | |||
| 520 | + | h.geometrySeq++ | |||
| 521 | + | h.geometryMu.Unlock() | |||
| 522 | + | select { | |||
| 523 | + | case h.geometryUpdate <- struct{}{}: | |||
| 524 | + | default: | |||
| 525 | + | } | |||
| 526 | + | case <-geometryCtx.Done(): | |||
| 527 | + | return | |||
| 528 | + | } | |||
| 529 | + | } | |||
| 530 | + | }() | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | func (h *attachmentHost) stopGeometry() { | |||
| 534 | + | if h == nil || h.geometryCancel == nil { | |||
| 535 | + | return | |||
| 536 | + | } | |||
| 537 | + | h.geometryCancel() | |||
| 538 | + | <-h.geometryDone | |||
| 539 | + | h.geometryCancel = nil | |||
| 540 | + | h.geometryDone = nil | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func (h *attachmentHost) latestGeometry() (domain.Geometry, bool) { | |||
| 544 | + | if h == nil { | |||
| 545 | + | return domain.Geometry{}, false | |||
| 546 | + | } | |||
| 547 | + | h.geometryMu.Lock() | |||
| 548 | + | defer h.geometryMu.Unlock() | |||
| 549 | + | return h.geometry, h.geometryValid | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func (h *attachmentHost) geometryAfter(sequence uint64) (domain.Geometry, uint64, bool) { | |||
| 553 | + | h.geometryMu.Lock() | |||
| 554 | + | defer h.geometryMu.Unlock() | |||
| 555 | + | return h.geometry, h.geometrySeq, h.geometryValid && h.geometrySeq > sequence | |||
| 556 | + | } | |||
| 557 | + | ||||
| 455 | func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground { | 558 | func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground { | ||
| 456 | return &attachmentForeground{ | 559 | return &attachmentForeground{ | ||
| 457 | authority: &h.authority, | 560 | authority: &h.authority, | ||
| @@ -462,6 +565,8 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke | |||||
| 462 | term: h.term, | 565 | term: h.term, | ||
| 463 | ui: h.ui, | 566 | ui: h.ui, | ||
| 464 | resizes: h.resizes, | 567 | resizes: h.resizes, | ||
| 568 | + | actions: h.actions, | |||
| 569 | + | host: h, | |||
| 465 | done: make(chan struct{}), | 570 | done: make(chan struct{}), | ||
| 466 | decided: true, | 571 | decided: true, | ||
| 467 | onAttached: h.onAttach, | 572 | onAttached: h.onAttach, | ||
| @@ -578,6 +683,8 @@ type attachmentForeground struct { | |||||
| 578 | term ports.Terminal | 683 | term ports.Terminal | ||
| 579 | ui ports.UIOutputTransaction | 684 | ui ports.UIOutputTransaction | ||
| 580 | resizes <-chan domain.Geometry | 685 | resizes <-chan domain.Geometry | ||
| 686 | + | actions <-chan AttachmentLifecycleAction | |||
| 687 | + | host *attachmentHost | |||
| 581 | done chan struct{} | 688 | done chan struct{} | ||
| 582 | // finalizedOnce and finishedOnce split the single teardown into the | 689 | // finalizedOnce and finishedOnce split the single teardown into the | ||
| 583 | // deterministic final input window: finalize closes Done and stops output | 690 | // deterministic final input window: finalize closes Done and stops output | ||
| @@ -588,10 +695,11 @@ type attachmentForeground struct { | |||||
| 588 | // by marking a freshly received delivery undecided; AckInput or PreserveInput | 695 | // by marking a freshly received delivery undecided; AckInput or PreserveInput | ||
| 589 | // decides it at most once. decided starts true, so a decision with no | 696 | // decides it at most once. decided starts true, so a decision with no | ||
| 590 | // outstanding delivery is refused instead of silently committing nothing. | 697 | // outstanding delivery is refused instead of silently committing nothing. | ||
| 591 | - | deliveryMu sync.Mutex | 698 | + | deliveryMu sync.Mutex |
| 592 | - | decided bool | 699 | + | decided bool |
| 593 | - | attached atomic.Bool | 700 | + | geometrySeq uint64 |
| 594 | - | onAttached func(AttachmentToken) | 701 | + | attached atomic.Bool |
| 702 | + | onAttached func(AttachmentToken) | |||
| 595 | } | 703 | } | ||
| 596 | 704 | ||||
| 597 | // Token is the generation/attempt identity of this grant. | 705 | // Token is the generation/attempt identity of this grant. | ||
| @@ -633,7 +741,15 @@ func (f *attachmentForeground) finish() { | |||||
| 633 | if f == nil { | 741 | if f == nil { | ||
| 634 | return | 742 | return | ||
| 635 | } | 743 | } | ||
| 636 | - | f.finishedOnce.Do(func() { f.authority.revoke(f) }) | 744 | + | f.finishedOnce.Do(func() { |
| 745 | + | // Autonomous supervisor foregrounds have a host-lifetime geometry | |||
| 746 | + | // collector. At that owner-class boundary, no attachment byte may reach | |||
| 747 | + | // the picker. Isolated legacy worker replacement remains lossless. | |||
| 748 | + | if f.input != nil && f.host != nil && f.host.ownerBoundary { | |||
| 749 | + | f.input.dropOwned(f.consumer) | |||
| 750 | + | } | |||
| 751 | + | f.authority.revoke(f) | |||
| 752 | + | }) | |||
| 637 | } | 753 | } | ||
| 638 | 754 | ||||
| 639 | // beginDelivery opens the decision window for the delivery the worker just | 755 | // beginDelivery opens the decision window for the delivery the worker just | ||
| @@ -682,7 +798,7 @@ func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent, | |||||
| 682 | return AttachmentInputEvent{}, false | 798 | return AttachmentInputEvent{}, false | ||
| 683 | case <-f.done: | 799 | case <-f.done: | ||
| 684 | return AttachmentInputEvent{}, false | 800 | return AttachmentInputEvent{}, false | ||
| 685 | - | case <-f.input.ready: | 801 | + | case <-f.input.readyFor(f.consumer): |
| 686 | } | 802 | } | ||
| 687 | } | 803 | } | ||
| 688 | } | 804 | } | ||
| @@ -717,29 +833,72 @@ func (f *attachmentForeground) PreserveInput(data []byte) { | |||||
| 717 | } | 833 | } | ||
| 718 | 834 | ||||
| 719 | // Resize waits for the next authorized terminal resize. | 835 | // Resize waits for the next authorized terminal resize. | ||
| 720 | - | func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) { | 836 | + | func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) { |
| 721 | if ctx == nil { | 837 | if ctx == nil { | ||
| 722 | ctx = context.Background() | 838 | ctx = context.Background() | ||
| 723 | } | 839 | } | ||
| 724 | - | if !f.authority.actionAuthorized(f) { | |||
| 725 | - | return domain.Geometry{}, false | |||
| 726 | - | } | |||
| 727 | - | resizes := f.resizes | |||
| 728 | for { | 840 | for { | ||
| 841 | + | if !f.authority.actionAuthorized(f) { | |||
| 842 | + | return 0, false | |||
| 843 | + | } | |||
| 729 | select { | 844 | select { | ||
| 730 | - | case geometry, ok := <-resizes: | 845 | + | case action, ok := <-f.actions: |
| 731 | - | if !ok { | 846 | + | if !ok || !f.authority.actionAuthorized(f) { |
| 732 | - | resizes = nil | 847 | + | return 0, false |
| 848 | + | } | |||
| 849 | + | if action.Token != f.token { | |||
| 733 | continue | 850 | continue | ||
| 734 | - | } // uiterm has no resize source | 851 | + | } |
| 735 | - | if !f.authority.actionAuthorized(f) { | 852 | + | return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit |
| 853 | + | case <-ctx.Done(): | |||
| 854 | + | return 0, false | |||
| 855 | + | case <-f.done: | |||
| 856 | + | return 0, false | |||
| 857 | + | } | |||
| 858 | + | } | |||
| 859 | + | } | |||
| 860 | + | ||||
| 861 | + | func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) { | |||
| 862 | + | if ctx == nil { | |||
| 863 | + | ctx = context.Background() | |||
| 864 | + | } | |||
| 865 | + | if !f.authority.actionAuthorized(f) || f.host == nil { | |||
| 866 | + | return domain.Geometry{}, false | |||
| 867 | + | } | |||
| 868 | + | if !f.host.ownerBoundary { | |||
| 869 | + | resizes := f.resizes | |||
| 870 | + | for { | |||
| 871 | + | select { | |||
| 872 | + | case geometry, ok := <-resizes: | |||
| 873 | + | if !ok { | |||
| 874 | + | resizes = nil | |||
| 875 | + | continue | |||
| 876 | + | } | |||
| 877 | + | if !f.authority.actionAuthorized(f) { | |||
| 878 | + | return domain.Geometry{}, false | |||
| 879 | + | } | |||
| 880 | + | return geometry, true | |||
| 881 | + | case <-ctx.Done(): | |||
| 882 | + | return domain.Geometry{}, false | |||
| 883 | + | case <-f.done: | |||
| 736 | return domain.Geometry{}, false | 884 | return domain.Geometry{}, false | ||
| 737 | } | 885 | } | ||
| 738 | - | return geometry, true | 886 | + | } |
| 887 | + | } | |||
| 888 | + | for { | |||
| 889 | + | if geometry, next, ok := f.host.geometryAfter(f.geometrySeq); ok { | |||
| 890 | + | f.geometrySeq = next | |||
| 891 | + | if f.authority.actionAuthorized(f) { | |||
| 892 | + | return geometry, true | |||
| 893 | + | } | |||
| 894 | + | return domain.Geometry{}, false | |||
| 895 | + | } | |||
| 896 | + | select { | |||
| 739 | case <-ctx.Done(): | 897 | case <-ctx.Done(): | ||
| 740 | return domain.Geometry{}, false | 898 | return domain.Geometry{}, false | ||
| 741 | case <-f.done: | 899 | case <-f.done: | ||
| 742 | return domain.Geometry{}, false | 900 | return domain.Geometry{}, false | ||
| 901 | + | case <-f.host.geometryUpdate: | |||
| 743 | } | 902 | } | ||
| 744 | } | 903 | } | ||
| 745 | } | 904 | } | ||
M internal/usecase/client/client.go +91 -28
| @@ -435,7 +435,7 @@ func (r *Runner) probeKittyDirectGraphics(ctx context.Context, input *terminalIn | |||
| 435 | 435 | case <-timer.C(): | |
| 436 | 436 | replay = append(replay, probe.Finish()...) | |
| 437 | 437 | goto done | |
| 438 | - | case <-input.ready: | |
| 438 | + | case <-input.readyFor(consumer): | |
| 439 | 439 | result, ok := input.take(ctx, consumer) | |
| 440 | 440 | if !ok { | |
| 441 | 441 | if ctx.Err() != nil { | |
| @@ -3627,7 +3627,9 @@ type terminalInputPump struct { | |||
| 3627 | 3627 | activation uint64 | |
| 3628 | 3628 | closed bool | |
| 3629 | 3629 | active bool | |
| 3630 | - | ready chan struct{} | |
| 3630 | + | readyMu sync.Mutex | |
| 3631 | + | ready map[uint64]chan struct{} | |
| 3632 | + | claimChanged chan struct{} | |
| 3631 | 3633 | space chan struct{} | |
| 3632 | 3634 | state chan struct{} | |
| 3633 | 3635 | exited chan struct{} | |
| @@ -3640,15 +3642,16 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||
| 3640 | 3642 | space := make(chan struct{}, 1) | |
| 3641 | 3643 | space <- struct{}{} | |
| 3642 | 3644 | return &terminalInputPump{ | |
| 3643 | - | in: in, | |
| 3644 | - | done: make(chan struct{}), | |
| 3645 | - | automation: make(chan terminalAutomationRequest), | |
| 3646 | - | activation: 1, | |
| 3647 | - | active: true, | |
| 3648 | - | ready: make(chan struct{}, 1), | |
| 3649 | - | space: space, | |
| 3650 | - | state: make(chan struct{}, 1), | |
| 3651 | - | exited: make(chan struct{}), | |
| 3645 | + | in: in, | |
| 3646 | + | done: make(chan struct{}), | |
| 3647 | + | automation: make(chan terminalAutomationRequest), | |
| 3648 | + | activation: 1, | |
| 3649 | + | active: true, | |
| 3650 | + | ready: make(map[uint64]chan struct{}), | |
| 3651 | + | claimChanged: make(chan struct{}, 1), | |
| 3652 | + | space: space, | |
| 3653 | + | state: make(chan struct{}, 1), | |
| 3654 | + | exited: make(chan struct{}), | |
| 3652 | 3655 | } | |
| 3653 | 3656 | } | |
| 3654 | 3657 | ||
| @@ -3665,13 +3668,23 @@ func (p *terminalInputPump) claim() uint64 { | |||
| 3665 | 3668 | // tryClaim lets competing foreground owners decline admission without panicking. | |
| 3666 | 3669 | func (p *terminalInputPump) tryClaim() (uint64, bool) { | |
| 3667 | 3670 | p.mu.Lock() | |
| 3668 | - | defer p.mu.Unlock() | |
| 3669 | 3671 | if p.consumer != 0 { | |
| 3672 | + | p.mu.Unlock() | |
| 3670 | 3673 | return 0, false | |
| 3671 | 3674 | } | |
| 3672 | 3675 | p.nextID++ | |
| 3673 | 3676 | p.consumer = p.nextID | |
| 3674 | - | return p.consumer, true | |
| 3677 | + | consumer := p.consumer | |
| 3678 | + | p.readyMu.Lock() | |
| 3679 | + | p.ready[consumer] = make(chan struct{}, 1) | |
| 3680 | + | p.readyMu.Unlock() | |
| 3681 | + | ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0 | |
| 3682 | + | p.mu.Unlock() | |
| 3683 | + | p.signalClaimChanged() | |
| 3684 | + | if ready { | |
| 3685 | + | p.signalReady(consumer) | |
| 3686 | + | } | |
| 3687 | + | return consumer, true | |
| 3675 | 3688 | } | |
| 3676 | 3689 | ||
| 3677 | 3690 | // revoke invalidates an attempt before its replacement is allowed to claim | |
| @@ -3681,22 +3694,48 @@ func (p *terminalInputPump) revoke(consumer uint64) { | |||
| 3681 | 3694 | if p.consumer == consumer { | |
| 3682 | 3695 | p.consumer = 0 | |
| 3683 | 3696 | } | |
| 3684 | - | // An unacknowledged read was never delivered by this scanner. Leave it | |
| 3685 | - | // pending and make it available to its replacement. | |
| 3686 | 3697 | if p.delivering == consumer { | |
| 3698 | + | // Legacy attach-attempt replacement remains lossless. Autonomous | |
| 3699 | + | // supervisor ownership uses ack-before-picker-handoff and | |
| 3700 | + | // drop-before-attachment-handoff, so bytes cannot cross owner kinds. | |
| 3687 | 3701 | p.delivering = 0 | |
| 3688 | 3702 | p.deliveringResidual = false | |
| 3689 | 3703 | } | |
| 3690 | 3704 | p.mu.Unlock() | |
| 3691 | - | p.signalReady() | |
| 3705 | + | p.readyMu.Lock() | |
| 3706 | + | delete(p.ready, consumer) | |
| 3707 | + | p.readyMu.Unlock() | |
| 3708 | + | p.signalClaimChanged() | |
| 3692 | 3709 | if p.afterRevoke != nil { | |
| 3693 | 3710 | p.afterRevoke() | |
| 3694 | 3711 | } | |
| 3695 | 3712 | } | |
| 3696 | 3713 | ||
| 3697 | - | func (p *terminalInputPump) signalReady() { | |
| 3714 | + | func (p *terminalInputPump) signalClaimChanged() { | |
| 3698 | 3715 | select { | |
| 3699 | - | case p.ready <- struct{}{}: | |
| 3716 | + | case p.claimChanged <- struct{}{}: | |
| 3717 | + | default: | |
| 3718 | + | } | |
| 3719 | + | } | |
| 3720 | + | ||
| 3721 | + | func (p *terminalInputPump) readyFor(consumer uint64) <-chan struct{} { | |
| 3722 | + | p.readyMu.Lock() | |
| 3723 | + | defer p.readyMu.Unlock() | |
| 3724 | + | return p.ready[consumer] | |
| 3725 | + | } | |
| 3726 | + | ||
| 3727 | + | func (p *terminalInputPump) signalReady(consumer uint64) { | |
| 3728 | + | if consumer == 0 { | |
| 3729 | + | return | |
| 3730 | + | } | |
| 3731 | + | p.readyMu.Lock() | |
| 3732 | + | ready := p.ready[consumer] | |
| 3733 | + | p.readyMu.Unlock() | |
| 3734 | + | if ready == nil { | |
| 3735 | + | return | |
| 3736 | + | } | |
| 3737 | + | select { | |
| 3738 | + | case ready <- struct{}{}: | |
| 3700 | 3739 | default: | |
| 3701 | 3740 | } | |
| 3702 | 3741 | } | |
| @@ -3716,8 +3755,9 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64 | |||
| 3716 | 3755 | return false | |
| 3717 | 3756 | } | |
| 3718 | 3757 | p.pending = &result | |
| 3758 | + | consumer := p.consumer | |
| 3719 | 3759 | p.mu.Unlock() | |
| 3720 | - | p.signalReady() | |
| 3760 | + | p.signalReady(consumer) | |
| 3721 | 3761 | return true | |
| 3722 | 3762 | } | |
| 3723 | 3763 | ||
| @@ -3727,11 +3767,7 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64 | |||
| 3727 | 3767 | func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) { | |
| 3728 | 3768 | p.mu.Lock() | |
| 3729 | 3769 | if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 { | |
| 3730 | - | ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0 | |
| 3731 | 3770 | p.mu.Unlock() | |
| 3732 | - | if ready { | |
| 3733 | - | p.signalReady() | |
| 3734 | - | } | |
| 3735 | 3771 | return terminalReadResult{}, false | |
| 3736 | 3772 | } | |
| 3737 | 3773 | if len(p.residual) != 0 { | |
| @@ -3770,6 +3806,33 @@ func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) { | |||
| 3770 | 3806 | ||
| 3771 | 3807 | // ack commits scanner delivery of a leased read and lets the lifecycle reader | |
| 3772 | 3808 | // accept the next result. Only the consumer that holds the lease can ack it. | |
| 3809 | + | // dropOwned disposes every undecided byte at an autonomous owner-class | |
| 3810 | + | // boundary. Picker-queued bytes are dropped before attachment claim, and | |
| 3811 | + | // attachment-queued bytes are dropped before picker claim. No delivery, | |
| 3812 | + | // pending read, or preserved residual may cross between those owner classes. | |
| 3813 | + | // revoke remains lossless for legacy attachment-to-attachment replacement. | |
| 3814 | + | func (p *terminalInputPump) dropOwned(consumer uint64) { | |
| 3815 | + | p.mu.Lock() | |
| 3816 | + | if p.consumer != consumer { | |
| 3817 | + | p.mu.Unlock() | |
| 3818 | + | return | |
| 3819 | + | } | |
| 3820 | + | hadPending := p.pending != nil | |
| 3821 | + | p.pending = nil | |
| 3822 | + | p.residual = nil | |
| 3823 | + | if p.delivering == consumer { | |
| 3824 | + | p.delivering = 0 | |
| 3825 | + | p.deliveringResidual = false | |
| 3826 | + | } | |
| 3827 | + | p.mu.Unlock() | |
| 3828 | + | if hadPending { | |
| 3829 | + | select { | |
| 3830 | + | case p.space <- struct{}{}: | |
| 3831 | + | default: | |
| 3832 | + | } | |
| 3833 | + | } | |
| 3834 | + | } | |
| 3835 | + | ||
| 3773 | 3836 | func (p *terminalInputPump) ack(consumer uint64) { | |
| 3774 | 3837 | p.mu.Lock() | |
| 3775 | 3838 | if p.consumer != consumer || p.delivering != consumer { | |
| @@ -3788,7 +3851,7 @@ func (p *terminalInputPump) ack(consumer uint64) { | |||
| 3788 | 3851 | p.mu.Unlock() | |
| 3789 | 3852 | if wasResidual { | |
| 3790 | 3853 | if more { | |
| 3791 | - | p.signalReady() | |
| 3854 | + | p.signalReady(consumer) | |
| 3792 | 3855 | } | |
| 3793 | 3856 | return | |
| 3794 | 3857 | } | |
| @@ -3799,7 +3862,7 @@ func (p *terminalInputPump) finish() { | |||
| 3799 | 3862 | p.mu.Lock() | |
| 3800 | 3863 | p.closed = true | |
| 3801 | 3864 | p.mu.Unlock() | |
| 3802 | - | p.signalReady() | |
| 3865 | + | p.signalClaimChanged() | |
| 3803 | 3866 | p.signalState() | |
| 3804 | 3867 | } | |
| 3805 | 3868 | ||
| @@ -3893,7 +3956,7 @@ func (p *terminalInputPump) suspend() { | |||
| 3893 | 3956 | } | |
| 3894 | 3957 | } | |
| 3895 | 3958 | p.signalState() | |
| 3896 | - | p.signalReady() | |
| 3959 | + | p.signalClaimChanged() | |
| 3897 | 3960 | } | |
| 3898 | 3961 | ||
| 3899 | 3962 | func (p *terminalInputPump) resume() { | |
| @@ -3915,7 +3978,7 @@ func (p *terminalInputPump) stop() { | |||
| 3915 | 3978 | p.closed = true | |
| 3916 | 3979 | p.mu.Unlock() | |
| 3917 | 3980 | close(p.done) | |
| 3918 | - | p.signalReady() | |
| 3981 | + | p.signalClaimChanged() | |
| 3919 | 3982 | p.signalState() | |
| 3920 | 3983 | }) | |
| 3921 | 3984 | } | |
| @@ -4266,7 +4329,7 @@ func (p *stdinPump) run() { | |||
| 4266 | 4329 | result = request.record | |
| 4267 | 4330 | automated = true | |
| 4268 | 4331 | actionID.Store(result.actionID) | |
| 4269 | - | case <-input.ready: | |
| 4332 | + | case <-input.readyFor(consumer): | |
| 4270 | 4333 | var ok bool | |
| 4271 | 4334 | result, ok = input.take(p.ctx, consumer) | |
| 4272 | 4335 | if !ok { | |
| @@ -435,7 +435,7 @@ func (r *Runner) probeKittyDirectGraphics(ctx context.Context, input *terminalIn | |||||
| 435 | case <-timer.C(): | 435 | case <-timer.C(): | ||
| 436 | replay = append(replay, probe.Finish()...) | 436 | replay = append(replay, probe.Finish()...) | ||
| 437 | goto done | 437 | goto done | ||
| 438 | - | case <-input.ready: | 438 | + | case <-input.readyFor(consumer): |
| 439 | result, ok := input.take(ctx, consumer) | 439 | result, ok := input.take(ctx, consumer) | ||
| 440 | if !ok { | 440 | if !ok { | ||
| 441 | if ctx.Err() != nil { | 441 | if ctx.Err() != nil { | ||
| @@ -3627,7 +3627,9 @@ type terminalInputPump struct { | |||||
| 3627 | activation uint64 | 3627 | activation uint64 | ||
| 3628 | closed bool | 3628 | closed bool | ||
| 3629 | active bool | 3629 | active bool | ||
| 3630 | - | ready chan struct{} | 3630 | + | readyMu sync.Mutex |
| 3631 | + | ready map[uint64]chan struct{} | |||
| 3632 | + | claimChanged chan struct{} | |||
| 3631 | space chan struct{} | 3633 | space chan struct{} | ||
| 3632 | state chan struct{} | 3634 | state chan struct{} | ||
| 3633 | exited chan struct{} | 3635 | exited chan struct{} | ||
| @@ -3640,15 +3642,16 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||||
| 3640 | space := make(chan struct{}, 1) | 3642 | space := make(chan struct{}, 1) | ||
| 3641 | space <- struct{}{} | 3643 | space <- struct{}{} | ||
| 3642 | return &terminalInputPump{ | 3644 | return &terminalInputPump{ | ||
| 3643 | - | in: in, | 3645 | + | in: in, |
| 3644 | - | done: make(chan struct{}), | 3646 | + | done: make(chan struct{}), |
| 3645 | - | automation: make(chan terminalAutomationRequest), | 3647 | + | automation: make(chan terminalAutomationRequest), |
| 3646 | - | activation: 1, | 3648 | + | activation: 1, |
| 3647 | - | active: true, | 3649 | + | active: true, |
| 3648 | - | ready: make(chan struct{}, 1), | 3650 | + | ready: make(map[uint64]chan struct{}), |
| 3649 | - | space: space, | 3651 | + | claimChanged: make(chan struct{}, 1), |
| 3650 | - | state: make(chan struct{}, 1), | 3652 | + | space: space, |
| 3651 | - | exited: make(chan struct{}), | 3653 | + | state: make(chan struct{}, 1), |
| 3654 | + | exited: make(chan struct{}), | |||
| 3652 | } | 3655 | } | ||
| 3653 | } | 3656 | } | ||
| 3654 | 3657 | ||||
| @@ -3665,13 +3668,23 @@ func (p *terminalInputPump) claim() uint64 { | |||||
| 3665 | // tryClaim lets competing foreground owners decline admission without panicking. | 3668 | // tryClaim lets competing foreground owners decline admission without panicking. | ||
| 3666 | func (p *terminalInputPump) tryClaim() (uint64, bool) { | 3669 | func (p *terminalInputPump) tryClaim() (uint64, bool) { | ||
| 3667 | p.mu.Lock() | 3670 | p.mu.Lock() | ||
| 3668 | - | defer p.mu.Unlock() | |||
| 3669 | if p.consumer != 0 { | 3671 | if p.consumer != 0 { | ||
| 3672 | + | p.mu.Unlock() | |||
| 3670 | return 0, false | 3673 | return 0, false | ||
| 3671 | } | 3674 | } | ||
| 3672 | p.nextID++ | 3675 | p.nextID++ | ||
| 3673 | p.consumer = p.nextID | 3676 | p.consumer = p.nextID | ||
| 3674 | - | return p.consumer, true | 3677 | + | consumer := p.consumer |
| 3678 | + | p.readyMu.Lock() | |||
| 3679 | + | p.ready[consumer] = make(chan struct{}, 1) | |||
| 3680 | + | p.readyMu.Unlock() | |||
| 3681 | + | ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0 | |||
| 3682 | + | p.mu.Unlock() | |||
| 3683 | + | p.signalClaimChanged() | |||
| 3684 | + | if ready { | |||
| 3685 | + | p.signalReady(consumer) | |||
| 3686 | + | } | |||
| 3687 | + | return consumer, true | |||
| 3675 | } | 3688 | } | ||
| 3676 | 3689 | ||||
| 3677 | // revoke invalidates an attempt before its replacement is allowed to claim | 3690 | // revoke invalidates an attempt before its replacement is allowed to claim | ||
| @@ -3681,22 +3694,48 @@ func (p *terminalInputPump) revoke(consumer uint64) { | |||||
| 3681 | if p.consumer == consumer { | 3694 | if p.consumer == consumer { | ||
| 3682 | p.consumer = 0 | 3695 | p.consumer = 0 | ||
| 3683 | } | 3696 | } | ||
| 3684 | - | // An unacknowledged read was never delivered by this scanner. Leave it | |||
| 3685 | - | // pending and make it available to its replacement. | |||
| 3686 | if p.delivering == consumer { | 3697 | if p.delivering == consumer { | ||
| 3698 | + | // Legacy attach-attempt replacement remains lossless. Autonomous | |||
| 3699 | + | // supervisor ownership uses ack-before-picker-handoff and | |||
| 3700 | + | // drop-before-attachment-handoff, so bytes cannot cross owner kinds. | |||
| 3687 | p.delivering = 0 | 3701 | p.delivering = 0 | ||
| 3688 | p.deliveringResidual = false | 3702 | p.deliveringResidual = false | ||
| 3689 | } | 3703 | } | ||
| 3690 | p.mu.Unlock() | 3704 | p.mu.Unlock() | ||
| 3691 | - | p.signalReady() | 3705 | + | p.readyMu.Lock() |
| 3706 | + | delete(p.ready, consumer) | |||
| 3707 | + | p.readyMu.Unlock() | |||
| 3708 | + | p.signalClaimChanged() | |||
| 3692 | if p.afterRevoke != nil { | 3709 | if p.afterRevoke != nil { | ||
| 3693 | p.afterRevoke() | 3710 | p.afterRevoke() | ||
| 3694 | } | 3711 | } | ||
| 3695 | } | 3712 | } | ||
| 3696 | 3713 | ||||
| 3697 | - | func (p *terminalInputPump) signalReady() { | 3714 | + | func (p *terminalInputPump) signalClaimChanged() { |
| 3698 | select { | 3715 | select { | ||
| 3699 | - | case p.ready <- struct{}{}: | 3716 | + | case p.claimChanged <- struct{}{}: |
| 3717 | + | default: | |||
| 3718 | + | } | |||
| 3719 | + | } | |||
| 3720 | + | ||||
| 3721 | + | func (p *terminalInputPump) readyFor(consumer uint64) <-chan struct{} { | |||
| 3722 | + | p.readyMu.Lock() | |||
| 3723 | + | defer p.readyMu.Unlock() | |||
| 3724 | + | return p.ready[consumer] | |||
| 3725 | + | } | |||
| 3726 | + | ||||
| 3727 | + | func (p *terminalInputPump) signalReady(consumer uint64) { | |||
| 3728 | + | if consumer == 0 { | |||
| 3729 | + | return | |||
| 3730 | + | } | |||
| 3731 | + | p.readyMu.Lock() | |||
| 3732 | + | ready := p.ready[consumer] | |||
| 3733 | + | p.readyMu.Unlock() | |||
| 3734 | + | if ready == nil { | |||
| 3735 | + | return | |||
| 3736 | + | } | |||
| 3737 | + | select { | |||
| 3738 | + | case ready <- struct{}{}: | |||
| 3700 | default: | 3739 | default: | ||
| 3701 | } | 3740 | } | ||
| 3702 | } | 3741 | } | ||
| @@ -3716,8 +3755,9 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64 | |||||
| 3716 | return false | 3755 | return false | ||
| 3717 | } | 3756 | } | ||
| 3718 | p.pending = &result | 3757 | p.pending = &result | ||
| 3758 | + | consumer := p.consumer | |||
| 3719 | p.mu.Unlock() | 3759 | p.mu.Unlock() | ||
| 3720 | - | p.signalReady() | 3760 | + | p.signalReady(consumer) |
| 3721 | return true | 3761 | return true | ||
| 3722 | } | 3762 | } | ||
| 3723 | 3763 | ||||
| @@ -3727,11 +3767,7 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64 | |||||
| 3727 | func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) { | 3767 | func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) { | ||
| 3728 | p.mu.Lock() | 3768 | p.mu.Lock() | ||
| 3729 | if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 { | 3769 | if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 { | ||
| 3730 | - | ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0 | |||
| 3731 | p.mu.Unlock() | 3770 | p.mu.Unlock() | ||
| 3732 | - | if ready { | |||
| 3733 | - | p.signalReady() | |||
| 3734 | - | } | |||
| 3735 | return terminalReadResult{}, false | 3771 | return terminalReadResult{}, false | ||
| 3736 | } | 3772 | } | ||
| 3737 | if len(p.residual) != 0 { | 3773 | if len(p.residual) != 0 { | ||
| @@ -3770,6 +3806,33 @@ func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) { | |||||
| 3770 | 3806 | ||||
| 3771 | // ack commits scanner delivery of a leased read and lets the lifecycle reader | 3807 | // ack commits scanner delivery of a leased read and lets the lifecycle reader | ||
| 3772 | // accept the next result. Only the consumer that holds the lease can ack it. | 3808 | // accept the next result. Only the consumer that holds the lease can ack it. | ||
| 3809 | + | // dropOwned disposes every undecided byte at an autonomous owner-class | |||
| 3810 | + | // boundary. Picker-queued bytes are dropped before attachment claim, and | |||
| 3811 | + | // attachment-queued bytes are dropped before picker claim. No delivery, | |||
| 3812 | + | // pending read, or preserved residual may cross between those owner classes. | |||
| 3813 | + | // revoke remains lossless for legacy attachment-to-attachment replacement. | |||
| 3814 | + | func (p *terminalInputPump) dropOwned(consumer uint64) { | |||
| 3815 | + | p.mu.Lock() | |||
| 3816 | + | if p.consumer != consumer { | |||
| 3817 | + | p.mu.Unlock() | |||
| 3818 | + | return | |||
| 3819 | + | } | |||
| 3820 | + | hadPending := p.pending != nil | |||
| 3821 | + | p.pending = nil | |||
| 3822 | + | p.residual = nil | |||
| 3823 | + | if p.delivering == consumer { | |||
| 3824 | + | p.delivering = 0 | |||
| 3825 | + | p.deliveringResidual = false | |||
| 3826 | + | } | |||
| 3827 | + | p.mu.Unlock() | |||
| 3828 | + | if hadPending { | |||
| 3829 | + | select { | |||
| 3830 | + | case p.space <- struct{}{}: | |||
| 3831 | + | default: | |||
| 3832 | + | } | |||
| 3833 | + | } | |||
| 3834 | + | } | |||
| 3835 | + | ||||
| 3773 | func (p *terminalInputPump) ack(consumer uint64) { | 3836 | func (p *terminalInputPump) ack(consumer uint64) { | ||
| 3774 | p.mu.Lock() | 3837 | p.mu.Lock() | ||
| 3775 | if p.consumer != consumer || p.delivering != consumer { | 3838 | if p.consumer != consumer || p.delivering != consumer { | ||
| @@ -3788,7 +3851,7 @@ func (p *terminalInputPump) ack(consumer uint64) { | |||||
| 3788 | p.mu.Unlock() | 3851 | p.mu.Unlock() | ||
| 3789 | if wasResidual { | 3852 | if wasResidual { | ||
| 3790 | if more { | 3853 | if more { | ||
| 3791 | - | p.signalReady() | 3854 | + | p.signalReady(consumer) |
| 3792 | } | 3855 | } | ||
| 3793 | return | 3856 | return | ||
| 3794 | } | 3857 | } | ||
| @@ -3799,7 +3862,7 @@ func (p *terminalInputPump) finish() { | |||||
| 3799 | p.mu.Lock() | 3862 | p.mu.Lock() | ||
| 3800 | p.closed = true | 3863 | p.closed = true | ||
| 3801 | p.mu.Unlock() | 3864 | p.mu.Unlock() | ||
| 3802 | - | p.signalReady() | 3865 | + | p.signalClaimChanged() |
| 3803 | p.signalState() | 3866 | p.signalState() | ||
| 3804 | } | 3867 | } | ||
| 3805 | 3868 | ||||
| @@ -3893,7 +3956,7 @@ func (p *terminalInputPump) suspend() { | |||||
| 3893 | } | 3956 | } | ||
| 3894 | } | 3957 | } | ||
| 3895 | p.signalState() | 3958 | p.signalState() | ||
| 3896 | - | p.signalReady() | 3959 | + | p.signalClaimChanged() |
| 3897 | } | 3960 | } | ||
| 3898 | 3961 | ||||
| 3899 | func (p *terminalInputPump) resume() { | 3962 | func (p *terminalInputPump) resume() { | ||
| @@ -3915,7 +3978,7 @@ func (p *terminalInputPump) stop() { | |||||
| 3915 | p.closed = true | 3978 | p.closed = true | ||
| 3916 | p.mu.Unlock() | 3979 | p.mu.Unlock() | ||
| 3917 | close(p.done) | 3980 | close(p.done) | ||
| 3918 | - | p.signalReady() | 3981 | + | p.signalClaimChanged() |
| 3919 | p.signalState() | 3982 | p.signalState() | ||
| 3920 | }) | 3983 | }) | ||
| 3921 | } | 3984 | } | ||
| @@ -4266,7 +4329,7 @@ func (p *stdinPump) run() { | |||||
| 4266 | result = request.record | 4329 | result = request.record | ||
| 4267 | automated = true | 4330 | automated = true | ||
| 4268 | actionID.Store(result.actionID) | 4331 | actionID.Store(result.actionID) | ||
| 4269 | - | case <-input.ready: | 4332 | + | case <-input.readyFor(consumer): |
| 4270 | var ok bool | 4333 | var ok bool | ||
| 4271 | result, ok = input.take(p.ctx, consumer) | 4334 | result, ok = input.take(p.ctx, consumer) | ||
| 4272 | if !ok { | 4335 | if !ok { | ||
A internal/usecase/client/lifecycle_p54a_test.go +444 -0
| @@ -0,0 +1,444 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestP54LifecycleActionsAreDistinct(t *testing.T) { | |
| 18 | + | for _, tc := range []struct { | |
| 19 | + | name string | |
| 20 | + | action AttachmentLifecycleActionKind | |
| 21 | + | wantNotice LifecycleNoticeKind | |
| 22 | + | wantExit bool | |
| 23 | + | }{ | |
| 24 | + | {name: "detach to picker", action: AttachmentDetachToPicker, wantNotice: LifecycleNoticeDetachToPicker}, | |
| 25 | + | {name: "detach and exit", action: AttachmentDetachAndExit, wantNotice: LifecycleNoticeDetachAndExit, wantExit: true}, | |
| 26 | + | } { | |
| 27 | + | t.Run(tc.name, func(t *testing.T) { | |
| 28 | + | actions := make(chan AttachmentLifecycleAction, 1) | |
| 29 | + | notices := make(chan LifecycleNotice, 4) | |
| 30 | + | picker := newAttachTestPicker() | |
| 31 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |
| 32 | + | cfg.LifecycleActions = actions | |
| 33 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |
| 34 | + | }) | |
| 35 | + | stream := newSessionTestStream() | |
| 36 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 37 | + | return stream, nil | |
| 38 | + | }) | |
| 39 | + | picker.commit(sessionTestRequest(true)) | |
| 40 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |
| 41 | + | deliverReadyStream(t, stream) | |
| 42 | + | awaitAttachedState(t, harness.sup) | |
| 43 | + | actions <- AttachmentLifecycleAction{Token: harness.sup.attachments.authority.currentToken(), Kind: tc.action} | |
| 44 | + | require.Equal(t, tc.wantNotice, (<-notices).Kind) | |
| 45 | + | if tc.wantExit { | |
| 46 | + | require.NoError(t, harness.waitRun(t)) | |
| 47 | + | } else { | |
| 48 | + | awaitPickerState(t, harness.sup) | |
| 49 | + | require.True(t, picker.owns()) | |
| 50 | + | require.Len(t, harness.service.openedRequests(), 1, "detach must not auto-reattach") | |
| 51 | + | } | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestP54AttachmentOutcomesReturnToPicker(t *testing.T) { | |
| 57 | + | for _, tc := range []struct { | |
| 58 | + | name string | |
| 59 | + | settle func(*sessionTestStream) | |
| 60 | + | notice LifecycleNoticeKind | |
| 61 | + | }{ | |
| 62 | + | {name: "session termination", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonDetach}) }, notice: LifecycleNoticeSessionEnded}, | |
| 63 | + | {name: "session deletion", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonSessionKilled}) }, notice: LifecycleNoticeSessionEnded}, | |
| 64 | + | {name: "destination failure", settle: func(s *sessionTestStream) { | |
| 65 | + | s.fail(ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("private diagnostic")}) | |
| 66 | + | }, notice: LifecycleNoticeDestinationFailed}, | |
| 67 | + | } { | |
| 68 | + | t.Run(tc.name, func(t *testing.T) { | |
| 69 | + | picker := newAttachTestPicker() | |
| 70 | + | notices := make(chan LifecycleNotice, 4) | |
| 71 | + | harness := startAttachHarness(t, picker) | |
| 72 | + | harness.sup.cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |
| 73 | + | stream := newSessionTestStream() | |
| 74 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 75 | + | return stream, nil | |
| 76 | + | }) | |
| 77 | + | picker.commit(sessionTestRequest(true)) | |
| 78 | + | require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond) | |
| 79 | + | deliverReadyStream(t, stream) | |
| 80 | + | awaitAttachedState(t, harness.sup) | |
| 81 | + | tc.settle(stream) | |
| 82 | + | awaitPickerState(t, harness.sup) | |
| 83 | + | require.Equal(t, tc.notice, (<-notices).Kind) | |
| 84 | + | require.Len(t, harness.service.openedRequests(), 1) | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestP54ResolveInitialUsesRealCatalogueCreation(t *testing.T) { | |
| 90 | + | clock := newSupervisorTestClock() | |
| 91 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 92 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now())}})) | |
| 93 | + | controller := &pickerController{catalogue: catalogue} | |
| 94 | + | request, err := controller.ResolveInitial(InitialNavigationCreateEphemeral, pickerTestBase()) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission) | |
| 97 | + | require.True(t, request.Local) | |
| 98 | + | require.Empty(t, request.Name) | |
| 99 | + | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestP54InitialNavigationCreatesEphemeralThroughBroker(t *testing.T) { | |
| 103 | + | picker := newAttachTestPicker() | |
| 104 | + | navigation := InitialNavigationCreateEphemeral | |
| 105 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 106 | + | defer cancel() | |
| 107 | + | clock := newSupervisorTestClock() | |
| 108 | + | reader := newAttachTestReader() | |
| 109 | + | terminal := &attachTestTerminal{in: reader} | |
| 110 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 111 | + | service.publish(3, 1) | |
| 112 | + | stream := newSessionTestStream() | |
| 113 | + | service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 114 | + | return stream, nil | |
| 115 | + | }) | |
| 116 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation}) | |
| 117 | + | done := make(chan error, 1) | |
| 118 | + | go func() { done <- sup.Run(ctx) }() | |
| 119 | + | require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |
| 120 | + | request := service.openedRequests()[0] | |
| 121 | + | require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission) | |
| 122 | + | require.Equal(t, sessionTestRequest(true).Local, request.Local) | |
| 123 | + | require.Equal(t, 1, picker.resolveCount()) | |
| 124 | + | stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"}) | |
| 125 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond) | |
| 126 | + | cancel() | |
| 127 | + | require.ErrorIs(t, <-done, context.Canceled) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestP54InitialNavigationIsNotReplayedAfterReconnect(t *testing.T) { | |
| 131 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 132 | + | defer cancel() | |
| 133 | + | clock := newSupervisorTestClock() | |
| 134 | + | reader := newAttachTestReader() | |
| 135 | + | terminal := &attachTestTerminal{in: reader} | |
| 136 | + | picker := newAttachTestPicker() | |
| 137 | + | navigation := InitialNavigationCreateEphemeral | |
| 138 | + | first := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 139 | + | first.publish(1, 1) | |
| 140 | + | second := newSupervisorTestService(ports.BrokerConnectionID{2}) | |
| 141 | + | stream := newSessionTestStream() | |
| 142 | + | first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 143 | + | return stream, nil | |
| 144 | + | }) | |
| 145 | + | connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) { | |
| 146 | + | if call == 1 { | |
| 147 | + | return first, nil | |
| 148 | + | } | |
| 149 | + | return second, nil | |
| 150 | + | }) | |
| 151 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation}) | |
| 152 | + | done := make(chan error, 1) | |
| 153 | + | go func() { done <- sup.Run(ctx) }() | |
| 154 | + | require.Equal(t, 1, connector.awaitStart(t)) | |
| 155 | + | require.Eventually(t, func() bool { return len(first.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |
| 156 | + | stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"}) | |
| 157 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond) | |
| 158 | + | first.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable}) | |
| 159 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |
| 160 | + | for connector.callCount() < 2 { | |
| 161 | + | clock.mu.Lock() | |
| 162 | + | var timer *supervisorTestTimer | |
| 163 | + | if len(clock.timers) != 0 { | |
| 164 | + | timer = clock.timers[len(clock.timers)-1] | |
| 165 | + | } | |
| 166 | + | clock.mu.Unlock() | |
| 167 | + | if timer != nil { | |
| 168 | + | timer.fire() | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | second.publish(2, 1) | |
| 172 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 173 | + | require.Empty(t, second.openedRequests(), "one-shot navigation must not replay on the replacement generation") | |
| 174 | + | cancel() | |
| 175 | + | require.ErrorIs(t, <-done, context.Canceled) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestP54SingleReaderDeliversAttachedByteAfterRealSupervisorHandoff(t *testing.T) { | |
| 179 | + | picker := newAttachTestPicker() | |
| 180 | + | harness := startAttachHarness(t, picker) | |
| 181 | + | stream := newSessionTestStream() | |
| 182 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 183 | + | return stream, nil | |
| 184 | + | }) | |
| 185 | + | picker.commit(sessionTestRequest(true)) | |
| 186 | + | require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond) | |
| 187 | + | deliverReadyStream(t, stream) | |
| 188 | + | awaitAttachedState(t, harness.sup) | |
| 189 | + | harness.reader.push([]byte("x")) | |
| 190 | + | require.Eventually(t, func() bool { | |
| 191 | + | for _, message := range stream.messages() { | |
| 192 | + | if input, ok := message.(protocol.Input); ok && string(input.Data) == "x" { | |
| 193 | + | return true | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | return false | |
| 197 | + | }, 5*time.Second, time.Millisecond, "one typed byte must survive the picker-to-attachment claim handoff") | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestP54BrokerRestartDoesNotReattachOrReplayUnknownMutation(t *testing.T) { | |
| 201 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 202 | + | defer cancel() | |
| 203 | + | clock := newSupervisorTestClock() | |
| 204 | + | reader := newSupervisorTestReader() | |
| 205 | + | t.Cleanup(reader.unblock) | |
| 206 | + | first := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 207 | + | first.publish(1, 1) | |
| 208 | + | second := newSupervisorTestService(ports.BrokerConnectionID{2}) | |
| 209 | + | connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) { | |
| 210 | + | if call == 1 { | |
| 211 | + | return first, nil | |
| 212 | + | } | |
| 213 | + | return second, nil | |
| 214 | + | }) | |
| 215 | + | notices := make(chan LifecycleNotice, 4) | |
| 216 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: newSupervisorTestTerminal(reader), Clock: clock, Jitter: func() float64 { return 0 }, NotifyLifecycle: func(n LifecycleNotice) { notices <- n }}) | |
| 217 | + | done := make(chan error, 1) | |
| 218 | + | go func() { done <- sup.Run(ctx) }() | |
| 219 | + | require.Equal(t, 1, connector.awaitStart(t)) | |
| 220 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 221 | + | first.lose(ports.BrokerError{Code: ports.BrokerErrorOutcomeUnknown, Text: "reply lost after mutation"}) | |
| 222 | + | require.Equal(t, LifecycleNoticeBrokerLost, (<-notices).Kind) | |
| 223 | + | retry := clock.awaitTimer(t) | |
| 224 | + | retry.fire() | |
| 225 | + | require.Equal(t, 2, connector.awaitStart(t)) | |
| 226 | + | second.publish(2, 1) | |
| 227 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 228 | + | require.Equal(t, LifecycleNoticeBrokerReconnected, (<-notices).Kind) | |
| 229 | + | require.Empty(t, first.openedRequests()) | |
| 230 | + | require.Empty(t, second.openedRequests(), "restart must not auto-reattach or replay a mutation") | |
| 231 | + | cancel() | |
| 232 | + | require.ErrorIs(t, <-done, context.Canceled) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestP54ReadyMapPrunedAcrossClaims(t *testing.T) { | |
| 236 | + | pump := newTerminalInputPump(nil) | |
| 237 | + | for i := 0; i < 100; i++ { | |
| 238 | + | consumer := pump.claim() | |
| 239 | + | pump.revoke(consumer) | |
| 240 | + | } | |
| 241 | + | pump.readyMu.Lock() | |
| 242 | + | defer pump.readyMu.Unlock() | |
| 243 | + | require.Empty(t, pump.ready, "revoked monotonic claim IDs must not retain readiness channels") | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // TestP54PickerCommittedReadIsNotReplayedToAttachment proves a picker read the | |
| 247 | + | // user already acted on is committed at the handoff and never re-delivered as | |
| 248 | + | // session input. | |
| 249 | + | func TestP54PickerCommittedReadIsNotReplayedToAttachment(t *testing.T) { | |
| 250 | + | reader := newAttachTestReader() | |
| 251 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |
| 252 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |
| 253 | + | defer lifetime.stop() | |
| 254 | + | reader.push([]byte("1\r")) | |
| 255 | + | var committed []byte | |
| 256 | + | require.Eventually(t, func() bool { | |
| 257 | + | select { | |
| 258 | + | case committed = <-consumer.reads: | |
| 259 | + | return true | |
| 260 | + | default: | |
| 261 | + | return false | |
| 262 | + | } | |
| 263 | + | }, 5*time.Second, time.Millisecond) | |
| 264 | + | require.Equal(t, []byte("1\r"), committed) | |
| 265 | + | lifetime.releasePicker() | |
| 266 | + | attachment := lifetime.pump.claim() | |
| 267 | + | _, ok := lifetime.pump.take(context.Background(), attachment) | |
| 268 | + | require.False(t, ok, "committed picker read must not cross the handoff") | |
| 269 | + | lifetime.pump.revoke(attachment) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestP54AttachmentFinalizeDropsUnleasedPendingBeforePicker(t *testing.T) { | |
| 273 | + | reader := newAttachTestReader() | |
| 274 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |
| 275 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |
| 276 | + | defer lifetime.stop() | |
| 277 | + | lifetime.releasePicker() | |
| 278 | + | host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump}) | |
| 279 | + | host.ownerBoundary = true | |
| 280 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |
| 281 | + | require.True(t, host.authority.grant(fg)) | |
| 282 | + | reader.push([]byte("ls\r")) | |
| 283 | + | require.Eventually(t, func() bool { | |
| 284 | + | lifetime.pump.mu.Lock() | |
| 285 | + | defer lifetime.pump.mu.Unlock() | |
| 286 | + | return lifetime.pump.pending != nil && lifetime.pump.delivering == 0 | |
| 287 | + | }, 5*time.Second, time.Millisecond) | |
| 288 | + | fg.finish() | |
| 289 | + | lifetime.acquirePicker() | |
| 290 | + | assertNoPickerRead(t, consumer.reads) | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestP54AttachmentFinalizeDropsPreservedResidualBeforePicker(t *testing.T) { | |
| 294 | + | reader := newAttachTestReader() | |
| 295 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |
| 296 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |
| 297 | + | defer lifetime.stop() | |
| 298 | + | lifetime.releasePicker() | |
| 299 | + | host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump}) | |
| 300 | + | host.ownerBoundary = true | |
| 301 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |
| 302 | + | require.True(t, host.authority.grant(fg)) | |
| 303 | + | reader.push([]byte("\r")) | |
| 304 | + | event, ok := fg.Input(context.Background()) | |
| 305 | + | require.True(t, ok) | |
| 306 | + | fg.PreserveInput(event.Data) | |
| 307 | + | fg.finish() | |
| 308 | + | lifetime.acquirePicker() | |
| 309 | + | assertNoPickerRead(t, consumer.reads) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func assertNoPickerRead(t *testing.T, reads <-chan []byte) { | |
| 313 | + | t.Helper() | |
| 314 | + | select { | |
| 315 | + | case got := <-reads: | |
| 316 | + | t.Fatalf("attachment bytes crossed into picker: %q", got) | |
| 317 | + | case <-time.After(100 * time.Millisecond): | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestP54UndecidedAttachmentReadIsDroppedBeforePicker(t *testing.T) { | |
| 322 | + | reader := newAttachTestReader() | |
| 323 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |
| 324 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |
| 325 | + | defer lifetime.stop() | |
| 326 | + | lifetime.releasePicker() | |
| 327 | + | attachment := lifetime.pump.claim() | |
| 328 | + | reader.push([]byte("ls\r")) | |
| 329 | + | var delivery terminalReadResult | |
| 330 | + | require.Eventually(t, func() bool { | |
| 331 | + | var ok bool | |
| 332 | + | delivery, ok = lifetime.pump.take(context.Background(), attachment) | |
| 333 | + | return ok | |
| 334 | + | }, 5*time.Second, time.Millisecond) | |
| 335 | + | require.Equal(t, []byte("ls\r"), delivery.data) | |
| 336 | + | lifetime.pump.dropOwned(attachment) | |
| 337 | + | lifetime.pump.revoke(attachment) | |
| 338 | + | lifetime.acquirePicker() | |
| 339 | + | select { | |
| 340 | + | case got := <-consumer.reads: | |
| 341 | + | t.Fatalf("attachment bytes crossed into picker: %q", got) | |
| 342 | + | case <-time.After(100 * time.Millisecond): | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | type recordingPickerConsumer struct{ reads chan []byte } | |
| 347 | + | ||
| 348 | + | func (c *recordingPickerConsumer) ConsumeTerminalRead(data []byte) bool { | |
| 349 | + | c.reads <- append([]byte(nil), data...) | |
| 350 | + | return true | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestP54InputDecisionProtocol(t *testing.T) { | |
| 354 | + | pump := newTerminalInputPump(nil) | |
| 355 | + | <-pump.space | |
| 356 | + | host := newAttachmentHost(attachmentHostConfig{Input: pump}) | |
| 357 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |
| 358 | + | require.True(t, host.authority.grant(fg)) | |
| 359 | + | pump.mu.Lock() | |
| 360 | + | pump.pending = &terminalReadResult{data: []byte("once")} | |
| 361 | + | pump.mu.Unlock() | |
| 362 | + | pump.signalReady(fg.consumer) | |
| 363 | + | event, ok := fg.Input(context.Background()) | |
| 364 | + | require.True(t, ok) | |
| 365 | + | require.Equal(t, []byte("once"), event.Data) | |
| 366 | + | fg.PreserveInput(event.Data) | |
| 367 | + | fg.AckInput() // refused: already decided | |
| 368 | + | host.authority.revoke(fg) | |
| 369 | + | next := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream()) | |
| 370 | + | require.True(t, host.authority.grant(next)) | |
| 371 | + | replayed, ok := next.Input(context.Background()) | |
| 372 | + | require.True(t, ok) | |
| 373 | + | require.Equal(t, []byte("once"), replayed.Data) | |
| 374 | + | next.AckInput() | |
| 375 | + | next.PreserveInput([]byte("duplicate")) // refused: no outstanding delivery | |
| 376 | + | require.Empty(t, pump.residual) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func TestP54GeometryIsPerAttachmentAndCarriesAcrossPicker(t *testing.T) { | |
| 380 | + | resizes := make(chan domain.Geometry, 4) | |
| 381 | + | host := newAttachmentHost(attachmentHostConfig{}) | |
| 382 | + | host.resizes = resizes | |
| 383 | + | host.ownerBoundary = true | |
| 384 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 385 | + | defer cancel() | |
| 386 | + | host.startGeometry(ctx) | |
| 387 | + | defer host.stopGeometry() | |
| 388 | + | first := domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}} | |
| 389 | + | latest := domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}} | |
| 390 | + | resizes <- first | |
| 391 | + | resizes <- latest | |
| 392 | + | require.Eventually(t, func() bool { geometry, ok := host.latestGeometry(); return ok && geometry.Size == latest.Size }, 5*time.Second, time.Millisecond) | |
| 393 | + | fg1 := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |
| 394 | + | require.True(t, host.authority.grant(fg1)) | |
| 395 | + | geometry, ok := fg1.Resize(context.Background()) | |
| 396 | + | require.True(t, ok) | |
| 397 | + | require.Equal(t, latest.Size, geometry.Size) | |
| 398 | + | host.authority.revoke(fg1) | |
| 399 | + | fg2 := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream()) | |
| 400 | + | require.True(t, host.authority.grant(fg2)) | |
| 401 | + | geometry, ok = fg2.Resize(context.Background()) | |
| 402 | + | require.True(t, ok) | |
| 403 | + | require.Equal(t, latest.Size, geometry.Size) | |
| 404 | + | _, stale := fg1.Resize(context.Background()) | |
| 405 | + | require.False(t, stale) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func TestP54LifecycleSkipsStaleTokenAndDeliversLiveDetach(t *testing.T) { | |
| 409 | + | actions := make(chan AttachmentLifecycleAction, 2) | |
| 410 | + | host := newAttachmentHost(attachmentHostConfig{Actions: actions}) | |
| 411 | + | fg := host.newForeground(AttachmentToken{Generation: 2, Attempt: 3}, newSessionTestStream()) | |
| 412 | + | require.True(t, host.authority.grant(fg)) | |
| 413 | + | actions <- AttachmentLifecycleAction{Token: AttachmentToken{Generation: 1, Attempt: 9}, Kind: AttachmentDetachAndExit} | |
| 414 | + | actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker} | |
| 415 | + | action, ok := fg.Lifecycle(context.Background()) | |
| 416 | + | require.True(t, ok) | |
| 417 | + | require.Equal(t, AttachmentDetachToPicker, action) | |
| 418 | + | fg.finish() | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func TestP54LifecycleInterleavingsRace(t *testing.T) { | |
| 422 | + | for i := 0; i < 50; i++ { | |
| 423 | + | pump := newTerminalInputPump(nil) | |
| 424 | + | resizes := make(chan domain.Geometry, 1) | |
| 425 | + | actions := make(chan AttachmentLifecycleAction, 1) | |
| 426 | + | host := newAttachmentHost(attachmentHostConfig{Input: pump, Actions: actions}) | |
| 427 | + | host.resizes = resizes | |
| 428 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 429 | + | host.startGeometry(ctx) | |
| 430 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |
| 431 | + | require.True(t, host.authority.grant(fg)) | |
| 432 | + | var wg sync.WaitGroup | |
| 433 | + | wg.Add(3) | |
| 434 | + | go func() { defer wg.Done(); resizes <- domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}} }() | |
| 435 | + | go func() { | |
| 436 | + | defer wg.Done() | |
| 437 | + | actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker} | |
| 438 | + | }() | |
| 439 | + | go func() { defer wg.Done(); fg.finish() }() | |
| 440 | + | wg.Wait() | |
| 441 | + | cancel() | |
| 442 | + | host.stopGeometry() | |
| 443 | + | } | |
| 444 | + | } | |
| @@ -0,0 +1,444 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/internal/protocol" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestP54LifecycleActionsAreDistinct(t *testing.T) { | |||
| 18 | + | for _, tc := range []struct { | |||
| 19 | + | name string | |||
| 20 | + | action AttachmentLifecycleActionKind | |||
| 21 | + | wantNotice LifecycleNoticeKind | |||
| 22 | + | wantExit bool | |||
| 23 | + | }{ | |||
| 24 | + | {name: "detach to picker", action: AttachmentDetachToPicker, wantNotice: LifecycleNoticeDetachToPicker}, | |||
| 25 | + | {name: "detach and exit", action: AttachmentDetachAndExit, wantNotice: LifecycleNoticeDetachAndExit, wantExit: true}, | |||
| 26 | + | } { | |||
| 27 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 28 | + | actions := make(chan AttachmentLifecycleAction, 1) | |||
| 29 | + | notices := make(chan LifecycleNotice, 4) | |||
| 30 | + | picker := newAttachTestPicker() | |||
| 31 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |||
| 32 | + | cfg.LifecycleActions = actions | |||
| 33 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |||
| 34 | + | }) | |||
| 35 | + | stream := newSessionTestStream() | |||
| 36 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 37 | + | return stream, nil | |||
| 38 | + | }) | |||
| 39 | + | picker.commit(sessionTestRequest(true)) | |||
| 40 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |||
| 41 | + | deliverReadyStream(t, stream) | |||
| 42 | + | awaitAttachedState(t, harness.sup) | |||
| 43 | + | actions <- AttachmentLifecycleAction{Token: harness.sup.attachments.authority.currentToken(), Kind: tc.action} | |||
| 44 | + | require.Equal(t, tc.wantNotice, (<-notices).Kind) | |||
| 45 | + | if tc.wantExit { | |||
| 46 | + | require.NoError(t, harness.waitRun(t)) | |||
| 47 | + | } else { | |||
| 48 | + | awaitPickerState(t, harness.sup) | |||
| 49 | + | require.True(t, picker.owns()) | |||
| 50 | + | require.Len(t, harness.service.openedRequests(), 1, "detach must not auto-reattach") | |||
| 51 | + | } | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestP54AttachmentOutcomesReturnToPicker(t *testing.T) { | |||
| 57 | + | for _, tc := range []struct { | |||
| 58 | + | name string | |||
| 59 | + | settle func(*sessionTestStream) | |||
| 60 | + | notice LifecycleNoticeKind | |||
| 61 | + | }{ | |||
| 62 | + | {name: "session termination", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonDetach}) }, notice: LifecycleNoticeSessionEnded}, | |||
| 63 | + | {name: "session deletion", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonSessionKilled}) }, notice: LifecycleNoticeSessionEnded}, | |||
| 64 | + | {name: "destination failure", settle: func(s *sessionTestStream) { | |||
| 65 | + | s.fail(ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("private diagnostic")}) | |||
| 66 | + | }, notice: LifecycleNoticeDestinationFailed}, | |||
| 67 | + | } { | |||
| 68 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 69 | + | picker := newAttachTestPicker() | |||
| 70 | + | notices := make(chan LifecycleNotice, 4) | |||
| 71 | + | harness := startAttachHarness(t, picker) | |||
| 72 | + | harness.sup.cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |||
| 73 | + | stream := newSessionTestStream() | |||
| 74 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 75 | + | return stream, nil | |||
| 76 | + | }) | |||
| 77 | + | picker.commit(sessionTestRequest(true)) | |||
| 78 | + | require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond) | |||
| 79 | + | deliverReadyStream(t, stream) | |||
| 80 | + | awaitAttachedState(t, harness.sup) | |||
| 81 | + | tc.settle(stream) | |||
| 82 | + | awaitPickerState(t, harness.sup) | |||
| 83 | + | require.Equal(t, tc.notice, (<-notices).Kind) | |||
| 84 | + | require.Len(t, harness.service.openedRequests(), 1) | |||
| 85 | + | }) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestP54ResolveInitialUsesRealCatalogueCreation(t *testing.T) { | |||
| 90 | + | clock := newSupervisorTestClock() | |||
| 91 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 92 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now())}})) | |||
| 93 | + | controller := &pickerController{catalogue: catalogue} | |||
| 94 | + | request, err := controller.ResolveInitial(InitialNavigationCreateEphemeral, pickerTestBase()) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission) | |||
| 97 | + | require.True(t, request.Local) | |||
| 98 | + | require.Empty(t, request.Name) | |||
| 99 | + | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestP54InitialNavigationCreatesEphemeralThroughBroker(t *testing.T) { | |||
| 103 | + | picker := newAttachTestPicker() | |||
| 104 | + | navigation := InitialNavigationCreateEphemeral | |||
| 105 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 106 | + | defer cancel() | |||
| 107 | + | clock := newSupervisorTestClock() | |||
| 108 | + | reader := newAttachTestReader() | |||
| 109 | + | terminal := &attachTestTerminal{in: reader} | |||
| 110 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 111 | + | service.publish(3, 1) | |||
| 112 | + | stream := newSessionTestStream() | |||
| 113 | + | service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 114 | + | return stream, nil | |||
| 115 | + | }) | |||
| 116 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation}) | |||
| 117 | + | done := make(chan error, 1) | |||
| 118 | + | go func() { done <- sup.Run(ctx) }() | |||
| 119 | + | require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |||
| 120 | + | request := service.openedRequests()[0] | |||
| 121 | + | require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission) | |||
| 122 | + | require.Equal(t, sessionTestRequest(true).Local, request.Local) | |||
| 123 | + | require.Equal(t, 1, picker.resolveCount()) | |||
| 124 | + | stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"}) | |||
| 125 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond) | |||
| 126 | + | cancel() | |||
| 127 | + | require.ErrorIs(t, <-done, context.Canceled) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestP54InitialNavigationIsNotReplayedAfterReconnect(t *testing.T) { | |||
| 131 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 132 | + | defer cancel() | |||
| 133 | + | clock := newSupervisorTestClock() | |||
| 134 | + | reader := newAttachTestReader() | |||
| 135 | + | terminal := &attachTestTerminal{in: reader} | |||
| 136 | + | picker := newAttachTestPicker() | |||
| 137 | + | navigation := InitialNavigationCreateEphemeral | |||
| 138 | + | first := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 139 | + | first.publish(1, 1) | |||
| 140 | + | second := newSupervisorTestService(ports.BrokerConnectionID{2}) | |||
| 141 | + | stream := newSessionTestStream() | |||
| 142 | + | first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 143 | + | return stream, nil | |||
| 144 | + | }) | |||
| 145 | + | connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) { | |||
| 146 | + | if call == 1 { | |||
| 147 | + | return first, nil | |||
| 148 | + | } | |||
| 149 | + | return second, nil | |||
| 150 | + | }) | |||
| 151 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation}) | |||
| 152 | + | done := make(chan error, 1) | |||
| 153 | + | go func() { done <- sup.Run(ctx) }() | |||
| 154 | + | require.Equal(t, 1, connector.awaitStart(t)) | |||
| 155 | + | require.Eventually(t, func() bool { return len(first.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |||
| 156 | + | stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"}) | |||
| 157 | + | require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond) | |||
| 158 | + | first.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable}) | |||
| 159 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |||
| 160 | + | for connector.callCount() < 2 { | |||
| 161 | + | clock.mu.Lock() | |||
| 162 | + | var timer *supervisorTestTimer | |||
| 163 | + | if len(clock.timers) != 0 { | |||
| 164 | + | timer = clock.timers[len(clock.timers)-1] | |||
| 165 | + | } | |||
| 166 | + | clock.mu.Unlock() | |||
| 167 | + | if timer != nil { | |||
| 168 | + | timer.fire() | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | second.publish(2, 1) | |||
| 172 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 173 | + | require.Empty(t, second.openedRequests(), "one-shot navigation must not replay on the replacement generation") | |||
| 174 | + | cancel() | |||
| 175 | + | require.ErrorIs(t, <-done, context.Canceled) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestP54SingleReaderDeliversAttachedByteAfterRealSupervisorHandoff(t *testing.T) { | |||
| 179 | + | picker := newAttachTestPicker() | |||
| 180 | + | harness := startAttachHarness(t, picker) | |||
| 181 | + | stream := newSessionTestStream() | |||
| 182 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 183 | + | return stream, nil | |||
| 184 | + | }) | |||
| 185 | + | picker.commit(sessionTestRequest(true)) | |||
| 186 | + | require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond) | |||
| 187 | + | deliverReadyStream(t, stream) | |||
| 188 | + | awaitAttachedState(t, harness.sup) | |||
| 189 | + | harness.reader.push([]byte("x")) | |||
| 190 | + | require.Eventually(t, func() bool { | |||
| 191 | + | for _, message := range stream.messages() { | |||
| 192 | + | if input, ok := message.(protocol.Input); ok && string(input.Data) == "x" { | |||
| 193 | + | return true | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | return false | |||
| 197 | + | }, 5*time.Second, time.Millisecond, "one typed byte must survive the picker-to-attachment claim handoff") | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestP54BrokerRestartDoesNotReattachOrReplayUnknownMutation(t *testing.T) { | |||
| 201 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 202 | + | defer cancel() | |||
| 203 | + | clock := newSupervisorTestClock() | |||
| 204 | + | reader := newSupervisorTestReader() | |||
| 205 | + | t.Cleanup(reader.unblock) | |||
| 206 | + | first := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 207 | + | first.publish(1, 1) | |||
| 208 | + | second := newSupervisorTestService(ports.BrokerConnectionID{2}) | |||
| 209 | + | connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) { | |||
| 210 | + | if call == 1 { | |||
| 211 | + | return first, nil | |||
| 212 | + | } | |||
| 213 | + | return second, nil | |||
| 214 | + | }) | |||
| 215 | + | notices := make(chan LifecycleNotice, 4) | |||
| 216 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: newSupervisorTestTerminal(reader), Clock: clock, Jitter: func() float64 { return 0 }, NotifyLifecycle: func(n LifecycleNotice) { notices <- n }}) | |||
| 217 | + | done := make(chan error, 1) | |||
| 218 | + | go func() { done <- sup.Run(ctx) }() | |||
| 219 | + | require.Equal(t, 1, connector.awaitStart(t)) | |||
| 220 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 221 | + | first.lose(ports.BrokerError{Code: ports.BrokerErrorOutcomeUnknown, Text: "reply lost after mutation"}) | |||
| 222 | + | require.Equal(t, LifecycleNoticeBrokerLost, (<-notices).Kind) | |||
| 223 | + | retry := clock.awaitTimer(t) | |||
| 224 | + | retry.fire() | |||
| 225 | + | require.Equal(t, 2, connector.awaitStart(t)) | |||
| 226 | + | second.publish(2, 1) | |||
| 227 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 228 | + | require.Equal(t, LifecycleNoticeBrokerReconnected, (<-notices).Kind) | |||
| 229 | + | require.Empty(t, first.openedRequests()) | |||
| 230 | + | require.Empty(t, second.openedRequests(), "restart must not auto-reattach or replay a mutation") | |||
| 231 | + | cancel() | |||
| 232 | + | require.ErrorIs(t, <-done, context.Canceled) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestP54ReadyMapPrunedAcrossClaims(t *testing.T) { | |||
| 236 | + | pump := newTerminalInputPump(nil) | |||
| 237 | + | for i := 0; i < 100; i++ { | |||
| 238 | + | consumer := pump.claim() | |||
| 239 | + | pump.revoke(consumer) | |||
| 240 | + | } | |||
| 241 | + | pump.readyMu.Lock() | |||
| 242 | + | defer pump.readyMu.Unlock() | |||
| 243 | + | require.Empty(t, pump.ready, "revoked monotonic claim IDs must not retain readiness channels") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // TestP54PickerCommittedReadIsNotReplayedToAttachment proves a picker read the | |||
| 247 | + | // user already acted on is committed at the handoff and never re-delivered as | |||
| 248 | + | // session input. | |||
| 249 | + | func TestP54PickerCommittedReadIsNotReplayedToAttachment(t *testing.T) { | |||
| 250 | + | reader := newAttachTestReader() | |||
| 251 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |||
| 252 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |||
| 253 | + | defer lifetime.stop() | |||
| 254 | + | reader.push([]byte("1\r")) | |||
| 255 | + | var committed []byte | |||
| 256 | + | require.Eventually(t, func() bool { | |||
| 257 | + | select { | |||
| 258 | + | case committed = <-consumer.reads: | |||
| 259 | + | return true | |||
| 260 | + | default: | |||
| 261 | + | return false | |||
| 262 | + | } | |||
| 263 | + | }, 5*time.Second, time.Millisecond) | |||
| 264 | + | require.Equal(t, []byte("1\r"), committed) | |||
| 265 | + | lifetime.releasePicker() | |||
| 266 | + | attachment := lifetime.pump.claim() | |||
| 267 | + | _, ok := lifetime.pump.take(context.Background(), attachment) | |||
| 268 | + | require.False(t, ok, "committed picker read must not cross the handoff") | |||
| 269 | + | lifetime.pump.revoke(attachment) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestP54AttachmentFinalizeDropsUnleasedPendingBeforePicker(t *testing.T) { | |||
| 273 | + | reader := newAttachTestReader() | |||
| 274 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |||
| 275 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |||
| 276 | + | defer lifetime.stop() | |||
| 277 | + | lifetime.releasePicker() | |||
| 278 | + | host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump}) | |||
| 279 | + | host.ownerBoundary = true | |||
| 280 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |||
| 281 | + | require.True(t, host.authority.grant(fg)) | |||
| 282 | + | reader.push([]byte("ls\r")) | |||
| 283 | + | require.Eventually(t, func() bool { | |||
| 284 | + | lifetime.pump.mu.Lock() | |||
| 285 | + | defer lifetime.pump.mu.Unlock() | |||
| 286 | + | return lifetime.pump.pending != nil && lifetime.pump.delivering == 0 | |||
| 287 | + | }, 5*time.Second, time.Millisecond) | |||
| 288 | + | fg.finish() | |||
| 289 | + | lifetime.acquirePicker() | |||
| 290 | + | assertNoPickerRead(t, consumer.reads) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func TestP54AttachmentFinalizeDropsPreservedResidualBeforePicker(t *testing.T) { | |||
| 294 | + | reader := newAttachTestReader() | |||
| 295 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |||
| 296 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |||
| 297 | + | defer lifetime.stop() | |||
| 298 | + | lifetime.releasePicker() | |||
| 299 | + | host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump}) | |||
| 300 | + | host.ownerBoundary = true | |||
| 301 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |||
| 302 | + | require.True(t, host.authority.grant(fg)) | |||
| 303 | + | reader.push([]byte("\r")) | |||
| 304 | + | event, ok := fg.Input(context.Background()) | |||
| 305 | + | require.True(t, ok) | |||
| 306 | + | fg.PreserveInput(event.Data) | |||
| 307 | + | fg.finish() | |||
| 308 | + | lifetime.acquirePicker() | |||
| 309 | + | assertNoPickerRead(t, consumer.reads) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func assertNoPickerRead(t *testing.T, reads <-chan []byte) { | |||
| 313 | + | t.Helper() | |||
| 314 | + | select { | |||
| 315 | + | case got := <-reads: | |||
| 316 | + | t.Fatalf("attachment bytes crossed into picker: %q", got) | |||
| 317 | + | case <-time.After(100 * time.Millisecond): | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func TestP54UndecidedAttachmentReadIsDroppedBeforePicker(t *testing.T) { | |||
| 322 | + | reader := newAttachTestReader() | |||
| 323 | + | consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)} | |||
| 324 | + | lifetime := startTerminalInputLifetime(reader, consumer) | |||
| 325 | + | defer lifetime.stop() | |||
| 326 | + | lifetime.releasePicker() | |||
| 327 | + | attachment := lifetime.pump.claim() | |||
| 328 | + | reader.push([]byte("ls\r")) | |||
| 329 | + | var delivery terminalReadResult | |||
| 330 | + | require.Eventually(t, func() bool { | |||
| 331 | + | var ok bool | |||
| 332 | + | delivery, ok = lifetime.pump.take(context.Background(), attachment) | |||
| 333 | + | return ok | |||
| 334 | + | }, 5*time.Second, time.Millisecond) | |||
| 335 | + | require.Equal(t, []byte("ls\r"), delivery.data) | |||
| 336 | + | lifetime.pump.dropOwned(attachment) | |||
| 337 | + | lifetime.pump.revoke(attachment) | |||
| 338 | + | lifetime.acquirePicker() | |||
| 339 | + | select { | |||
| 340 | + | case got := <-consumer.reads: | |||
| 341 | + | t.Fatalf("attachment bytes crossed into picker: %q", got) | |||
| 342 | + | case <-time.After(100 * time.Millisecond): | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | type recordingPickerConsumer struct{ reads chan []byte } | |||
| 347 | + | ||||
| 348 | + | func (c *recordingPickerConsumer) ConsumeTerminalRead(data []byte) bool { | |||
| 349 | + | c.reads <- append([]byte(nil), data...) | |||
| 350 | + | return true | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func TestP54InputDecisionProtocol(t *testing.T) { | |||
| 354 | + | pump := newTerminalInputPump(nil) | |||
| 355 | + | <-pump.space | |||
| 356 | + | host := newAttachmentHost(attachmentHostConfig{Input: pump}) | |||
| 357 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |||
| 358 | + | require.True(t, host.authority.grant(fg)) | |||
| 359 | + | pump.mu.Lock() | |||
| 360 | + | pump.pending = &terminalReadResult{data: []byte("once")} | |||
| 361 | + | pump.mu.Unlock() | |||
| 362 | + | pump.signalReady(fg.consumer) | |||
| 363 | + | event, ok := fg.Input(context.Background()) | |||
| 364 | + | require.True(t, ok) | |||
| 365 | + | require.Equal(t, []byte("once"), event.Data) | |||
| 366 | + | fg.PreserveInput(event.Data) | |||
| 367 | + | fg.AckInput() // refused: already decided | |||
| 368 | + | host.authority.revoke(fg) | |||
| 369 | + | next := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream()) | |||
| 370 | + | require.True(t, host.authority.grant(next)) | |||
| 371 | + | replayed, ok := next.Input(context.Background()) | |||
| 372 | + | require.True(t, ok) | |||
| 373 | + | require.Equal(t, []byte("once"), replayed.Data) | |||
| 374 | + | next.AckInput() | |||
| 375 | + | next.PreserveInput([]byte("duplicate")) // refused: no outstanding delivery | |||
| 376 | + | require.Empty(t, pump.residual) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | func TestP54GeometryIsPerAttachmentAndCarriesAcrossPicker(t *testing.T) { | |||
| 380 | + | resizes := make(chan domain.Geometry, 4) | |||
| 381 | + | host := newAttachmentHost(attachmentHostConfig{}) | |||
| 382 | + | host.resizes = resizes | |||
| 383 | + | host.ownerBoundary = true | |||
| 384 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 385 | + | defer cancel() | |||
| 386 | + | host.startGeometry(ctx) | |||
| 387 | + | defer host.stopGeometry() | |||
| 388 | + | first := domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}} | |||
| 389 | + | latest := domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}} | |||
| 390 | + | resizes <- first | |||
| 391 | + | resizes <- latest | |||
| 392 | + | require.Eventually(t, func() bool { geometry, ok := host.latestGeometry(); return ok && geometry.Size == latest.Size }, 5*time.Second, time.Millisecond) | |||
| 393 | + | fg1 := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |||
| 394 | + | require.True(t, host.authority.grant(fg1)) | |||
| 395 | + | geometry, ok := fg1.Resize(context.Background()) | |||
| 396 | + | require.True(t, ok) | |||
| 397 | + | require.Equal(t, latest.Size, geometry.Size) | |||
| 398 | + | host.authority.revoke(fg1) | |||
| 399 | + | fg2 := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream()) | |||
| 400 | + | require.True(t, host.authority.grant(fg2)) | |||
| 401 | + | geometry, ok = fg2.Resize(context.Background()) | |||
| 402 | + | require.True(t, ok) | |||
| 403 | + | require.Equal(t, latest.Size, geometry.Size) | |||
| 404 | + | _, stale := fg1.Resize(context.Background()) | |||
| 405 | + | require.False(t, stale) | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func TestP54LifecycleSkipsStaleTokenAndDeliversLiveDetach(t *testing.T) { | |||
| 409 | + | actions := make(chan AttachmentLifecycleAction, 2) | |||
| 410 | + | host := newAttachmentHost(attachmentHostConfig{Actions: actions}) | |||
| 411 | + | fg := host.newForeground(AttachmentToken{Generation: 2, Attempt: 3}, newSessionTestStream()) | |||
| 412 | + | require.True(t, host.authority.grant(fg)) | |||
| 413 | + | actions <- AttachmentLifecycleAction{Token: AttachmentToken{Generation: 1, Attempt: 9}, Kind: AttachmentDetachAndExit} | |||
| 414 | + | actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker} | |||
| 415 | + | action, ok := fg.Lifecycle(context.Background()) | |||
| 416 | + | require.True(t, ok) | |||
| 417 | + | require.Equal(t, AttachmentDetachToPicker, action) | |||
| 418 | + | fg.finish() | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func TestP54LifecycleInterleavingsRace(t *testing.T) { | |||
| 422 | + | for i := 0; i < 50; i++ { | |||
| 423 | + | pump := newTerminalInputPump(nil) | |||
| 424 | + | resizes := make(chan domain.Geometry, 1) | |||
| 425 | + | actions := make(chan AttachmentLifecycleAction, 1) | |||
| 426 | + | host := newAttachmentHost(attachmentHostConfig{Input: pump, Actions: actions}) | |||
| 427 | + | host.resizes = resizes | |||
| 428 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 429 | + | host.startGeometry(ctx) | |||
| 430 | + | fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream()) | |||
| 431 | + | require.True(t, host.authority.grant(fg)) | |||
| 432 | + | var wg sync.WaitGroup | |||
| 433 | + | wg.Add(3) | |||
| 434 | + | go func() { defer wg.Done(); resizes <- domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}} }() | |||
| 435 | + | go func() { | |||
| 436 | + | defer wg.Done() | |||
| 437 | + | actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker} | |||
| 438 | + | }() | |||
| 439 | + | go func() { defer wg.Done(); fg.finish() }() | |||
| 440 | + | wg.Wait() | |||
| 441 | + | cancel() | |||
| 442 | + | host.stopGeometry() | |||
| 443 | + | } | |||
| 444 | + | } | |||
M internal/usecase/client/picker_controller.go +9 -0
| @@ -361,6 +361,15 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre | |||
| 361 | 361 | return p.ResolveKey(key, base) | |
| 362 | 362 | } | |
| 363 | 363 | ||
| 364 | + | // ResolveInitial resolves the one-shot no-argument ephemeral creation through | |
| 365 | + | // the current catalogue. It does not inspect mutable picker model state. | |
| 366 | + | func (p *pickerController) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 367 | + | if navigation != InitialNavigationCreateEphemeral || p == nil || p.catalogue == nil { | |
| 368 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is not available"} | |
| 369 | + | } | |
| 370 | + | return p.catalogue.ResolveCreation(true, "", pickerSelectionCreateEphemeral, "", base) | |
| 371 | + | } | |
| 372 | + | ||
| 364 | 373 | // ResolveKey revalidates exactly the supplied catalogue key against the latest | |
| 365 | 374 | // applied snapshot and returns the exact broker stream request. It is the | |
| 366 | 375 | // commit path: the key captured atomically with the commit decision (see | |
| @@ -361,6 +361,15 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre | |||||
| 361 | return p.ResolveKey(key, base) | 361 | return p.ResolveKey(key, base) | ||
| 362 | } | 362 | } | ||
| 363 | 363 | ||||
| 364 | + | // ResolveInitial resolves the one-shot no-argument ephemeral creation through | |||
| 365 | + | // the current catalogue. It does not inspect mutable picker model state. | |||
| 366 | + | func (p *pickerController) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 367 | + | if navigation != InitialNavigationCreateEphemeral || p == nil || p.catalogue == nil { | |||
| 368 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is not available"} | |||
| 369 | + | } | |||
| 370 | + | return p.catalogue.ResolveCreation(true, "", pickerSelectionCreateEphemeral, "", base) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 364 | // ResolveKey revalidates exactly the supplied catalogue key against the latest | 373 | // ResolveKey revalidates exactly the supplied catalogue key against the latest | ||
| 365 | // applied snapshot and returns the exact broker stream request. It is the | 374 | // applied snapshot and returns the exact broker stream request. It is the | ||
| 366 | // commit path: the key captured atomically with the commit decision (see | 375 | // commit path: the key captured atomically with the commit decision (see | ||
M internal/usecase/client/supervisor.go +186 -18
| @@ -137,6 +137,9 @@ type State struct { | |||
| 137 | 137 | // Err is the most recent visible connectivity failure, nil while healthy | |
| 138 | 138 | // and before any failure. | |
| 139 | 139 | Err error | |
| 140 | + | // ReadyLost is set only when an established ready generation was lost and | |
| 141 | + | // cleared by the replacement's committed publication. | |
| 142 | + | ReadyLost bool | |
| 140 | 143 | } | |
| 141 | 144 | ||
| 142 | 145 | // supervisorEventKind is one input to the pure supervisor reducer. | |
| @@ -191,7 +194,12 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||
| 191 | 194 | state.Connectivity = ConnectivityReady | |
| 192 | 195 | state.Attempt = 0 | |
| 193 | 196 | state.Err = nil | |
| 194 | - | case supervisorTransientFailure, supervisorBrokerLoss: | |
| 197 | + | case supervisorTransientFailure: | |
| 198 | + | state.Connectivity = ConnectivityRetryWait | |
| 199 | + | state.Attempt++ | |
| 200 | + | state.Err = event.err | |
| 201 | + | case supervisorBrokerLoss: | |
| 202 | + | state.ReadyLost = state.Connectivity == ConnectivityReady | |
| 195 | 203 | state.Connectivity = ConnectivityRetryWait | |
| 196 | 204 | state.Attempt++ | |
| 197 | 205 | state.Err = event.err | |
| @@ -221,6 +229,25 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||
| 221 | 229 | return state | |
| 222 | 230 | } | |
| 223 | 231 | ||
| 232 | + | // LifecycleNoticeKind is one bounded autonomous lifecycle transition. | |
| 233 | + | type LifecycleNoticeKind uint8 | |
| 234 | + | ||
| 235 | + | const ( | |
| 236 | + | LifecycleNoticeDetachToPicker LifecycleNoticeKind = iota + 1 | |
| 237 | + | LifecycleNoticeDetachAndExit | |
| 238 | + | LifecycleNoticeSessionEnded | |
| 239 | + | LifecycleNoticeDestinationFailed | |
| 240 | + | LifecycleNoticeBrokerLost | |
| 241 | + | LifecycleNoticeBrokerReconnected | |
| 242 | + | LifecycleNoticeOutcomeUnknown | |
| 243 | + | ) | |
| 244 | + | ||
| 245 | + | // LifecycleNotice carries classification only; adapter diagnostics and free | |
| 246 | + | // text never cross this presentation seam. | |
| 247 | + | type LifecycleNotice struct { | |
| 248 | + | Kind LifecycleNoticeKind | |
| 249 | + | } | |
| 250 | + | ||
| 224 | 251 | // SupervisorConfig supplies the autonomous client's dependencies. The | |
| 225 | 252 | // connector, terminal, and clock are required; render, notify, and jitter are | |
| 226 | 253 | // injectable seams with safe defaults. Render, notify, and jitter must not | |
| @@ -239,6 +266,8 @@ type SupervisorConfig struct { | |||
| 239 | 266 | // Notify surfaces a connectivity failure without leaving the picker. | |
| 240 | 267 | // Optional; the default notifies nothing. | |
| 241 | 268 | Notify func(State, error) | |
| 269 | + | // NotifyLifecycle reports typed, bounded lifecycle transitions. | |
| 270 | + | NotifyLifecycle func(LifecycleNotice) | |
| 242 | 271 | // Jitter returns an equal-jitter fraction in [0,1). It is the only | |
| 243 | 272 | // randomness seam so tests can pin the retry cadence exactly. Optional; the | |
| 244 | 273 | // default is a process-local random source. | |
| @@ -253,6 +282,14 @@ type SupervisorConfig struct { | |||
| 253 | 282 | // each attachment Hello. The use case deliberately does not inspect the | |
| 254 | 283 | // process environment; omitted fields retain their protocol zero values. | |
| 255 | 284 | AttachmentEnvironment AttachmentEnvironment | |
| 285 | + | // LifecycleActions carries explicit attachment lifecycle requests. A nil | |
| 286 | + | // channel disables external lifecycle actions. The actions are typed so | |
| 287 | + | // detach-to-picker can never be confused with detach-and-exit. | |
| 288 | + | LifecycleActions <-chan AttachmentLifecycleAction | |
| 289 | + | // InitialNavigation optionally requests one broker-native navigation after | |
| 290 | + | // the first committed catalogue publication. It is used by no-argument | |
| 291 | + | // composition to create an ephemeral session without a special attach path. | |
| 292 | + | InitialNavigation *InitialNavigation | |
| 256 | 293 | } | |
| 257 | 294 | ||
| 258 | 295 | // AttachmentEnvironment is the composition seam for client environment data | |
| @@ -263,6 +300,28 @@ type AttachmentEnvironment struct { | |||
| 263 | 300 | TrueColor bool | |
| 264 | 301 | } | |
| 265 | 302 | ||
| 303 | + | // InitialNavigation is a one-shot client navigation intent. | |
| 304 | + | type InitialNavigation uint8 | |
| 305 | + | ||
| 306 | + | const ( | |
| 307 | + | // InitialNavigationCreateEphemeral creates a broker-routed ephemeral session. | |
| 308 | + | InitialNavigationCreateEphemeral InitialNavigation = iota + 1 | |
| 309 | + | ) | |
| 310 | + | ||
| 311 | + | // AttachmentLifecycleAction is an explicit process/attachment decision. | |
| 312 | + | type AttachmentLifecycleActionKind uint8 | |
| 313 | + | ||
| 314 | + | const ( | |
| 315 | + | AttachmentDetachToPicker AttachmentLifecycleActionKind = iota + 1 | |
| 316 | + | AttachmentDetachAndExit | |
| 317 | + | ) | |
| 318 | + | ||
| 319 | + | // AttachmentLifecycleAction targets exactly one granted attachment. | |
| 320 | + | type AttachmentLifecycleAction struct { | |
| 321 | + | Token AttachmentToken | |
| 322 | + | Kind AttachmentLifecycleActionKind | |
| 323 | + | } | |
| 324 | + | ||
| 266 | 325 | // Supervisor owns one autonomous client process: raw mode, one terminal input | |
| 267 | 326 | // lifetime, and the broker-connectivity lifecycle. | |
| 268 | 327 | type Supervisor struct { | |
| @@ -324,6 +383,7 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||
| 324 | 383 | supervisor.attachments = newAttachmentHost(attachmentHostConfig{ | |
| 325 | 384 | Terminal: cfg.Terminal, | |
| 326 | 385 | Clock: cfg.Clock, | |
| 386 | + | Actions: cfg.LifecycleActions, | |
| 327 | 387 | OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) }, | |
| 328 | 388 | }) | |
| 329 | 389 | return supervisor, nil | |
| @@ -357,6 +417,10 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 357 | 417 | }() | |
| 358 | 418 | ||
| 359 | 419 | input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker) | |
| 420 | + | defer input.stop() | |
| 421 | + | s.attachments.setInput(input.pump) | |
| 422 | + | s.attachments.startGeometry(ctx) | |
| 423 | + | defer s.attachments.stopGeometry() | |
| 360 | 424 | ||
| 361 | 425 | // The active connection is owned across loop iterations. retire is | |
| 362 | 426 | // idempotent so every exit path closes exactly the connection it adopted. | |
| @@ -439,7 +503,23 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 439 | 503 | // while an established connection already has state. | |
| 440 | 504 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | |
| 441 | 505 | } | |
| 506 | + | wasReadyLost := s.State().ReadyLost | |
| 442 | 507 | s.transition(supervisorEvent{kind: supervisorReady}) | |
| 508 | + | if wasReadyLost { | |
| 509 | + | s.notifyLifecycle(LifecycleNoticeBrokerReconnected) | |
| 510 | + | s.mu.Lock() | |
| 511 | + | s.state.ReadyLost = false | |
| 512 | + | s.mu.Unlock() | |
| 513 | + | } | |
| 514 | + | if s.cfg.InitialNavigation != nil { | |
| 515 | + | navigation := *s.cfg.InitialNavigation | |
| 516 | + | s.cfg.InitialNavigation = nil | |
| 517 | + | if terminated, termErr := s.runInitialNavigation(ctx, input, service, navigation); terminated { | |
| 518 | + | retire() | |
| 519 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| 520 | + | return termErr | |
| 521 | + | } | |
| 522 | + | } | |
| 443 | 523 | ||
| 444 | 524 | // Ready phase: fold publications, admit committed attachments one at a | |
| 445 | 525 | // time, and return to the picker after each. A committed attachment | |
| @@ -474,6 +554,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 474 | 554 | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | |
| 475 | 555 | return cause | |
| 476 | 556 | } | |
| 557 | + | s.notifyLifecycle(LifecycleNoticeBrokerLost) | |
| 477 | 558 | if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated { | |
| 478 | 559 | return termErr | |
| 479 | 560 | } | |
| @@ -802,6 +883,12 @@ func normalizeUnavailable(err error) error { | |||
| 802 | 883 | } | |
| 803 | 884 | ||
| 804 | 885 | // notify surfaces a connectivity failure through the optional notifier. | |
| 886 | + | func (s *Supervisor) notifyLifecycle(kind LifecycleNoticeKind) { | |
| 887 | + | if s != nil && s.cfg.NotifyLifecycle != nil { | |
| 888 | + | s.cfg.NotifyLifecycle(LifecycleNotice{Kind: kind}) | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | ||
| 805 | 892 | func (s *Supervisor) notify(err error) { | |
| 806 | 893 | if s.cfg.Notify == nil || err == nil { | |
| 807 | 894 | return | |
| @@ -831,6 +918,10 @@ type terminalInputLifetime struct { | |||
| 831 | 918 | eof chan error | |
| 832 | 919 | once sync.Once | |
| 833 | 920 | consumer pickerInputConsumer | |
| 921 | + | pump *terminalInputPump | |
| 922 | + | ||
| 923 | + | mu sync.Mutex | |
| 924 | + | pickerID uint64 | |
| 834 | 925 | } | |
| 835 | 926 | ||
| 836 | 927 | // startTerminalInputLifetime starts the single terminal read. A nil reader is | |
| @@ -842,28 +933,105 @@ func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *ter | |||
| 842 | 933 | lifetime.finish(io.EOF) | |
| 843 | 934 | return lifetime | |
| 844 | 935 | } | |
| 936 | + | lifetime.pump = newTerminalInputPump(in) | |
| 937 | + | lifetime.pump.start() | |
| 938 | + | if consumer != nil { | |
| 939 | + | lifetime.acquirePicker() | |
| 940 | + | } | |
| 941 | + | go lifetime.runPicker() | |
| 845 | 942 | go func() { | |
| 846 | - | buffer := make([]byte, 4096) | |
| 847 | - | for { | |
| 848 | - | n, err := in.Read(buffer) | |
| 849 | - | if err != nil { | |
| 850 | - | lifetime.finish(err) | |
| 943 | + | <-lifetime.pump.exited | |
| 944 | + | lifetime.finish(io.EOF) | |
| 945 | + | }() | |
| 946 | + | return lifetime | |
| 947 | + | } | |
| 948 | + | ||
| 949 | + | func (l *terminalInputLifetime) acquirePicker() { | |
| 950 | + | if l == nil || l.pump == nil || l.consumer == nil { | |
| 951 | + | return | |
| 952 | + | } | |
| 953 | + | l.mu.Lock() | |
| 954 | + | if l.pickerID != 0 { | |
| 955 | + | l.mu.Unlock() | |
| 956 | + | return | |
| 957 | + | } | |
| 958 | + | if id, ok := l.pump.tryClaim(); ok { | |
| 959 | + | l.pickerID = id | |
| 960 | + | l.mu.Unlock() | |
| 961 | + | return | |
| 962 | + | } | |
| 963 | + | l.mu.Unlock() | |
| 964 | + | // A finalizing attachment still owns the claim. Retry when that claim is | |
| 965 | + | // actually released rather than permanently abandoning picker input. | |
| 966 | + | go func() { | |
| 967 | + | select { | |
| 968 | + | case <-l.pump.claimChanged: | |
| 969 | + | l.acquirePicker() | |
| 970 | + | case <-l.pump.done: | |
| 971 | + | } | |
| 972 | + | }() | |
| 973 | + | } | |
| 974 | + | ||
| 975 | + | func (l *terminalInputLifetime) releasePicker() { | |
| 976 | + | if l == nil || l.pump == nil { | |
| 977 | + | return | |
| 978 | + | } | |
| 979 | + | l.mu.Lock() | |
| 980 | + | id := l.pickerID | |
| 981 | + | l.pickerID = 0 | |
| 982 | + | l.mu.Unlock() | |
| 983 | + | if id != 0 { | |
| 984 | + | l.pump.ack(id) | |
| 985 | + | // Drop picker-owned bytes that were queued but never presented before | |
| 986 | + | // changing owner class; they are not session input. | |
| 987 | + | l.pump.dropOwned(id) | |
| 988 | + | l.pump.revoke(id) | |
| 989 | + | } | |
| 990 | + | } | |
| 991 | + | ||
| 992 | + | func (l *terminalInputLifetime) runPicker() { | |
| 993 | + | if l == nil || l.pump == nil { | |
| 994 | + | return | |
| 995 | + | } | |
| 996 | + | for { | |
| 997 | + | l.mu.Lock() | |
| 998 | + | id := l.pickerID | |
| 999 | + | l.mu.Unlock() | |
| 1000 | + | if id == 0 { | |
| 1001 | + | select { | |
| 1002 | + | case <-l.pump.claimChanged: | |
| 1003 | + | case <-l.pump.done: | |
| 851 | 1004 | return | |
| 852 | 1005 | } | |
| 853 | - | if n == 0 { | |
| 854 | - | // A zero-byte, nil-error read is legal but cannot make | |
| 855 | - | // progress; keep reading so a well-behaved reader is never | |
| 856 | - | // mistaken for EOF. | |
| 857 | - | continue | |
| 858 | - | } | |
| 859 | - | if lifetime.consumer != nil { | |
| 860 | - | // The consumer may retain a partial escape or UTF-8 prefix, so it | |
| 861 | - | // gets its own copy rather than the reused read buffer. | |
| 862 | - | lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...)) | |
| 1006 | + | continue | |
| 1007 | + | } | |
| 1008 | + | result, ok := l.pump.take(context.Background(), id) | |
| 1009 | + | if !ok { | |
| 1010 | + | select { | |
| 1011 | + | case <-l.pump.readyFor(id): | |
| 1012 | + | case <-l.pump.claimChanged: | |
| 1013 | + | case <-l.pump.done: | |
| 1014 | + | return | |
| 863 | 1015 | } | |
| 1016 | + | continue | |
| 864 | 1017 | } | |
| 865 | - | }() | |
| 866 | - | return lifetime | |
| 1018 | + | if len(result.data) != 0 { | |
| 1019 | + | l.consumer.ConsumeTerminalRead(result.data) | |
| 1020 | + | } | |
| 1021 | + | l.pump.ack(id) | |
| 1022 | + | if result.err != nil { | |
| 1023 | + | l.finish(result.err) | |
| 1024 | + | return | |
| 1025 | + | } | |
| 1026 | + | } | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | func (l *terminalInputLifetime) stop() { | |
| 1030 | + | if l == nil || l.pump == nil { | |
| 1031 | + | return | |
| 1032 | + | } | |
| 1033 | + | l.pump.stop() | |
| 1034 | + | l.pump.suspend() | |
| 867 | 1035 | } | |
| 868 | 1036 | ||
| 869 | 1037 | // finish publishes the terminal cause exactly once. | |
| @@ -137,6 +137,9 @@ type State struct { | |||||
| 137 | // Err is the most recent visible connectivity failure, nil while healthy | 137 | // Err is the most recent visible connectivity failure, nil while healthy | ||
| 138 | // and before any failure. | 138 | // and before any failure. | ||
| 139 | Err error | 139 | Err error | ||
| 140 | + | // ReadyLost is set only when an established ready generation was lost and | |||
| 141 | + | // cleared by the replacement's committed publication. | |||
| 142 | + | ReadyLost bool | |||
| 140 | } | 143 | } | ||
| 141 | 144 | ||||
| 142 | // supervisorEventKind is one input to the pure supervisor reducer. | 145 | // supervisorEventKind is one input to the pure supervisor reducer. | ||
| @@ -191,7 +194,12 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||||
| 191 | state.Connectivity = ConnectivityReady | 194 | state.Connectivity = ConnectivityReady | ||
| 192 | state.Attempt = 0 | 195 | state.Attempt = 0 | ||
| 193 | state.Err = nil | 196 | state.Err = nil | ||
| 194 | - | case supervisorTransientFailure, supervisorBrokerLoss: | 197 | + | case supervisorTransientFailure: |
| 198 | + | state.Connectivity = ConnectivityRetryWait | |||
| 199 | + | state.Attempt++ | |||
| 200 | + | state.Err = event.err | |||
| 201 | + | case supervisorBrokerLoss: | |||
| 202 | + | state.ReadyLost = state.Connectivity == ConnectivityReady | |||
| 195 | state.Connectivity = ConnectivityRetryWait | 203 | state.Connectivity = ConnectivityRetryWait | ||
| 196 | state.Attempt++ | 204 | state.Attempt++ | ||
| 197 | state.Err = event.err | 205 | state.Err = event.err | ||
| @@ -221,6 +229,25 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||||
| 221 | return state | 229 | return state | ||
| 222 | } | 230 | } | ||
| 223 | 231 | ||||
| 232 | + | // LifecycleNoticeKind is one bounded autonomous lifecycle transition. | |||
| 233 | + | type LifecycleNoticeKind uint8 | |||
| 234 | + | ||||
| 235 | + | const ( | |||
| 236 | + | LifecycleNoticeDetachToPicker LifecycleNoticeKind = iota + 1 | |||
| 237 | + | LifecycleNoticeDetachAndExit | |||
| 238 | + | LifecycleNoticeSessionEnded | |||
| 239 | + | LifecycleNoticeDestinationFailed | |||
| 240 | + | LifecycleNoticeBrokerLost | |||
| 241 | + | LifecycleNoticeBrokerReconnected | |||
| 242 | + | LifecycleNoticeOutcomeUnknown | |||
| 243 | + | ) | |||
| 244 | + | ||||
| 245 | + | // LifecycleNotice carries classification only; adapter diagnostics and free | |||
| 246 | + | // text never cross this presentation seam. | |||
| 247 | + | type LifecycleNotice struct { | |||
| 248 | + | Kind LifecycleNoticeKind | |||
| 249 | + | } | |||
| 250 | + | ||||
| 224 | // SupervisorConfig supplies the autonomous client's dependencies. The | 251 | // SupervisorConfig supplies the autonomous client's dependencies. The | ||
| 225 | // connector, terminal, and clock are required; render, notify, and jitter are | 252 | // connector, terminal, and clock are required; render, notify, and jitter are | ||
| 226 | // injectable seams with safe defaults. Render, notify, and jitter must not | 253 | // injectable seams with safe defaults. Render, notify, and jitter must not | ||
| @@ -239,6 +266,8 @@ type SupervisorConfig struct { | |||||
| 239 | // Notify surfaces a connectivity failure without leaving the picker. | 266 | // Notify surfaces a connectivity failure without leaving the picker. | ||
| 240 | // Optional; the default notifies nothing. | 267 | // Optional; the default notifies nothing. | ||
| 241 | Notify func(State, error) | 268 | Notify func(State, error) | ||
| 269 | + | // NotifyLifecycle reports typed, bounded lifecycle transitions. | |||
| 270 | + | NotifyLifecycle func(LifecycleNotice) | |||
| 242 | // Jitter returns an equal-jitter fraction in [0,1). It is the only | 271 | // Jitter returns an equal-jitter fraction in [0,1). It is the only | ||
| 243 | // randomness seam so tests can pin the retry cadence exactly. Optional; the | 272 | // randomness seam so tests can pin the retry cadence exactly. Optional; the | ||
| 244 | // default is a process-local random source. | 273 | // default is a process-local random source. | ||
| @@ -253,6 +282,14 @@ type SupervisorConfig struct { | |||||
| 253 | // each attachment Hello. The use case deliberately does not inspect the | 282 | // each attachment Hello. The use case deliberately does not inspect the | ||
| 254 | // process environment; omitted fields retain their protocol zero values. | 283 | // process environment; omitted fields retain their protocol zero values. | ||
| 255 | AttachmentEnvironment AttachmentEnvironment | 284 | AttachmentEnvironment AttachmentEnvironment | ||
| 285 | + | // LifecycleActions carries explicit attachment lifecycle requests. A nil | |||
| 286 | + | // channel disables external lifecycle actions. The actions are typed so | |||
| 287 | + | // detach-to-picker can never be confused with detach-and-exit. | |||
| 288 | + | LifecycleActions <-chan AttachmentLifecycleAction | |||
| 289 | + | // InitialNavigation optionally requests one broker-native navigation after | |||
| 290 | + | // the first committed catalogue publication. It is used by no-argument | |||
| 291 | + | // composition to create an ephemeral session without a special attach path. | |||
| 292 | + | InitialNavigation *InitialNavigation | |||
| 256 | } | 293 | } | ||
| 257 | 294 | ||||
| 258 | // AttachmentEnvironment is the composition seam for client environment data | 295 | // AttachmentEnvironment is the composition seam for client environment data | ||
| @@ -263,6 +300,28 @@ type AttachmentEnvironment struct { | |||||
| 263 | TrueColor bool | 300 | TrueColor bool | ||
| 264 | } | 301 | } | ||
| 265 | 302 | ||||
| 303 | + | // InitialNavigation is a one-shot client navigation intent. | |||
| 304 | + | type InitialNavigation uint8 | |||
| 305 | + | ||||
| 306 | + | const ( | |||
| 307 | + | // InitialNavigationCreateEphemeral creates a broker-routed ephemeral session. | |||
| 308 | + | InitialNavigationCreateEphemeral InitialNavigation = iota + 1 | |||
| 309 | + | ) | |||
| 310 | + | ||||
| 311 | + | // AttachmentLifecycleAction is an explicit process/attachment decision. | |||
| 312 | + | type AttachmentLifecycleActionKind uint8 | |||
| 313 | + | ||||
| 314 | + | const ( | |||
| 315 | + | AttachmentDetachToPicker AttachmentLifecycleActionKind = iota + 1 | |||
| 316 | + | AttachmentDetachAndExit | |||
| 317 | + | ) | |||
| 318 | + | ||||
| 319 | + | // AttachmentLifecycleAction targets exactly one granted attachment. | |||
| 320 | + | type AttachmentLifecycleAction struct { | |||
| 321 | + | Token AttachmentToken | |||
| 322 | + | Kind AttachmentLifecycleActionKind | |||
| 323 | + | } | |||
| 324 | + | ||||
| 266 | // Supervisor owns one autonomous client process: raw mode, one terminal input | 325 | // Supervisor owns one autonomous client process: raw mode, one terminal input | ||
| 267 | // lifetime, and the broker-connectivity lifecycle. | 326 | // lifetime, and the broker-connectivity lifecycle. | ||
| 268 | type Supervisor struct { | 327 | type Supervisor struct { | ||
| @@ -324,6 +383,7 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||||
| 324 | supervisor.attachments = newAttachmentHost(attachmentHostConfig{ | 383 | supervisor.attachments = newAttachmentHost(attachmentHostConfig{ | ||
| 325 | Terminal: cfg.Terminal, | 384 | Terminal: cfg.Terminal, | ||
| 326 | Clock: cfg.Clock, | 385 | Clock: cfg.Clock, | ||
| 386 | + | Actions: cfg.LifecycleActions, | |||
| 327 | OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) }, | 387 | OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) }, | ||
| 328 | }) | 388 | }) | ||
| 329 | return supervisor, nil | 389 | return supervisor, nil | ||
| @@ -357,6 +417,10 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 357 | }() | 417 | }() | ||
| 358 | 418 | ||||
| 359 | input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker) | 419 | input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker) | ||
| 420 | + | defer input.stop() | |||
| 421 | + | s.attachments.setInput(input.pump) | |||
| 422 | + | s.attachments.startGeometry(ctx) | |||
| 423 | + | defer s.attachments.stopGeometry() | |||
| 360 | 424 | ||||
| 361 | // The active connection is owned across loop iterations. retire is | 425 | // The active connection is owned across loop iterations. retire is | ||
| 362 | // idempotent so every exit path closes exactly the connection it adopted. | 426 | // idempotent so every exit path closes exactly the connection it adopted. | ||
| @@ -439,7 +503,23 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 439 | // while an established connection already has state. | 503 | // while an established connection already has state. | ||
| 440 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | 504 | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | ||
| 441 | } | 505 | } | ||
| 506 | + | wasReadyLost := s.State().ReadyLost | |||
| 442 | s.transition(supervisorEvent{kind: supervisorReady}) | 507 | s.transition(supervisorEvent{kind: supervisorReady}) | ||
| 508 | + | if wasReadyLost { | |||
| 509 | + | s.notifyLifecycle(LifecycleNoticeBrokerReconnected) | |||
| 510 | + | s.mu.Lock() | |||
| 511 | + | s.state.ReadyLost = false | |||
| 512 | + | s.mu.Unlock() | |||
| 513 | + | } | |||
| 514 | + | if s.cfg.InitialNavigation != nil { | |||
| 515 | + | navigation := *s.cfg.InitialNavigation | |||
| 516 | + | s.cfg.InitialNavigation = nil | |||
| 517 | + | if terminated, termErr := s.runInitialNavigation(ctx, input, service, navigation); terminated { | |||
| 518 | + | retire() | |||
| 519 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |||
| 520 | + | return termErr | |||
| 521 | + | } | |||
| 522 | + | } | |||
| 443 | 523 | ||||
| 444 | // Ready phase: fold publications, admit committed attachments one at a | 524 | // Ready phase: fold publications, admit committed attachments one at a | ||
| 445 | // time, and return to the picker after each. A committed attachment | 525 | // time, and return to the picker after each. A committed attachment | ||
| @@ -474,6 +554,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 474 | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | 554 | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | ||
| 475 | return cause | 555 | return cause | ||
| 476 | } | 556 | } | ||
| 557 | + | s.notifyLifecycle(LifecycleNoticeBrokerLost) | |||
| 477 | if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated { | 558 | if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated { | ||
| 478 | return termErr | 559 | return termErr | ||
| 479 | } | 560 | } | ||
| @@ -802,6 +883,12 @@ func normalizeUnavailable(err error) error { | |||||
| 802 | } | 883 | } | ||
| 803 | 884 | ||||
| 804 | // notify surfaces a connectivity failure through the optional notifier. | 885 | // notify surfaces a connectivity failure through the optional notifier. | ||
| 886 | + | func (s *Supervisor) notifyLifecycle(kind LifecycleNoticeKind) { | |||
| 887 | + | if s != nil && s.cfg.NotifyLifecycle != nil { | |||
| 888 | + | s.cfg.NotifyLifecycle(LifecycleNotice{Kind: kind}) | |||
| 889 | + | } | |||
| 890 | + | } | |||
| 891 | + | ||||
| 805 | func (s *Supervisor) notify(err error) { | 892 | func (s *Supervisor) notify(err error) { | ||
| 806 | if s.cfg.Notify == nil || err == nil { | 893 | if s.cfg.Notify == nil || err == nil { | ||
| 807 | return | 894 | return | ||
| @@ -831,6 +918,10 @@ type terminalInputLifetime struct { | |||||
| 831 | eof chan error | 918 | eof chan error | ||
| 832 | once sync.Once | 919 | once sync.Once | ||
| 833 | consumer pickerInputConsumer | 920 | consumer pickerInputConsumer | ||
| 921 | + | pump *terminalInputPump | |||
| 922 | + | ||||
| 923 | + | mu sync.Mutex | |||
| 924 | + | pickerID uint64 | |||
| 834 | } | 925 | } | ||
| 835 | 926 | ||||
| 836 | // startTerminalInputLifetime starts the single terminal read. A nil reader is | 927 | // startTerminalInputLifetime starts the single terminal read. A nil reader is | ||
| @@ -842,28 +933,105 @@ func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *ter | |||||
| 842 | lifetime.finish(io.EOF) | 933 | lifetime.finish(io.EOF) | ||
| 843 | return lifetime | 934 | return lifetime | ||
| 844 | } | 935 | } | ||
| 936 | + | lifetime.pump = newTerminalInputPump(in) | |||
| 937 | + | lifetime.pump.start() | |||
| 938 | + | if consumer != nil { | |||
| 939 | + | lifetime.acquirePicker() | |||
| 940 | + | } | |||
| 941 | + | go lifetime.runPicker() | |||
| 845 | go func() { | 942 | go func() { | ||
| 846 | - | buffer := make([]byte, 4096) | 943 | + | <-lifetime.pump.exited |
| 847 | - | for { | 944 | + | lifetime.finish(io.EOF) |
| 848 | - | n, err := in.Read(buffer) | 945 | + | }() |
| 849 | - | if err != nil { | 946 | + | return lifetime |
| 850 | - | lifetime.finish(err) | 947 | + | } |
| 948 | + | ||||
| 949 | + | func (l *terminalInputLifetime) acquirePicker() { | |||
| 950 | + | if l == nil || l.pump == nil || l.consumer == nil { | |||
| 951 | + | return | |||
| 952 | + | } | |||
| 953 | + | l.mu.Lock() | |||
| 954 | + | if l.pickerID != 0 { | |||
| 955 | + | l.mu.Unlock() | |||
| 956 | + | return | |||
| 957 | + | } | |||
| 958 | + | if id, ok := l.pump.tryClaim(); ok { | |||
| 959 | + | l.pickerID = id | |||
| 960 | + | l.mu.Unlock() | |||
| 961 | + | return | |||
| 962 | + | } | |||
| 963 | + | l.mu.Unlock() | |||
| 964 | + | // A finalizing attachment still owns the claim. Retry when that claim is | |||
| 965 | + | // actually released rather than permanently abandoning picker input. | |||
| 966 | + | go func() { | |||
| 967 | + | select { | |||
| 968 | + | case <-l.pump.claimChanged: | |||
| 969 | + | l.acquirePicker() | |||
| 970 | + | case <-l.pump.done: | |||
| 971 | + | } | |||
| 972 | + | }() | |||
| 973 | + | } | |||
| 974 | + | ||||
| 975 | + | func (l *terminalInputLifetime) releasePicker() { | |||
| 976 | + | if l == nil || l.pump == nil { | |||
| 977 | + | return | |||
| 978 | + | } | |||
| 979 | + | l.mu.Lock() | |||
| 980 | + | id := l.pickerID | |||
| 981 | + | l.pickerID = 0 | |||
| 982 | + | l.mu.Unlock() | |||
| 983 | + | if id != 0 { | |||
| 984 | + | l.pump.ack(id) | |||
| 985 | + | // Drop picker-owned bytes that were queued but never presented before | |||
| 986 | + | // changing owner class; they are not session input. | |||
| 987 | + | l.pump.dropOwned(id) | |||
| 988 | + | l.pump.revoke(id) | |||
| 989 | + | } | |||
| 990 | + | } | |||
| 991 | + | ||||
| 992 | + | func (l *terminalInputLifetime) runPicker() { | |||
| 993 | + | if l == nil || l.pump == nil { | |||
| 994 | + | return | |||
| 995 | + | } | |||
| 996 | + | for { | |||
| 997 | + | l.mu.Lock() | |||
| 998 | + | id := l.pickerID | |||
| 999 | + | l.mu.Unlock() | |||
| 1000 | + | if id == 0 { | |||
| 1001 | + | select { | |||
| 1002 | + | case <-l.pump.claimChanged: | |||
| 1003 | + | case <-l.pump.done: | |||
| 851 | return | 1004 | return | ||
| 852 | } | 1005 | } | ||
| 853 | - | if n == 0 { | 1006 | + | continue |
| 854 | - | // A zero-byte, nil-error read is legal but cannot make | 1007 | + | } |
| 855 | - | // progress; keep reading so a well-behaved reader is never | 1008 | + | result, ok := l.pump.take(context.Background(), id) |
| 856 | - | // mistaken for EOF. | 1009 | + | if !ok { |
| 857 | - | continue | 1010 | + | select { |
| 858 | - | } | 1011 | + | case <-l.pump.readyFor(id): |
| 859 | - | if lifetime.consumer != nil { | 1012 | + | case <-l.pump.claimChanged: |
| 860 | - | // The consumer may retain a partial escape or UTF-8 prefix, so it | 1013 | + | case <-l.pump.done: |
| 861 | - | // gets its own copy rather than the reused read buffer. | 1014 | + | return |
| 862 | - | lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...)) | |||
| 863 | } | 1015 | } | ||
| 1016 | + | continue | |||
| 864 | } | 1017 | } | ||
| 865 | - | }() | 1018 | + | if len(result.data) != 0 { |
| 866 | - | return lifetime | 1019 | + | l.consumer.ConsumeTerminalRead(result.data) |
| 1020 | + | } | |||
| 1021 | + | l.pump.ack(id) | |||
| 1022 | + | if result.err != nil { | |||
| 1023 | + | l.finish(result.err) | |||
| 1024 | + | return | |||
| 1025 | + | } | |||
| 1026 | + | } | |||
| 1027 | + | } | |||
| 1028 | + | ||||
| 1029 | + | func (l *terminalInputLifetime) stop() { | |||
| 1030 | + | if l == nil || l.pump == nil { | |||
| 1031 | + | return | |||
| 1032 | + | } | |||
| 1033 | + | l.pump.stop() | |||
| 1034 | + | l.pump.suspend() | |||
| 867 | } | 1035 | } | ||
| 868 | 1036 | ||||
| 869 | // finish publishes the terminal cause exactly once. | 1037 | // finish publishes the terminal cause exactly once. | ||
M internal/usecase/client/supervisor_attach.go +63 -14
| @@ -238,6 +238,16 @@ type attachmentSettlement struct { | |||
| 238 | 238 | // the exact broker stream request the user committed. The stream ID is | |
| 239 | 239 | // allocated strictly increasing per connection and is consumed even when | |
| 240 | 240 | // resolution refuses, matching the pool's never-reused ID contract. | |
| 241 | + | func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerService, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) { | |
| 242 | + | resolver, ok := s.cfg.Picker.(interface { | |
| 243 | + | ResolveInitial(InitialNavigation, pickerResolveBase) (ports.BrokerOpenStreamRequest, error) | |
| 244 | + | }) | |
| 245 | + | if !ok { | |
| 246 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is unavailable"} | |
| 247 | + | } | |
| 248 | + | return resolver.ResolveInitial(navigation, pickerResolveBase{Connection: service.ConnectionID(), Stream: ports.BrokerStreamID(s.nextStream.Add(1))}) | |
| 249 | + | } | |
| 250 | + | ||
| 241 | 251 | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) { | |
| 242 | 252 | base := pickerResolveBase{ | |
| 243 | 253 | Connection: service.ConnectionID(), | |
| @@ -250,9 +260,13 @@ func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, | |||
| 250 | 260 | // request. The worker receives no endpoint, route, or raw-mode authority: it | |
| 251 | 261 | // only drives the typed session protocol on the stream the supervisor opened. | |
| 252 | 262 | 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) | |
| 263 | + | geometry, ok := s.attachments.latestGeometry() | |
| 264 | + | if !ok { | |
| 265 | + | var err error | |
| 266 | + | geometry, err = s.cfg.Terminal.Geometry() | |
| 267 | + | if err != nil { | |
| 268 | + | return nil, fmt.Errorf("vev: reading terminal geometry: %w", err) | |
| 269 | + | } | |
| 256 | 270 | } | |
| 257 | 271 | environment := s.cfg.AttachmentEnvironment | |
| 258 | 272 | return newSessionAttachmentWorker(sessionAttachmentConfig{ | |
| @@ -272,20 +286,19 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||
| 272 | 286 | // the picker. It reports terminated=true only when the run itself must end | |
| 273 | 287 | // (process cancellation or terminal EOF); every attachment outcome returns to | |
| 274 | 288 | // 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 { | |
| 289 | + | func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, navigation InitialNavigation) (bool, error) { | |
| 290 | + | request, err := s.resolveInitialStreamRequest(service, navigation) | |
| 291 | + | if err != nil { | |
| 292 | + | s.reportAttachmentFailure(err) | |
| 278 | 293 | return false, nil | |
| 279 | 294 | } | |
| 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) | |
| 295 | + | return s.runResolvedAttachment(ctx, input, service, request) | |
| 296 | + | } | |
| 288 | 297 | ||
| 298 | + | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | |
| 299 | + | if s.cfg.Picker == nil { | |
| 300 | + | return false, nil | |
| 301 | + | } | |
| 289 | 302 | request, err := s.resolveCommittedStreamRequest(service, key) | |
| 290 | 303 | if err != nil { | |
| 291 | 304 | // A refused selection is a typed presentation notice; it never opens a | |
| @@ -293,7 +306,21 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal | |||
| 293 | 306 | s.reportAttachmentFailure(err) | |
| 294 | 307 | return false, nil | |
| 295 | 308 | } | |
| 309 | + | return s.runResolvedAttachment(ctx, input, service, request) | |
| 310 | + | } | |
| 296 | 311 | ||
| 312 | + | func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest) (bool, error) { | |
| 313 | + | picker := s.cfg.Picker | |
| 314 | + | if picker == nil { | |
| 315 | + | return false, nil | |
| 316 | + | } | |
| 317 | + | picker.SetOwnsInput(false) | |
| 318 | + | input.releasePicker() | |
| 319 | + | defer func() { | |
| 320 | + | picker.SetOwnsInput(true) | |
| 321 | + | input.acquirePicker() | |
| 322 | + | }() | |
| 323 | + | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |
| 297 | 324 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | |
| 298 | 325 | defer deadline.finish() | |
| 299 | 326 | ||
| @@ -351,11 +378,13 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||
| 351 | 378 | }() | |
| 352 | 379 | ||
| 353 | 380 | var result attachmentSettlement | |
| 381 | + | var brokerLost bool | |
| 354 | 382 | var terminated bool | |
| 355 | 383 | var termErr error | |
| 356 | 384 | select { | |
| 357 | 385 | case result = <-settled: | |
| 358 | 386 | case <-service.Done(): | |
| 387 | + | brokerLost = true | |
| 359 | 388 | // The broker connection is gone; the supervisor cancels the run and | |
| 360 | 389 | // lets the ready loop observe the loss and retire the connection. | |
| 361 | 390 | run.Cancel() | |
| @@ -379,8 +408,19 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||
| 379 | 408 | s.notifyAttachment(timeout) | |
| 380 | 409 | return false, nil | |
| 381 | 410 | } | |
| 411 | + | if errors.Is(result.event.Err, errDetachAndExit) { | |
| 412 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 413 | + | s.notifyLifecycle(LifecycleNoticeDetachAndExit) | |
| 414 | + | return true, nil | |
| 415 | + | } | |
| 416 | + | if errors.Is(result.event.Err, errDetachToPicker) { | |
| 417 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 418 | + | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | |
| 419 | + | return false, nil | |
| 420 | + | } | |
| 382 | 421 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | |
| 383 | 422 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | |
| 423 | + | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |
| 384 | 424 | s.notifyAttachment(result.event.Err) | |
| 385 | 425 | return false, nil | |
| 386 | 426 | } | |
| @@ -389,12 +429,21 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||
| 389 | 429 | // failed after attachment was lost/failed above, so it can never leave the | |
| 390 | 430 | // attached presentation showing. | |
| 391 | 431 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 432 | + | if !brokerLost { | |
| 433 | + | s.notifyLifecycle(LifecycleNoticeSessionEnded) | |
| 434 | + | } | |
| 392 | 435 | return false, nil | |
| 393 | 436 | } | |
| 394 | 437 | ||
| 395 | 438 | // reportAttachmentFailure returns to the picker with a typed, visible failure. | |
| 396 | 439 | func (s *Supervisor) reportAttachmentFailure(err error) { | |
| 397 | 440 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | |
| 441 | + | var brokerErr ports.BrokerError | |
| 442 | + | if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorOutcomeUnknown { | |
| 443 | + | s.notifyLifecycle(LifecycleNoticeOutcomeUnknown) | |
| 444 | + | } else { | |
| 445 | + | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |
| 446 | + | } | |
| 398 | 447 | s.notifyAttachment(err) | |
| 399 | 448 | } | |
| 400 | 449 | ||
| @@ -238,6 +238,16 @@ type attachmentSettlement struct { | |||||
| 238 | // the exact broker stream request the user committed. The stream ID is | 238 | // the exact broker stream request the user committed. The stream ID is | ||
| 239 | // allocated strictly increasing per connection and is consumed even when | 239 | // allocated strictly increasing per connection and is consumed even when | ||
| 240 | // resolution refuses, matching the pool's never-reused ID contract. | 240 | // resolution refuses, matching the pool's never-reused ID contract. | ||
| 241 | + | func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerService, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) { | |||
| 242 | + | resolver, ok := s.cfg.Picker.(interface { | |||
| 243 | + | ResolveInitial(InitialNavigation, pickerResolveBase) (ports.BrokerOpenStreamRequest, error) | |||
| 244 | + | }) | |||
| 245 | + | if !ok { | |||
| 246 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is unavailable"} | |||
| 247 | + | } | |||
| 248 | + | return resolver.ResolveInitial(navigation, pickerResolveBase{Connection: service.ConnectionID(), Stream: ports.BrokerStreamID(s.nextStream.Add(1))}) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 241 | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) { | 251 | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) { | ||
| 242 | base := pickerResolveBase{ | 252 | base := pickerResolveBase{ | ||
| 243 | Connection: service.ConnectionID(), | 253 | Connection: service.ConnectionID(), | ||
| @@ -250,9 +260,13 @@ func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, | |||||
| 250 | // request. The worker receives no endpoint, route, or raw-mode authority: it | 260 | // 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. | 261 | // 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) { | 262 | func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error) (AttachmentWorker, error) { | ||
| 253 | - | geometry, err := s.cfg.Terminal.Geometry() | 263 | + | geometry, ok := s.attachments.latestGeometry() |
| 254 | - | if err != nil { | 264 | + | if !ok { |
| 255 | - | return nil, fmt.Errorf("vev: reading terminal geometry: %w", err) | 265 | + | var err error |
| 266 | + | geometry, err = s.cfg.Terminal.Geometry() | |||
| 267 | + | if err != nil { | |||
| 268 | + | return nil, fmt.Errorf("vev: reading terminal geometry: %w", err) | |||
| 269 | + | } | |||
| 256 | } | 270 | } | ||
| 257 | environment := s.cfg.AttachmentEnvironment | 271 | environment := s.cfg.AttachmentEnvironment | ||
| 258 | return newSessionAttachmentWorker(sessionAttachmentConfig{ | 272 | return newSessionAttachmentWorker(sessionAttachmentConfig{ | ||
| @@ -272,20 +286,19 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||||
| 272 | // the picker. It reports terminated=true only when the run itself must end | 286 | // the picker. It reports terminated=true only when the run itself must end | ||
| 273 | // (process cancellation or terminal EOF); every attachment outcome returns to | 287 | // (process cancellation or terminal EOF); every attachment outcome returns to | ||
| 274 | // the picker in the same process. | 288 | // the picker in the same process. | ||
| 275 | - | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | 289 | + | func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, navigation InitialNavigation) (bool, error) { |
| 276 | - | picker := s.cfg.Picker | 290 | + | request, err := s.resolveInitialStreamRequest(service, navigation) |
| 277 | - | if picker == nil { | 291 | + | if err != nil { |
| 292 | + | s.reportAttachmentFailure(err) | |||
| 278 | return false, nil | 293 | return false, nil | ||
| 279 | } | 294 | } | ||
| 280 | - | // Present connecting before releasing picker input, so a slow resolve/open | 295 | + | return s.runResolvedAttachment(ctx, input, service, request) |
| 281 | - | // never leaves a visible picker whose keystrokes are silently discarded. | 296 | + | } |
| 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 | 297 | ||||
| 298 | + | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | |||
| 299 | + | if s.cfg.Picker == nil { | |||
| 300 | + | return false, nil | |||
| 301 | + | } | |||
| 289 | request, err := s.resolveCommittedStreamRequest(service, key) | 302 | request, err := s.resolveCommittedStreamRequest(service, key) | ||
| 290 | if err != nil { | 303 | if err != nil { | ||
| 291 | // A refused selection is a typed presentation notice; it never opens a | 304 | // A refused selection is a typed presentation notice; it never opens a | ||
| @@ -293,7 +306,21 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal | |||||
| 293 | s.reportAttachmentFailure(err) | 306 | s.reportAttachmentFailure(err) | ||
| 294 | return false, nil | 307 | return false, nil | ||
| 295 | } | 308 | } | ||
| 309 | + | return s.runResolvedAttachment(ctx, input, service, request) | |||
| 310 | + | } | |||
| 296 | 311 | ||||
| 312 | + | func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest) (bool, error) { | |||
| 313 | + | picker := s.cfg.Picker | |||
| 314 | + | if picker == nil { | |||
| 315 | + | return false, nil | |||
| 316 | + | } | |||
| 317 | + | picker.SetOwnsInput(false) | |||
| 318 | + | input.releasePicker() | |||
| 319 | + | defer func() { | |||
| 320 | + | picker.SetOwnsInput(true) | |||
| 321 | + | input.acquirePicker() | |||
| 322 | + | }() | |||
| 323 | + | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |||
| 297 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | 324 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | ||
| 298 | defer deadline.finish() | 325 | defer deadline.finish() | ||
| 299 | 326 | ||||
| @@ -351,11 +378,13 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||||
| 351 | }() | 378 | }() | ||
| 352 | 379 | ||||
| 353 | var result attachmentSettlement | 380 | var result attachmentSettlement | ||
| 381 | + | var brokerLost bool | |||
| 354 | var terminated bool | 382 | var terminated bool | ||
| 355 | var termErr error | 383 | var termErr error | ||
| 356 | select { | 384 | select { | ||
| 357 | case result = <-settled: | 385 | case result = <-settled: | ||
| 358 | case <-service.Done(): | 386 | case <-service.Done(): | ||
| 387 | + | brokerLost = true | |||
| 359 | // The broker connection is gone; the supervisor cancels the run and | 388 | // The broker connection is gone; the supervisor cancels the run and | ||
| 360 | // lets the ready loop observe the loss and retire the connection. | 389 | // lets the ready loop observe the loss and retire the connection. | ||
| 361 | run.Cancel() | 390 | run.Cancel() | ||
| @@ -379,8 +408,19 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||||
| 379 | s.notifyAttachment(timeout) | 408 | s.notifyAttachment(timeout) | ||
| 380 | return false, nil | 409 | return false, nil | ||
| 381 | } | 410 | } | ||
| 411 | + | if errors.Is(result.event.Err, errDetachAndExit) { | |||
| 412 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |||
| 413 | + | s.notifyLifecycle(LifecycleNoticeDetachAndExit) | |||
| 414 | + | return true, nil | |||
| 415 | + | } | |||
| 416 | + | if errors.Is(result.event.Err, errDetachToPicker) { | |||
| 417 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |||
| 418 | + | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | |||
| 419 | + | return false, nil | |||
| 420 | + | } | |||
| 382 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | 421 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | ||
| 383 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | 422 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | ||
| 423 | + | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |||
| 384 | s.notifyAttachment(result.event.Err) | 424 | s.notifyAttachment(result.event.Err) | ||
| 385 | return false, nil | 425 | return false, nil | ||
| 386 | } | 426 | } | ||
| @@ -389,12 +429,21 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||||
| 389 | // failed after attachment was lost/failed above, so it can never leave the | 429 | // failed after attachment was lost/failed above, so it can never leave the | ||
| 390 | // attached presentation showing. | 430 | // attached presentation showing. | ||
| 391 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | 431 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | ||
| 432 | + | if !brokerLost { | |||
| 433 | + | s.notifyLifecycle(LifecycleNoticeSessionEnded) | |||
| 434 | + | } | |||
| 392 | return false, nil | 435 | return false, nil | ||
| 393 | } | 436 | } | ||
| 394 | 437 | ||||
| 395 | // reportAttachmentFailure returns to the picker with a typed, visible failure. | 438 | // reportAttachmentFailure returns to the picker with a typed, visible failure. | ||
| 396 | func (s *Supervisor) reportAttachmentFailure(err error) { | 439 | func (s *Supervisor) reportAttachmentFailure(err error) { | ||
| 397 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | 440 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | ||
| 441 | + | var brokerErr ports.BrokerError | |||
| 442 | + | if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorOutcomeUnknown { | |||
| 443 | + | s.notifyLifecycle(LifecycleNoticeOutcomeUnknown) | |||
| 444 | + | } else { | |||
| 445 | + | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |||
| 446 | + | } | |||
| 398 | s.notifyAttachment(err) | 447 | s.notifyAttachment(err) | ||
| 399 | } | 448 | } | ||
| 400 | 449 | ||||
M internal/usecase/client/supervisor_attach_test.go +26 -2
| @@ -166,6 +166,22 @@ func (p *attachTestPicker) TakeOp() (pickerOp, string) { | |||
| 166 | 166 | return op, key | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | + | func (p *attachTestPicker) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 170 | + | if navigation != InitialNavigationCreateEphemeral { | |
| 171 | + | return ports.BrokerOpenStreamRequest{}, errors.New("unexpected initial navigation") | |
| 172 | + | } | |
| 173 | + | p.mu.Lock() | |
| 174 | + | defer p.mu.Unlock() | |
| 175 | + | p.resolved++ | |
| 176 | + | request := p.request | |
| 177 | + | request.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 178 | + | request.Target = protocol.ExactSessionTarget{} | |
| 179 | + | request.Local = true | |
| 180 | + | request.Connection = base.Connection | |
| 181 | + | request.Stream = base.Stream | |
| 182 | + | return request, nil | |
| 183 | + | } | |
| 184 | + | ||
| 169 | 185 | func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 170 | 186 | p.mu.Lock() | |
| 171 | 187 | defer p.mu.Unlock() | |
| @@ -232,6 +248,10 @@ type attachTestHarness struct { | |||
| 232 | 248 | // startAttachHarness runs one supervisor over a scripted picker and service. | |
| 233 | 249 | // The caller installs service.setOpenStream before committing a selection. | |
| 234 | 250 | func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness { | |
| 251 | + | return startAttachHarnessConfig(t, picker, nil) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func startAttachHarnessConfig(t *testing.T, picker pickerHost, configure func(*SupervisorConfig)) *attachTestHarness { | |
| 235 | 255 | t.Helper() | |
| 236 | 256 | ctx, cancel := context.WithCancel(context.Background()) | |
| 237 | 257 | clock := newSupervisorTestClock() | |
| @@ -243,13 +263,17 @@ func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness { | |||
| 243 | 263 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 244 | 264 | return service, nil | |
| 245 | 265 | }) | |
| 246 | - | sup := mustSupervisor(t, SupervisorConfig{ | |
| 266 | + | cfg := SupervisorConfig{ | |
| 247 | 267 | Connector: connector, | |
| 248 | 268 | Terminal: terminal, | |
| 249 | 269 | Clock: clock, | |
| 250 | 270 | Jitter: func() float64 { return 0 }, | |
| 251 | 271 | Picker: picker, | |
| 252 | - | }) | |
| 272 | + | } | |
| 273 | + | if configure != nil { | |
| 274 | + | configure(&cfg) | |
| 275 | + | } | |
| 276 | + | sup := mustSupervisor(t, cfg) | |
| 253 | 277 | ||
| 254 | 278 | harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})} | |
| 255 | 279 | go func() { | |
| @@ -166,6 +166,22 @@ func (p *attachTestPicker) TakeOp() (pickerOp, string) { | |||||
| 166 | return op, key | 166 | return op, key | ||
| 167 | } | 167 | } | ||
| 168 | 168 | ||||
| 169 | + | func (p *attachTestPicker) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 170 | + | if navigation != InitialNavigationCreateEphemeral { | |||
| 171 | + | return ports.BrokerOpenStreamRequest{}, errors.New("unexpected initial navigation") | |||
| 172 | + | } | |||
| 173 | + | p.mu.Lock() | |||
| 174 | + | defer p.mu.Unlock() | |||
| 175 | + | p.resolved++ | |||
| 176 | + | request := p.request | |||
| 177 | + | request.Admission = ports.BrokerAdmissionCreateEphemeral | |||
| 178 | + | request.Target = protocol.ExactSessionTarget{} | |||
| 179 | + | request.Local = true | |||
| 180 | + | request.Connection = base.Connection | |||
| 181 | + | request.Stream = base.Stream | |||
| 182 | + | return request, nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 169 | func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | 185 | func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | ||
| 170 | p.mu.Lock() | 186 | p.mu.Lock() | ||
| 171 | defer p.mu.Unlock() | 187 | defer p.mu.Unlock() | ||
| @@ -232,6 +248,10 @@ type attachTestHarness struct { | |||||
| 232 | // startAttachHarness runs one supervisor over a scripted picker and service. | 248 | // startAttachHarness runs one supervisor over a scripted picker and service. | ||
| 233 | // The caller installs service.setOpenStream before committing a selection. | 249 | // The caller installs service.setOpenStream before committing a selection. | ||
| 234 | func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness { | 250 | func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness { | ||
| 251 | + | return startAttachHarnessConfig(t, picker, nil) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func startAttachHarnessConfig(t *testing.T, picker pickerHost, configure func(*SupervisorConfig)) *attachTestHarness { | |||
| 235 | t.Helper() | 255 | t.Helper() | ||
| 236 | ctx, cancel := context.WithCancel(context.Background()) | 256 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 237 | clock := newSupervisorTestClock() | 257 | clock := newSupervisorTestClock() | ||
| @@ -243,13 +263,17 @@ func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness { | |||||
| 243 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | 263 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | ||
| 244 | return service, nil | 264 | return service, nil | ||
| 245 | }) | 265 | }) | ||
| 246 | - | sup := mustSupervisor(t, SupervisorConfig{ | 266 | + | cfg := SupervisorConfig{ |
| 247 | Connector: connector, | 267 | Connector: connector, | ||
| 248 | Terminal: terminal, | 268 | Terminal: terminal, | ||
| 249 | Clock: clock, | 269 | Clock: clock, | ||
| 250 | Jitter: func() float64 { return 0 }, | 270 | Jitter: func() float64 { return 0 }, | ||
| 251 | Picker: picker, | 271 | Picker: picker, | ||
| 252 | - | }) | 272 | + | } |
| 273 | + | if configure != nil { | |||
| 274 | + | configure(&cfg) | |||
| 275 | + | } | |||
| 276 | + | sup := mustSupervisor(t, cfg) | |||
| 253 | 277 | ||||
| 254 | harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})} | 278 | harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})} | ||
| 255 | go func() { | 279 | go func() { | ||
M internal/usecase/client/supervisor_test.go +1 -1
| @@ -525,7 +525,7 @@ func TestSupervisorReducerTransitions(t *testing.T) { | |||
| 525 | 525 | name: "broker loss returns to picker retry wait", | |
| 526 | 526 | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5}, | |
| 527 | 527 | event: supervisorEvent{kind: supervisorBrokerLoss, err: transient}, | |
| 528 | - | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient}, | |
| 528 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient, ReadyLost: true}, | |
| 529 | 529 | }, | |
| 530 | 530 | { | |
| 531 | 531 | name: "non-retryable failure disconnects and stays on the picker", | |
| @@ -525,7 +525,7 @@ func TestSupervisorReducerTransitions(t *testing.T) { | |||||
| 525 | name: "broker loss returns to picker retry wait", | 525 | name: "broker loss returns to picker retry wait", | ||
| 526 | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5}, | 526 | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5}, | ||
| 527 | event: supervisorEvent{kind: supervisorBrokerLoss, err: transient}, | 527 | event: supervisorEvent{kind: supervisorBrokerLoss, err: transient}, | ||
| 528 | - | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient}, | 528 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient, ReadyLost: true}, |
| 529 | }, | 529 | }, | ||
| 530 | { | 530 | { | ||
| 531 | name: "non-retryable failure disconnects and stays on the picker", | 531 | name: "non-retryable failure disconnects and stays on the picker", | ||