feat(client): add scoped attachment worker
- commit
- ca3d19416b077695f5800c4e050c401e91b75c50
- author
- brice · · 2026-09-17 09:40 UTC
- parent
- 21cbe0b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +1834 -2
A internal/usecase/client/attachment_worker.go +819 -0
| @@ -0,0 +1,819 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Attachment worker mechanisms (Plan 001 P5.1b, offline and unactivated). | |
| 17 | + | // | |
| 18 | + | // This file extracts the pieces of the legacy attach attempt that the | |
| 19 | + | // autonomous supervisor must own, without changing the legacy Runner and | |
| 20 | + | // without wiring the supervisor into production. It reuses the existing | |
| 21 | + | // terminal input pump (terminalInputPump) and foreground output gate | |
| 22 | + | // (foregroundSendLease) rather than duplicating them. | |
| 23 | + | // | |
| 24 | + | // Ownership split: | |
| 25 | + | // | |
| 26 | + | // - The supervisor owns the terminal: raw mode, the terminal writer, the | |
| 27 | + | // single input pump, the UI publication transaction, resize events, and the | |
| 28 | + | // process lifecycle. None of those move into a worker. | |
| 29 | + | // - One AttachmentWorker owns exactly one broker logical stream and the typed | |
| 30 | + | // session I/O on it. It cannot enter or restore raw mode, choose an | |
| 31 | + | // endpoint, restore a source route, retain an attachment in the cache, or | |
| 32 | + | // write the terminal directly. | |
| 33 | + | // - Every terminal-facing action a worker performs crosses the | |
| 34 | + | // supervisor-granted AttachmentForeground. The supervisor grants exactly | |
| 35 | + | // one foreground at a time; on cancellation or supersession it finalizes | |
| 36 | + | // that grant (closing Done and refusing output, UI, resize, and new input), | |
| 37 | + | // lets the woken worker Ack/Preserve the read it holds through a bounded | |
| 38 | + | // join, then revokes input authority before closing the owned stream. | |
| 39 | + | // - Every worker result carries the AttachmentToken (generation/attempt) the | |
| 40 | + | // supervisor granted, so a late result from a canceled or superseded | |
| 41 | + | // generation is discarded rather than applied. | |
| 42 | + | ||
| 43 | + | // Integration requirement: P5.1a currently owns a rival terminal reader. Before | |
| 44 | + | // activation it must use this same pump for picker and attachment consumers; | |
| 45 | + | // never start both readers. Runner and supervisor wiring remain unchanged. | |
| 46 | + | // | |
| 47 | + | // UI actions are deliberately not part of this slice. AttachmentForeground has | |
| 48 | + | // no action admission seam and attachmentHostConfig has no Actions field: | |
| 49 | + | // binding UI.bindForeground and admitting actions requires the P5 supervisor's | |
| 50 | + | // automation loop, which does not exist yet, so exposing it would publish a | |
| 51 | + | // generation no component can drive. The transactional UI output path | |
| 52 | + | // (ports.UIOutputTransaction, reached through Output) stays independent and | |
| 53 | + | // usable. When the P5 supervisor owns input automation, it must bind and revoke | |
| 54 | + | // UI actions at the same foreground boundaries this host finalizes. | |
| 55 | + | ||
| 56 | + | // attachmentWorkerJoinTimeout bounds the supervisor's join of a cancelled | |
| 57 | + | // worker during the final input window. A worker that honors neither | |
| 58 | + | // cancellation nor a closed Done is abandoned rather than allowed to pin the | |
| 59 | + | // supervisor; the supervisor then revokes the retained consumer and closes the | |
| 60 | + | // stream anyway. It mirrors the attachment retirement join budget. | |
| 61 | + | const attachmentWorkerJoinTimeout = 5 * time.Second | |
| 62 | + | ||
| 63 | + | // errAttachmentForegroundRevoked reports a worker action refused because the | |
| 64 | + | // supervisor revoked its input/output authority. | |
| 65 | + | var errAttachmentForegroundRevoked = errors.New("client: attachment foreground authority revoked") | |
| 66 | + | ||
| 67 | + | // AttachmentToken identifies one attachment worker run. Generation is the | |
| 68 | + | // supervisor-assigned foreground generation. The host never allocates it while | |
| 69 | + | // UI action binding is deferred, so the supervisor supplies it and the host | |
| 70 | + | // stamps it onto the event and every publication; Attempt is the worker | |
| 71 | + | // identity, not a UI generation. A zero Generation is never granted. | |
| 72 | + | type AttachmentToken struct { | |
| 73 | + | Generation uint64 | |
| 74 | + | Attempt uint64 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // IsZero reports whether the token was never assigned by a supervisor. | |
| 78 | + | func (t AttachmentToken) IsZero() bool { return t.Generation == 0 } | |
| 79 | + | ||
| 80 | + | // String renders the token for diagnostics. | |
| 81 | + | func (t AttachmentToken) String() string { | |
| 82 | + | return fmt.Sprintf("gen=%d attempt=%d", t.Generation, t.Attempt) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // AttachmentEventKind classifies one worker's terminal outcome. Every kind | |
| 86 | + | // returns the client to the picker: a worker event never terminates the process | |
| 87 | + | // by itself. Only the supervisor's own state machine decides that. | |
| 88 | + | type AttachmentEventKind uint8 | |
| 89 | + | ||
| 90 | + | const ( | |
| 91 | + | // AttachmentEventEnded is an orderly end: the logical stream reached EOF or | |
| 92 | + | // the worker detached without failure. | |
| 93 | + | AttachmentEventEnded AttachmentEventKind = iota + 1 | |
| 94 | + | // AttachmentEventLost is a transport or logical-stream loss. The logical | |
| 95 | + | // stream's own Done/Err carries the stable cause. | |
| 96 | + | AttachmentEventLost | |
| 97 | + | // AttachmentEventFailed is a typed protocol or application failure on the | |
| 98 | + | // stream, distinct from a carriage loss. | |
| 99 | + | AttachmentEventFailed | |
| 100 | + | ) | |
| 101 | + | ||
| 102 | + | // String renders the event kind for diagnostics. | |
| 103 | + | func (k AttachmentEventKind) String() string { | |
| 104 | + | switch k { | |
| 105 | + | case AttachmentEventEnded: | |
| 106 | + | return "ended" | |
| 107 | + | case AttachmentEventLost: | |
| 108 | + | return "lost" | |
| 109 | + | case AttachmentEventFailed: | |
| 110 | + | return "failed" | |
| 111 | + | default: | |
| 112 | + | return "unknown" | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // AttachmentEvent is the typed result of one worker run. Token is always the | |
| 117 | + | // token the supervisor granted: the host stamps it, so a worker cannot report | |
| 118 | + | // under another generation. | |
| 119 | + | type AttachmentEvent struct { | |
| 120 | + | Token AttachmentToken | |
| 121 | + | Kind AttachmentEventKind | |
| 122 | + | Err error | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // AttachmentInputEvent is one authorized terminal input delivery. Err carries a | |
| 126 | + | // terminal read failure (for example io.EOF) alongside any final bytes. | |
| 127 | + | type AttachmentInputEvent struct { | |
| 128 | + | Data []byte | |
| 129 | + | Err error | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // AttachmentForeground is the supervisor-granted handle through which a worker | |
| 133 | + | // touches the terminal. It is the worker's only path to input, resize, output, | |
| 134 | + | // UI publication, and the attached transition. Every method is refused once the | |
| 135 | + | // supervisor revokes the grant, so a canceled or superseded generation cannot | |
| 136 | + | // read input, resize, write output, publish UI, or mark itself attached. | |
| 137 | + | type AttachmentForeground interface { | |
| 138 | + | // Token is the generation/attempt identity of this grant. | |
| 139 | + | Token() AttachmentToken | |
| 140 | + | // Stream is the supervisor-admitted logical stream this worker owns for its | |
| 141 | + | // lifetime. The supervisor retains close authority. | |
| 142 | + | Stream() ports.BrokerLogicalConnection | |
| 143 | + | // Input blocks until the next authorized terminal input delivery. It returns | |
| 144 | + | // false once authority is revoked or ctx ends. | |
| 145 | + | Input(ctx context.Context) (AttachmentInputEvent, bool) | |
| 146 | + | // AckInput commits the last Input delivery so the shared reader may accept | |
| 147 | + | // the next one. It decides that delivery: at most one of AckInput and | |
| 148 | + | // PreserveInput may decide a delivery, and a second decision for the same | |
| 149 | + | // delivery is rejected as a no-op. It is a no-op once authority is revoked. | |
| 150 | + | AckInput() | |
| 151 | + | // PreserveInput re-queues the undecided bytes of the last Input delivery so | |
| 152 | + | // the next authorized attempt can replay them exactly once. It decides that | |
| 153 | + | // delivery: a duplicate decision (a later AckInput or PreserveInput for the | |
| 154 | + | // same delivery, or a call with no outstanding delivery) is rejected as a | |
| 155 | + | // no-op instead of re-queuing different bytes. It stays authorized while the | |
| 156 | + | // supervisor finalizes this grant (after Done closes and before revocation), | |
| 157 | + | // so a cancelled worker can still save input; it is a no-op once authority is | |
| 158 | + | // revoked. | |
| 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. | |
| 163 | + | Resize(ctx context.Context) (domain.Geometry, bool) | |
| 164 | + | // Output publishes context and writes and flushes bytes in one authorized | |
| 165 | + | // transaction. Missing UI or terminal returns ports.ErrUIUnavailable. | |
| 166 | + | Output(uiContext ports.UIContext, data []byte) error | |
| 167 | + | // MarkAttached records the attached transition. It is refused once authority | |
| 168 | + | // is revoked. | |
| 169 | + | MarkAttached() bool | |
| 170 | + | // Attached reports whether MarkAttached has succeeded. | |
| 171 | + | Attached() bool | |
| 172 | + | // Done is closed when the supervisor finalizes this grant. It stops new | |
| 173 | + | // input, resize, output, and UI publication and wakes the worker, while | |
| 174 | + | // AckInput and PreserveInput stay authorized for the read the worker already | |
| 175 | + | // holds until the supervisor revokes the grant. | |
| 176 | + | Done() <-chan struct{} | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // AttachmentWorker is the contract for one broker logical stream's typed | |
| 180 | + | // session I/O. Run owns the stream returned by Stream (via the foreground) and | |
| 181 | + | // must honor ctx cancellation: the supervisor closes the stream after revoking | |
| 182 | + | // authority, which must unblock receive/send. Run must never enter or restore | |
| 183 | + | // raw mode, choose an endpoint, restore a source route, or touch the attachment | |
| 184 | + | // cache; none of those authorities are reachable from its arguments. | |
| 185 | + | type AttachmentWorker interface { | |
| 186 | + | Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // attachmentAuthorityState is the explicit lifecycle of the single foreground | |
| 190 | + | // slot. Finalization is a first-class state rather than a side effect of a | |
| 191 | + | // closed Done channel: while finalizing, only Ack/Preserve remain authorized, | |
| 192 | + | // every other action and a competing grant are refused. | |
| 193 | + | type attachmentAuthorityState uint8 | |
| 194 | + | ||
| 195 | + | const ( | |
| 196 | + | // authorityIdle has no foreground owner. | |
| 197 | + | authorityIdle attachmentAuthorityState = iota | |
| 198 | + | // authorityActive grants the foreground every AttachmentForeground action. | |
| 199 | + | authorityActive | |
| 200 | + | // authorityFinalizing retains the same foreground only for Ack/Preserve | |
| 201 | + | // while the supervisor joins a cancelled worker. New input, resize, output, | |
| 202 | + | // UI publication, MarkAttached, and a competing grant are all refused. | |
| 203 | + | authorityFinalizing | |
| 204 | + | ) | |
| 205 | + | ||
| 206 | + | // attachmentAuthority owns the single supervisor foreground slot. It is safe for | |
| 207 | + | // concurrent use: the supervisor finalizes and revokes from its own goroutine | |
| 208 | + | // while a worker acts from its run goroutine. | |
| 209 | + | type attachmentAuthority struct { | |
| 210 | + | mu sync.Mutex | |
| 211 | + | current *attachmentForeground | |
| 212 | + | state attachmentAuthorityState | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // 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, | |
| 217 | + | // which is what keeps exactly one foreground owner. The host never allocates a | |
| 218 | + | // UI generation here: UI action binding is deferred to the P5 supervisor. | |
| 219 | + | func (a *attachmentAuthority) grant(fg *attachmentForeground) bool { | |
| 220 | + | if a == nil || fg == nil { | |
| 221 | + | return false | |
| 222 | + | } | |
| 223 | + | a.mu.Lock() | |
| 224 | + | defer a.mu.Unlock() | |
| 225 | + | if a.current != nil { | |
| 226 | + | return false | |
| 227 | + | } | |
| 228 | + | if fg.input != nil { | |
| 229 | + | var ok bool | |
| 230 | + | fg.consumer, ok = fg.input.tryClaim() | |
| 231 | + | if !ok { | |
| 232 | + | return false | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | a.current = fg | |
| 236 | + | a.state = authorityActive | |
| 237 | + | return true | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // beginFinalize moves fg into the finalizing state: the worker keeps the slot | |
| 241 | + | // and its pump consumer for Ack/Preserve, but every other action is refused. | |
| 242 | + | // It reports false when fg no longer holds the slot. | |
| 243 | + | func (a *attachmentAuthority) beginFinalize(fg *attachmentForeground) bool { | |
| 244 | + | if a == nil || fg == nil { | |
| 245 | + | return false | |
| 246 | + | } | |
| 247 | + | a.mu.Lock() | |
| 248 | + | defer a.mu.Unlock() | |
| 249 | + | if a.current != fg { | |
| 250 | + | return false | |
| 251 | + | } | |
| 252 | + | a.state = authorityFinalizing | |
| 253 | + | return true | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // revoke clears fg if it is still the foreground, releasing the pump consumer | |
| 257 | + | // and any future UI binding, and reports whether it was. It runs after the | |
| 258 | + | // bounded join. Input release and clearing the slot are atomic with respect to | |
| 259 | + | // the next grant. | |
| 260 | + | func (a *attachmentAuthority) revoke(fg *attachmentForeground) bool { | |
| 261 | + | if a == nil || fg == nil { | |
| 262 | + | return false | |
| 263 | + | } | |
| 264 | + | a.mu.Lock() | |
| 265 | + | defer a.mu.Unlock() | |
| 266 | + | if a.current != fg { | |
| 267 | + | return false | |
| 268 | + | } | |
| 269 | + | if fg.input != nil && fg.consumer != 0 { | |
| 270 | + | fg.input.revoke(fg.consumer) | |
| 271 | + | } | |
| 272 | + | a.current = nil | |
| 273 | + | a.state = authorityIdle | |
| 274 | + | return true | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // foreground is the sole synchronized ownership accessor. It returns the | |
| 278 | + | // owner in both the active and finalizing states. | |
| 279 | + | func (a *attachmentAuthority) foreground() *attachmentForeground { | |
| 280 | + | if a == nil { | |
| 281 | + | return nil | |
| 282 | + | } | |
| 283 | + | a.mu.Lock() | |
| 284 | + | defer a.mu.Unlock() | |
| 285 | + | return a.current | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // actionAuthorized reports whether fg may take a terminal-facing action now: it | |
| 289 | + | // must hold the slot and the slot must not be finalizing. | |
| 290 | + | func (a *attachmentAuthority) actionAuthorized(fg *attachmentForeground) bool { | |
| 291 | + | if a == nil || fg == nil { | |
| 292 | + | return false | |
| 293 | + | } | |
| 294 | + | a.mu.Lock() | |
| 295 | + | defer a.mu.Unlock() | |
| 296 | + | return a.current == fg && a.state == authorityActive | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // act runs fn under the authority lock exactly when fg may still act, so an | |
| 300 | + | // action or state transition (the attached marker) is atomic with respect to | |
| 301 | + | // finalization and revocation. | |
| 302 | + | func (a *attachmentAuthority) act(fg *attachmentForeground, fn func()) bool { | |
| 303 | + | if a == nil || fg == nil { | |
| 304 | + | return false | |
| 305 | + | } | |
| 306 | + | a.mu.Lock() | |
| 307 | + | defer a.mu.Unlock() | |
| 308 | + | if a.current != fg || a.state != authorityActive { | |
| 309 | + | return false | |
| 310 | + | } | |
| 311 | + | fn() | |
| 312 | + | return true | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // retain runs fn under the authority lock exactly when fg still owns the slot, | |
| 316 | + | // including while finalizing. It is the Ack/Preserve path: those only commit or | |
| 317 | + | // re-queue input the worker already held, so a cancelled generation may still | |
| 318 | + | // save it during the final input window. Every other action uses act or | |
| 319 | + | // actionAuthorized. | |
| 320 | + | func (a *attachmentAuthority) retain(fg *attachmentForeground, fn func()) bool { | |
| 321 | + | if a == nil || fg == nil { | |
| 322 | + | return false | |
| 323 | + | } | |
| 324 | + | a.mu.Lock() | |
| 325 | + | defer a.mu.Unlock() | |
| 326 | + | if a.current != fg { | |
| 327 | + | return false | |
| 328 | + | } | |
| 329 | + | fn() | |
| 330 | + | return true | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // attachmentHostConfig supplies the supervisor-owned dependencies. Terminal, | |
| 334 | + | // clock, and input belong to the supervisor; UI and OnAttached are optional | |
| 335 | + | // seams. There is no Actions seam: UI action admission needs the P5 | |
| 336 | + | // supervisor's automation loop before it can be exposed (see the package | |
| 337 | + | // integration requirement above). | |
| 338 | + | type attachmentHostConfig struct { | |
| 339 | + | // Terminal is the controlling terminal the supervisor owns. Optional only | |
| 340 | + | // so a mechanism test can omit output entirely; production supplies it. | |
| 341 | + | Terminal ports.Terminal | |
| 342 | + | // Clock supplies the bounded-join timer. Use ports.Clock, not the wall | |
| 343 | + | // clock. | |
| 344 | + | Clock ports.Clock | |
| 345 | + | // Input is the supervisor-owned single terminal reader. The host claims one | |
| 346 | + | // consumer per foreground and revokes it after finalization. Optional. | |
| 347 | + | Input *terminalInputPump | |
| 348 | + | // UI is the supervisor-owned UI publication transaction. When nil it is | |
| 349 | + | // taken from Terminal when that implements ports.UIOutputTransaction. | |
| 350 | + | UI ports.UIOutputTransaction | |
| 351 | + | // OnAttached observes a successful MarkAttached. Optional. | |
| 352 | + | OnAttached func(AttachmentToken) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // attachmentHost is the supervisor-side owner of the terminal foreground. It | |
| 356 | + | // grants one worker generation at a time input/output authority over a single | |
| 357 | + | // admitted logical stream, joins that worker within a bound, and discards late | |
| 358 | + | // results. It owns no raw mode and restores no terminal: those stay with the | |
| 359 | + | // supervisor. | |
| 360 | + | type attachmentHost struct { | |
| 361 | + | term ports.Terminal | |
| 362 | + | clock ports.Clock | |
| 363 | + | input *terminalInputPump | |
| 364 | + | ui ports.UIOutputTransaction | |
| 365 | + | resizes <-chan domain.Geometry | |
| 366 | + | onAttach func(AttachmentToken) | |
| 367 | + | authority attachmentAuthority | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | |
| 371 | + | // to the system clock; a nil terminal leaves output refused but still enforces | |
| 372 | + | // authority. | |
| 373 | + | func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |
| 374 | + | clock := cfg.Clock | |
| 375 | + | if supervisorNil(clock) { | |
| 376 | + | clock = systemClock{} | |
| 377 | + | } | |
| 378 | + | ui := cfg.UI | |
| 379 | + | resizes := (<-chan domain.Geometry)(nil) | |
| 380 | + | if !supervisorNil(cfg.Terminal) { | |
| 381 | + | if supervisorNil(ui) { | |
| 382 | + | ui, _ = cfg.Terminal.(ports.UIOutputTransaction) | |
| 383 | + | } | |
| 384 | + | resizes = cfg.Terminal.ResizeEvents() | |
| 385 | + | } | |
| 386 | + | return &attachmentHost{ | |
| 387 | + | term: cfg.Terminal, | |
| 388 | + | clock: clock, | |
| 389 | + | input: cfg.Input, | |
| 390 | + | ui: ui, | |
| 391 | + | resizes: resizes, | |
| 392 | + | onAttach: cfg.OnAttached, | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // Begin grants one generation's foreground and starts its worker on a | |
| 397 | + | // supervisor-joined goroutine. It never closes or otherwise mutates stream, so | |
| 398 | + | // on false the caller retains sole ownership of it. It returns false when: | |
| 399 | + | // | |
| 400 | + | // - the host is nil; | |
| 401 | + | // - the token is zero (no supervisor generation was assigned); | |
| 402 | + | // - the worker or stream is nil, in any typed-nil shape; | |
| 403 | + | // - another foreground is already live, or is finalizing a previous grant; | |
| 404 | + | // - the shared input pump already has a consumer (a picker or rival | |
| 405 | + | // foreground owns input). | |
| 406 | + | // | |
| 407 | + | // The granted token, event token, and published UI context always use the | |
| 408 | + | // caller-supplied Generation. The caller must Wait or Cancel the returned run. | |
| 409 | + | func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (*attachmentRun, bool) { | |
| 410 | + | if h == nil || token.IsZero() || supervisorNil(worker) || supervisorNil(stream) { | |
| 411 | + | return nil, false | |
| 412 | + | } | |
| 413 | + | if ctx == nil { | |
| 414 | + | ctx = context.Background() | |
| 415 | + | } | |
| 416 | + | workerCtx, cancel := context.WithCancel(ctx) | |
| 417 | + | fg := h.newForeground(token, stream) | |
| 418 | + | if !h.authority.grant(fg) { | |
| 419 | + | cancel() | |
| 420 | + | return nil, false | |
| 421 | + | } | |
| 422 | + | run := &attachmentRun{ | |
| 423 | + | host: h, | |
| 424 | + | token: token, | |
| 425 | + | stream: stream, | |
| 426 | + | fg: fg, | |
| 427 | + | cancel: cancel, | |
| 428 | + | outcome: make(chan AttachmentEvent, 1), | |
| 429 | + | done: make(chan struct{}), | |
| 430 | + | retired: make(chan struct{}), | |
| 431 | + | } | |
| 432 | + | go func() { | |
| 433 | + | defer close(run.done) | |
| 434 | + | event := worker.Run(workerCtx, fg) | |
| 435 | + | // The host stamps the granted token: a worker can never report under a | |
| 436 | + | // generation it was not granted, and the settlement check is exact. | |
| 437 | + | event.Token = token | |
| 438 | + | run.outcome <- event | |
| 439 | + | }() | |
| 440 | + | return run, true | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // Run is Begin followed immediately by Wait, for callers that join in place. | |
| 444 | + | // The second result is false when the run was superseded or cancelled, in which | |
| 445 | + | // case the worker's late event is discarded. | |
| 446 | + | func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (AttachmentEvent, bool) { | |
| 447 | + | run, ok := h.Begin(ctx, token, worker, stream) | |
| 448 | + | if !ok { | |
| 449 | + | return AttachmentEvent{}, false | |
| 450 | + | } | |
| 451 | + | return run.Wait(ctx) | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // newForeground allocates a candidate; grant claims input before publishing it. | |
| 455 | + | func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground { | |
| 456 | + | return &attachmentForeground{ | |
| 457 | + | authority: &h.authority, | |
| 458 | + | token: token, | |
| 459 | + | stream: stream, | |
| 460 | + | input: h.input, | |
| 461 | + | lease: newForegroundSendLease(), | |
| 462 | + | term: h.term, | |
| 463 | + | ui: h.ui, | |
| 464 | + | resizes: h.resizes, | |
| 465 | + | done: make(chan struct{}), | |
| 466 | + | decided: true, | |
| 467 | + | onAttached: h.onAttach, | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | // attachmentRun is one in-flight worker generation owned by the supervisor. | |
| 472 | + | type attachmentRun struct { | |
| 473 | + | host *attachmentHost | |
| 474 | + | token AttachmentToken | |
| 475 | + | stream ports.BrokerLogicalConnection | |
| 476 | + | fg *attachmentForeground | |
| 477 | + | cancel context.CancelFunc | |
| 478 | + | // outcome is capacity one so a worker that settles after the supervisor has | |
| 479 | + | // already moved on can never block on its own return. | |
| 480 | + | outcome chan AttachmentEvent | |
| 481 | + | // done closes when the worker goroutine has returned. | |
| 482 | + | done chan struct{} | |
| 483 | + | joinOnce sync.Once | |
| 484 | + | stateMu sync.Mutex | |
| 485 | + | retired chan struct{} | |
| 486 | + | isRetired bool | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // Wait joins the run. It returns the worker's typed event with adopted=true | |
| 490 | + | // only when the worker settled on its own and the supervisor had not cancelled; | |
| 491 | + | // a run that was cancelled or superseded is joined and its late event discarded | |
| 492 | + | // (adopted=false). The caller's ctx stops waiting for an outcome; retirement | |
| 493 | + | // then uses the separate bounded worker-join budget. | |
| 494 | + | func (r *attachmentRun) Wait(ctx context.Context) (AttachmentEvent, bool) { | |
| 495 | + | if ctx == nil { | |
| 496 | + | ctx = context.Background() | |
| 497 | + | } | |
| 498 | + | select { | |
| 499 | + | case event := <-r.outcome: | |
| 500 | + | // Settlement and external retirement compete at one state boundary. | |
| 501 | + | adopted := r.retire() && ctx.Err() == nil && event.Token == r.token | |
| 502 | + | r.cleanup() | |
| 503 | + | if adopted { | |
| 504 | + | return event, true | |
| 505 | + | } | |
| 506 | + | case <-r.retired: | |
| 507 | + | r.cleanup() | |
| 508 | + | case <-ctx.Done(): | |
| 509 | + | r.Cancel() | |
| 510 | + | } | |
| 511 | + | return AttachmentEvent{}, false | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // retire is authoritative even when outcome and cancellation are both ready. | |
| 515 | + | func (r *attachmentRun) retire() bool { | |
| 516 | + | r.stateMu.Lock() | |
| 517 | + | defer r.stateMu.Unlock() | |
| 518 | + | if r.isRetired { | |
| 519 | + | return false | |
| 520 | + | } | |
| 521 | + | r.isRetired = true | |
| 522 | + | close(r.retired) | |
| 523 | + | return true | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | // Cancel marks retirement, runs the deterministic final input window, and | |
| 527 | + | // returns once the worker is joined or the join bound expires and the retained | |
| 528 | + | // consumer and stream are released. | |
| 529 | + | func (r *attachmentRun) Cancel() { | |
| 530 | + | r.retire() | |
| 531 | + | r.cleanup() | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | // cleanup is the ordered teardown. It refuses further output/UI/resize/new input | |
| 535 | + | // and closes Done, wakes the worker, then joins within the bound while the pump | |
| 536 | + | // consumer stays authorized for Ack/Preserve, so a worker woken by Done can | |
| 537 | + | // still save the read it holds. Only after the join (or its timeout) does it | |
| 538 | + | // revoke the consumer and foreground slot, then close the owned stream. | |
| 539 | + | func (r *attachmentRun) cleanup() { | |
| 540 | + | r.joinOnce.Do(func() { | |
| 541 | + | r.fg.finalize() | |
| 542 | + | r.cancel() | |
| 543 | + | r.join() | |
| 544 | + | r.fg.finish() | |
| 545 | + | _ = r.stream.Close() | |
| 546 | + | }) | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // join waits for the worker goroutine within the shared join bound. It allocates | |
| 550 | + | // a timer only when the worker has not already returned. | |
| 551 | + | func (r *attachmentRun) join() { | |
| 552 | + | select { | |
| 553 | + | case <-r.done: | |
| 554 | + | return | |
| 555 | + | default: | |
| 556 | + | } | |
| 557 | + | timer := r.host.clock.NewTimer(attachmentWorkerJoinTimeout) | |
| 558 | + | if supervisorNil(timer) { | |
| 559 | + | timer = systemClock{}.NewTimer(attachmentWorkerJoinTimeout) | |
| 560 | + | } | |
| 561 | + | defer stopSupervisorTimer(timer) | |
| 562 | + | select { | |
| 563 | + | case <-r.done: | |
| 564 | + | case <-timer.C(): | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // attachmentForeground is the supervisor-granted implementation of | |
| 569 | + | // AttachmentForeground. It reuses the existing terminal input pump for input | |
| 570 | + | // and the existing foreground send lease for output serialization. | |
| 571 | + | type attachmentForeground struct { | |
| 572 | + | authority *attachmentAuthority | |
| 573 | + | token AttachmentToken | |
| 574 | + | stream ports.BrokerLogicalConnection | |
| 575 | + | input *terminalInputPump | |
| 576 | + | consumer uint64 | |
| 577 | + | lease *foregroundSendLease | |
| 578 | + | term ports.Terminal | |
| 579 | + | ui ports.UIOutputTransaction | |
| 580 | + | resizes <-chan domain.Geometry | |
| 581 | + | done chan struct{} | |
| 582 | + | // finalizedOnce and finishedOnce split the single teardown into the | |
| 583 | + | // deterministic final input window: finalize closes Done and stops output | |
| 584 | + | // first, finish revokes the retained consumer only after the join. | |
| 585 | + | finalizedOnce sync.Once | |
| 586 | + | finishedOnce sync.Once | |
| 587 | + | // deliveryMu guards the at-most-once input decision. Input opens the window | |
| 588 | + | // by marking a freshly received delivery undecided; AckInput or PreserveInput | |
| 589 | + | // decides it at most once. decided starts true, so a decision with no | |
| 590 | + | // outstanding delivery is refused instead of silently committing nothing. | |
| 591 | + | deliveryMu sync.Mutex | |
| 592 | + | decided bool | |
| 593 | + | attached atomic.Bool | |
| 594 | + | onAttached func(AttachmentToken) | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // Token is the generation/attempt identity of this grant. | |
| 598 | + | func (f *attachmentForeground) Token() AttachmentToken { return f.token } | |
| 599 | + | ||
| 600 | + | // Stream is the supervisor-admitted logical stream this worker owns. | |
| 601 | + | func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream } | |
| 602 | + | ||
| 603 | + | // Done is closed when the supervisor starts finalizing this grant. It stops | |
| 604 | + | // Input and Resize and wakes a worker so it can still Ack/Preserve the read it | |
| 605 | + | // holds; the output gate is stopped at the same time. | |
| 606 | + | func (f *attachmentForeground) Done() <-chan struct{} { return f.done } | |
| 607 | + | ||
| 608 | + | // finalize opens the final input window exactly once: it moves the grant into | |
| 609 | + | // the finalizing authority state, stops the output gate (waiting for any | |
| 610 | + | // in-flight output transaction to end), and closes Done so Input/Resize refuse | |
| 611 | + | // new work and the worker wakes. The pump consumer and foreground slot stay | |
| 612 | + | // owned, so the worker may still Ack/Preserve the read it holds. | |
| 613 | + | // Lock order is authority -> send lease; beginFinalize releases authority before | |
| 614 | + | // waiting on the lease, so output never observes authority while it holds the | |
| 615 | + | // lease. | |
| 616 | + | func (f *attachmentForeground) finalize() { | |
| 617 | + | if f == nil { | |
| 618 | + | return | |
| 619 | + | } | |
| 620 | + | f.finalizedOnce.Do(func() { | |
| 621 | + | f.authority.beginFinalize(f) | |
| 622 | + | if f.lease != nil { | |
| 623 | + | f.lease.stop() | |
| 624 | + | } | |
| 625 | + | close(f.done) | |
| 626 | + | }) | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | // finish revokes the retained consumer and clears the foreground slot after the | |
| 630 | + | // bounded join. It runs before the stream is closed, so undecided input either | |
| 631 | + | // reached the pump during finalization or is dropped with the revocation. | |
| 632 | + | func (f *attachmentForeground) finish() { | |
| 633 | + | if f == nil { | |
| 634 | + | return | |
| 635 | + | } | |
| 636 | + | f.finishedOnce.Do(func() { f.authority.revoke(f) }) | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | // beginDelivery opens the decision window for the delivery the worker just | |
| 640 | + | // received: the next AckInput or PreserveInput decides it, and a further | |
| 641 | + | // decision is refused until Input receives another delivery. | |
| 642 | + | func (f *attachmentForeground) beginDelivery() { | |
| 643 | + | f.deliveryMu.Lock() | |
| 644 | + | f.decided = false | |
| 645 | + | f.deliveryMu.Unlock() | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | // decideDelivery reports whether this is the first decision for the current | |
| 649 | + | // delivery and marks it decided. A second Ack/Preserve for the same delivery, | |
| 650 | + | // or a decision with no outstanding delivery, is refused. | |
| 651 | + | func (f *attachmentForeground) decideDelivery() bool { | |
| 652 | + | f.deliveryMu.Lock() | |
| 653 | + | defer f.deliveryMu.Unlock() | |
| 654 | + | if f.decided { | |
| 655 | + | return false | |
| 656 | + | } | |
| 657 | + | f.decided = true | |
| 658 | + | return true | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | // Input leases the next authorized terminal read. It waits on the pump's ready | |
| 662 | + | // signal exactly like the attach scanner, so a shared single reader is never | |
| 663 | + | // duplicated. | |
| 664 | + | func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent, bool) { | |
| 665 | + | if ctx == nil { | |
| 666 | + | ctx = context.Background() | |
| 667 | + | } | |
| 668 | + | for { | |
| 669 | + | if !f.authority.actionAuthorized(f) || f.input == nil { | |
| 670 | + | return AttachmentInputEvent{}, false | |
| 671 | + | } | |
| 672 | + | result, ok := f.input.take(ctx, f.consumer) | |
| 673 | + | if ok { | |
| 674 | + | f.beginDelivery() | |
| 675 | + | return AttachmentInputEvent{Data: result.data, Err: result.err}, true | |
| 676 | + | } | |
| 677 | + | if ctx.Err() != nil { | |
| 678 | + | return AttachmentInputEvent{}, false | |
| 679 | + | } | |
| 680 | + | select { | |
| 681 | + | case <-ctx.Done(): | |
| 682 | + | return AttachmentInputEvent{}, false | |
| 683 | + | case <-f.done: | |
| 684 | + | return AttachmentInputEvent{}, false | |
| 685 | + | case <-f.input.ready: | |
| 686 | + | } | |
| 687 | + | } | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | // AckInput commits the last delivery so the shared reader may publish the next. | |
| 691 | + | // It decides that delivery, so a duplicate Ack/Preserve for it is a no-op. | |
| 692 | + | func (f *attachmentForeground) AckInput() { | |
| 693 | + | if f == nil || f.input == nil { | |
| 694 | + | return | |
| 695 | + | } | |
| 696 | + | if !f.decideDelivery() { | |
| 697 | + | return | |
| 698 | + | } | |
| 699 | + | f.authority.retain(f, func() { f.input.ack(f.consumer) }) | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | // PreserveInput re-queues the undecided bytes of the last delivery for the next | |
| 703 | + | // authorized attempt. It decides that delivery, so a duplicate Ack/Preserve for | |
| 704 | + | // it is a no-op and can never re-queue different bytes. It stays authorized | |
| 705 | + | // while the supervisor finalizes the grant. | |
| 706 | + | func (f *attachmentForeground) PreserveInput(data []byte) { | |
| 707 | + | if f == nil || f.input == nil { | |
| 708 | + | return | |
| 709 | + | } | |
| 710 | + | if !f.decideDelivery() { | |
| 711 | + | return | |
| 712 | + | } | |
| 713 | + | f.authority.retain(f, func() { | |
| 714 | + | f.input.ack(f.consumer) | |
| 715 | + | f.input.preserveResidual(f.consumer, data) | |
| 716 | + | }) | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | // Resize waits for the next authorized terminal resize. | |
| 720 | + | func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) { | |
| 721 | + | if ctx == nil { | |
| 722 | + | ctx = context.Background() | |
| 723 | + | } | |
| 724 | + | if !f.authority.actionAuthorized(f) { | |
| 725 | + | return domain.Geometry{}, false | |
| 726 | + | } | |
| 727 | + | resizes := f.resizes | |
| 728 | + | for { | |
| 729 | + | select { | |
| 730 | + | case geometry, ok := <-resizes: | |
| 731 | + | if !ok { | |
| 732 | + | resizes = nil | |
| 733 | + | continue | |
| 734 | + | } // uiterm has no resize source | |
| 735 | + | if !f.authority.actionAuthorized(f) { | |
| 736 | + | return domain.Geometry{}, false | |
| 737 | + | } | |
| 738 | + | return geometry, true | |
| 739 | + | case <-ctx.Done(): | |
| 740 | + | return domain.Geometry{}, false | |
| 741 | + | case <-f.done: | |
| 742 | + | return domain.Geometry{}, false | |
| 743 | + | } | |
| 744 | + | } | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | // Output holds the same send lease across the entire UI observation boundary. | |
| 748 | + | // It mirrors the legacy attach output path: a UI capture that is merely | |
| 749 | + | // unavailable (ports.ErrUIUnavailable) never stops the frame, so the write and | |
| 750 | + | // flush still proceed; any other publication failure aborts. EndOutput success | |
| 751 | + | // follows the frame write and flush, so a failed publication, short write, or | |
| 752 | + | // failed write or flush leaves EndOutput(false). | |
| 753 | + | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | |
| 754 | + | if f == nil { | |
| 755 | + | return errAttachmentForegroundRevoked | |
| 756 | + | } | |
| 757 | + | var outputErr error | |
| 758 | + | ok := f.lease.send(func() bool { | |
| 759 | + | if !f.authority.actionAuthorized(f) { | |
| 760 | + | return false | |
| 761 | + | } | |
| 762 | + | if supervisorNil(f.ui) || supervisorNil(f.term) { | |
| 763 | + | outputErr = ports.ErrUIUnavailable | |
| 764 | + | return true | |
| 765 | + | } | |
| 766 | + | writer := f.term.Out() | |
| 767 | + | if supervisorNil(writer) { | |
| 768 | + | outputErr = ports.ErrUIUnavailable | |
| 769 | + | return true | |
| 770 | + | } | |
| 771 | + | uiContext.Generation = f.token.Generation | |
| 772 | + | f.ui.BeginOutput(uiContext) | |
| 773 | + | success := false | |
| 774 | + | defer func() { f.ui.EndOutput(success) }() | |
| 775 | + | // An unavailable UI capture is tolerated, exactly like the legacy attach | |
| 776 | + | // path: the frame is still written and flushed. Any other publication | |
| 777 | + | // error aborts before the frame is written. | |
| 778 | + | if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 779 | + | outputErr = err | |
| 780 | + | return true | |
| 781 | + | } | |
| 782 | + | var n int | |
| 783 | + | n, outputErr = writer.Write(data) | |
| 784 | + | if outputErr == nil && n != len(data) { | |
| 785 | + | outputErr = io.ErrShortWrite | |
| 786 | + | } | |
| 787 | + | if outputErr != nil { | |
| 788 | + | return true | |
| 789 | + | } | |
| 790 | + | outputErr = f.term.Flush() | |
| 791 | + | success = outputErr == nil | |
| 792 | + | return true | |
| 793 | + | }) | |
| 794 | + | if !ok { | |
| 795 | + | return errAttachmentForegroundRevoked | |
| 796 | + | } | |
| 797 | + | return outputErr | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | // MarkAttached records the attached transition atomically with respect to | |
| 801 | + | // finalization and revocation. | |
| 802 | + | func (f *attachmentForeground) MarkAttached() bool { | |
| 803 | + | if f == nil { | |
| 804 | + | return false | |
| 805 | + | } | |
| 806 | + | set := f.authority.act(f, func() { f.attached.Store(true) }) | |
| 807 | + | if !set { | |
| 808 | + | return false | |
| 809 | + | } | |
| 810 | + | if f.onAttached != nil { | |
| 811 | + | f.onAttached(f.token) | |
| 812 | + | } | |
| 813 | + | return true | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | // Attached reports whether MarkAttached has succeeded. | |
| 817 | + | func (f *attachmentForeground) Attached() bool { | |
| 818 | + | return f != nil && f.attached.Load() | |
| 819 | + | } | |
| @@ -0,0 +1,819 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Attachment worker mechanisms (Plan 001 P5.1b, offline and unactivated). | |||
| 17 | + | // | |||
| 18 | + | // This file extracts the pieces of the legacy attach attempt that the | |||
| 19 | + | // autonomous supervisor must own, without changing the legacy Runner and | |||
| 20 | + | // without wiring the supervisor into production. It reuses the existing | |||
| 21 | + | // terminal input pump (terminalInputPump) and foreground output gate | |||
| 22 | + | // (foregroundSendLease) rather than duplicating them. | |||
| 23 | + | // | |||
| 24 | + | // Ownership split: | |||
| 25 | + | // | |||
| 26 | + | // - The supervisor owns the terminal: raw mode, the terminal writer, the | |||
| 27 | + | // single input pump, the UI publication transaction, resize events, and the | |||
| 28 | + | // process lifecycle. None of those move into a worker. | |||
| 29 | + | // - One AttachmentWorker owns exactly one broker logical stream and the typed | |||
| 30 | + | // session I/O on it. It cannot enter or restore raw mode, choose an | |||
| 31 | + | // endpoint, restore a source route, retain an attachment in the cache, or | |||
| 32 | + | // write the terminal directly. | |||
| 33 | + | // - Every terminal-facing action a worker performs crosses the | |||
| 34 | + | // supervisor-granted AttachmentForeground. The supervisor grants exactly | |||
| 35 | + | // one foreground at a time; on cancellation or supersession it finalizes | |||
| 36 | + | // that grant (closing Done and refusing output, UI, resize, and new input), | |||
| 37 | + | // lets the woken worker Ack/Preserve the read it holds through a bounded | |||
| 38 | + | // join, then revokes input authority before closing the owned stream. | |||
| 39 | + | // - Every worker result carries the AttachmentToken (generation/attempt) the | |||
| 40 | + | // supervisor granted, so a late result from a canceled or superseded | |||
| 41 | + | // generation is discarded rather than applied. | |||
| 42 | + | ||||
| 43 | + | // Integration requirement: P5.1a currently owns a rival terminal reader. Before | |||
| 44 | + | // activation it must use this same pump for picker and attachment consumers; | |||
| 45 | + | // never start both readers. Runner and supervisor wiring remain unchanged. | |||
| 46 | + | // | |||
| 47 | + | // UI actions are deliberately not part of this slice. AttachmentForeground has | |||
| 48 | + | // no action admission seam and attachmentHostConfig has no Actions field: | |||
| 49 | + | // binding UI.bindForeground and admitting actions requires the P5 supervisor's | |||
| 50 | + | // automation loop, which does not exist yet, so exposing it would publish a | |||
| 51 | + | // generation no component can drive. The transactional UI output path | |||
| 52 | + | // (ports.UIOutputTransaction, reached through Output) stays independent and | |||
| 53 | + | // usable. When the P5 supervisor owns input automation, it must bind and revoke | |||
| 54 | + | // UI actions at the same foreground boundaries this host finalizes. | |||
| 55 | + | ||||
| 56 | + | // attachmentWorkerJoinTimeout bounds the supervisor's join of a cancelled | |||
| 57 | + | // worker during the final input window. A worker that honors neither | |||
| 58 | + | // cancellation nor a closed Done is abandoned rather than allowed to pin the | |||
| 59 | + | // supervisor; the supervisor then revokes the retained consumer and closes the | |||
| 60 | + | // stream anyway. It mirrors the attachment retirement join budget. | |||
| 61 | + | const attachmentWorkerJoinTimeout = 5 * time.Second | |||
| 62 | + | ||||
| 63 | + | // errAttachmentForegroundRevoked reports a worker action refused because the | |||
| 64 | + | // supervisor revoked its input/output authority. | |||
| 65 | + | var errAttachmentForegroundRevoked = errors.New("client: attachment foreground authority revoked") | |||
| 66 | + | ||||
| 67 | + | // AttachmentToken identifies one attachment worker run. Generation is the | |||
| 68 | + | // supervisor-assigned foreground generation. The host never allocates it while | |||
| 69 | + | // UI action binding is deferred, so the supervisor supplies it and the host | |||
| 70 | + | // stamps it onto the event and every publication; Attempt is the worker | |||
| 71 | + | // identity, not a UI generation. A zero Generation is never granted. | |||
| 72 | + | type AttachmentToken struct { | |||
| 73 | + | Generation uint64 | |||
| 74 | + | Attempt uint64 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // IsZero reports whether the token was never assigned by a supervisor. | |||
| 78 | + | func (t AttachmentToken) IsZero() bool { return t.Generation == 0 } | |||
| 79 | + | ||||
| 80 | + | // String renders the token for diagnostics. | |||
| 81 | + | func (t AttachmentToken) String() string { | |||
| 82 | + | return fmt.Sprintf("gen=%d attempt=%d", t.Generation, t.Attempt) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // AttachmentEventKind classifies one worker's terminal outcome. Every kind | |||
| 86 | + | // returns the client to the picker: a worker event never terminates the process | |||
| 87 | + | // by itself. Only the supervisor's own state machine decides that. | |||
| 88 | + | type AttachmentEventKind uint8 | |||
| 89 | + | ||||
| 90 | + | const ( | |||
| 91 | + | // AttachmentEventEnded is an orderly end: the logical stream reached EOF or | |||
| 92 | + | // the worker detached without failure. | |||
| 93 | + | AttachmentEventEnded AttachmentEventKind = iota + 1 | |||
| 94 | + | // AttachmentEventLost is a transport or logical-stream loss. The logical | |||
| 95 | + | // stream's own Done/Err carries the stable cause. | |||
| 96 | + | AttachmentEventLost | |||
| 97 | + | // AttachmentEventFailed is a typed protocol or application failure on the | |||
| 98 | + | // stream, distinct from a carriage loss. | |||
| 99 | + | AttachmentEventFailed | |||
| 100 | + | ) | |||
| 101 | + | ||||
| 102 | + | // String renders the event kind for diagnostics. | |||
| 103 | + | func (k AttachmentEventKind) String() string { | |||
| 104 | + | switch k { | |||
| 105 | + | case AttachmentEventEnded: | |||
| 106 | + | return "ended" | |||
| 107 | + | case AttachmentEventLost: | |||
| 108 | + | return "lost" | |||
| 109 | + | case AttachmentEventFailed: | |||
| 110 | + | return "failed" | |||
| 111 | + | default: | |||
| 112 | + | return "unknown" | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // AttachmentEvent is the typed result of one worker run. Token is always the | |||
| 117 | + | // token the supervisor granted: the host stamps it, so a worker cannot report | |||
| 118 | + | // under another generation. | |||
| 119 | + | type AttachmentEvent struct { | |||
| 120 | + | Token AttachmentToken | |||
| 121 | + | Kind AttachmentEventKind | |||
| 122 | + | Err error | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // AttachmentInputEvent is one authorized terminal input delivery. Err carries a | |||
| 126 | + | // terminal read failure (for example io.EOF) alongside any final bytes. | |||
| 127 | + | type AttachmentInputEvent struct { | |||
| 128 | + | Data []byte | |||
| 129 | + | Err error | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // AttachmentForeground is the supervisor-granted handle through which a worker | |||
| 133 | + | // touches the terminal. It is the worker's only path to input, resize, output, | |||
| 134 | + | // UI publication, and the attached transition. Every method is refused once the | |||
| 135 | + | // supervisor revokes the grant, so a canceled or superseded generation cannot | |||
| 136 | + | // read input, resize, write output, publish UI, or mark itself attached. | |||
| 137 | + | type AttachmentForeground interface { | |||
| 138 | + | // Token is the generation/attempt identity of this grant. | |||
| 139 | + | Token() AttachmentToken | |||
| 140 | + | // Stream is the supervisor-admitted logical stream this worker owns for its | |||
| 141 | + | // lifetime. The supervisor retains close authority. | |||
| 142 | + | Stream() ports.BrokerLogicalConnection | |||
| 143 | + | // Input blocks until the next authorized terminal input delivery. It returns | |||
| 144 | + | // false once authority is revoked or ctx ends. | |||
| 145 | + | Input(ctx context.Context) (AttachmentInputEvent, bool) | |||
| 146 | + | // AckInput commits the last Input delivery so the shared reader may accept | |||
| 147 | + | // the next one. It decides that delivery: at most one of AckInput and | |||
| 148 | + | // PreserveInput may decide a delivery, and a second decision for the same | |||
| 149 | + | // delivery is rejected as a no-op. It is a no-op once authority is revoked. | |||
| 150 | + | AckInput() | |||
| 151 | + | // PreserveInput re-queues the undecided bytes of the last Input delivery so | |||
| 152 | + | // the next authorized attempt can replay them exactly once. It decides that | |||
| 153 | + | // delivery: a duplicate decision (a later AckInput or PreserveInput for the | |||
| 154 | + | // same delivery, or a call with no outstanding delivery) is rejected as a | |||
| 155 | + | // no-op instead of re-queuing different bytes. It stays authorized while the | |||
| 156 | + | // supervisor finalizes this grant (after Done closes and before revocation), | |||
| 157 | + | // so a cancelled worker can still save input; it is a no-op once authority is | |||
| 158 | + | // revoked. | |||
| 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. | |||
| 163 | + | Resize(ctx context.Context) (domain.Geometry, bool) | |||
| 164 | + | // Output publishes context and writes and flushes bytes in one authorized | |||
| 165 | + | // transaction. Missing UI or terminal returns ports.ErrUIUnavailable. | |||
| 166 | + | Output(uiContext ports.UIContext, data []byte) error | |||
| 167 | + | // MarkAttached records the attached transition. It is refused once authority | |||
| 168 | + | // is revoked. | |||
| 169 | + | MarkAttached() bool | |||
| 170 | + | // Attached reports whether MarkAttached has succeeded. | |||
| 171 | + | Attached() bool | |||
| 172 | + | // Done is closed when the supervisor finalizes this grant. It stops new | |||
| 173 | + | // input, resize, output, and UI publication and wakes the worker, while | |||
| 174 | + | // AckInput and PreserveInput stay authorized for the read the worker already | |||
| 175 | + | // holds until the supervisor revokes the grant. | |||
| 176 | + | Done() <-chan struct{} | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // AttachmentWorker is the contract for one broker logical stream's typed | |||
| 180 | + | // session I/O. Run owns the stream returned by Stream (via the foreground) and | |||
| 181 | + | // must honor ctx cancellation: the supervisor closes the stream after revoking | |||
| 182 | + | // authority, which must unblock receive/send. Run must never enter or restore | |||
| 183 | + | // raw mode, choose an endpoint, restore a source route, or touch the attachment | |||
| 184 | + | // cache; none of those authorities are reachable from its arguments. | |||
| 185 | + | type AttachmentWorker interface { | |||
| 186 | + | Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // attachmentAuthorityState is the explicit lifecycle of the single foreground | |||
| 190 | + | // slot. Finalization is a first-class state rather than a side effect of a | |||
| 191 | + | // closed Done channel: while finalizing, only Ack/Preserve remain authorized, | |||
| 192 | + | // every other action and a competing grant are refused. | |||
| 193 | + | type attachmentAuthorityState uint8 | |||
| 194 | + | ||||
| 195 | + | const ( | |||
| 196 | + | // authorityIdle has no foreground owner. | |||
| 197 | + | authorityIdle attachmentAuthorityState = iota | |||
| 198 | + | // authorityActive grants the foreground every AttachmentForeground action. | |||
| 199 | + | authorityActive | |||
| 200 | + | // authorityFinalizing retains the same foreground only for Ack/Preserve | |||
| 201 | + | // while the supervisor joins a cancelled worker. New input, resize, output, | |||
| 202 | + | // UI publication, MarkAttached, and a competing grant are all refused. | |||
| 203 | + | authorityFinalizing | |||
| 204 | + | ) | |||
| 205 | + | ||||
| 206 | + | // attachmentAuthority owns the single supervisor foreground slot. It is safe for | |||
| 207 | + | // concurrent use: the supervisor finalizes and revokes from its own goroutine | |||
| 208 | + | // while a worker acts from its run goroutine. | |||
| 209 | + | type attachmentAuthority struct { | |||
| 210 | + | mu sync.Mutex | |||
| 211 | + | current *attachmentForeground | |||
| 212 | + | state attachmentAuthorityState | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // 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, | |||
| 217 | + | // which is what keeps exactly one foreground owner. The host never allocates a | |||
| 218 | + | // UI generation here: UI action binding is deferred to the P5 supervisor. | |||
| 219 | + | func (a *attachmentAuthority) grant(fg *attachmentForeground) bool { | |||
| 220 | + | if a == nil || fg == nil { | |||
| 221 | + | return false | |||
| 222 | + | } | |||
| 223 | + | a.mu.Lock() | |||
| 224 | + | defer a.mu.Unlock() | |||
| 225 | + | if a.current != nil { | |||
| 226 | + | return false | |||
| 227 | + | } | |||
| 228 | + | if fg.input != nil { | |||
| 229 | + | var ok bool | |||
| 230 | + | fg.consumer, ok = fg.input.tryClaim() | |||
| 231 | + | if !ok { | |||
| 232 | + | return false | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | a.current = fg | |||
| 236 | + | a.state = authorityActive | |||
| 237 | + | return true | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // beginFinalize moves fg into the finalizing state: the worker keeps the slot | |||
| 241 | + | // and its pump consumer for Ack/Preserve, but every other action is refused. | |||
| 242 | + | // It reports false when fg no longer holds the slot. | |||
| 243 | + | func (a *attachmentAuthority) beginFinalize(fg *attachmentForeground) bool { | |||
| 244 | + | if a == nil || fg == nil { | |||
| 245 | + | return false | |||
| 246 | + | } | |||
| 247 | + | a.mu.Lock() | |||
| 248 | + | defer a.mu.Unlock() | |||
| 249 | + | if a.current != fg { | |||
| 250 | + | return false | |||
| 251 | + | } | |||
| 252 | + | a.state = authorityFinalizing | |||
| 253 | + | return true | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // revoke clears fg if it is still the foreground, releasing the pump consumer | |||
| 257 | + | // and any future UI binding, and reports whether it was. It runs after the | |||
| 258 | + | // bounded join. Input release and clearing the slot are atomic with respect to | |||
| 259 | + | // the next grant. | |||
| 260 | + | func (a *attachmentAuthority) revoke(fg *attachmentForeground) bool { | |||
| 261 | + | if a == nil || fg == nil { | |||
| 262 | + | return false | |||
| 263 | + | } | |||
| 264 | + | a.mu.Lock() | |||
| 265 | + | defer a.mu.Unlock() | |||
| 266 | + | if a.current != fg { | |||
| 267 | + | return false | |||
| 268 | + | } | |||
| 269 | + | if fg.input != nil && fg.consumer != 0 { | |||
| 270 | + | fg.input.revoke(fg.consumer) | |||
| 271 | + | } | |||
| 272 | + | a.current = nil | |||
| 273 | + | a.state = authorityIdle | |||
| 274 | + | return true | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // foreground is the sole synchronized ownership accessor. It returns the | |||
| 278 | + | // owner in both the active and finalizing states. | |||
| 279 | + | func (a *attachmentAuthority) foreground() *attachmentForeground { | |||
| 280 | + | if a == nil { | |||
| 281 | + | return nil | |||
| 282 | + | } | |||
| 283 | + | a.mu.Lock() | |||
| 284 | + | defer a.mu.Unlock() | |||
| 285 | + | return a.current | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // actionAuthorized reports whether fg may take a terminal-facing action now: it | |||
| 289 | + | // must hold the slot and the slot must not be finalizing. | |||
| 290 | + | func (a *attachmentAuthority) actionAuthorized(fg *attachmentForeground) bool { | |||
| 291 | + | if a == nil || fg == nil { | |||
| 292 | + | return false | |||
| 293 | + | } | |||
| 294 | + | a.mu.Lock() | |||
| 295 | + | defer a.mu.Unlock() | |||
| 296 | + | return a.current == fg && a.state == authorityActive | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // act runs fn under the authority lock exactly when fg may still act, so an | |||
| 300 | + | // action or state transition (the attached marker) is atomic with respect to | |||
| 301 | + | // finalization and revocation. | |||
| 302 | + | func (a *attachmentAuthority) act(fg *attachmentForeground, fn func()) bool { | |||
| 303 | + | if a == nil || fg == nil { | |||
| 304 | + | return false | |||
| 305 | + | } | |||
| 306 | + | a.mu.Lock() | |||
| 307 | + | defer a.mu.Unlock() | |||
| 308 | + | if a.current != fg || a.state != authorityActive { | |||
| 309 | + | return false | |||
| 310 | + | } | |||
| 311 | + | fn() | |||
| 312 | + | return true | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | // retain runs fn under the authority lock exactly when fg still owns the slot, | |||
| 316 | + | // including while finalizing. It is the Ack/Preserve path: those only commit or | |||
| 317 | + | // re-queue input the worker already held, so a cancelled generation may still | |||
| 318 | + | // save it during the final input window. Every other action uses act or | |||
| 319 | + | // actionAuthorized. | |||
| 320 | + | func (a *attachmentAuthority) retain(fg *attachmentForeground, fn func()) bool { | |||
| 321 | + | if a == nil || fg == nil { | |||
| 322 | + | return false | |||
| 323 | + | } | |||
| 324 | + | a.mu.Lock() | |||
| 325 | + | defer a.mu.Unlock() | |||
| 326 | + | if a.current != fg { | |||
| 327 | + | return false | |||
| 328 | + | } | |||
| 329 | + | fn() | |||
| 330 | + | return true | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // attachmentHostConfig supplies the supervisor-owned dependencies. Terminal, | |||
| 334 | + | // clock, and input belong to the supervisor; UI and OnAttached are optional | |||
| 335 | + | // seams. There is no Actions seam: UI action admission needs the P5 | |||
| 336 | + | // supervisor's automation loop before it can be exposed (see the package | |||
| 337 | + | // integration requirement above). | |||
| 338 | + | type attachmentHostConfig struct { | |||
| 339 | + | // Terminal is the controlling terminal the supervisor owns. Optional only | |||
| 340 | + | // so a mechanism test can omit output entirely; production supplies it. | |||
| 341 | + | Terminal ports.Terminal | |||
| 342 | + | // Clock supplies the bounded-join timer. Use ports.Clock, not the wall | |||
| 343 | + | // clock. | |||
| 344 | + | Clock ports.Clock | |||
| 345 | + | // Input is the supervisor-owned single terminal reader. The host claims one | |||
| 346 | + | // consumer per foreground and revokes it after finalization. Optional. | |||
| 347 | + | Input *terminalInputPump | |||
| 348 | + | // UI is the supervisor-owned UI publication transaction. When nil it is | |||
| 349 | + | // taken from Terminal when that implements ports.UIOutputTransaction. | |||
| 350 | + | UI ports.UIOutputTransaction | |||
| 351 | + | // OnAttached observes a successful MarkAttached. Optional. | |||
| 352 | + | OnAttached func(AttachmentToken) | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // attachmentHost is the supervisor-side owner of the terminal foreground. It | |||
| 356 | + | // grants one worker generation at a time input/output authority over a single | |||
| 357 | + | // admitted logical stream, joins that worker within a bound, and discards late | |||
| 358 | + | // results. It owns no raw mode and restores no terminal: those stay with the | |||
| 359 | + | // supervisor. | |||
| 360 | + | type attachmentHost struct { | |||
| 361 | + | term ports.Terminal | |||
| 362 | + | clock ports.Clock | |||
| 363 | + | input *terminalInputPump | |||
| 364 | + | ui ports.UIOutputTransaction | |||
| 365 | + | resizes <-chan domain.Geometry | |||
| 366 | + | onAttach func(AttachmentToken) | |||
| 367 | + | authority attachmentAuthority | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | |||
| 371 | + | // to the system clock; a nil terminal leaves output refused but still enforces | |||
| 372 | + | // authority. | |||
| 373 | + | func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||
| 374 | + | clock := cfg.Clock | |||
| 375 | + | if supervisorNil(clock) { | |||
| 376 | + | clock = systemClock{} | |||
| 377 | + | } | |||
| 378 | + | ui := cfg.UI | |||
| 379 | + | resizes := (<-chan domain.Geometry)(nil) | |||
| 380 | + | if !supervisorNil(cfg.Terminal) { | |||
| 381 | + | if supervisorNil(ui) { | |||
| 382 | + | ui, _ = cfg.Terminal.(ports.UIOutputTransaction) | |||
| 383 | + | } | |||
| 384 | + | resizes = cfg.Terminal.ResizeEvents() | |||
| 385 | + | } | |||
| 386 | + | return &attachmentHost{ | |||
| 387 | + | term: cfg.Terminal, | |||
| 388 | + | clock: clock, | |||
| 389 | + | input: cfg.Input, | |||
| 390 | + | ui: ui, | |||
| 391 | + | resizes: resizes, | |||
| 392 | + | onAttach: cfg.OnAttached, | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | // Begin grants one generation's foreground and starts its worker on a | |||
| 397 | + | // supervisor-joined goroutine. It never closes or otherwise mutates stream, so | |||
| 398 | + | // on false the caller retains sole ownership of it. It returns false when: | |||
| 399 | + | // | |||
| 400 | + | // - the host is nil; | |||
| 401 | + | // - the token is zero (no supervisor generation was assigned); | |||
| 402 | + | // - the worker or stream is nil, in any typed-nil shape; | |||
| 403 | + | // - another foreground is already live, or is finalizing a previous grant; | |||
| 404 | + | // - the shared input pump already has a consumer (a picker or rival | |||
| 405 | + | // foreground owns input). | |||
| 406 | + | // | |||
| 407 | + | // The granted token, event token, and published UI context always use the | |||
| 408 | + | // caller-supplied Generation. The caller must Wait or Cancel the returned run. | |||
| 409 | + | func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (*attachmentRun, bool) { | |||
| 410 | + | if h == nil || token.IsZero() || supervisorNil(worker) || supervisorNil(stream) { | |||
| 411 | + | return nil, false | |||
| 412 | + | } | |||
| 413 | + | if ctx == nil { | |||
| 414 | + | ctx = context.Background() | |||
| 415 | + | } | |||
| 416 | + | workerCtx, cancel := context.WithCancel(ctx) | |||
| 417 | + | fg := h.newForeground(token, stream) | |||
| 418 | + | if !h.authority.grant(fg) { | |||
| 419 | + | cancel() | |||
| 420 | + | return nil, false | |||
| 421 | + | } | |||
| 422 | + | run := &attachmentRun{ | |||
| 423 | + | host: h, | |||
| 424 | + | token: token, | |||
| 425 | + | stream: stream, | |||
| 426 | + | fg: fg, | |||
| 427 | + | cancel: cancel, | |||
| 428 | + | outcome: make(chan AttachmentEvent, 1), | |||
| 429 | + | done: make(chan struct{}), | |||
| 430 | + | retired: make(chan struct{}), | |||
| 431 | + | } | |||
| 432 | + | go func() { | |||
| 433 | + | defer close(run.done) | |||
| 434 | + | event := worker.Run(workerCtx, fg) | |||
| 435 | + | // The host stamps the granted token: a worker can never report under a | |||
| 436 | + | // generation it was not granted, and the settlement check is exact. | |||
| 437 | + | event.Token = token | |||
| 438 | + | run.outcome <- event | |||
| 439 | + | }() | |||
| 440 | + | return run, true | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | // Run is Begin followed immediately by Wait, for callers that join in place. | |||
| 444 | + | // The second result is false when the run was superseded or cancelled, in which | |||
| 445 | + | // case the worker's late event is discarded. | |||
| 446 | + | func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (AttachmentEvent, bool) { | |||
| 447 | + | run, ok := h.Begin(ctx, token, worker, stream) | |||
| 448 | + | if !ok { | |||
| 449 | + | return AttachmentEvent{}, false | |||
| 450 | + | } | |||
| 451 | + | return run.Wait(ctx) | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // newForeground allocates a candidate; grant claims input before publishing it. | |||
| 455 | + | func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground { | |||
| 456 | + | return &attachmentForeground{ | |||
| 457 | + | authority: &h.authority, | |||
| 458 | + | token: token, | |||
| 459 | + | stream: stream, | |||
| 460 | + | input: h.input, | |||
| 461 | + | lease: newForegroundSendLease(), | |||
| 462 | + | term: h.term, | |||
| 463 | + | ui: h.ui, | |||
| 464 | + | resizes: h.resizes, | |||
| 465 | + | done: make(chan struct{}), | |||
| 466 | + | decided: true, | |||
| 467 | + | onAttached: h.onAttach, | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | // attachmentRun is one in-flight worker generation owned by the supervisor. | |||
| 472 | + | type attachmentRun struct { | |||
| 473 | + | host *attachmentHost | |||
| 474 | + | token AttachmentToken | |||
| 475 | + | stream ports.BrokerLogicalConnection | |||
| 476 | + | fg *attachmentForeground | |||
| 477 | + | cancel context.CancelFunc | |||
| 478 | + | // outcome is capacity one so a worker that settles after the supervisor has | |||
| 479 | + | // already moved on can never block on its own return. | |||
| 480 | + | outcome chan AttachmentEvent | |||
| 481 | + | // done closes when the worker goroutine has returned. | |||
| 482 | + | done chan struct{} | |||
| 483 | + | joinOnce sync.Once | |||
| 484 | + | stateMu sync.Mutex | |||
| 485 | + | retired chan struct{} | |||
| 486 | + | isRetired bool | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // Wait joins the run. It returns the worker's typed event with adopted=true | |||
| 490 | + | // only when the worker settled on its own and the supervisor had not cancelled; | |||
| 491 | + | // a run that was cancelled or superseded is joined and its late event discarded | |||
| 492 | + | // (adopted=false). The caller's ctx stops waiting for an outcome; retirement | |||
| 493 | + | // then uses the separate bounded worker-join budget. | |||
| 494 | + | func (r *attachmentRun) Wait(ctx context.Context) (AttachmentEvent, bool) { | |||
| 495 | + | if ctx == nil { | |||
| 496 | + | ctx = context.Background() | |||
| 497 | + | } | |||
| 498 | + | select { | |||
| 499 | + | case event := <-r.outcome: | |||
| 500 | + | // Settlement and external retirement compete at one state boundary. | |||
| 501 | + | adopted := r.retire() && ctx.Err() == nil && event.Token == r.token | |||
| 502 | + | r.cleanup() | |||
| 503 | + | if adopted { | |||
| 504 | + | return event, true | |||
| 505 | + | } | |||
| 506 | + | case <-r.retired: | |||
| 507 | + | r.cleanup() | |||
| 508 | + | case <-ctx.Done(): | |||
| 509 | + | r.Cancel() | |||
| 510 | + | } | |||
| 511 | + | return AttachmentEvent{}, false | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | // retire is authoritative even when outcome and cancellation are both ready. | |||
| 515 | + | func (r *attachmentRun) retire() bool { | |||
| 516 | + | r.stateMu.Lock() | |||
| 517 | + | defer r.stateMu.Unlock() | |||
| 518 | + | if r.isRetired { | |||
| 519 | + | return false | |||
| 520 | + | } | |||
| 521 | + | r.isRetired = true | |||
| 522 | + | close(r.retired) | |||
| 523 | + | return true | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | // Cancel marks retirement, runs the deterministic final input window, and | |||
| 527 | + | // returns once the worker is joined or the join bound expires and the retained | |||
| 528 | + | // consumer and stream are released. | |||
| 529 | + | func (r *attachmentRun) Cancel() { | |||
| 530 | + | r.retire() | |||
| 531 | + | r.cleanup() | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | // cleanup is the ordered teardown. It refuses further output/UI/resize/new input | |||
| 535 | + | // and closes Done, wakes the worker, then joins within the bound while the pump | |||
| 536 | + | // consumer stays authorized for Ack/Preserve, so a worker woken by Done can | |||
| 537 | + | // still save the read it holds. Only after the join (or its timeout) does it | |||
| 538 | + | // revoke the consumer and foreground slot, then close the owned stream. | |||
| 539 | + | func (r *attachmentRun) cleanup() { | |||
| 540 | + | r.joinOnce.Do(func() { | |||
| 541 | + | r.fg.finalize() | |||
| 542 | + | r.cancel() | |||
| 543 | + | r.join() | |||
| 544 | + | r.fg.finish() | |||
| 545 | + | _ = r.stream.Close() | |||
| 546 | + | }) | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // join waits for the worker goroutine within the shared join bound. It allocates | |||
| 550 | + | // a timer only when the worker has not already returned. | |||
| 551 | + | func (r *attachmentRun) join() { | |||
| 552 | + | select { | |||
| 553 | + | case <-r.done: | |||
| 554 | + | return | |||
| 555 | + | default: | |||
| 556 | + | } | |||
| 557 | + | timer := r.host.clock.NewTimer(attachmentWorkerJoinTimeout) | |||
| 558 | + | if supervisorNil(timer) { | |||
| 559 | + | timer = systemClock{}.NewTimer(attachmentWorkerJoinTimeout) | |||
| 560 | + | } | |||
| 561 | + | defer stopSupervisorTimer(timer) | |||
| 562 | + | select { | |||
| 563 | + | case <-r.done: | |||
| 564 | + | case <-timer.C(): | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | // attachmentForeground is the supervisor-granted implementation of | |||
| 569 | + | // AttachmentForeground. It reuses the existing terminal input pump for input | |||
| 570 | + | // and the existing foreground send lease for output serialization. | |||
| 571 | + | type attachmentForeground struct { | |||
| 572 | + | authority *attachmentAuthority | |||
| 573 | + | token AttachmentToken | |||
| 574 | + | stream ports.BrokerLogicalConnection | |||
| 575 | + | input *terminalInputPump | |||
| 576 | + | consumer uint64 | |||
| 577 | + | lease *foregroundSendLease | |||
| 578 | + | term ports.Terminal | |||
| 579 | + | ui ports.UIOutputTransaction | |||
| 580 | + | resizes <-chan domain.Geometry | |||
| 581 | + | done chan struct{} | |||
| 582 | + | // finalizedOnce and finishedOnce split the single teardown into the | |||
| 583 | + | // deterministic final input window: finalize closes Done and stops output | |||
| 584 | + | // first, finish revokes the retained consumer only after the join. | |||
| 585 | + | finalizedOnce sync.Once | |||
| 586 | + | finishedOnce sync.Once | |||
| 587 | + | // deliveryMu guards the at-most-once input decision. Input opens the window | |||
| 588 | + | // by marking a freshly received delivery undecided; AckInput or PreserveInput | |||
| 589 | + | // decides it at most once. decided starts true, so a decision with no | |||
| 590 | + | // outstanding delivery is refused instead of silently committing nothing. | |||
| 591 | + | deliveryMu sync.Mutex | |||
| 592 | + | decided bool | |||
| 593 | + | attached atomic.Bool | |||
| 594 | + | onAttached func(AttachmentToken) | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | // Token is the generation/attempt identity of this grant. | |||
| 598 | + | func (f *attachmentForeground) Token() AttachmentToken { return f.token } | |||
| 599 | + | ||||
| 600 | + | // Stream is the supervisor-admitted logical stream this worker owns. | |||
| 601 | + | func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream } | |||
| 602 | + | ||||
| 603 | + | // Done is closed when the supervisor starts finalizing this grant. It stops | |||
| 604 | + | // Input and Resize and wakes a worker so it can still Ack/Preserve the read it | |||
| 605 | + | // holds; the output gate is stopped at the same time. | |||
| 606 | + | func (f *attachmentForeground) Done() <-chan struct{} { return f.done } | |||
| 607 | + | ||||
| 608 | + | // finalize opens the final input window exactly once: it moves the grant into | |||
| 609 | + | // the finalizing authority state, stops the output gate (waiting for any | |||
| 610 | + | // in-flight output transaction to end), and closes Done so Input/Resize refuse | |||
| 611 | + | // new work and the worker wakes. The pump consumer and foreground slot stay | |||
| 612 | + | // owned, so the worker may still Ack/Preserve the read it holds. | |||
| 613 | + | // Lock order is authority -> send lease; beginFinalize releases authority before | |||
| 614 | + | // waiting on the lease, so output never observes authority while it holds the | |||
| 615 | + | // lease. | |||
| 616 | + | func (f *attachmentForeground) finalize() { | |||
| 617 | + | if f == nil { | |||
| 618 | + | return | |||
| 619 | + | } | |||
| 620 | + | f.finalizedOnce.Do(func() { | |||
| 621 | + | f.authority.beginFinalize(f) | |||
| 622 | + | if f.lease != nil { | |||
| 623 | + | f.lease.stop() | |||
| 624 | + | } | |||
| 625 | + | close(f.done) | |||
| 626 | + | }) | |||
| 627 | + | } | |||
| 628 | + | ||||
| 629 | + | // finish revokes the retained consumer and clears the foreground slot after the | |||
| 630 | + | // bounded join. It runs before the stream is closed, so undecided input either | |||
| 631 | + | // reached the pump during finalization or is dropped with the revocation. | |||
| 632 | + | func (f *attachmentForeground) finish() { | |||
| 633 | + | if f == nil { | |||
| 634 | + | return | |||
| 635 | + | } | |||
| 636 | + | f.finishedOnce.Do(func() { f.authority.revoke(f) }) | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | // beginDelivery opens the decision window for the delivery the worker just | |||
| 640 | + | // received: the next AckInput or PreserveInput decides it, and a further | |||
| 641 | + | // decision is refused until Input receives another delivery. | |||
| 642 | + | func (f *attachmentForeground) beginDelivery() { | |||
| 643 | + | f.deliveryMu.Lock() | |||
| 644 | + | f.decided = false | |||
| 645 | + | f.deliveryMu.Unlock() | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | // decideDelivery reports whether this is the first decision for the current | |||
| 649 | + | // delivery and marks it decided. A second Ack/Preserve for the same delivery, | |||
| 650 | + | // or a decision with no outstanding delivery, is refused. | |||
| 651 | + | func (f *attachmentForeground) decideDelivery() bool { | |||
| 652 | + | f.deliveryMu.Lock() | |||
| 653 | + | defer f.deliveryMu.Unlock() | |||
| 654 | + | if f.decided { | |||
| 655 | + | return false | |||
| 656 | + | } | |||
| 657 | + | f.decided = true | |||
| 658 | + | return true | |||
| 659 | + | } | |||
| 660 | + | ||||
| 661 | + | // Input leases the next authorized terminal read. It waits on the pump's ready | |||
| 662 | + | // signal exactly like the attach scanner, so a shared single reader is never | |||
| 663 | + | // duplicated. | |||
| 664 | + | func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent, bool) { | |||
| 665 | + | if ctx == nil { | |||
| 666 | + | ctx = context.Background() | |||
| 667 | + | } | |||
| 668 | + | for { | |||
| 669 | + | if !f.authority.actionAuthorized(f) || f.input == nil { | |||
| 670 | + | return AttachmentInputEvent{}, false | |||
| 671 | + | } | |||
| 672 | + | result, ok := f.input.take(ctx, f.consumer) | |||
| 673 | + | if ok { | |||
| 674 | + | f.beginDelivery() | |||
| 675 | + | return AttachmentInputEvent{Data: result.data, Err: result.err}, true | |||
| 676 | + | } | |||
| 677 | + | if ctx.Err() != nil { | |||
| 678 | + | return AttachmentInputEvent{}, false | |||
| 679 | + | } | |||
| 680 | + | select { | |||
| 681 | + | case <-ctx.Done(): | |||
| 682 | + | return AttachmentInputEvent{}, false | |||
| 683 | + | case <-f.done: | |||
| 684 | + | return AttachmentInputEvent{}, false | |||
| 685 | + | case <-f.input.ready: | |||
| 686 | + | } | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | // AckInput commits the last delivery so the shared reader may publish the next. | |||
| 691 | + | // It decides that delivery, so a duplicate Ack/Preserve for it is a no-op. | |||
| 692 | + | func (f *attachmentForeground) AckInput() { | |||
| 693 | + | if f == nil || f.input == nil { | |||
| 694 | + | return | |||
| 695 | + | } | |||
| 696 | + | if !f.decideDelivery() { | |||
| 697 | + | return | |||
| 698 | + | } | |||
| 699 | + | f.authority.retain(f, func() { f.input.ack(f.consumer) }) | |||
| 700 | + | } | |||
| 701 | + | ||||
| 702 | + | // PreserveInput re-queues the undecided bytes of the last delivery for the next | |||
| 703 | + | // authorized attempt. It decides that delivery, so a duplicate Ack/Preserve for | |||
| 704 | + | // it is a no-op and can never re-queue different bytes. It stays authorized | |||
| 705 | + | // while the supervisor finalizes the grant. | |||
| 706 | + | func (f *attachmentForeground) PreserveInput(data []byte) { | |||
| 707 | + | if f == nil || f.input == nil { | |||
| 708 | + | return | |||
| 709 | + | } | |||
| 710 | + | if !f.decideDelivery() { | |||
| 711 | + | return | |||
| 712 | + | } | |||
| 713 | + | f.authority.retain(f, func() { | |||
| 714 | + | f.input.ack(f.consumer) | |||
| 715 | + | f.input.preserveResidual(f.consumer, data) | |||
| 716 | + | }) | |||
| 717 | + | } | |||
| 718 | + | ||||
| 719 | + | // Resize waits for the next authorized terminal resize. | |||
| 720 | + | func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) { | |||
| 721 | + | if ctx == nil { | |||
| 722 | + | ctx = context.Background() | |||
| 723 | + | } | |||
| 724 | + | if !f.authority.actionAuthorized(f) { | |||
| 725 | + | return domain.Geometry{}, false | |||
| 726 | + | } | |||
| 727 | + | resizes := f.resizes | |||
| 728 | + | for { | |||
| 729 | + | select { | |||
| 730 | + | case geometry, ok := <-resizes: | |||
| 731 | + | if !ok { | |||
| 732 | + | resizes = nil | |||
| 733 | + | continue | |||
| 734 | + | } // uiterm has no resize source | |||
| 735 | + | if !f.authority.actionAuthorized(f) { | |||
| 736 | + | return domain.Geometry{}, false | |||
| 737 | + | } | |||
| 738 | + | return geometry, true | |||
| 739 | + | case <-ctx.Done(): | |||
| 740 | + | return domain.Geometry{}, false | |||
| 741 | + | case <-f.done: | |||
| 742 | + | return domain.Geometry{}, false | |||
| 743 | + | } | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | // Output holds the same send lease across the entire UI observation boundary. | |||
| 748 | + | // It mirrors the legacy attach output path: a UI capture that is merely | |||
| 749 | + | // unavailable (ports.ErrUIUnavailable) never stops the frame, so the write and | |||
| 750 | + | // flush still proceed; any other publication failure aborts. EndOutput success | |||
| 751 | + | // follows the frame write and flush, so a failed publication, short write, or | |||
| 752 | + | // failed write or flush leaves EndOutput(false). | |||
| 753 | + | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | |||
| 754 | + | if f == nil { | |||
| 755 | + | return errAttachmentForegroundRevoked | |||
| 756 | + | } | |||
| 757 | + | var outputErr error | |||
| 758 | + | ok := f.lease.send(func() bool { | |||
| 759 | + | if !f.authority.actionAuthorized(f) { | |||
| 760 | + | return false | |||
| 761 | + | } | |||
| 762 | + | if supervisorNil(f.ui) || supervisorNil(f.term) { | |||
| 763 | + | outputErr = ports.ErrUIUnavailable | |||
| 764 | + | return true | |||
| 765 | + | } | |||
| 766 | + | writer := f.term.Out() | |||
| 767 | + | if supervisorNil(writer) { | |||
| 768 | + | outputErr = ports.ErrUIUnavailable | |||
| 769 | + | return true | |||
| 770 | + | } | |||
| 771 | + | uiContext.Generation = f.token.Generation | |||
| 772 | + | f.ui.BeginOutput(uiContext) | |||
| 773 | + | success := false | |||
| 774 | + | defer func() { f.ui.EndOutput(success) }() | |||
| 775 | + | // An unavailable UI capture is tolerated, exactly like the legacy attach | |||
| 776 | + | // path: the frame is still written and flushed. Any other publication | |||
| 777 | + | // error aborts before the frame is written. | |||
| 778 | + | if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |||
| 779 | + | outputErr = err | |||
| 780 | + | return true | |||
| 781 | + | } | |||
| 782 | + | var n int | |||
| 783 | + | n, outputErr = writer.Write(data) | |||
| 784 | + | if outputErr == nil && n != len(data) { | |||
| 785 | + | outputErr = io.ErrShortWrite | |||
| 786 | + | } | |||
| 787 | + | if outputErr != nil { | |||
| 788 | + | return true | |||
| 789 | + | } | |||
| 790 | + | outputErr = f.term.Flush() | |||
| 791 | + | success = outputErr == nil | |||
| 792 | + | return true | |||
| 793 | + | }) | |||
| 794 | + | if !ok { | |||
| 795 | + | return errAttachmentForegroundRevoked | |||
| 796 | + | } | |||
| 797 | + | return outputErr | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | // MarkAttached records the attached transition atomically with respect to | |||
| 801 | + | // finalization and revocation. | |||
| 802 | + | func (f *attachmentForeground) MarkAttached() bool { | |||
| 803 | + | if f == nil { | |||
| 804 | + | return false | |||
| 805 | + | } | |||
| 806 | + | set := f.authority.act(f, func() { f.attached.Store(true) }) | |||
| 807 | + | if !set { | |||
| 808 | + | return false | |||
| 809 | + | } | |||
| 810 | + | if f.onAttached != nil { | |||
| 811 | + | f.onAttached(f.token) | |||
| 812 | + | } | |||
| 813 | + | return true | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | // Attached reports whether MarkAttached has succeeded. | |||
| 817 | + | func (f *attachmentForeground) Attached() bool { | |||
| 818 | + | return f != nil && f.attached.Load() | |||
| 819 | + | } | |||
A internal/usecase/client/attachment_worker_test.go +1004 -0
| @@ -0,0 +1,1004 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | "github.com/bnema/vev/internal/protocol" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // workerTestStream is one scripted broker logical stream. It records typed | |
| 21 | + | // client messages and unblocks ReceiveServer when the supervisor closes it, | |
| 22 | + | // exactly like a real logical stream's Close contract. | |
| 23 | + | type workerTestStream struct { | |
| 24 | + | mu sync.Mutex | |
| 25 | + | sent []protocol.ClientMessage | |
| 26 | + | closed chan struct{} | |
| 27 | + | closeOnce sync.Once | |
| 28 | + | // closeHook, when set, runs during Close. It lets a test observe the | |
| 29 | + | // supervisor's revocation-before-close ordering. | |
| 30 | + | closeHook func() | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func newWorkerTestStream() *workerTestStream { | |
| 34 | + | return &workerTestStream{closed: make(chan struct{})} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (s *workerTestStream) SendClient(message protocol.ClientMessage) error { | |
| 38 | + | select { | |
| 39 | + | case <-s.closed: | |
| 40 | + | return errors.New("worker test stream is closed") | |
| 41 | + | default: | |
| 42 | + | } | |
| 43 | + | s.mu.Lock() | |
| 44 | + | s.sent = append(s.sent, message) | |
| 45 | + | s.mu.Unlock() | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (s *workerTestStream) ReceiveServer() (protocol.ServerMessage, error) { | |
| 50 | + | <-s.closed | |
| 51 | + | return nil, io.EOF | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (s *workerTestStream) Capabilities() protocol.ConnectionCapabilities { | |
| 55 | + | return protocol.ConnectionCapabilities{} | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (s *workerTestStream) LinkState() ports.LinkState { return 0 } | |
| 59 | + | ||
| 60 | + | func (s *workerTestStream) LinkEvents() <-chan ports.LinkEvent { return nil } | |
| 61 | + | ||
| 62 | + | func (s *workerTestStream) Close() error { | |
| 63 | + | s.closeOnce.Do(func() { | |
| 64 | + | close(s.closed) | |
| 65 | + | if s.closeHook != nil { | |
| 66 | + | s.closeHook() | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (s *workerTestStream) Done() <-chan struct{} { return s.closed } | |
| 73 | + | ||
| 74 | + | func (s *workerTestStream) Err() error { return nil } | |
| 75 | + | ||
| 76 | + | func (s *workerTestStream) messages() []protocol.ClientMessage { | |
| 77 | + | s.mu.Lock() | |
| 78 | + | defer s.mu.Unlock() | |
| 79 | + | return append([]protocol.ClientMessage(nil), s.sent...) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (s *workerTestStream) isClosed() bool { | |
| 83 | + | select { | |
| 84 | + | case <-s.closed: | |
| 85 | + | return true | |
| 86 | + | default: | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // workerTestTerminal is the supervisor-owned terminal: it counts raw restores | |
| 92 | + | // so a test can prove the worker mechanism never restores, captures written | |
| 93 | + | // output, and doubles as the UI publication transaction. | |
| 94 | + | type workerTestTerminal struct { | |
| 95 | + | mu sync.Mutex | |
| 96 | + | out bytes.Buffer | |
| 97 | + | flushes int | |
| 98 | + | restores int | |
| 99 | + | resizes chan domain.Geometry | |
| 100 | + | published []ports.UIContext | |
| 101 | + | steps []string | |
| 102 | + | successes []bool | |
| 103 | + | writeErr error | |
| 104 | + | shortWrite int | |
| 105 | + | flushErr error | |
| 106 | + | publishErr error | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func newWorkerTestTerminal() *workerTestTerminal { | |
| 110 | + | return &workerTestTerminal{resizes: make(chan domain.Geometry, 4)} | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (t *workerTestTerminal) EnterRaw() (func() error, error) { | |
| 114 | + | return func() error { | |
| 115 | + | t.mu.Lock() | |
| 116 | + | t.restores++ | |
| 117 | + | t.mu.Unlock() | |
| 118 | + | return nil | |
| 119 | + | }, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (t *workerTestTerminal) Geometry() (domain.Geometry, error) { return domain.Geometry{}, nil } | |
| 123 | + | ||
| 124 | + | func (t *workerTestTerminal) ResizeEvents() <-chan domain.Geometry { return t.resizes } | |
| 125 | + | ||
| 126 | + | func (t *workerTestTerminal) In() io.Reader { return nil } | |
| 127 | + | ||
| 128 | + | func (t *workerTestTerminal) Out() io.Writer { return &workerTestWriter{t} } | |
| 129 | + | ||
| 130 | + | func (t *workerTestTerminal) Flush() error { | |
| 131 | + | t.mu.Lock() | |
| 132 | + | t.flushes++ | |
| 133 | + | t.steps = append(t.steps, "flush") | |
| 134 | + | t.mu.Unlock() | |
| 135 | + | return t.flushErr | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (t *workerTestTerminal) BeginOutput(ports.UIContext) { | |
| 139 | + | t.mu.Lock() | |
| 140 | + | defer t.mu.Unlock() | |
| 141 | + | t.steps = append(t.steps, "begin") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (t *workerTestTerminal) EndOutput(success bool) { | |
| 145 | + | t.mu.Lock() | |
| 146 | + | defer t.mu.Unlock() | |
| 147 | + | t.steps = append(t.steps, "end") | |
| 148 | + | t.successes = append(t.successes, success) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (t *workerTestTerminal) PublishContext(context ports.UIContext) error { | |
| 152 | + | t.mu.Lock() | |
| 153 | + | defer t.mu.Unlock() | |
| 154 | + | t.published = append(t.published, context) | |
| 155 | + | t.steps = append(t.steps, "publish") | |
| 156 | + | return t.publishErr | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (t *workerTestTerminal) written() string { | |
| 160 | + | t.mu.Lock() | |
| 161 | + | defer t.mu.Unlock() | |
| 162 | + | return t.out.String() | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (t *workerTestTerminal) flushCount() int { | |
| 166 | + | t.mu.Lock() | |
| 167 | + | defer t.mu.Unlock() | |
| 168 | + | return t.flushes | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (t *workerTestTerminal) restoreCount() int { | |
| 172 | + | t.mu.Lock() | |
| 173 | + | defer t.mu.Unlock() | |
| 174 | + | return t.restores | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (t *workerTestTerminal) publications() []ports.UIContext { | |
| 178 | + | t.mu.Lock() | |
| 179 | + | defer t.mu.Unlock() | |
| 180 | + | return append([]ports.UIContext(nil), t.published...) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | type workerTestWriter struct{ term *workerTestTerminal } | |
| 184 | + | ||
| 185 | + | func (w *workerTestWriter) Write(data []byte) (int, error) { | |
| 186 | + | w.term.mu.Lock() | |
| 187 | + | defer w.term.mu.Unlock() | |
| 188 | + | w.term.steps = append(w.term.steps, "write") | |
| 189 | + | if w.term.writeErr != nil { | |
| 190 | + | return 0, w.term.writeErr | |
| 191 | + | } | |
| 192 | + | if w.term.shortWrite > 0 { | |
| 193 | + | n := min(w.term.shortWrite, len(data)) | |
| 194 | + | _, _ = w.term.out.Write(data[:n]) | |
| 195 | + | return n, nil | |
| 196 | + | } | |
| 197 | + | return w.term.out.Write(data) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // fakeWorker adapts a function to the AttachmentWorker contract. | |
| 201 | + | type fakeWorker struct { | |
| 202 | + | run func(ctx context.Context, fg AttachmentForeground) AttachmentEvent | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (w *fakeWorker) Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 206 | + | return w.run(ctx, fg) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // blockingWorker ignores input/output and parks until its context ends. | |
| 210 | + | func blockingWorker() *fakeWorker { | |
| 211 | + | return &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 212 | + | <-ctx.Done() | |
| 213 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 214 | + | }} | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func newWorkerTestHost(term *workerTestTerminal, input *terminalInputPump, onAttached func(AttachmentToken)) *attachmentHost { | |
| 218 | + | return newAttachmentHost(attachmentHostConfig{ | |
| 219 | + | Terminal: term, | |
| 220 | + | Clock: newSupervisorTestClock(), | |
| 221 | + | Input: input, | |
| 222 | + | OnAttached: onAttached, | |
| 223 | + | }) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // TestAttachmentWorkerExclusiveForeground proves exactly one worker generation | |
| 227 | + | // owns the terminal foreground at a time: a second Begin is refused while the | |
| 228 | + | // first is live and succeeds once the first is cancelled. | |
| 229 | + | func TestAttachmentWorkerExclusiveForeground(t *testing.T) { | |
| 230 | + | term := newWorkerTestTerminal() | |
| 231 | + | host := newWorkerTestHost(term, nil, nil) | |
| 232 | + | ||
| 233 | + | run1, ok := host.Begin(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |
| 234 | + | require.True(t, ok) | |
| 235 | + | require.NotNil(t, run1) | |
| 236 | + | ||
| 237 | + | _, ok = host.Begin(context.Background(), AttachmentToken{Generation: 2, Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |
| 238 | + | require.False(t, ok, "second foreground must be refused while the first is live") | |
| 239 | + | require.Equal(t, AttachmentToken{Generation: 1, Attempt: 1}, host.authority.foreground().Token()) | |
| 240 | + | ||
| 241 | + | run1.Cancel() | |
| 242 | + | require.Nil(t, host.authority.foreground()) | |
| 243 | + | ||
| 244 | + | run2, ok := host.Begin(context.Background(), AttachmentToken{Generation: 2, Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |
| 245 | + | require.True(t, ok, "a fresh generation may own the foreground after cancellation") | |
| 246 | + | run2.Cancel() | |
| 247 | + | require.Nil(t, host.authority.foreground()) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // TestAttachmentWorkerZeroTokenRefused proves an unassigned token is never | |
| 251 | + | // granted, so a worker can never run without a supervisor generation. | |
| 252 | + | func TestAttachmentWorkerZeroTokenRefused(t *testing.T) { | |
| 253 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |
| 254 | + | _, ok := host.Begin(context.Background(), AttachmentToken{}, blockingWorker(), newWorkerTestStream()) | |
| 255 | + | require.False(t, ok) | |
| 256 | + | require.Nil(t, host.authority.foreground()) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // TestAttachmentWorkerCanceledGenerationCannotAct proves cancellation revokes | |
| 260 | + | // input/output authority: a canceled generation cannot write, flush, consume or | |
| 261 | + | // forward input, resize, publish UI, or mark itself attached, and its grant is | |
| 262 | + | // closed. | |
| 263 | + | func TestAttachmentWorkerCanceledGenerationCannotAct(t *testing.T) { | |
| 264 | + | term := newWorkerTestTerminal() | |
| 265 | + | host := newWorkerTestHost(term, nil, nil) | |
| 266 | + | stream := newWorkerTestStream() | |
| 267 | + | ||
| 268 | + | fgReady := make(chan AttachmentForeground, 1) | |
| 269 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 270 | + | fgReady <- fg | |
| 271 | + | <-ctx.Done() | |
| 272 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 273 | + | }} | |
| 274 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: 4, Attempt: 2}, worker, stream) | |
| 275 | + | require.True(t, ok) | |
| 276 | + | fg := <-fgReady | |
| 277 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 2}, fg.Token()) | |
| 278 | + | ||
| 279 | + | run.Cancel() | |
| 280 | + | ||
| 281 | + | select { | |
| 282 | + | case <-fg.Done(): | |
| 283 | + | default: | |
| 284 | + | t.Fatal("grant Done must be closed after cancellation") | |
| 285 | + | } | |
| 286 | + | require.ErrorIs(t, fg.Output(ports.UIContext{}, []byte("late")), errAttachmentForegroundRevoked) | |
| 287 | + | require.ErrorIs(t, fg.Output(ports.UIContext{AttachmentHandle: "late"}, nil), errAttachmentForegroundRevoked) | |
| 288 | + | require.False(t, fg.MarkAttached()) | |
| 289 | + | require.False(t, fg.Attached()) | |
| 290 | + | ||
| 291 | + | _, okInput := fg.Input(context.Background()) | |
| 292 | + | require.False(t, okInput) | |
| 293 | + | _, okResize := fg.Resize(context.Background()) | |
| 294 | + | require.False(t, okResize) | |
| 295 | + | ||
| 296 | + | require.Empty(t, term.written(), "a canceled generation must not write the terminal") | |
| 297 | + | require.Empty(t, term.publications(), "a canceled generation must not publish UI") | |
| 298 | + | require.True(t, stream.isClosed(), "the supervisor closes the owned stream") | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // TestAttachmentWorkerConsumesAndForwardsInput proves an authorized worker | |
| 302 | + | // consumes the shared terminal pump and forwards the bytes on its own stream. | |
| 303 | + | func TestAttachmentWorkerConsumesAndForwardsInput(t *testing.T) { | |
| 304 | + | term := newWorkerTestTerminal() | |
| 305 | + | pr, pw := io.Pipe() | |
| 306 | + | pump := newTerminalInputPump(pr) | |
| 307 | + | pump.start() | |
| 308 | + | defer pump.stop() | |
| 309 | + | defer pw.Close() | |
| 310 | + | ||
| 311 | + | host := newWorkerTestHost(term, pump, nil) | |
| 312 | + | stream := newWorkerTestStream() | |
| 313 | + | ||
| 314 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 315 | + | event, ok := fg.Input(ctx) | |
| 316 | + | if !ok { | |
| 317 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no authorized input")} | |
| 318 | + | } | |
| 319 | + | fg.AckInput() | |
| 320 | + | if err := fg.Stream().SendClient(protocol.Input{InputSeq: 1, Data: event.Data}); err != nil { | |
| 321 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |
| 322 | + | } | |
| 323 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 324 | + | }} | |
| 325 | + | ||
| 326 | + | go func() { _, _ = pw.Write([]byte("hello")) }() | |
| 327 | + | ||
| 328 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |
| 329 | + | require.True(t, adopted) | |
| 330 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |
| 331 | + | ||
| 332 | + | messages := stream.messages() | |
| 333 | + | require.Len(t, messages, 1) | |
| 334 | + | inputMessage, ok := messages[0].(protocol.Input) | |
| 335 | + | require.True(t, ok) | |
| 336 | + | require.Equal(t, []byte("hello"), inputMessage.Data) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // TestAttachmentWorkerResizeAuthorized proves an authorized worker observes the | |
| 340 | + | // supervisor's resize events and forwards them on its stream. | |
| 341 | + | func TestAttachmentWorkerResizeAuthorized(t *testing.T) { | |
| 342 | + | term := newWorkerTestTerminal() | |
| 343 | + | host := newWorkerTestHost(term, nil, nil) | |
| 344 | + | stream := newWorkerTestStream() | |
| 345 | + | geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}} | |
| 346 | + | ||
| 347 | + | saw := make(chan domain.Geometry, 1) | |
| 348 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 349 | + | term.resizes <- geometry | |
| 350 | + | resize, ok := fg.Resize(ctx) | |
| 351 | + | if !ok { | |
| 352 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no authorized resize")} | |
| 353 | + | } | |
| 354 | + | saw <- resize | |
| 355 | + | if err := fg.Stream().SendClient(protocol.Resize{Size: resize.Size, PixelWidth: resize.PixelWidth, PixelHeight: resize.PixelHeight}); err != nil { | |
| 356 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |
| 357 | + | } | |
| 358 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 359 | + | }} | |
| 360 | + | ||
| 361 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |
| 362 | + | require.True(t, adopted) | |
| 363 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |
| 364 | + | require.Equal(t, geometry, <-saw) | |
| 365 | + | ||
| 366 | + | messages := stream.messages() | |
| 367 | + | require.Len(t, messages, 1) | |
| 368 | + | _, ok := messages[0].(protocol.Resize) | |
| 369 | + | require.True(t, ok) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // TestAttachmentWorkerPublishesUIAuthorized proves an authorized worker may | |
| 373 | + | // publish UI context and mark the attached transition, and that the grant's | |
| 374 | + | // generation is stamped onto the publication. | |
| 375 | + | func TestAttachmentWorkerPublishesUIAuthorized(t *testing.T) { | |
| 376 | + | term := newWorkerTestTerminal() | |
| 377 | + | attached := make(chan AttachmentToken, 1) | |
| 378 | + | host := newWorkerTestHost(term, nil, func(token AttachmentToken) { attached <- token }) | |
| 379 | + | stream := newWorkerTestStream() | |
| 380 | + | ||
| 381 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 382 | + | if err := fg.Output(ports.UIContext{}, nil); err != nil { | |
| 383 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |
| 384 | + | } | |
| 385 | + | if !fg.MarkAttached() { | |
| 386 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("attach refused")} | |
| 387 | + | } | |
| 388 | + | if !fg.Attached() { | |
| 389 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("attach not recorded")} | |
| 390 | + | } | |
| 391 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 392 | + | }} | |
| 393 | + | ||
| 394 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 7, Attempt: 2}, worker, stream) | |
| 395 | + | require.True(t, adopted) | |
| 396 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |
| 397 | + | ||
| 398 | + | publications := term.publications() | |
| 399 | + | require.Len(t, publications, 1) | |
| 400 | + | require.Equal(t, uint64(7), publications[0].Generation, "the granted generation is stamped") | |
| 401 | + | require.Equal(t, AttachmentToken{Generation: 7, Attempt: 2}, <-attached) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // TestAttachmentWorkerEventReturnsToPickerWithoutTerminalRestore proves a | |
| 405 | + | // worker end, stream loss, or typed failure returns a typed event stamped with | |
| 406 | + | // the granted token while the worker mechanism never restores the terminal and | |
| 407 | + | // never closes the process. | |
| 408 | + | func TestAttachmentWorkerEventReturnsToPickerWithoutTerminalRestore(t *testing.T) { | |
| 409 | + | cases := []struct { | |
| 410 | + | name string | |
| 411 | + | kind AttachmentEventKind | |
| 412 | + | err error | |
| 413 | + | }{ | |
| 414 | + | {name: "ended", kind: AttachmentEventEnded}, | |
| 415 | + | {name: "lost", kind: AttachmentEventLost, err: errors.New("carriage lost")}, | |
| 416 | + | {name: "failed", kind: AttachmentEventFailed, err: &ProtocolError{Code: 7, Text: "bad welcome"}}, | |
| 417 | + | } | |
| 418 | + | for _, tc := range cases { | |
| 419 | + | t.Run(tc.name, func(t *testing.T) { | |
| 420 | + | term := newWorkerTestTerminal() | |
| 421 | + | host := newWorkerTestHost(term, nil, nil) | |
| 422 | + | stream := newWorkerTestStream() | |
| 423 | + | // The worker reports a spoofed token; the host must override it with | |
| 424 | + | // the granted generation/attempt. | |
| 425 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 426 | + | return AttachmentEvent{Token: AttachmentToken{Generation: 999}, Kind: tc.kind, Err: tc.err} | |
| 427 | + | }} | |
| 428 | + | ||
| 429 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 3, Attempt: 5}, worker, stream) | |
| 430 | + | require.True(t, adopted) | |
| 431 | + | require.Equal(t, tc.kind, event.Kind) | |
| 432 | + | require.Equal(t, AttachmentToken{Generation: 3, Attempt: 5}, event.Token) | |
| 433 | + | if tc.err != nil { | |
| 434 | + | require.ErrorIs(t, event.Err, tc.err) | |
| 435 | + | } else { | |
| 436 | + | require.NoError(t, event.Err) | |
| 437 | + | } | |
| 438 | + | require.Equal(t, 0, term.restoreCount(), "the worker mechanism must not restore the terminal") | |
| 439 | + | require.True(t, stream.isClosed(), "the supervisor closes the stream after the worker settles") | |
| 440 | + | require.Nil(t, host.authority.foreground(), "the foreground is released after the worker settles") | |
| 441 | + | }) | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // TestAttachmentWorkerLateEventDiscardedOnCancellation proves a worker that | |
| 446 | + | // settles after the supervisor cancelled is joined and its late event is | |
| 447 | + | // discarded rather than returned as an adopted result. | |
| 448 | + | func TestAttachmentWorkerLateEventDiscardedOnCancellation(t *testing.T) { | |
| 449 | + | term := newWorkerTestTerminal() | |
| 450 | + | host := newWorkerTestHost(term, nil, nil) | |
| 451 | + | stream := newWorkerTestStream() | |
| 452 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, blockingWorker(), stream) | |
| 453 | + | require.True(t, ok) | |
| 454 | + | ||
| 455 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 456 | + | cancel() | |
| 457 | + | ||
| 458 | + | event, adopted := run.Wait(ctx) | |
| 459 | + | require.False(t, adopted) | |
| 460 | + | require.Equal(t, AttachmentEvent{}, event, "a late event from a canceled run is discarded") | |
| 461 | + | require.True(t, stream.isClosed()) | |
| 462 | + | require.Nil(t, host.authority.foreground()) | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | // TestAttachmentWorkerPreservesResidualInputAcrossAttempts proves the reused | |
| 466 | + | // input pump keeps undecided bytes for the next authorized generation instead of | |
| 467 | + | // duplicating a reader. | |
| 468 | + | func TestAttachmentWorkerPreservesResidualInputAcrossAttempts(t *testing.T) { | |
| 469 | + | term := newWorkerTestTerminal() | |
| 470 | + | pr, pw := io.Pipe() | |
| 471 | + | pump := newTerminalInputPump(pr) | |
| 472 | + | pump.start() | |
| 473 | + | defer pump.stop() | |
| 474 | + | defer pw.Close() | |
| 475 | + | ||
| 476 | + | host := newWorkerTestHost(term, pump, nil) | |
| 477 | + | ||
| 478 | + | observed := make(chan []byte, 2) | |
| 479 | + | first := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 480 | + | event, ok := fg.Input(ctx) | |
| 481 | + | if !ok { | |
| 482 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("first attempt saw no input")} | |
| 483 | + | } | |
| 484 | + | observed <- append([]byte(nil), event.Data...) | |
| 485 | + | // Leave the bytes undecided: the next attempt must see them again. | |
| 486 | + | fg.PreserveInput(event.Data) | |
| 487 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 488 | + | }} | |
| 489 | + | second := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 490 | + | event, ok := fg.Input(ctx) | |
| 491 | + | if !ok { | |
| 492 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("second attempt saw no input")} | |
| 493 | + | } | |
| 494 | + | fg.AckInput() | |
| 495 | + | observed <- append([]byte(nil), event.Data...) | |
| 496 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 497 | + | }} | |
| 498 | + | ||
| 499 | + | go func() { _, _ = pw.Write([]byte("ab")) }() | |
| 500 | + | ||
| 501 | + | _, adopted := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, first, newWorkerTestStream()) | |
| 502 | + | require.True(t, adopted) | |
| 503 | + | _, adopted = host.Run(context.Background(), AttachmentToken{Generation: 2, Attempt: 1}, second, newWorkerTestStream()) | |
| 504 | + | require.True(t, adopted) | |
| 505 | + | ||
| 506 | + | require.Equal(t, []byte("ab"), <-observed) | |
| 507 | + | require.Equal(t, []byte("ab"), <-observed, "the replacement generation replays the preserved bytes") | |
| 508 | + | go func() { _, _ = pw.Write([]byte("fresh")) }() | |
| 509 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 510 | + | defer cancel() | |
| 511 | + | _, adopted = host.Run(ctx, AttachmentToken{Generation: 3, Attempt: 3}, second, newWorkerTestStream()) | |
| 512 | + | require.True(t, adopted) | |
| 513 | + | require.Equal(t, []byte("fresh"), <-observed, "third attempt must not replay the original pending read") | |
| 514 | + | ||
| 515 | + | } | |
| 516 | + | ||
| 517 | + | // TestAttachmentWorkerRevokesBeforeClosingStream proves the supervisor revokes | |
| 518 | + | // input/output authority before it closes the owned stream: at the instant of | |
| 519 | + | // Close the foreground is already released and a late write is refused. | |
| 520 | + | func TestAttachmentWorkerRevokesBeforeClosingStream(t *testing.T) { | |
| 521 | + | term := newWorkerTestTerminal() | |
| 522 | + | host := newWorkerTestHost(term, nil, nil) | |
| 523 | + | stream := newWorkerTestStream() | |
| 524 | + | ||
| 525 | + | fgReady := make(chan AttachmentForeground, 1) | |
| 526 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 527 | + | fgReady <- fg | |
| 528 | + | <-ctx.Done() | |
| 529 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 530 | + | }} | |
| 531 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |
| 532 | + | require.True(t, ok) | |
| 533 | + | fg := <-fgReady | |
| 534 | + | ||
| 535 | + | var closeWriteErr error | |
| 536 | + | stream.closeHook = func() { | |
| 537 | + | require.Nil(t, host.authority.foreground(), "authority must be revoked before the stream closes") | |
| 538 | + | closeWriteErr = fg.Output(ports.UIContext{}, []byte("late")) | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | run.Cancel() | |
| 542 | + | require.ErrorIs(t, closeWriteErr, errAttachmentForegroundRevoked) | |
| 543 | + | require.Empty(t, term.written()) | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // TestAttachmentWorkerConcurrentBeginSingleOwner proves concurrent Begin calls | |
| 547 | + | // admit exactly one foreground owner under the race detector. | |
| 548 | + | func TestAttachmentWorkerConcurrentBeginSingleOwner(t *testing.T) { | |
| 549 | + | term := newWorkerTestTerminal() | |
| 550 | + | host := newWorkerTestHost(term, nil, nil) | |
| 551 | + | ||
| 552 | + | const contenders = 8 | |
| 553 | + | var wg sync.WaitGroup | |
| 554 | + | var successes atomic.Int64 | |
| 555 | + | runs := make(chan *attachmentRun, contenders) | |
| 556 | + | start := make(chan struct{}) | |
| 557 | + | for i := 0; i < contenders; i++ { | |
| 558 | + | wg.Add(1) | |
| 559 | + | go func(i int) { | |
| 560 | + | defer wg.Done() | |
| 561 | + | <-start | |
| 562 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: uint64(i + 1), Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |
| 563 | + | if ok { | |
| 564 | + | successes.Add(1) | |
| 565 | + | runs <- run | |
| 566 | + | } | |
| 567 | + | }(i) | |
| 568 | + | } | |
| 569 | + | close(start) | |
| 570 | + | wg.Wait() | |
| 571 | + | close(runs) | |
| 572 | + | ||
| 573 | + | require.Equal(t, int64(1), successes.Load(), "exactly one concurrent Begin may own the foreground") | |
| 574 | + | for run := range runs { | |
| 575 | + | run.Cancel() | |
| 576 | + | } | |
| 577 | + | require.Nil(t, host.authority.foreground()) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | // TestAttachmentWorkerRepeatedAttemptsNoLeak proves repeated grant/cancel cycles | |
| 581 | + | // release the foreground, close every stream, and never leave a live owner. | |
| 582 | + | func TestAttachmentWorkerRepeatedAttemptsNoLeak(t *testing.T) { | |
| 583 | + | term := newWorkerTestTerminal() | |
| 584 | + | host := newWorkerTestHost(term, nil, nil) | |
| 585 | + | ||
| 586 | + | const attempts = 64 | |
| 587 | + | for i := 0; i < attempts; i++ { | |
| 588 | + | stream := newWorkerTestStream() | |
| 589 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 590 | + | if err := fg.Output(ports.UIContext{}, []byte("x")); err != nil { | |
| 591 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |
| 592 | + | } | |
| 593 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 594 | + | }} | |
| 595 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: uint64(i + 1), Attempt: 1}, worker, stream) | |
| 596 | + | require.True(t, adopted) | |
| 597 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |
| 598 | + | require.True(t, stream.isClosed()) | |
| 599 | + | require.Nil(t, host.authority.foreground()) | |
| 600 | + | } | |
| 601 | + | require.Equal(t, attempts, term.flushCount()) | |
| 602 | + | require.Equal(t, 0, term.restoreCount(), "the mechanism never enters or restores raw mode") | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | func TestAttachmentWorkerRetiredOutcomeNeverAdopted(t *testing.T) { | |
| 606 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |
| 607 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |
| 608 | + | require.True(t, ok) | |
| 609 | + | run.Cancel() | |
| 610 | + | for range 3 { | |
| 611 | + | event, adopted := run.Wait(context.Background()) | |
| 612 | + | require.False(t, adopted) | |
| 613 | + | require.Zero(t, event) | |
| 614 | + | } | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | func TestAttachmentWorkerBoundedJoin(t *testing.T) { | |
| 618 | + | clock := newSupervisorTestClock() | |
| 619 | + | pr, pw := io.Pipe() | |
| 620 | + | defer pr.Close() | |
| 621 | + | defer pw.Close() | |
| 622 | + | pump := newTerminalInputPump(pr) | |
| 623 | + | pump.start() | |
| 624 | + | defer pump.stop() | |
| 625 | + | host := newAttachmentHost(attachmentHostConfig{Clock: clock, Input: pump}) | |
| 626 | + | unblock := make(chan struct{}) | |
| 627 | + | defer close(unblock) | |
| 628 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, &fakeWorker{run: func(context.Context, AttachmentForeground) AttachmentEvent { | |
| 629 | + | <-unblock | |
| 630 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 631 | + | }}, newWorkerTestStream()) | |
| 632 | + | require.True(t, ok) | |
| 633 | + | cancelled := make(chan struct{}) | |
| 634 | + | go func() { run.Cancel(); close(cancelled) }() | |
| 635 | + | timer := clock.awaitTimer(t) | |
| 636 | + | require.Equal(t, attachmentWorkerJoinTimeout, timer.delay) | |
| 637 | + | select { | |
| 638 | + | case <-run.retired: | |
| 639 | + | default: | |
| 640 | + | t.Fatal("retirement must precede join") | |
| 641 | + | } | |
| 642 | + | // During the final input window the grant is finalizing, not revoked: Done is | |
| 643 | + | // closed, output and new input are refused, the retained consumer still holds | |
| 644 | + | // the shared reader, and the stream is still open. | |
| 645 | + | require.Same(t, run.fg, host.authority.foreground()) | |
| 646 | + | select { | |
| 647 | + | case <-run.fg.Done(): | |
| 648 | + | default: | |
| 649 | + | t.Fatal("finalize must close Done before the join") | |
| 650 | + | } | |
| 651 | + | require.ErrorIs(t, run.fg.Output(ports.UIContext{}, []byte("late")), errAttachmentForegroundRevoked) | |
| 652 | + | _, ok = host.Begin(context.Background(), AttachmentToken{2, 1}, blockingWorker(), newWorkerTestStream()) | |
| 653 | + | require.False(t, ok, "a finalizing grant blocks a competing Begin") | |
| 654 | + | _, claimed := pump.tryClaim() | |
| 655 | + | require.False(t, claimed, "the consumer is retained during the final input window") | |
| 656 | + | require.False(t, run.stream.(*workerTestStream).isClosed(), "the stream closes after the join") | |
| 657 | + | timer.fire() | |
| 658 | + | select { | |
| 659 | + | case <-cancelled: | |
| 660 | + | case <-time.After(time.Second): | |
| 661 | + | t.Fatal("uncooperative worker pinned cancel") | |
| 662 | + | } | |
| 663 | + | require.True(t, timer.stopped()) | |
| 664 | + | // After the timeout the supervisor revokes anyway: slot freed, consumer | |
| 665 | + | // released, and the stream closed. | |
| 666 | + | require.Nil(t, host.authority.foreground()) | |
| 667 | + | consumer, claimed := pump.tryClaim() | |
| 668 | + | require.True(t, claimed, "revocation releases the consumer after the join") | |
| 669 | + | pump.revoke(consumer) | |
| 670 | + | require.True(t, run.stream.(*workerTestStream).isClosed()) | |
| 671 | + | _, adopted := run.Wait(context.Background()) | |
| 672 | + | require.False(t, adopted) | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | func TestAttachmentWorkerPumpClaimAndCancelRace(t *testing.T) { | |
| 676 | + | pr, pw := io.Pipe() | |
| 677 | + | defer pr.Close() | |
| 678 | + | defer pw.Close() | |
| 679 | + | pump := newTerminalInputPump(pr) | |
| 680 | + | pump.start() | |
| 681 | + | defer pump.stop() | |
| 682 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |
| 683 | + | rival := pump.claim() | |
| 684 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |
| 685 | + | require.False(t, ok) | |
| 686 | + | require.Nil(t, host.authority.foreground()) | |
| 687 | + | pump.revoke(rival) | |
| 688 | + | for i := range 100 { | |
| 689 | + | run, ok := host.Begin(context.Background(), AttachmentToken{uint64(i + 1), 1}, blockingWorker(), newWorkerTestStream()) | |
| 690 | + | require.True(t, ok) | |
| 691 | + | var next *attachmentRun | |
| 692 | + | var wg sync.WaitGroup | |
| 693 | + | wg.Add(2) | |
| 694 | + | go func() { defer wg.Done(); run.Cancel() }() | |
| 695 | + | go func() { | |
| 696 | + | defer wg.Done() | |
| 697 | + | next, _ = host.Begin(context.Background(), AttachmentToken{uint64(i + 2), 2}, blockingWorker(), newWorkerTestStream()) | |
| 698 | + | }() | |
| 699 | + | wg.Wait() | |
| 700 | + | if next != nil { | |
| 701 | + | next.Cancel() | |
| 702 | + | } | |
| 703 | + | consumer, claimed := pump.tryClaim() | |
| 704 | + | require.True(t, claimed) | |
| 705 | + | pump.revoke(consumer) | |
| 706 | + | } | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | func TestAttachmentWorkerClosedResizeDisabled(t *testing.T) { | |
| 710 | + | term := newWorkerTestTerminal() | |
| 711 | + | close(term.resizes) // uiterm-like terminal: no physical resize events | |
| 712 | + | host := newWorkerTestHost(term, nil, nil) | |
| 713 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |
| 714 | + | require.True(t, ok) | |
| 715 | + | result := make(chan bool, 1) | |
| 716 | + | go func() { _, ok := run.fg.Resize(context.Background()); result <- ok }() | |
| 717 | + | select { | |
| 718 | + | case <-result: | |
| 719 | + | t.Fatal("closed resize source terminated attachment") | |
| 720 | + | case <-time.After(10 * time.Millisecond): | |
| 721 | + | } | |
| 722 | + | require.True(t, run.fg.MarkAttached()) | |
| 723 | + | run.Cancel() | |
| 724 | + | select { | |
| 725 | + | case ok := <-result: | |
| 726 | + | require.False(t, ok) | |
| 727 | + | case <-time.After(time.Second): | |
| 728 | + | t.Fatal("authority Done did not stop resize") | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | func TestAttachmentWorkerOutputTransaction(t *testing.T) { | |
| 733 | + | failure := errors.New("output failure") | |
| 734 | + | for _, tc := range []struct { | |
| 735 | + | name string | |
| 736 | + | writeErr, flushErr, publishErr error | |
| 737 | + | shortWrite int | |
| 738 | + | wantErr error | |
| 739 | + | steps []string | |
| 740 | + | }{ | |
| 741 | + | {name: "success", steps: []string{"begin", "publish", "write", "flush", "end"}}, | |
| 742 | + | {name: "short write", shortWrite: 2, wantErr: io.ErrShortWrite, steps: []string{"begin", "publish", "write", "end"}}, | |
| 743 | + | {name: "write failure", writeErr: failure, wantErr: failure, steps: []string{"begin", "publish", "write", "end"}}, | |
| 744 | + | {name: "flush failure", flushErr: failure, wantErr: failure, steps: []string{"begin", "publish", "write", "flush", "end"}}, | |
| 745 | + | {name: "publish failure", publishErr: failure, wantErr: failure, steps: []string{"begin", "publish", "end"}}, | |
| 746 | + | {name: "publish unavailable tolerated", publishErr: ports.ErrUIUnavailable, steps: []string{"begin", "publish", "write", "flush", "end"}}, | |
| 747 | + | } { | |
| 748 | + | t.Run(tc.name, func(t *testing.T) { | |
| 749 | + | term := newWorkerTestTerminal() | |
| 750 | + | term.writeErr, term.flushErr, term.publishErr, term.shortWrite = tc.writeErr, tc.flushErr, tc.publishErr, tc.shortWrite | |
| 751 | + | host := newWorkerTestHost(term, nil, nil) | |
| 752 | + | run, ok := host.Begin(context.Background(), AttachmentToken{9, 2}, blockingWorker(), newWorkerTestStream()) | |
| 753 | + | require.True(t, ok) | |
| 754 | + | defer run.Cancel() | |
| 755 | + | err := run.fg.Output(ports.UIContext{Generation: 999}, []byte("frame")) | |
| 756 | + | success := tc.wantErr == nil | |
| 757 | + | if success { | |
| 758 | + | require.NoError(t, err) | |
| 759 | + | } else { | |
| 760 | + | require.ErrorIs(t, err, tc.wantErr) | |
| 761 | + | } | |
| 762 | + | require.Equal(t, tc.steps, term.steps) | |
| 763 | + | require.Equal(t, []bool{success}, term.successes) | |
| 764 | + | require.Equal(t, uint64(9), term.published[0].Generation) | |
| 765 | + | }) | |
| 766 | + | } | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | // Hide the transaction methods to model a physical terminal without UI output. | |
| 770 | + | type workerBareTerminal struct{ ports.Terminal } | |
| 771 | + | ||
| 772 | + | func TestAttachmentWorkerNilSeams(t *testing.T) { | |
| 773 | + | var nilTerm *workerTestTerminal | |
| 774 | + | var nilWorker *fakeWorker | |
| 775 | + | var nilStream *workerTestStream | |
| 776 | + | var nilClock *supervisorTestClock | |
| 777 | + | for _, cfg := range []attachmentHostConfig{ | |
| 778 | + | {}, | |
| 779 | + | {Terminal: nilTerm, UI: nilTerm, Clock: nilClock}, | |
| 780 | + | {Terminal: workerBareTerminal{newWorkerTestTerminal()}, UI: nilTerm}, | |
| 781 | + | } { | |
| 782 | + | host := newAttachmentHost(cfg) | |
| 783 | + | _, ok := host.Begin(nil, AttachmentToken{1, 1}, nilWorker, newWorkerTestStream()) | |
| 784 | + | require.False(t, ok) | |
| 785 | + | _, ok = host.Begin(nil, AttachmentToken{1, 1}, blockingWorker(), nilStream) | |
| 786 | + | require.False(t, ok) | |
| 787 | + | run, ok := host.Begin(nil, AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |
| 788 | + | require.True(t, ok) | |
| 789 | + | require.ErrorIs(t, run.fg.Output(ports.UIContext{}, nil), ports.ErrUIUnavailable) | |
| 790 | + | run.Cancel() | |
| 791 | + | require.ErrorIs(t, run.fg.Output(ports.UIContext{}, nil), errAttachmentForegroundRevoked) | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | // TestAttachmentWorkerGenerationIsSupervisorSupplied proves the host no longer | |
| 796 | + | // allocates a UI generation: it grants and stamps the caller's generation, and | |
| 797 | + | // the same value can be re-granted across sequential runs because the | |
| 798 | + | // supervisor owns the counter. UI action binding stays deferred to the P5 | |
| 799 | + | // supervisor, so no UI admission is published here; the transactional output | |
| 800 | + | // path still stamps the granted generation on every publication. | |
| 801 | + | func TestAttachmentWorkerGenerationIsSupervisorSupplied(t *testing.T) { | |
| 802 | + | term := newWorkerTestTerminal() | |
| 803 | + | pump := newTerminalInputPump(nil) | |
| 804 | + | host := newWorkerTestHost(term, pump, nil) | |
| 805 | + | for i := uint64(1); i <= 3; i++ { | |
| 806 | + | run, ok := host.Begin(context.Background(), AttachmentToken{i, i + 10}, blockingWorker(), newWorkerTestStream()) | |
| 807 | + | require.True(t, ok) | |
| 808 | + | require.Equal(t, AttachmentToken{i, i + 10}, run.fg.Token()) | |
| 809 | + | require.NoError(t, run.fg.Output(ports.UIContext{Generation: 999}, nil)) | |
| 810 | + | require.Equal(t, i, term.publications()[i-1].Generation) | |
| 811 | + | run.Cancel() | |
| 812 | + | } | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | // TestAttachmentWorkerBeginRefusalsRetainStream proves every Begin refusal path | |
| 816 | + | // leaves the caller's stream untouched: it is not closed, receives no message, | |
| 817 | + | // no foreground is granted, and the caller keeps ownership. | |
| 818 | + | func TestAttachmentWorkerBeginRefusalsRetainStream(t *testing.T) { | |
| 819 | + | t.Run("nil host", func(t *testing.T) { | |
| 820 | + | var host *attachmentHost | |
| 821 | + | stream := newWorkerTestStream() | |
| 822 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), stream) | |
| 823 | + | require.False(t, ok) | |
| 824 | + | require.Nil(t, run) | |
| 825 | + | require.False(t, stream.isClosed()) | |
| 826 | + | require.Empty(t, stream.messages()) | |
| 827 | + | }) | |
| 828 | + | t.Run("zero token", func(t *testing.T) { | |
| 829 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |
| 830 | + | stream := newWorkerTestStream() | |
| 831 | + | _, ok := host.Begin(context.Background(), AttachmentToken{}, blockingWorker(), stream) | |
| 832 | + | require.False(t, ok) | |
| 833 | + | require.False(t, stream.isClosed()) | |
| 834 | + | require.Empty(t, stream.messages()) | |
| 835 | + | require.Nil(t, host.authority.foreground()) | |
| 836 | + | }) | |
| 837 | + | t.Run("nil worker", func(t *testing.T) { | |
| 838 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |
| 839 | + | stream := newWorkerTestStream() | |
| 840 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, nil, stream) | |
| 841 | + | require.False(t, ok) | |
| 842 | + | require.False(t, stream.isClosed()) | |
| 843 | + | require.Nil(t, host.authority.foreground()) | |
| 844 | + | }) | |
| 845 | + | t.Run("nil stream", func(t *testing.T) { | |
| 846 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |
| 847 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), nil) | |
| 848 | + | require.False(t, ok) | |
| 849 | + | require.Nil(t, host.authority.foreground()) | |
| 850 | + | }) | |
| 851 | + | t.Run("foreground already live", func(t *testing.T) { | |
| 852 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |
| 853 | + | first, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |
| 854 | + | require.True(t, ok) | |
| 855 | + | stream := newWorkerTestStream() | |
| 856 | + | _, ok = host.Begin(context.Background(), AttachmentToken{2, 1}, blockingWorker(), stream) | |
| 857 | + | require.False(t, ok) | |
| 858 | + | require.False(t, stream.isClosed(), "a refused Begin must not close the caller's stream") | |
| 859 | + | require.Empty(t, stream.messages()) | |
| 860 | + | require.Same(t, first.fg, host.authority.foreground()) | |
| 861 | + | first.Cancel() | |
| 862 | + | }) | |
| 863 | + | t.Run("rival consumer owns input", func(t *testing.T) { | |
| 864 | + | pump := newTerminalInputPump(nil) | |
| 865 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |
| 866 | + | rival := pump.claim() | |
| 867 | + | defer pump.revoke(rival) | |
| 868 | + | stream := newWorkerTestStream() | |
| 869 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), stream) | |
| 870 | + | require.False(t, ok) | |
| 871 | + | require.False(t, stream.isClosed()) | |
| 872 | + | require.Empty(t, stream.messages()) | |
| 873 | + | require.Nil(t, host.authority.foreground()) | |
| 874 | + | }) | |
| 875 | + | } | |
| 876 | + | ||
| 877 | + | // TestAttachmentWorkerFinalizePreservesUndecidedInput proves the deterministic | |
| 878 | + | // final input window: cancellation wakes the worker through Done while the pump | |
| 879 | + | // consumer is still authorized for Ack/Preserve only, so the undecided read | |
| 880 | + | // survives to the next generation exactly once, and a duplicate PreserveInput | |
| 881 | + | // for the same delivery can never overwrite it. | |
| 882 | + | func TestAttachmentWorkerFinalizePreservesUndecidedInput(t *testing.T) { | |
| 883 | + | pr, pw := io.Pipe() | |
| 884 | + | defer pr.Close() | |
| 885 | + | defer pw.Close() | |
| 886 | + | pump := newTerminalInputPump(pr) | |
| 887 | + | pump.start() | |
| 888 | + | defer pump.stop() | |
| 889 | + | host := newAttachmentHost(attachmentHostConfig{Input: pump}) | |
| 890 | + | ||
| 891 | + | var mu sync.Mutex | |
| 892 | + | var refusals []string | |
| 893 | + | refuse := func(what string) { | |
| 894 | + | mu.Lock() | |
| 895 | + | refusals = append(refusals, what) | |
| 896 | + | mu.Unlock() | |
| 897 | + | } | |
| 898 | + | delivered := make(chan []byte, 1) | |
| 899 | + | preserved := make(chan struct{}) | |
| 900 | + | first := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 901 | + | event, ok := fg.Input(ctx) | |
| 902 | + | if !ok { | |
| 903 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("first attempt saw no input")} | |
| 904 | + | } | |
| 905 | + | delivered <- append([]byte(nil), event.Data...) | |
| 906 | + | // The supervisor finalizes: Done wakes us while Ack/Preserve stay | |
| 907 | + | // authorized and every other action is refused. | |
| 908 | + | <-fg.Done() | |
| 909 | + | if _, ok := fg.Input(ctx); ok { | |
| 910 | + | refuse("new input") | |
| 911 | + | } | |
| 912 | + | if _, ok := fg.Resize(ctx); ok { | |
| 913 | + | refuse("resize") | |
| 914 | + | } | |
| 915 | + | if err := fg.Output(ports.UIContext{}, []byte("late")); !errors.Is(err, errAttachmentForegroundRevoked) { | |
| 916 | + | refuse("output") | |
| 917 | + | } | |
| 918 | + | fg.PreserveInput(event.Data) | |
| 919 | + | fg.PreserveInput([]byte("overwrite")) | |
| 920 | + | close(preserved) | |
| 921 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 922 | + | }} | |
| 923 | + | ||
| 924 | + | written := make(chan error, 1) | |
| 925 | + | go func() { _, err := pw.Write([]byte("ab")); written <- err }() | |
| 926 | + | ||
| 927 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, first, newWorkerTestStream()) | |
| 928 | + | require.True(t, ok) | |
| 929 | + | select { | |
| 930 | + | case data := <-delivered: | |
| 931 | + | require.Equal(t, []byte("ab"), data) | |
| 932 | + | case <-time.After(time.Second): | |
| 933 | + | t.Fatal("first attempt never received input") | |
| 934 | + | } | |
| 935 | + | run.Cancel() | |
| 936 | + | select { | |
| 937 | + | case <-preserved: | |
| 938 | + | case <-time.After(time.Second): | |
| 939 | + | t.Fatal("worker was not woken to preserve input") | |
| 940 | + | } | |
| 941 | + | mu.Lock() | |
| 942 | + | require.Empty(t, refusals, "finalization must refuse every action except Ack/Preserve") | |
| 943 | + | mu.Unlock() | |
| 944 | + | require.NoError(t, <-written) | |
| 945 | + | require.Nil(t, host.authority.foreground()) | |
| 946 | + | ||
| 947 | + | // The next generation replays the preserved bytes once, not the rejected | |
| 948 | + | // duplicate. | |
| 949 | + | observed := make(chan []byte, 2) | |
| 950 | + | second := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 951 | + | event, ok := fg.Input(ctx) | |
| 952 | + | if !ok { | |
| 953 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("second attempt saw no input")} | |
| 954 | + | } | |
| 955 | + | fg.AckInput() | |
| 956 | + | observed <- append([]byte(nil), event.Data...) | |
| 957 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 958 | + | }} | |
| 959 | + | _, adopted := host.Run(context.Background(), AttachmentToken{2, 1}, second, newWorkerTestStream()) | |
| 960 | + | require.True(t, adopted) | |
| 961 | + | require.Equal(t, []byte("ab"), <-observed) | |
| 962 | + | ||
| 963 | + | // A third generation sees only fresh input: the preserved read was consumed | |
| 964 | + | // exactly once. | |
| 965 | + | freshWritten := make(chan error, 1) | |
| 966 | + | go func() { _, err := pw.Write([]byte("fresh")); freshWritten <- err }() | |
| 967 | + | _, adopted = host.Run(context.Background(), AttachmentToken{3, 1}, second, newWorkerTestStream()) | |
| 968 | + | require.True(t, adopted) | |
| 969 | + | require.Equal(t, []byte("fresh"), <-observed) | |
| 970 | + | require.NoError(t, <-freshWritten) | |
| 971 | + | } | |
| 972 | + | ||
| 973 | + | func TestAttachmentWorkerRetirementPrecedesWorkerCancellation(t *testing.T) { | |
| 974 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |
| 975 | + | ready := make(chan *attachmentRun, 1) | |
| 976 | + | checked := make(chan bool, 1) | |
| 977 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |
| 978 | + | run := <-ready | |
| 979 | + | <-ctx.Done() | |
| 980 | + | select { | |
| 981 | + | case <-run.retired: | |
| 982 | + | checked <- true | |
| 983 | + | default: | |
| 984 | + | checked <- false | |
| 985 | + | } | |
| 986 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |
| 987 | + | }} | |
| 988 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, worker, newWorkerTestStream()) | |
| 989 | + | require.True(t, ok) | |
| 990 | + | ready <- run | |
| 991 | + | run.Cancel() | |
| 992 | + | require.True(t, <-checked) | |
| 993 | + | _, adopted := run.Wait(context.Background()) | |
| 994 | + | require.False(t, adopted) | |
| 995 | + | } | |
| 996 | + | ||
| 997 | + | func TestAttachmentWorkerBoundEventUsesGrantedGeneration(t *testing.T) { | |
| 998 | + | host := newAttachmentHost(attachmentHostConfig{Input: newTerminalInputPump(nil)}) | |
| 999 | + | event, adopted := host.Run(context.Background(), AttachmentToken{99, 42}, &fakeWorker{run: func(context.Context, AttachmentForeground) AttachmentEvent { | |
| 1000 | + | return AttachmentEvent{Token: AttachmentToken{999, 999}, Kind: AttachmentEventEnded} | |
| 1001 | + | }}, newWorkerTestStream()) | |
| 1002 | + | require.True(t, adopted) | |
| 1003 | + | require.Equal(t, AttachmentToken{99, 42}, event.Token) | |
| 1004 | + | } | |
| @@ -0,0 +1,1004 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | + | "github.com/bnema/vev/internal/ports" | |||
| 17 | + | "github.com/bnema/vev/internal/protocol" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // workerTestStream is one scripted broker logical stream. It records typed | |||
| 21 | + | // client messages and unblocks ReceiveServer when the supervisor closes it, | |||
| 22 | + | // exactly like a real logical stream's Close contract. | |||
| 23 | + | type workerTestStream struct { | |||
| 24 | + | mu sync.Mutex | |||
| 25 | + | sent []protocol.ClientMessage | |||
| 26 | + | closed chan struct{} | |||
| 27 | + | closeOnce sync.Once | |||
| 28 | + | // closeHook, when set, runs during Close. It lets a test observe the | |||
| 29 | + | // supervisor's revocation-before-close ordering. | |||
| 30 | + | closeHook func() | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func newWorkerTestStream() *workerTestStream { | |||
| 34 | + | return &workerTestStream{closed: make(chan struct{})} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (s *workerTestStream) SendClient(message protocol.ClientMessage) error { | |||
| 38 | + | select { | |||
| 39 | + | case <-s.closed: | |||
| 40 | + | return errors.New("worker test stream is closed") | |||
| 41 | + | default: | |||
| 42 | + | } | |||
| 43 | + | s.mu.Lock() | |||
| 44 | + | s.sent = append(s.sent, message) | |||
| 45 | + | s.mu.Unlock() | |||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (s *workerTestStream) ReceiveServer() (protocol.ServerMessage, error) { | |||
| 50 | + | <-s.closed | |||
| 51 | + | return nil, io.EOF | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (s *workerTestStream) Capabilities() protocol.ConnectionCapabilities { | |||
| 55 | + | return protocol.ConnectionCapabilities{} | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (s *workerTestStream) LinkState() ports.LinkState { return 0 } | |||
| 59 | + | ||||
| 60 | + | func (s *workerTestStream) LinkEvents() <-chan ports.LinkEvent { return nil } | |||
| 61 | + | ||||
| 62 | + | func (s *workerTestStream) Close() error { | |||
| 63 | + | s.closeOnce.Do(func() { | |||
| 64 | + | close(s.closed) | |||
| 65 | + | if s.closeHook != nil { | |||
| 66 | + | s.closeHook() | |||
| 67 | + | } | |||
| 68 | + | }) | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (s *workerTestStream) Done() <-chan struct{} { return s.closed } | |||
| 73 | + | ||||
| 74 | + | func (s *workerTestStream) Err() error { return nil } | |||
| 75 | + | ||||
| 76 | + | func (s *workerTestStream) messages() []protocol.ClientMessage { | |||
| 77 | + | s.mu.Lock() | |||
| 78 | + | defer s.mu.Unlock() | |||
| 79 | + | return append([]protocol.ClientMessage(nil), s.sent...) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (s *workerTestStream) isClosed() bool { | |||
| 83 | + | select { | |||
| 84 | + | case <-s.closed: | |||
| 85 | + | return true | |||
| 86 | + | default: | |||
| 87 | + | return false | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // workerTestTerminal is the supervisor-owned terminal: it counts raw restores | |||
| 92 | + | // so a test can prove the worker mechanism never restores, captures written | |||
| 93 | + | // output, and doubles as the UI publication transaction. | |||
| 94 | + | type workerTestTerminal struct { | |||
| 95 | + | mu sync.Mutex | |||
| 96 | + | out bytes.Buffer | |||
| 97 | + | flushes int | |||
| 98 | + | restores int | |||
| 99 | + | resizes chan domain.Geometry | |||
| 100 | + | published []ports.UIContext | |||
| 101 | + | steps []string | |||
| 102 | + | successes []bool | |||
| 103 | + | writeErr error | |||
| 104 | + | shortWrite int | |||
| 105 | + | flushErr error | |||
| 106 | + | publishErr error | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func newWorkerTestTerminal() *workerTestTerminal { | |||
| 110 | + | return &workerTestTerminal{resizes: make(chan domain.Geometry, 4)} | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (t *workerTestTerminal) EnterRaw() (func() error, error) { | |||
| 114 | + | return func() error { | |||
| 115 | + | t.mu.Lock() | |||
| 116 | + | t.restores++ | |||
| 117 | + | t.mu.Unlock() | |||
| 118 | + | return nil | |||
| 119 | + | }, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (t *workerTestTerminal) Geometry() (domain.Geometry, error) { return domain.Geometry{}, nil } | |||
| 123 | + | ||||
| 124 | + | func (t *workerTestTerminal) ResizeEvents() <-chan domain.Geometry { return t.resizes } | |||
| 125 | + | ||||
| 126 | + | func (t *workerTestTerminal) In() io.Reader { return nil } | |||
| 127 | + | ||||
| 128 | + | func (t *workerTestTerminal) Out() io.Writer { return &workerTestWriter{t} } | |||
| 129 | + | ||||
| 130 | + | func (t *workerTestTerminal) Flush() error { | |||
| 131 | + | t.mu.Lock() | |||
| 132 | + | t.flushes++ | |||
| 133 | + | t.steps = append(t.steps, "flush") | |||
| 134 | + | t.mu.Unlock() | |||
| 135 | + | return t.flushErr | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func (t *workerTestTerminal) BeginOutput(ports.UIContext) { | |||
| 139 | + | t.mu.Lock() | |||
| 140 | + | defer t.mu.Unlock() | |||
| 141 | + | t.steps = append(t.steps, "begin") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (t *workerTestTerminal) EndOutput(success bool) { | |||
| 145 | + | t.mu.Lock() | |||
| 146 | + | defer t.mu.Unlock() | |||
| 147 | + | t.steps = append(t.steps, "end") | |||
| 148 | + | t.successes = append(t.successes, success) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func (t *workerTestTerminal) PublishContext(context ports.UIContext) error { | |||
| 152 | + | t.mu.Lock() | |||
| 153 | + | defer t.mu.Unlock() | |||
| 154 | + | t.published = append(t.published, context) | |||
| 155 | + | t.steps = append(t.steps, "publish") | |||
| 156 | + | return t.publishErr | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (t *workerTestTerminal) written() string { | |||
| 160 | + | t.mu.Lock() | |||
| 161 | + | defer t.mu.Unlock() | |||
| 162 | + | return t.out.String() | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (t *workerTestTerminal) flushCount() int { | |||
| 166 | + | t.mu.Lock() | |||
| 167 | + | defer t.mu.Unlock() | |||
| 168 | + | return t.flushes | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (t *workerTestTerminal) restoreCount() int { | |||
| 172 | + | t.mu.Lock() | |||
| 173 | + | defer t.mu.Unlock() | |||
| 174 | + | return t.restores | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (t *workerTestTerminal) publications() []ports.UIContext { | |||
| 178 | + | t.mu.Lock() | |||
| 179 | + | defer t.mu.Unlock() | |||
| 180 | + | return append([]ports.UIContext(nil), t.published...) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | type workerTestWriter struct{ term *workerTestTerminal } | |||
| 184 | + | ||||
| 185 | + | func (w *workerTestWriter) Write(data []byte) (int, error) { | |||
| 186 | + | w.term.mu.Lock() | |||
| 187 | + | defer w.term.mu.Unlock() | |||
| 188 | + | w.term.steps = append(w.term.steps, "write") | |||
| 189 | + | if w.term.writeErr != nil { | |||
| 190 | + | return 0, w.term.writeErr | |||
| 191 | + | } | |||
| 192 | + | if w.term.shortWrite > 0 { | |||
| 193 | + | n := min(w.term.shortWrite, len(data)) | |||
| 194 | + | _, _ = w.term.out.Write(data[:n]) | |||
| 195 | + | return n, nil | |||
| 196 | + | } | |||
| 197 | + | return w.term.out.Write(data) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // fakeWorker adapts a function to the AttachmentWorker contract. | |||
| 201 | + | type fakeWorker struct { | |||
| 202 | + | run func(ctx context.Context, fg AttachmentForeground) AttachmentEvent | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (w *fakeWorker) Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 206 | + | return w.run(ctx, fg) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // blockingWorker ignores input/output and parks until its context ends. | |||
| 210 | + | func blockingWorker() *fakeWorker { | |||
| 211 | + | return &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 212 | + | <-ctx.Done() | |||
| 213 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 214 | + | }} | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func newWorkerTestHost(term *workerTestTerminal, input *terminalInputPump, onAttached func(AttachmentToken)) *attachmentHost { | |||
| 218 | + | return newAttachmentHost(attachmentHostConfig{ | |||
| 219 | + | Terminal: term, | |||
| 220 | + | Clock: newSupervisorTestClock(), | |||
| 221 | + | Input: input, | |||
| 222 | + | OnAttached: onAttached, | |||
| 223 | + | }) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // TestAttachmentWorkerExclusiveForeground proves exactly one worker generation | |||
| 227 | + | // owns the terminal foreground at a time: a second Begin is refused while the | |||
| 228 | + | // first is live and succeeds once the first is cancelled. | |||
| 229 | + | func TestAttachmentWorkerExclusiveForeground(t *testing.T) { | |||
| 230 | + | term := newWorkerTestTerminal() | |||
| 231 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 232 | + | ||||
| 233 | + | run1, ok := host.Begin(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |||
| 234 | + | require.True(t, ok) | |||
| 235 | + | require.NotNil(t, run1) | |||
| 236 | + | ||||
| 237 | + | _, ok = host.Begin(context.Background(), AttachmentToken{Generation: 2, Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |||
| 238 | + | require.False(t, ok, "second foreground must be refused while the first is live") | |||
| 239 | + | require.Equal(t, AttachmentToken{Generation: 1, Attempt: 1}, host.authority.foreground().Token()) | |||
| 240 | + | ||||
| 241 | + | run1.Cancel() | |||
| 242 | + | require.Nil(t, host.authority.foreground()) | |||
| 243 | + | ||||
| 244 | + | run2, ok := host.Begin(context.Background(), AttachmentToken{Generation: 2, Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |||
| 245 | + | require.True(t, ok, "a fresh generation may own the foreground after cancellation") | |||
| 246 | + | run2.Cancel() | |||
| 247 | + | require.Nil(t, host.authority.foreground()) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // TestAttachmentWorkerZeroTokenRefused proves an unassigned token is never | |||
| 251 | + | // granted, so a worker can never run without a supervisor generation. | |||
| 252 | + | func TestAttachmentWorkerZeroTokenRefused(t *testing.T) { | |||
| 253 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |||
| 254 | + | _, ok := host.Begin(context.Background(), AttachmentToken{}, blockingWorker(), newWorkerTestStream()) | |||
| 255 | + | require.False(t, ok) | |||
| 256 | + | require.Nil(t, host.authority.foreground()) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // TestAttachmentWorkerCanceledGenerationCannotAct proves cancellation revokes | |||
| 260 | + | // input/output authority: a canceled generation cannot write, flush, consume or | |||
| 261 | + | // forward input, resize, publish UI, or mark itself attached, and its grant is | |||
| 262 | + | // closed. | |||
| 263 | + | func TestAttachmentWorkerCanceledGenerationCannotAct(t *testing.T) { | |||
| 264 | + | term := newWorkerTestTerminal() | |||
| 265 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 266 | + | stream := newWorkerTestStream() | |||
| 267 | + | ||||
| 268 | + | fgReady := make(chan AttachmentForeground, 1) | |||
| 269 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 270 | + | fgReady <- fg | |||
| 271 | + | <-ctx.Done() | |||
| 272 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 273 | + | }} | |||
| 274 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: 4, Attempt: 2}, worker, stream) | |||
| 275 | + | require.True(t, ok) | |||
| 276 | + | fg := <-fgReady | |||
| 277 | + | require.Equal(t, AttachmentToken{Generation: 4, Attempt: 2}, fg.Token()) | |||
| 278 | + | ||||
| 279 | + | run.Cancel() | |||
| 280 | + | ||||
| 281 | + | select { | |||
| 282 | + | case <-fg.Done(): | |||
| 283 | + | default: | |||
| 284 | + | t.Fatal("grant Done must be closed after cancellation") | |||
| 285 | + | } | |||
| 286 | + | require.ErrorIs(t, fg.Output(ports.UIContext{}, []byte("late")), errAttachmentForegroundRevoked) | |||
| 287 | + | require.ErrorIs(t, fg.Output(ports.UIContext{AttachmentHandle: "late"}, nil), errAttachmentForegroundRevoked) | |||
| 288 | + | require.False(t, fg.MarkAttached()) | |||
| 289 | + | require.False(t, fg.Attached()) | |||
| 290 | + | ||||
| 291 | + | _, okInput := fg.Input(context.Background()) | |||
| 292 | + | require.False(t, okInput) | |||
| 293 | + | _, okResize := fg.Resize(context.Background()) | |||
| 294 | + | require.False(t, okResize) | |||
| 295 | + | ||||
| 296 | + | require.Empty(t, term.written(), "a canceled generation must not write the terminal") | |||
| 297 | + | require.Empty(t, term.publications(), "a canceled generation must not publish UI") | |||
| 298 | + | require.True(t, stream.isClosed(), "the supervisor closes the owned stream") | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // TestAttachmentWorkerConsumesAndForwardsInput proves an authorized worker | |||
| 302 | + | // consumes the shared terminal pump and forwards the bytes on its own stream. | |||
| 303 | + | func TestAttachmentWorkerConsumesAndForwardsInput(t *testing.T) { | |||
| 304 | + | term := newWorkerTestTerminal() | |||
| 305 | + | pr, pw := io.Pipe() | |||
| 306 | + | pump := newTerminalInputPump(pr) | |||
| 307 | + | pump.start() | |||
| 308 | + | defer pump.stop() | |||
| 309 | + | defer pw.Close() | |||
| 310 | + | ||||
| 311 | + | host := newWorkerTestHost(term, pump, nil) | |||
| 312 | + | stream := newWorkerTestStream() | |||
| 313 | + | ||||
| 314 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 315 | + | event, ok := fg.Input(ctx) | |||
| 316 | + | if !ok { | |||
| 317 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no authorized input")} | |||
| 318 | + | } | |||
| 319 | + | fg.AckInput() | |||
| 320 | + | if err := fg.Stream().SendClient(protocol.Input{InputSeq: 1, Data: event.Data}); err != nil { | |||
| 321 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |||
| 322 | + | } | |||
| 323 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 324 | + | }} | |||
| 325 | + | ||||
| 326 | + | go func() { _, _ = pw.Write([]byte("hello")) }() | |||
| 327 | + | ||||
| 328 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |||
| 329 | + | require.True(t, adopted) | |||
| 330 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |||
| 331 | + | ||||
| 332 | + | messages := stream.messages() | |||
| 333 | + | require.Len(t, messages, 1) | |||
| 334 | + | inputMessage, ok := messages[0].(protocol.Input) | |||
| 335 | + | require.True(t, ok) | |||
| 336 | + | require.Equal(t, []byte("hello"), inputMessage.Data) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // TestAttachmentWorkerResizeAuthorized proves an authorized worker observes the | |||
| 340 | + | // supervisor's resize events and forwards them on its stream. | |||
| 341 | + | func TestAttachmentWorkerResizeAuthorized(t *testing.T) { | |||
| 342 | + | term := newWorkerTestTerminal() | |||
| 343 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 344 | + | stream := newWorkerTestStream() | |||
| 345 | + | geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}} | |||
| 346 | + | ||||
| 347 | + | saw := make(chan domain.Geometry, 1) | |||
| 348 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 349 | + | term.resizes <- geometry | |||
| 350 | + | resize, ok := fg.Resize(ctx) | |||
| 351 | + | if !ok { | |||
| 352 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no authorized resize")} | |||
| 353 | + | } | |||
| 354 | + | saw <- resize | |||
| 355 | + | if err := fg.Stream().SendClient(protocol.Resize{Size: resize.Size, PixelWidth: resize.PixelWidth, PixelHeight: resize.PixelHeight}); err != nil { | |||
| 356 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |||
| 357 | + | } | |||
| 358 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 359 | + | }} | |||
| 360 | + | ||||
| 361 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |||
| 362 | + | require.True(t, adopted) | |||
| 363 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |||
| 364 | + | require.Equal(t, geometry, <-saw) | |||
| 365 | + | ||||
| 366 | + | messages := stream.messages() | |||
| 367 | + | require.Len(t, messages, 1) | |||
| 368 | + | _, ok := messages[0].(protocol.Resize) | |||
| 369 | + | require.True(t, ok) | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // TestAttachmentWorkerPublishesUIAuthorized proves an authorized worker may | |||
| 373 | + | // publish UI context and mark the attached transition, and that the grant's | |||
| 374 | + | // generation is stamped onto the publication. | |||
| 375 | + | func TestAttachmentWorkerPublishesUIAuthorized(t *testing.T) { | |||
| 376 | + | term := newWorkerTestTerminal() | |||
| 377 | + | attached := make(chan AttachmentToken, 1) | |||
| 378 | + | host := newWorkerTestHost(term, nil, func(token AttachmentToken) { attached <- token }) | |||
| 379 | + | stream := newWorkerTestStream() | |||
| 380 | + | ||||
| 381 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 382 | + | if err := fg.Output(ports.UIContext{}, nil); err != nil { | |||
| 383 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |||
| 384 | + | } | |||
| 385 | + | if !fg.MarkAttached() { | |||
| 386 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("attach refused")} | |||
| 387 | + | } | |||
| 388 | + | if !fg.Attached() { | |||
| 389 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("attach not recorded")} | |||
| 390 | + | } | |||
| 391 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 392 | + | }} | |||
| 393 | + | ||||
| 394 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 7, Attempt: 2}, worker, stream) | |||
| 395 | + | require.True(t, adopted) | |||
| 396 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |||
| 397 | + | ||||
| 398 | + | publications := term.publications() | |||
| 399 | + | require.Len(t, publications, 1) | |||
| 400 | + | require.Equal(t, uint64(7), publications[0].Generation, "the granted generation is stamped") | |||
| 401 | + | require.Equal(t, AttachmentToken{Generation: 7, Attempt: 2}, <-attached) | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | // TestAttachmentWorkerEventReturnsToPickerWithoutTerminalRestore proves a | |||
| 405 | + | // worker end, stream loss, or typed failure returns a typed event stamped with | |||
| 406 | + | // the granted token while the worker mechanism never restores the terminal and | |||
| 407 | + | // never closes the process. | |||
| 408 | + | func TestAttachmentWorkerEventReturnsToPickerWithoutTerminalRestore(t *testing.T) { | |||
| 409 | + | cases := []struct { | |||
| 410 | + | name string | |||
| 411 | + | kind AttachmentEventKind | |||
| 412 | + | err error | |||
| 413 | + | }{ | |||
| 414 | + | {name: "ended", kind: AttachmentEventEnded}, | |||
| 415 | + | {name: "lost", kind: AttachmentEventLost, err: errors.New("carriage lost")}, | |||
| 416 | + | {name: "failed", kind: AttachmentEventFailed, err: &ProtocolError{Code: 7, Text: "bad welcome"}}, | |||
| 417 | + | } | |||
| 418 | + | for _, tc := range cases { | |||
| 419 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 420 | + | term := newWorkerTestTerminal() | |||
| 421 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 422 | + | stream := newWorkerTestStream() | |||
| 423 | + | // The worker reports a spoofed token; the host must override it with | |||
| 424 | + | // the granted generation/attempt. | |||
| 425 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 426 | + | return AttachmentEvent{Token: AttachmentToken{Generation: 999}, Kind: tc.kind, Err: tc.err} | |||
| 427 | + | }} | |||
| 428 | + | ||||
| 429 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: 3, Attempt: 5}, worker, stream) | |||
| 430 | + | require.True(t, adopted) | |||
| 431 | + | require.Equal(t, tc.kind, event.Kind) | |||
| 432 | + | require.Equal(t, AttachmentToken{Generation: 3, Attempt: 5}, event.Token) | |||
| 433 | + | if tc.err != nil { | |||
| 434 | + | require.ErrorIs(t, event.Err, tc.err) | |||
| 435 | + | } else { | |||
| 436 | + | require.NoError(t, event.Err) | |||
| 437 | + | } | |||
| 438 | + | require.Equal(t, 0, term.restoreCount(), "the worker mechanism must not restore the terminal") | |||
| 439 | + | require.True(t, stream.isClosed(), "the supervisor closes the stream after the worker settles") | |||
| 440 | + | require.Nil(t, host.authority.foreground(), "the foreground is released after the worker settles") | |||
| 441 | + | }) | |||
| 442 | + | } | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | // TestAttachmentWorkerLateEventDiscardedOnCancellation proves a worker that | |||
| 446 | + | // settles after the supervisor cancelled is joined and its late event is | |||
| 447 | + | // discarded rather than returned as an adopted result. | |||
| 448 | + | func TestAttachmentWorkerLateEventDiscardedOnCancellation(t *testing.T) { | |||
| 449 | + | term := newWorkerTestTerminal() | |||
| 450 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 451 | + | stream := newWorkerTestStream() | |||
| 452 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, blockingWorker(), stream) | |||
| 453 | + | require.True(t, ok) | |||
| 454 | + | ||||
| 455 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 456 | + | cancel() | |||
| 457 | + | ||||
| 458 | + | event, adopted := run.Wait(ctx) | |||
| 459 | + | require.False(t, adopted) | |||
| 460 | + | require.Equal(t, AttachmentEvent{}, event, "a late event from a canceled run is discarded") | |||
| 461 | + | require.True(t, stream.isClosed()) | |||
| 462 | + | require.Nil(t, host.authority.foreground()) | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | // TestAttachmentWorkerPreservesResidualInputAcrossAttempts proves the reused | |||
| 466 | + | // input pump keeps undecided bytes for the next authorized generation instead of | |||
| 467 | + | // duplicating a reader. | |||
| 468 | + | func TestAttachmentWorkerPreservesResidualInputAcrossAttempts(t *testing.T) { | |||
| 469 | + | term := newWorkerTestTerminal() | |||
| 470 | + | pr, pw := io.Pipe() | |||
| 471 | + | pump := newTerminalInputPump(pr) | |||
| 472 | + | pump.start() | |||
| 473 | + | defer pump.stop() | |||
| 474 | + | defer pw.Close() | |||
| 475 | + | ||||
| 476 | + | host := newWorkerTestHost(term, pump, nil) | |||
| 477 | + | ||||
| 478 | + | observed := make(chan []byte, 2) | |||
| 479 | + | first := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 480 | + | event, ok := fg.Input(ctx) | |||
| 481 | + | if !ok { | |||
| 482 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("first attempt saw no input")} | |||
| 483 | + | } | |||
| 484 | + | observed <- append([]byte(nil), event.Data...) | |||
| 485 | + | // Leave the bytes undecided: the next attempt must see them again. | |||
| 486 | + | fg.PreserveInput(event.Data) | |||
| 487 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 488 | + | }} | |||
| 489 | + | second := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 490 | + | event, ok := fg.Input(ctx) | |||
| 491 | + | if !ok { | |||
| 492 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("second attempt saw no input")} | |||
| 493 | + | } | |||
| 494 | + | fg.AckInput() | |||
| 495 | + | observed <- append([]byte(nil), event.Data...) | |||
| 496 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 497 | + | }} | |||
| 498 | + | ||||
| 499 | + | go func() { _, _ = pw.Write([]byte("ab")) }() | |||
| 500 | + | ||||
| 501 | + | _, adopted := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, first, newWorkerTestStream()) | |||
| 502 | + | require.True(t, adopted) | |||
| 503 | + | _, adopted = host.Run(context.Background(), AttachmentToken{Generation: 2, Attempt: 1}, second, newWorkerTestStream()) | |||
| 504 | + | require.True(t, adopted) | |||
| 505 | + | ||||
| 506 | + | require.Equal(t, []byte("ab"), <-observed) | |||
| 507 | + | require.Equal(t, []byte("ab"), <-observed, "the replacement generation replays the preserved bytes") | |||
| 508 | + | go func() { _, _ = pw.Write([]byte("fresh")) }() | |||
| 509 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 510 | + | defer cancel() | |||
| 511 | + | _, adopted = host.Run(ctx, AttachmentToken{Generation: 3, Attempt: 3}, second, newWorkerTestStream()) | |||
| 512 | + | require.True(t, adopted) | |||
| 513 | + | require.Equal(t, []byte("fresh"), <-observed, "third attempt must not replay the original pending read") | |||
| 514 | + | ||||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | // TestAttachmentWorkerRevokesBeforeClosingStream proves the supervisor revokes | |||
| 518 | + | // input/output authority before it closes the owned stream: at the instant of | |||
| 519 | + | // Close the foreground is already released and a late write is refused. | |||
| 520 | + | func TestAttachmentWorkerRevokesBeforeClosingStream(t *testing.T) { | |||
| 521 | + | term := newWorkerTestTerminal() | |||
| 522 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 523 | + | stream := newWorkerTestStream() | |||
| 524 | + | ||||
| 525 | + | fgReady := make(chan AttachmentForeground, 1) | |||
| 526 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 527 | + | fgReady <- fg | |||
| 528 | + | <-ctx.Done() | |||
| 529 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 530 | + | }} | |||
| 531 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream) | |||
| 532 | + | require.True(t, ok) | |||
| 533 | + | fg := <-fgReady | |||
| 534 | + | ||||
| 535 | + | var closeWriteErr error | |||
| 536 | + | stream.closeHook = func() { | |||
| 537 | + | require.Nil(t, host.authority.foreground(), "authority must be revoked before the stream closes") | |||
| 538 | + | closeWriteErr = fg.Output(ports.UIContext{}, []byte("late")) | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | run.Cancel() | |||
| 542 | + | require.ErrorIs(t, closeWriteErr, errAttachmentForegroundRevoked) | |||
| 543 | + | require.Empty(t, term.written()) | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | // TestAttachmentWorkerConcurrentBeginSingleOwner proves concurrent Begin calls | |||
| 547 | + | // admit exactly one foreground owner under the race detector. | |||
| 548 | + | func TestAttachmentWorkerConcurrentBeginSingleOwner(t *testing.T) { | |||
| 549 | + | term := newWorkerTestTerminal() | |||
| 550 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 551 | + | ||||
| 552 | + | const contenders = 8 | |||
| 553 | + | var wg sync.WaitGroup | |||
| 554 | + | var successes atomic.Int64 | |||
| 555 | + | runs := make(chan *attachmentRun, contenders) | |||
| 556 | + | start := make(chan struct{}) | |||
| 557 | + | for i := 0; i < contenders; i++ { | |||
| 558 | + | wg.Add(1) | |||
| 559 | + | go func(i int) { | |||
| 560 | + | defer wg.Done() | |||
| 561 | + | <-start | |||
| 562 | + | run, ok := host.Begin(context.Background(), AttachmentToken{Generation: uint64(i + 1), Attempt: 1}, blockingWorker(), newWorkerTestStream()) | |||
| 563 | + | if ok { | |||
| 564 | + | successes.Add(1) | |||
| 565 | + | runs <- run | |||
| 566 | + | } | |||
| 567 | + | }(i) | |||
| 568 | + | } | |||
| 569 | + | close(start) | |||
| 570 | + | wg.Wait() | |||
| 571 | + | close(runs) | |||
| 572 | + | ||||
| 573 | + | require.Equal(t, int64(1), successes.Load(), "exactly one concurrent Begin may own the foreground") | |||
| 574 | + | for run := range runs { | |||
| 575 | + | run.Cancel() | |||
| 576 | + | } | |||
| 577 | + | require.Nil(t, host.authority.foreground()) | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | // TestAttachmentWorkerRepeatedAttemptsNoLeak proves repeated grant/cancel cycles | |||
| 581 | + | // release the foreground, close every stream, and never leave a live owner. | |||
| 582 | + | func TestAttachmentWorkerRepeatedAttemptsNoLeak(t *testing.T) { | |||
| 583 | + | term := newWorkerTestTerminal() | |||
| 584 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 585 | + | ||||
| 586 | + | const attempts = 64 | |||
| 587 | + | for i := 0; i < attempts; i++ { | |||
| 588 | + | stream := newWorkerTestStream() | |||
| 589 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 590 | + | if err := fg.Output(ports.UIContext{}, []byte("x")); err != nil { | |||
| 591 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: err} | |||
| 592 | + | } | |||
| 593 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 594 | + | }} | |||
| 595 | + | event, adopted := host.Run(context.Background(), AttachmentToken{Generation: uint64(i + 1), Attempt: 1}, worker, stream) | |||
| 596 | + | require.True(t, adopted) | |||
| 597 | + | require.Equal(t, AttachmentEventEnded, event.Kind) | |||
| 598 | + | require.True(t, stream.isClosed()) | |||
| 599 | + | require.Nil(t, host.authority.foreground()) | |||
| 600 | + | } | |||
| 601 | + | require.Equal(t, attempts, term.flushCount()) | |||
| 602 | + | require.Equal(t, 0, term.restoreCount(), "the mechanism never enters or restores raw mode") | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | func TestAttachmentWorkerRetiredOutcomeNeverAdopted(t *testing.T) { | |||
| 606 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |||
| 607 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |||
| 608 | + | require.True(t, ok) | |||
| 609 | + | run.Cancel() | |||
| 610 | + | for range 3 { | |||
| 611 | + | event, adopted := run.Wait(context.Background()) | |||
| 612 | + | require.False(t, adopted) | |||
| 613 | + | require.Zero(t, event) | |||
| 614 | + | } | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | func TestAttachmentWorkerBoundedJoin(t *testing.T) { | |||
| 618 | + | clock := newSupervisorTestClock() | |||
| 619 | + | pr, pw := io.Pipe() | |||
| 620 | + | defer pr.Close() | |||
| 621 | + | defer pw.Close() | |||
| 622 | + | pump := newTerminalInputPump(pr) | |||
| 623 | + | pump.start() | |||
| 624 | + | defer pump.stop() | |||
| 625 | + | host := newAttachmentHost(attachmentHostConfig{Clock: clock, Input: pump}) | |||
| 626 | + | unblock := make(chan struct{}) | |||
| 627 | + | defer close(unblock) | |||
| 628 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, &fakeWorker{run: func(context.Context, AttachmentForeground) AttachmentEvent { | |||
| 629 | + | <-unblock | |||
| 630 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 631 | + | }}, newWorkerTestStream()) | |||
| 632 | + | require.True(t, ok) | |||
| 633 | + | cancelled := make(chan struct{}) | |||
| 634 | + | go func() { run.Cancel(); close(cancelled) }() | |||
| 635 | + | timer := clock.awaitTimer(t) | |||
| 636 | + | require.Equal(t, attachmentWorkerJoinTimeout, timer.delay) | |||
| 637 | + | select { | |||
| 638 | + | case <-run.retired: | |||
| 639 | + | default: | |||
| 640 | + | t.Fatal("retirement must precede join") | |||
| 641 | + | } | |||
| 642 | + | // During the final input window the grant is finalizing, not revoked: Done is | |||
| 643 | + | // closed, output and new input are refused, the retained consumer still holds | |||
| 644 | + | // the shared reader, and the stream is still open. | |||
| 645 | + | require.Same(t, run.fg, host.authority.foreground()) | |||
| 646 | + | select { | |||
| 647 | + | case <-run.fg.Done(): | |||
| 648 | + | default: | |||
| 649 | + | t.Fatal("finalize must close Done before the join") | |||
| 650 | + | } | |||
| 651 | + | require.ErrorIs(t, run.fg.Output(ports.UIContext{}, []byte("late")), errAttachmentForegroundRevoked) | |||
| 652 | + | _, ok = host.Begin(context.Background(), AttachmentToken{2, 1}, blockingWorker(), newWorkerTestStream()) | |||
| 653 | + | require.False(t, ok, "a finalizing grant blocks a competing Begin") | |||
| 654 | + | _, claimed := pump.tryClaim() | |||
| 655 | + | require.False(t, claimed, "the consumer is retained during the final input window") | |||
| 656 | + | require.False(t, run.stream.(*workerTestStream).isClosed(), "the stream closes after the join") | |||
| 657 | + | timer.fire() | |||
| 658 | + | select { | |||
| 659 | + | case <-cancelled: | |||
| 660 | + | case <-time.After(time.Second): | |||
| 661 | + | t.Fatal("uncooperative worker pinned cancel") | |||
| 662 | + | } | |||
| 663 | + | require.True(t, timer.stopped()) | |||
| 664 | + | // After the timeout the supervisor revokes anyway: slot freed, consumer | |||
| 665 | + | // released, and the stream closed. | |||
| 666 | + | require.Nil(t, host.authority.foreground()) | |||
| 667 | + | consumer, claimed := pump.tryClaim() | |||
| 668 | + | require.True(t, claimed, "revocation releases the consumer after the join") | |||
| 669 | + | pump.revoke(consumer) | |||
| 670 | + | require.True(t, run.stream.(*workerTestStream).isClosed()) | |||
| 671 | + | _, adopted := run.Wait(context.Background()) | |||
| 672 | + | require.False(t, adopted) | |||
| 673 | + | } | |||
| 674 | + | ||||
| 675 | + | func TestAttachmentWorkerPumpClaimAndCancelRace(t *testing.T) { | |||
| 676 | + | pr, pw := io.Pipe() | |||
| 677 | + | defer pr.Close() | |||
| 678 | + | defer pw.Close() | |||
| 679 | + | pump := newTerminalInputPump(pr) | |||
| 680 | + | pump.start() | |||
| 681 | + | defer pump.stop() | |||
| 682 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |||
| 683 | + | rival := pump.claim() | |||
| 684 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |||
| 685 | + | require.False(t, ok) | |||
| 686 | + | require.Nil(t, host.authority.foreground()) | |||
| 687 | + | pump.revoke(rival) | |||
| 688 | + | for i := range 100 { | |||
| 689 | + | run, ok := host.Begin(context.Background(), AttachmentToken{uint64(i + 1), 1}, blockingWorker(), newWorkerTestStream()) | |||
| 690 | + | require.True(t, ok) | |||
| 691 | + | var next *attachmentRun | |||
| 692 | + | var wg sync.WaitGroup | |||
| 693 | + | wg.Add(2) | |||
| 694 | + | go func() { defer wg.Done(); run.Cancel() }() | |||
| 695 | + | go func() { | |||
| 696 | + | defer wg.Done() | |||
| 697 | + | next, _ = host.Begin(context.Background(), AttachmentToken{uint64(i + 2), 2}, blockingWorker(), newWorkerTestStream()) | |||
| 698 | + | }() | |||
| 699 | + | wg.Wait() | |||
| 700 | + | if next != nil { | |||
| 701 | + | next.Cancel() | |||
| 702 | + | } | |||
| 703 | + | consumer, claimed := pump.tryClaim() | |||
| 704 | + | require.True(t, claimed) | |||
| 705 | + | pump.revoke(consumer) | |||
| 706 | + | } | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | func TestAttachmentWorkerClosedResizeDisabled(t *testing.T) { | |||
| 710 | + | term := newWorkerTestTerminal() | |||
| 711 | + | close(term.resizes) // uiterm-like terminal: no physical resize events | |||
| 712 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 713 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |||
| 714 | + | require.True(t, ok) | |||
| 715 | + | result := make(chan bool, 1) | |||
| 716 | + | go func() { _, ok := run.fg.Resize(context.Background()); result <- ok }() | |||
| 717 | + | select { | |||
| 718 | + | case <-result: | |||
| 719 | + | t.Fatal("closed resize source terminated attachment") | |||
| 720 | + | case <-time.After(10 * time.Millisecond): | |||
| 721 | + | } | |||
| 722 | + | require.True(t, run.fg.MarkAttached()) | |||
| 723 | + | run.Cancel() | |||
| 724 | + | select { | |||
| 725 | + | case ok := <-result: | |||
| 726 | + | require.False(t, ok) | |||
| 727 | + | case <-time.After(time.Second): | |||
| 728 | + | t.Fatal("authority Done did not stop resize") | |||
| 729 | + | } | |||
| 730 | + | } | |||
| 731 | + | ||||
| 732 | + | func TestAttachmentWorkerOutputTransaction(t *testing.T) { | |||
| 733 | + | failure := errors.New("output failure") | |||
| 734 | + | for _, tc := range []struct { | |||
| 735 | + | name string | |||
| 736 | + | writeErr, flushErr, publishErr error | |||
| 737 | + | shortWrite int | |||
| 738 | + | wantErr error | |||
| 739 | + | steps []string | |||
| 740 | + | }{ | |||
| 741 | + | {name: "success", steps: []string{"begin", "publish", "write", "flush", "end"}}, | |||
| 742 | + | {name: "short write", shortWrite: 2, wantErr: io.ErrShortWrite, steps: []string{"begin", "publish", "write", "end"}}, | |||
| 743 | + | {name: "write failure", writeErr: failure, wantErr: failure, steps: []string{"begin", "publish", "write", "end"}}, | |||
| 744 | + | {name: "flush failure", flushErr: failure, wantErr: failure, steps: []string{"begin", "publish", "write", "flush", "end"}}, | |||
| 745 | + | {name: "publish failure", publishErr: failure, wantErr: failure, steps: []string{"begin", "publish", "end"}}, | |||
| 746 | + | {name: "publish unavailable tolerated", publishErr: ports.ErrUIUnavailable, steps: []string{"begin", "publish", "write", "flush", "end"}}, | |||
| 747 | + | } { | |||
| 748 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 749 | + | term := newWorkerTestTerminal() | |||
| 750 | + | term.writeErr, term.flushErr, term.publishErr, term.shortWrite = tc.writeErr, tc.flushErr, tc.publishErr, tc.shortWrite | |||
| 751 | + | host := newWorkerTestHost(term, nil, nil) | |||
| 752 | + | run, ok := host.Begin(context.Background(), AttachmentToken{9, 2}, blockingWorker(), newWorkerTestStream()) | |||
| 753 | + | require.True(t, ok) | |||
| 754 | + | defer run.Cancel() | |||
| 755 | + | err := run.fg.Output(ports.UIContext{Generation: 999}, []byte("frame")) | |||
| 756 | + | success := tc.wantErr == nil | |||
| 757 | + | if success { | |||
| 758 | + | require.NoError(t, err) | |||
| 759 | + | } else { | |||
| 760 | + | require.ErrorIs(t, err, tc.wantErr) | |||
| 761 | + | } | |||
| 762 | + | require.Equal(t, tc.steps, term.steps) | |||
| 763 | + | require.Equal(t, []bool{success}, term.successes) | |||
| 764 | + | require.Equal(t, uint64(9), term.published[0].Generation) | |||
| 765 | + | }) | |||
| 766 | + | } | |||
| 767 | + | } | |||
| 768 | + | ||||
| 769 | + | // Hide the transaction methods to model a physical terminal without UI output. | |||
| 770 | + | type workerBareTerminal struct{ ports.Terminal } | |||
| 771 | + | ||||
| 772 | + | func TestAttachmentWorkerNilSeams(t *testing.T) { | |||
| 773 | + | var nilTerm *workerTestTerminal | |||
| 774 | + | var nilWorker *fakeWorker | |||
| 775 | + | var nilStream *workerTestStream | |||
| 776 | + | var nilClock *supervisorTestClock | |||
| 777 | + | for _, cfg := range []attachmentHostConfig{ | |||
| 778 | + | {}, | |||
| 779 | + | {Terminal: nilTerm, UI: nilTerm, Clock: nilClock}, | |||
| 780 | + | {Terminal: workerBareTerminal{newWorkerTestTerminal()}, UI: nilTerm}, | |||
| 781 | + | } { | |||
| 782 | + | host := newAttachmentHost(cfg) | |||
| 783 | + | _, ok := host.Begin(nil, AttachmentToken{1, 1}, nilWorker, newWorkerTestStream()) | |||
| 784 | + | require.False(t, ok) | |||
| 785 | + | _, ok = host.Begin(nil, AttachmentToken{1, 1}, blockingWorker(), nilStream) | |||
| 786 | + | require.False(t, ok) | |||
| 787 | + | run, ok := host.Begin(nil, AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |||
| 788 | + | require.True(t, ok) | |||
| 789 | + | require.ErrorIs(t, run.fg.Output(ports.UIContext{}, nil), ports.ErrUIUnavailable) | |||
| 790 | + | run.Cancel() | |||
| 791 | + | require.ErrorIs(t, run.fg.Output(ports.UIContext{}, nil), errAttachmentForegroundRevoked) | |||
| 792 | + | } | |||
| 793 | + | } | |||
| 794 | + | ||||
| 795 | + | // TestAttachmentWorkerGenerationIsSupervisorSupplied proves the host no longer | |||
| 796 | + | // allocates a UI generation: it grants and stamps the caller's generation, and | |||
| 797 | + | // the same value can be re-granted across sequential runs because the | |||
| 798 | + | // supervisor owns the counter. UI action binding stays deferred to the P5 | |||
| 799 | + | // supervisor, so no UI admission is published here; the transactional output | |||
| 800 | + | // path still stamps the granted generation on every publication. | |||
| 801 | + | func TestAttachmentWorkerGenerationIsSupervisorSupplied(t *testing.T) { | |||
| 802 | + | term := newWorkerTestTerminal() | |||
| 803 | + | pump := newTerminalInputPump(nil) | |||
| 804 | + | host := newWorkerTestHost(term, pump, nil) | |||
| 805 | + | for i := uint64(1); i <= 3; i++ { | |||
| 806 | + | run, ok := host.Begin(context.Background(), AttachmentToken{i, i + 10}, blockingWorker(), newWorkerTestStream()) | |||
| 807 | + | require.True(t, ok) | |||
| 808 | + | require.Equal(t, AttachmentToken{i, i + 10}, run.fg.Token()) | |||
| 809 | + | require.NoError(t, run.fg.Output(ports.UIContext{Generation: 999}, nil)) | |||
| 810 | + | require.Equal(t, i, term.publications()[i-1].Generation) | |||
| 811 | + | run.Cancel() | |||
| 812 | + | } | |||
| 813 | + | } | |||
| 814 | + | ||||
| 815 | + | // TestAttachmentWorkerBeginRefusalsRetainStream proves every Begin refusal path | |||
| 816 | + | // leaves the caller's stream untouched: it is not closed, receives no message, | |||
| 817 | + | // no foreground is granted, and the caller keeps ownership. | |||
| 818 | + | func TestAttachmentWorkerBeginRefusalsRetainStream(t *testing.T) { | |||
| 819 | + | t.Run("nil host", func(t *testing.T) { | |||
| 820 | + | var host *attachmentHost | |||
| 821 | + | stream := newWorkerTestStream() | |||
| 822 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), stream) | |||
| 823 | + | require.False(t, ok) | |||
| 824 | + | require.Nil(t, run) | |||
| 825 | + | require.False(t, stream.isClosed()) | |||
| 826 | + | require.Empty(t, stream.messages()) | |||
| 827 | + | }) | |||
| 828 | + | t.Run("zero token", func(t *testing.T) { | |||
| 829 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |||
| 830 | + | stream := newWorkerTestStream() | |||
| 831 | + | _, ok := host.Begin(context.Background(), AttachmentToken{}, blockingWorker(), stream) | |||
| 832 | + | require.False(t, ok) | |||
| 833 | + | require.False(t, stream.isClosed()) | |||
| 834 | + | require.Empty(t, stream.messages()) | |||
| 835 | + | require.Nil(t, host.authority.foreground()) | |||
| 836 | + | }) | |||
| 837 | + | t.Run("nil worker", func(t *testing.T) { | |||
| 838 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |||
| 839 | + | stream := newWorkerTestStream() | |||
| 840 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, nil, stream) | |||
| 841 | + | require.False(t, ok) | |||
| 842 | + | require.False(t, stream.isClosed()) | |||
| 843 | + | require.Nil(t, host.authority.foreground()) | |||
| 844 | + | }) | |||
| 845 | + | t.Run("nil stream", func(t *testing.T) { | |||
| 846 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |||
| 847 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), nil) | |||
| 848 | + | require.False(t, ok) | |||
| 849 | + | require.Nil(t, host.authority.foreground()) | |||
| 850 | + | }) | |||
| 851 | + | t.Run("foreground already live", func(t *testing.T) { | |||
| 852 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |||
| 853 | + | first, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), newWorkerTestStream()) | |||
| 854 | + | require.True(t, ok) | |||
| 855 | + | stream := newWorkerTestStream() | |||
| 856 | + | _, ok = host.Begin(context.Background(), AttachmentToken{2, 1}, blockingWorker(), stream) | |||
| 857 | + | require.False(t, ok) | |||
| 858 | + | require.False(t, stream.isClosed(), "a refused Begin must not close the caller's stream") | |||
| 859 | + | require.Empty(t, stream.messages()) | |||
| 860 | + | require.Same(t, first.fg, host.authority.foreground()) | |||
| 861 | + | first.Cancel() | |||
| 862 | + | }) | |||
| 863 | + | t.Run("rival consumer owns input", func(t *testing.T) { | |||
| 864 | + | pump := newTerminalInputPump(nil) | |||
| 865 | + | host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil) | |||
| 866 | + | rival := pump.claim() | |||
| 867 | + | defer pump.revoke(rival) | |||
| 868 | + | stream := newWorkerTestStream() | |||
| 869 | + | _, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, blockingWorker(), stream) | |||
| 870 | + | require.False(t, ok) | |||
| 871 | + | require.False(t, stream.isClosed()) | |||
| 872 | + | require.Empty(t, stream.messages()) | |||
| 873 | + | require.Nil(t, host.authority.foreground()) | |||
| 874 | + | }) | |||
| 875 | + | } | |||
| 876 | + | ||||
| 877 | + | // TestAttachmentWorkerFinalizePreservesUndecidedInput proves the deterministic | |||
| 878 | + | // final input window: cancellation wakes the worker through Done while the pump | |||
| 879 | + | // consumer is still authorized for Ack/Preserve only, so the undecided read | |||
| 880 | + | // survives to the next generation exactly once, and a duplicate PreserveInput | |||
| 881 | + | // for the same delivery can never overwrite it. | |||
| 882 | + | func TestAttachmentWorkerFinalizePreservesUndecidedInput(t *testing.T) { | |||
| 883 | + | pr, pw := io.Pipe() | |||
| 884 | + | defer pr.Close() | |||
| 885 | + | defer pw.Close() | |||
| 886 | + | pump := newTerminalInputPump(pr) | |||
| 887 | + | pump.start() | |||
| 888 | + | defer pump.stop() | |||
| 889 | + | host := newAttachmentHost(attachmentHostConfig{Input: pump}) | |||
| 890 | + | ||||
| 891 | + | var mu sync.Mutex | |||
| 892 | + | var refusals []string | |||
| 893 | + | refuse := func(what string) { | |||
| 894 | + | mu.Lock() | |||
| 895 | + | refusals = append(refusals, what) | |||
| 896 | + | mu.Unlock() | |||
| 897 | + | } | |||
| 898 | + | delivered := make(chan []byte, 1) | |||
| 899 | + | preserved := make(chan struct{}) | |||
| 900 | + | first := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 901 | + | event, ok := fg.Input(ctx) | |||
| 902 | + | if !ok { | |||
| 903 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("first attempt saw no input")} | |||
| 904 | + | } | |||
| 905 | + | delivered <- append([]byte(nil), event.Data...) | |||
| 906 | + | // The supervisor finalizes: Done wakes us while Ack/Preserve stay | |||
| 907 | + | // authorized and every other action is refused. | |||
| 908 | + | <-fg.Done() | |||
| 909 | + | if _, ok := fg.Input(ctx); ok { | |||
| 910 | + | refuse("new input") | |||
| 911 | + | } | |||
| 912 | + | if _, ok := fg.Resize(ctx); ok { | |||
| 913 | + | refuse("resize") | |||
| 914 | + | } | |||
| 915 | + | if err := fg.Output(ports.UIContext{}, []byte("late")); !errors.Is(err, errAttachmentForegroundRevoked) { | |||
| 916 | + | refuse("output") | |||
| 917 | + | } | |||
| 918 | + | fg.PreserveInput(event.Data) | |||
| 919 | + | fg.PreserveInput([]byte("overwrite")) | |||
| 920 | + | close(preserved) | |||
| 921 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 922 | + | }} | |||
| 923 | + | ||||
| 924 | + | written := make(chan error, 1) | |||
| 925 | + | go func() { _, err := pw.Write([]byte("ab")); written <- err }() | |||
| 926 | + | ||||
| 927 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, first, newWorkerTestStream()) | |||
| 928 | + | require.True(t, ok) | |||
| 929 | + | select { | |||
| 930 | + | case data := <-delivered: | |||
| 931 | + | require.Equal(t, []byte("ab"), data) | |||
| 932 | + | case <-time.After(time.Second): | |||
| 933 | + | t.Fatal("first attempt never received input") | |||
| 934 | + | } | |||
| 935 | + | run.Cancel() | |||
| 936 | + | select { | |||
| 937 | + | case <-preserved: | |||
| 938 | + | case <-time.After(time.Second): | |||
| 939 | + | t.Fatal("worker was not woken to preserve input") | |||
| 940 | + | } | |||
| 941 | + | mu.Lock() | |||
| 942 | + | require.Empty(t, refusals, "finalization must refuse every action except Ack/Preserve") | |||
| 943 | + | mu.Unlock() | |||
| 944 | + | require.NoError(t, <-written) | |||
| 945 | + | require.Nil(t, host.authority.foreground()) | |||
| 946 | + | ||||
| 947 | + | // The next generation replays the preserved bytes once, not the rejected | |||
| 948 | + | // duplicate. | |||
| 949 | + | observed := make(chan []byte, 2) | |||
| 950 | + | second := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 951 | + | event, ok := fg.Input(ctx) | |||
| 952 | + | if !ok { | |||
| 953 | + | return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("second attempt saw no input")} | |||
| 954 | + | } | |||
| 955 | + | fg.AckInput() | |||
| 956 | + | observed <- append([]byte(nil), event.Data...) | |||
| 957 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 958 | + | }} | |||
| 959 | + | _, adopted := host.Run(context.Background(), AttachmentToken{2, 1}, second, newWorkerTestStream()) | |||
| 960 | + | require.True(t, adopted) | |||
| 961 | + | require.Equal(t, []byte("ab"), <-observed) | |||
| 962 | + | ||||
| 963 | + | // A third generation sees only fresh input: the preserved read was consumed | |||
| 964 | + | // exactly once. | |||
| 965 | + | freshWritten := make(chan error, 1) | |||
| 966 | + | go func() { _, err := pw.Write([]byte("fresh")); freshWritten <- err }() | |||
| 967 | + | _, adopted = host.Run(context.Background(), AttachmentToken{3, 1}, second, newWorkerTestStream()) | |||
| 968 | + | require.True(t, adopted) | |||
| 969 | + | require.Equal(t, []byte("fresh"), <-observed) | |||
| 970 | + | require.NoError(t, <-freshWritten) | |||
| 971 | + | } | |||
| 972 | + | ||||
| 973 | + | func TestAttachmentWorkerRetirementPrecedesWorkerCancellation(t *testing.T) { | |||
| 974 | + | host := newWorkerTestHost(newWorkerTestTerminal(), nil, nil) | |||
| 975 | + | ready := make(chan *attachmentRun, 1) | |||
| 976 | + | checked := make(chan bool, 1) | |||
| 977 | + | worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent { | |||
| 978 | + | run := <-ready | |||
| 979 | + | <-ctx.Done() | |||
| 980 | + | select { | |||
| 981 | + | case <-run.retired: | |||
| 982 | + | checked <- true | |||
| 983 | + | default: | |||
| 984 | + | checked <- false | |||
| 985 | + | } | |||
| 986 | + | return AttachmentEvent{Kind: AttachmentEventEnded} | |||
| 987 | + | }} | |||
| 988 | + | run, ok := host.Begin(context.Background(), AttachmentToken{1, 1}, worker, newWorkerTestStream()) | |||
| 989 | + | require.True(t, ok) | |||
| 990 | + | ready <- run | |||
| 991 | + | run.Cancel() | |||
| 992 | + | require.True(t, <-checked) | |||
| 993 | + | _, adopted := run.Wait(context.Background()) | |||
| 994 | + | require.False(t, adopted) | |||
| 995 | + | } | |||
| 996 | + | ||||
| 997 | + | func TestAttachmentWorkerBoundEventUsesGrantedGeneration(t *testing.T) { | |||
| 998 | + | host := newAttachmentHost(attachmentHostConfig{Input: newTerminalInputPump(nil)}) | |||
| 999 | + | event, adopted := host.Run(context.Background(), AttachmentToken{99, 42}, &fakeWorker{run: func(context.Context, AttachmentForeground) AttachmentEvent { | |||
| 1000 | + | return AttachmentEvent{Token: AttachmentToken{999, 999}, Kind: AttachmentEventEnded} | |||
| 1001 | + | }}, newWorkerTestStream()) | |||
| 1002 | + | require.True(t, adopted) | |||
| 1003 | + | require.Equal(t, AttachmentToken{99, 42}, event.Token) | |||
| 1004 | + | } | |||
M internal/usecase/client/client.go +11 -2
| @@ -3655,14 +3655,23 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||
| 3655 | 3655 | // claim grants one attach scanner exclusive permission to dequeue terminal | |
| 3656 | 3656 | // input. The reader itself remains lifecycle-owned across reconnects. | |
| 3657 | 3657 | func (p *terminalInputPump) claim() uint64 { | |
| 3658 | + | consumer, ok := p.tryClaim() | |
| 3659 | + | if !ok { | |
| 3660 | + | panic("terminal input consumer already claimed") | |
| 3661 | + | } | |
| 3662 | + | return consumer | |
| 3663 | + | } | |
| 3664 | + | ||
| 3665 | + | // tryClaim lets competing foreground owners decline admission without panicking. | |
| 3666 | + | func (p *terminalInputPump) tryClaim() (uint64, bool) { | |
| 3658 | 3667 | p.mu.Lock() | |
| 3659 | 3668 | defer p.mu.Unlock() | |
| 3660 | 3669 | if p.consumer != 0 { | |
| 3661 | - | panic("terminal input consumer already claimed") | |
| 3670 | + | return 0, false | |
| 3662 | 3671 | } | |
| 3663 | 3672 | p.nextID++ | |
| 3664 | 3673 | p.consumer = p.nextID | |
| 3665 | - | return p.consumer | |
| 3674 | + | return p.consumer, true | |
| 3666 | 3675 | } | |
| 3667 | 3676 | ||
| 3668 | 3677 | // revoke invalidates an attempt before its replacement is allowed to claim | |
| @@ -3655,14 +3655,23 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||||
| 3655 | // claim grants one attach scanner exclusive permission to dequeue terminal | 3655 | // claim grants one attach scanner exclusive permission to dequeue terminal | ||
| 3656 | // input. The reader itself remains lifecycle-owned across reconnects. | 3656 | // input. The reader itself remains lifecycle-owned across reconnects. | ||
| 3657 | func (p *terminalInputPump) claim() uint64 { | 3657 | func (p *terminalInputPump) claim() uint64 { | ||
| 3658 | + | consumer, ok := p.tryClaim() | |||
| 3659 | + | if !ok { | |||
| 3660 | + | panic("terminal input consumer already claimed") | |||
| 3661 | + | } | |||
| 3662 | + | return consumer | |||
| 3663 | + | } | |||
| 3664 | + | ||||
| 3665 | + | // tryClaim lets competing foreground owners decline admission without panicking. | |||
| 3666 | + | func (p *terminalInputPump) tryClaim() (uint64, bool) { | |||
| 3658 | p.mu.Lock() | 3667 | p.mu.Lock() | ||
| 3659 | defer p.mu.Unlock() | 3668 | defer p.mu.Unlock() | ||
| 3660 | if p.consumer != 0 { | 3669 | if p.consumer != 0 { | ||
| 3661 | - | panic("terminal input consumer already claimed") | 3670 | + | return 0, false |
| 3662 | } | 3671 | } | ||
| 3663 | p.nextID++ | 3672 | p.nextID++ | ||
| 3664 | p.consumer = p.nextID | 3673 | p.consumer = p.nextID | ||
| 3665 | - | return p.consumer | 3674 | + | return p.consumer, true |
| 3666 | } | 3675 | } | ||
| 3667 | 3676 | ||||
| 3668 | // revoke invalidates an attempt before its replacement is allowed to claim | 3677 | // revoke invalidates an attempt before its replacement is allowed to claim | ||