package client import ( "context" "time" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" ) // bindForeground runs at the existing foreground ownership boundary. func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consumer uint64) uint64 { u.mu.Lock() defer u.mu.Unlock() u.generation++ u.input = input u.consumer = consumer u.foreground = ctx u.boundary = ports.UIActionResult{} u.suppressed = ports.UIContext{} if u.handoff != nil && u.handoff.destinationGeneration == 0 { u.handoff.destinationGeneration = u.generation } else if u.pending != 0 { u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) u.pending = 0 } u.signalLocked() return u.generation } // follow retains the accepted input action across an exact daemon navigation. // It waits for the destination's first committed publication, not merely a // successful send on the source attachment. func (u *UI) follow(generation, actionID uint64) bool { u.mu.Lock() defer u.mu.Unlock() record, ok := u.records[actionID] if !ok || actionID == 0 || generation != u.generation || record.Context.Generation != generation || (record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed) { return false } if u.pending != 0 && u.pending != actionID { u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) } record.Status = ports.UIActionPending u.records[actionID] = record u.pending = actionID u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation} u.signalLocked() return true } // arriveInPlace marks the destination of a followed same-peer switch as // committed on the unchanged foreground generation. The switch keeps the // attachment, so no new foreground binding names the destination: the worker // calls this just before publishing the destination's first output, and that // publication settles the action. func (u *UI) arriveInPlace(generation uint64) { u.mu.Lock() defer u.mu.Unlock() if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 { return } u.handoff.destinationGeneration = generation } // failInPlace fails a followed same-peer switch the daemon refused: the source // attachment is unchanged and the destination never commits. func (u *UI) failInPlace(generation uint64) { u.mu.Lock() defer u.mu.Unlock() if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 { return } u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{}) } // releaseForeground retires exactly the binding installed for generation. // The supervisor owns the presentation fence after the foreground drains. func (u *UI) releaseForeground(generation uint64) { u.mu.Lock() if generation != u.generation { u.mu.Unlock() return } u.input = nil u.consumer = 0 u.foreground = nil u.boundary = ports.UIActionResult{} if u.pending != 0 && (u.handoff == nil || u.handoff.actionID != u.pending) { u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) } u.signalLocked() u.mu.Unlock() } func (u *UI) accept(id, generation uint64) bool { u.mu.Lock() defer u.mu.Unlock() if u.pending != id || u.generation != generation { return false } if len(u.order) == uiActionHistory { evicted := u.order[0] delete(u.records, evicted) delete(u.dispatched, evicted) if done, ok := u.completion[evicted]; ok { close(done) delete(u.completion, evicted) } u.order = u.order[1:] } u.order = append(u.order, id) u.completion[id] = make(chan struct{}) u.records[id] = ports.UIActionResult{ActionID: id, Accepted: true, Status: ports.UIActionPending, Context: u.reservedContext} u.signalLocked() return true } func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports.UIActionResult) { record, ok := u.records[id] if !ok { return } record.Status = status if status == ports.UIActionProcessed { u.dispatched[id] = true record.Revision = boundary.Revision record.Context = boundary.Context } u.records[id] = record if done, ok := u.completion[id]; ok { close(done) delete(u.completion, id) } if u.handoff != nil && u.handoff.actionID == id { u.handoff = nil } if u.pending == id { u.pending = 0 } if u.overlayAction == id { u.overlayAction = 0 } u.signalLocked() } // published remembers only a committed output boundary, not receipt receive // time or an unrelated status-only revision. func (u *UI) published(generation uint64) { snapshot, err := u.state.Snapshot() if err != nil { return } u.mu.Lock() defer u.mu.Unlock() if generation != u.generation || snapshot.Context.Generation != generation { return } u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} u.suppressed = ports.UIContext{} if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached { u.handoff.boundary = u.boundary if u.dispatched[u.handoff.actionID] { u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary) } } u.signalLocked() } func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) { u.mu.Lock() defer u.mu.Unlock() if u.handoff != nil && u.handoff.actionID == receipt.ActionID { return } record, ok := u.records[receipt.ActionID] if !ok || record.Status != ports.UIActionPending || record.Context.Generation != generation || generation != u.generation || receipt.Validate() != nil { return } if receipt.Outcome == protocol.UIReceiptUnavailable { u.finishLocked(receipt.ActionID, ports.UIActionUnavailable, ports.UIActionResult{}) return } boundary := u.boundary if boundary.Revision == 0 { return } if receiptMatches(boundary.Context, receipt) { u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary) return } // The client picker overlay suppressed the output that reached this // boundary: it was applied, not written. Its committed context is the // boundary, and the last written revision keeps later waits eligible. if u.suppressed.Generation == generation && receiptMatches(u.suppressed, receipt) { u.finishLocked(receipt.ActionID, ports.UIActionProcessed, ports.UIActionResult{Revision: boundary.Revision, Context: u.suppressed}) } } func receiptMatches(context ports.UIContext, receipt protocol.UIReceipt) bool { return context.OutputEpoch == receipt.Epoch && context.OutputState == receipt.State && context.ViewPublication == receipt.ViewPublication } // suppressedOutput records the committed context of attachment output the // client picker overlay applied without writing, so a daemon receipt fenced // on it still settles its action. func (u *UI) suppressedOutput(generation uint64, context ports.UIContext) { u.mu.Lock() defer u.mu.Unlock() if generation == 0 || generation != u.generation || context.Generation != generation || context.Status != ports.UIStatusAttached { return } u.suppressed = context } // OverlayContext is the attached context a composition publishes while the // client picker is composed over the live attachment. The attachment is still // the actionable generation, so the picker frame keeps its session identity and // generation instead of an unattached Picker presentation. It reports false when // no attached boundary of the current foreground exists. func (u *UI) OverlayContext() (ports.UIContext, bool) { if u == nil { return ports.UIContext{}, false } u.mu.Lock() defer u.mu.Unlock() context := u.boundary.Context if u.suppressed.Generation != 0 { context = u.suppressed } if u.input == nil || context.Generation == 0 || context.Generation != u.generation || context.Status != ports.UIStatusAttached { return ports.UIContext{}, false } return context, true } // markOverlayInput records that the admitted action id is about to be consumed // by the client picker overlay. It runs before the picker sees the bytes, so a // commit the read decides can always find the action to follow. func (u *UI) markOverlayInput(generation, id uint64) { u.mu.Lock() defer u.mu.Unlock() if id == 0 || generation != u.generation || u.pending != id { return } u.overlayAction = id } // followOverlay retains the overlay-consumed action across the attachment swap // a picker commit started, so it settles on the destination's first committed // publication. func (u *UI) followOverlay(generation uint64) { u.mu.Lock() id := u.overlayAction u.mu.Unlock() if id != 0 { u.follow(generation, id) } } // settleOverlay completes one overlay-consumed action at the supervisor's // event-loop boundary: the picker applied its input and repainted. A followed // action is left to the destination publication. func (u *UI) settleOverlay(generation, id uint64) { snapshot, err := u.state.Snapshot() u.mu.Lock() defer u.mu.Unlock() if id == 0 || u.overlayAction != id || u.pending != id || generation != u.generation { return } if u.handoff != nil && u.handoff.actionID == id { return } boundary := u.boundary if err == nil && snapshot.Context.Generation == generation && snapshot.Context.Status == ports.UIStatusAttached { boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} } u.finishLocked(id, ports.UIActionProcessed, boundary) } func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) { timeout, err := uiTimeout(request.Timeout) if err != nil { return ports.UIActionResult{}, err } if request.Attachment != u.handle { return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment} } u.mu.Lock() available := u.input != nil u.mu.Unlock() if !available { return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable} } snapshot, err := u.state.Snapshot() if err != nil { return ports.UIActionResult{}, err } if request.Generation == 0 || snapshot.Context.Generation != request.Generation { return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment} } var data []byte switch { case len(request.Keys) > 0 && request.Text == "": data, err = encodeUIKeys(request.Keys, snapshot.ApplicationCursor) case len(request.Keys) == 0 && request.Text != "": data, err = encodeUIText(request.Text) default: err = errUIInvalidInput } if err != nil { return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest} } u.mu.Lock() if u.input == nil { u.mu.Unlock() return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable} } if request.Generation != u.generation { u.mu.Unlock() return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment} } if u.pending != 0 { u.mu.Unlock() return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrBusy} } if u.foreground == nil || u.foreground.Err() != nil || snapshot.Context.Status != ports.UIStatusAttached { u.mu.Unlock() return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable} } u.nextAction++ id := u.nextAction u.pending = id u.reservedContext = snapshot.Context input, consumer, foreground := u.input, u.consumer, u.foreground u.mu.Unlock() timer := u.clock.NewTimer(timeout) defer timer.Stop() admissionCtx, cancelAdmission := context.WithCancel(ctx) defer cancelAdmission() batch := terminalAutomationRequest{ctx: admissionCtx, owner: u, consumer: consumer, record: terminalReadResult{data: data, source: terminalInputAutomation, generation: request.Generation, actionID: id, endBatch: true, keys: request.Keys, text: request.Text}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)} reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) { u.mu.Lock() delete(u.records, id) if u.pending == id { u.pending = 0 } for i, entry := range u.order { if entry == id { u.order = append(u.order[:i], u.order[i+1:]...) break } } u.signalLocked() u.mu.Unlock() return ports.UIActionResult{}, &ports.UIError{Code: code} } select { case input.automation <- batch: case <-ctx.Done(): return reject(ports.UIErrTimeout) case <-foreground.Done(): return reject(ports.UIErrUnavailable) case <-timer.C(): return reject(ports.UIErrTimeout) } // Once the owner receives the request it returns admission without I/O. Learn // that decision even if cancellation races it; never claim rollback of input. admitted := <-batch.admitted if !admitted { return reject(ports.UIErrInputBusy) } go u.trackDispatch(foreground, id, batch.dispatched) for { u.mu.Lock() record, exists := u.records[id] changed := u.changed u.mu.Unlock() if !exists { return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrActionExpired, Accepted: true, ActionID: id} } if record.Status == ports.UIActionProcessed { return record, nil } if record.Status != ports.UIActionPending { return record, &ports.UIError{Code: ports.UIErrorCode(record.Status), Accepted: true, ActionID: id} } select { case <-changed: case <-ctx.Done(): return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id} case <-timer.C(): return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id} } } } func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan bool) { timer := u.clock.NewTimer(30 * time.Second) defer timer.Stop() foregroundDone := ctx.Done() for { u.mu.Lock() record, exists := u.records[id] changed := u.changed u.mu.Unlock() if !exists || record.Status != ports.UIActionPending { return } var status ports.UIActionStatus select { case ok := <-dispatched: if !ok { status = ports.UIActionOutcomeUnknown } else { u.mu.Lock() if _, exists := u.records[id]; exists { u.dispatched[id] = true if u.handoff != nil && u.handoff.boundary.Revision != 0 { u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary) } } u.mu.Unlock() } dispatched = nil case <-foregroundDone: u.mu.Lock() following := u.handoff != nil && u.handoff.actionID == id u.mu.Unlock() if following { foregroundDone = nil } else { status = ports.UIActionOutcomeUnknown } case <-timer.C(): status = ports.UIActionUnavailable case <-changed: } if status != "" { u.mu.Lock() if current := u.records[id]; current.Status == ports.UIActionPending { u.finishLocked(id, status, ports.UIActionResult{}) } u.mu.Unlock() return } } } var _ ports.UIService = (*UI)(nil)