1package client
2
3import (
4 "context"
5 "time"
6
7 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/protocol"
9)
10
11// bindForeground runs at the existing foreground ownership boundary.
12func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consumer uint64) uint64 {
13 u.mu.Lock()
14 defer u.mu.Unlock()
15 u.generation++
16 u.input = input
17 u.consumer = consumer
18 u.foreground = ctx
19 u.boundary = ports.UIActionResult{}
20 u.suppressed = ports.UIContext{}
21 if u.handoff != nil && u.handoff.destinationGeneration == 0 {
22 u.handoff.destinationGeneration = u.generation
23 } else if u.pending != 0 {
24 u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
25 u.pending = 0
26 }
27 u.signalLocked()
28 return u.generation
29}
30
31// follow retains the accepted input action across an exact daemon navigation.
32// It waits for the destination's first committed publication, not merely a
33// successful send on the source attachment.
34func (u *UI) follow(generation, actionID uint64) bool {
35 u.mu.Lock()
36 defer u.mu.Unlock()
37 record, ok := u.records[actionID]
38 if !ok || actionID == 0 || generation != u.generation || record.Context.Generation != generation ||
39 (record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed) {
40 return false
41 }
42 if u.pending != 0 && u.pending != actionID {
43 u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
44 }
45 record.Status = ports.UIActionPending
46 u.records[actionID] = record
47 u.pending = actionID
48 u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation}
49 u.signalLocked()
50 return true
51}
52
53// arriveInPlace marks the destination of a followed same-peer switch as
54// committed on the unchanged foreground generation. The switch keeps the
55// attachment, so no new foreground binding names the destination: the worker
56// calls this just before publishing the destination's first output, and that
57// publication settles the action.
58func (u *UI) arriveInPlace(generation uint64) {
59 u.mu.Lock()
60 defer u.mu.Unlock()
61 if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
62 return
63 }
64 u.handoff.destinationGeneration = generation
65}
66
67// failInPlace fails a followed same-peer switch the daemon refused: the source
68// attachment is unchanged and the destination never commits.
69func (u *UI) failInPlace(generation uint64) {
70 u.mu.Lock()
71 defer u.mu.Unlock()
72 if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
73 return
74 }
75 u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
76}
77
78// releaseForeground retires exactly the binding installed for generation.
79// The supervisor owns the presentation fence after the foreground drains.
80func (u *UI) releaseForeground(generation uint64) {
81 u.mu.Lock()
82 if generation != u.generation {
83 u.mu.Unlock()
84 return
85 }
86 u.input = nil
87 u.consumer = 0
88 u.foreground = nil
89 u.boundary = ports.UIActionResult{}
90 if u.pending != 0 && (u.handoff == nil || u.handoff.actionID != u.pending) {
91 u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
92 }
93 u.signalLocked()
94 u.mu.Unlock()
95}
96
97func (u *UI) accept(id, generation uint64) bool {
98 u.mu.Lock()
99 defer u.mu.Unlock()
100 if u.pending != id || u.generation != generation {
101 return false
102 }
103 if len(u.order) == uiActionHistory {
104 evicted := u.order[0]
105 delete(u.records, evicted)
106 delete(u.dispatched, evicted)
107 if done, ok := u.completion[evicted]; ok {
108 close(done)
109 delete(u.completion, evicted)
110 }
111 u.order = u.order[1:]
112 }
113 u.order = append(u.order, id)
114 u.completion[id] = make(chan struct{})
115 u.records[id] = ports.UIActionResult{ActionID: id, Accepted: true, Status: ports.UIActionPending, Context: u.reservedContext}
116 u.signalLocked()
117 return true
118}
119
120func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports.UIActionResult) {
121 record, ok := u.records[id]
122 if !ok {
123 return
124 }
125 record.Status = status
126 if status == ports.UIActionProcessed {
127 u.dispatched[id] = true
128 record.Revision = boundary.Revision
129 record.Context = boundary.Context
130 }
131 u.records[id] = record
132 if done, ok := u.completion[id]; ok {
133 close(done)
134 delete(u.completion, id)
135 }
136 if u.handoff != nil && u.handoff.actionID == id {
137 u.handoff = nil
138 }
139 if u.pending == id {
140 u.pending = 0
141 }
142 if u.overlayAction == id {
143 u.overlayAction = 0
144 }
145 u.signalLocked()
146}
147
148// published remembers only a committed output boundary, not receipt receive
149// time or an unrelated status-only revision.
150func (u *UI) published(generation uint64) {
151 // Runs once per committed frame: read only the boundary, never the cells.
152 boundary, err := u.state.Boundary()
153 if err != nil {
154 return
155 }
156 u.mu.Lock()
157 defer u.mu.Unlock()
158 if generation != u.generation || boundary.Context.Generation != generation {
159 return
160 }
161 u.boundary = boundary
162 u.suppressed = ports.UIContext{}
163 if u.handoff != nil && u.handoff.destinationGeneration == generation && boundary.Context.Status == ports.UIStatusAttached {
164 u.handoff.boundary = u.boundary
165 if u.dispatched[u.handoff.actionID] {
166 u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary)
167 }
168 }
169 u.signalLocked()
170}
171
172func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) {
173 u.mu.Lock()
174 defer u.mu.Unlock()
175 if u.handoff != nil && u.handoff.actionID == receipt.ActionID {
176 return
177 }
178 record, ok := u.records[receipt.ActionID]
179 if !ok || record.Status != ports.UIActionPending || record.Context.Generation != generation || generation != u.generation || receipt.Validate() != nil {
180 return
181 }
182 if receipt.Outcome == protocol.UIReceiptUnavailable {
183 u.finishLocked(receipt.ActionID, ports.UIActionUnavailable, ports.UIActionResult{})
184 return
185 }
186 boundary := u.boundary
187 if boundary.Revision == 0 {
188 return
189 }
190 if receiptMatches(boundary.Context, receipt) {
191 u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary)
192 return
193 }
194 // The client picker overlay suppressed the output that reached this
195 // boundary: it was applied, not written. Its committed context is the
196 // boundary, and the last written revision keeps later waits eligible.
197 if u.suppressed.Generation == generation && receiptMatches(u.suppressed, receipt) {
198 u.finishLocked(receipt.ActionID, ports.UIActionProcessed, ports.UIActionResult{Revision: boundary.Revision, Context: u.suppressed})
199 }
200}
201
202func receiptMatches(context ports.UIContext, receipt protocol.UIReceipt) bool {
203 return context.OutputEpoch == receipt.Epoch && context.OutputState == receipt.State && context.ViewPublication == receipt.ViewPublication
204}
205
206// suppressedOutput records the committed context of attachment output the
207// client picker overlay applied without writing, so a daemon receipt fenced
208// on it still settles its action.
209func (u *UI) suppressedOutput(generation uint64, context ports.UIContext) {
210 u.mu.Lock()
211 defer u.mu.Unlock()
212 if generation == 0 || generation != u.generation || context.Generation != generation || context.Status != ports.UIStatusAttached {
213 return
214 }
215 u.suppressed = context
216}
217
218// OverlayContext is the attached context a composition publishes while the
219// client picker is composed over the live attachment. The attachment is still
220// the actionable generation, so the picker frame keeps its session identity and
221// generation instead of an unattached Picker presentation. It reports false when
222// no attached boundary of the current foreground exists.
223func (u *UI) OverlayContext() (ports.UIContext, bool) {
224 if u == nil {
225 return ports.UIContext{}, false
226 }
227 u.mu.Lock()
228 defer u.mu.Unlock()
229 context := u.boundary.Context
230 if u.suppressed.Generation != 0 {
231 context = u.suppressed
232 }
233 if u.input == nil || context.Generation == 0 || context.Generation != u.generation || context.Status != ports.UIStatusAttached {
234 return ports.UIContext{}, false
235 }
236 return context, true
237}
238
239// markOverlayInput records that the admitted action id is about to be consumed
240// by the client picker overlay. It runs before the picker sees the bytes, so a
241// commit the read decides can always find the action to follow.
242func (u *UI) markOverlayInput(generation, id uint64) {
243 u.mu.Lock()
244 defer u.mu.Unlock()
245 if id == 0 || generation != u.generation || u.pending != id {
246 return
247 }
248 u.overlayAction = id
249}
250
251// followOverlay retains the overlay-consumed action across the attachment swap
252// a picker commit started, so it settles on the destination's first committed
253// publication.
254func (u *UI) followOverlay(generation uint64) {
255 u.mu.Lock()
256 id := u.overlayAction
257 u.mu.Unlock()
258 if id != 0 {
259 u.follow(generation, id)
260 }
261}
262
263// settleOverlay completes one overlay-consumed action at the supervisor's
264// event-loop boundary: the picker applied its input and repainted. A followed
265// action is left to the destination publication.
266func (u *UI) settleOverlay(generation, id uint64) {
267 snapshot, err := u.state.Snapshot()
268 u.mu.Lock()
269 defer u.mu.Unlock()
270 if id == 0 || u.overlayAction != id || u.pending != id || generation != u.generation {
271 return
272 }
273 if u.handoff != nil && u.handoff.actionID == id {
274 return
275 }
276 boundary := u.boundary
277 if err == nil && snapshot.Context.Generation == generation && snapshot.Context.Status == ports.UIStatusAttached {
278 boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
279 }
280 u.finishLocked(id, ports.UIActionProcessed, boundary)
281}
282
283func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) {
284 timeout, err := uiTimeout(request.Timeout)
285 if err != nil {
286 return ports.UIActionResult{}, err
287 }
288 if request.Attachment != u.handle {
289 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
290 }
291 u.mu.Lock()
292 available := u.input != nil
293 u.mu.Unlock()
294 if !available {
295 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable}
296 }
297 snapshot, err := u.state.Snapshot()
298 if err != nil {
299 return ports.UIActionResult{}, err
300 }
301 if request.Generation == 0 || snapshot.Context.Generation != request.Generation {
302 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
303 }
304 var data []byte
305 switch {
306 case len(request.Keys) > 0 && request.Text == "":
307 data, err = encodeUIKeys(request.Keys, snapshot.ApplicationCursor)
308 case len(request.Keys) == 0 && request.Text != "":
309 data, err = encodeUIText(request.Text)
310 default:
311 err = errUIInvalidInput
312 }
313 if err != nil {
314 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest}
315 }
316 u.mu.Lock()
317 if u.input == nil {
318 u.mu.Unlock()
319 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable}
320 }
321 if request.Generation != u.generation {
322 u.mu.Unlock()
323 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
324 }
325 if u.pending != 0 {
326 u.mu.Unlock()
327 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrBusy}
328 }
329 if u.foreground == nil || u.foreground.Err() != nil || snapshot.Context.Status != ports.UIStatusAttached {
330 u.mu.Unlock()
331 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable}
332 }
333 u.nextAction++
334 id := u.nextAction
335 u.pending = id
336 u.reservedContext = snapshot.Context
337 input, consumer, foreground := u.input, u.consumer, u.foreground
338 u.mu.Unlock()
339 timer := u.clock.NewTimer(timeout)
340 defer timer.Stop()
341 admissionCtx, cancelAdmission := context.WithCancel(ctx)
342 defer cancelAdmission()
343 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)}
344 reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) {
345 u.mu.Lock()
346 delete(u.records, id)
347 if u.pending == id {
348 u.pending = 0
349 }
350 for i, entry := range u.order {
351 if entry == id {
352 u.order = append(u.order[:i], u.order[i+1:]...)
353 break
354 }
355 }
356 u.signalLocked()
357 u.mu.Unlock()
358 return ports.UIActionResult{}, &ports.UIError{Code: code}
359 }
360 select {
361 case input.automation <- batch:
362 case <-ctx.Done():
363 return reject(ports.UIErrTimeout)
364 case <-foreground.Done():
365 return reject(ports.UIErrUnavailable)
366 case <-timer.C():
367 return reject(ports.UIErrTimeout)
368 }
369 // Once the owner receives the request it returns admission without I/O. Learn
370 // that decision even if cancellation races it; never claim rollback of input.
371 admitted := <-batch.admitted
372 if !admitted {
373 return reject(ports.UIErrInputBusy)
374 }
375 go u.trackDispatch(foreground, id, batch.dispatched)
376 for {
377 u.mu.Lock()
378 record, exists := u.records[id]
379 changed := u.changed
380 u.mu.Unlock()
381 if !exists {
382 return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrActionExpired, Accepted: true, ActionID: id}
383 }
384 if record.Status == ports.UIActionProcessed {
385 return record, nil
386 }
387 if record.Status != ports.UIActionPending {
388 return record, &ports.UIError{Code: ports.UIErrorCode(record.Status), Accepted: true, ActionID: id}
389 }
390 select {
391 case <-changed:
392 case <-ctx.Done():
393 return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id}
394 case <-timer.C():
395 return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id}
396 }
397 }
398}
399
400func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan bool) {
401 timer := u.clock.NewTimer(30 * time.Second)
402 defer timer.Stop()
403 foregroundDone := ctx.Done()
404 for {
405 u.mu.Lock()
406 record, exists := u.records[id]
407 changed := u.changed
408 u.mu.Unlock()
409 if !exists || record.Status != ports.UIActionPending {
410 return
411 }
412 var status ports.UIActionStatus
413 select {
414 case ok := <-dispatched:
415 if !ok {
416 status = ports.UIActionOutcomeUnknown
417 } else {
418 u.mu.Lock()
419 if _, exists := u.records[id]; exists {
420 u.dispatched[id] = true
421 if u.handoff != nil && u.handoff.boundary.Revision != 0 {
422 u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary)
423 }
424 }
425 u.mu.Unlock()
426 }
427 dispatched = nil
428 case <-foregroundDone:
429 u.mu.Lock()
430 following := u.handoff != nil && u.handoff.actionID == id
431 u.mu.Unlock()
432 if following {
433 foregroundDone = nil
434 } else {
435 status = ports.UIActionOutcomeUnknown
436 }
437 case <-timer.C():
438 status = ports.UIActionUnavailable
439 case <-changed:
440 }
441 if status != "" {
442 u.mu.Lock()
443 if current := u.records[id]; current.Status == ports.UIActionPending {
444 u.finishLocked(id, status, ports.UIActionResult{})
445 }
446 u.mu.Unlock()
447 return
448 }
449 }
450}
451
452var _ ports.UIService = (*UI)(nil)