1package ui
2
3import (
4 "context"
5 "time"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk/v4/glib"
11)
12
13// ResidencyTimeoutMaxMs bounds the startup-only idle timeout in milliseconds.
14const ResidencyTimeoutMaxMs = 300000
15
16// ResidencyTimeoutFromMillis resolves the effective bounded-residency idle
17// timeout for CEF from the startup configuration value in milliseconds.
18// Values outside 0..300000 are rejected by config validation; this resolver
19// additionally clamps defensively so an unvalidated caller can never arm an
20// unbounded or negative deadline.
21func ResidencyTimeoutFromMillis(timeoutMs int) time.Duration {
22 if timeoutMs <= 0 {
23 return 0
24 }
25 if timeoutMs > ResidencyTimeoutMaxMs {
26 timeoutMs = ResidencyTimeoutMaxMs
27 }
28 return time.Duration(timeoutMs) * time.Millisecond
29}
30
31// SetResidencyTimeout latches the effective idle timeout once at process
32// startup. Call it before App.Run; later config reloads must use
33// ResidencyTimeoutRestartRequired instead of calling this again.
34func (a *App) SetResidencyTimeout(timeout time.Duration) {
35 if a == nil {
36 return
37 }
38 a.residencyTimeout = timeout
39}
40
41// LatchedResidencyTimeout returns the startup-latched idle timeout.
42func (a *App) LatchedResidencyTimeout() time.Duration {
43 if a == nil {
44 return 0
45 }
46 return a.residencyTimeout
47}
48
49// ResidencyTimeoutRestartRequired reports whether a reloaded configuration
50// value differs from the startup latch. A true result means the process must
51// restart to apply it; residency is never partially hot-applied.
52func (a *App) ResidencyTimeoutRestartRequired(timeoutMs int) bool {
53 if a == nil {
54 return false
55 }
56 return ResidencyTimeoutFromMillis(timeoutMs) != a.residencyTimeout
57}
58
59// residencyApplication is the application-lifetime subset used for bounded
60// residency: one hold while opt-in residency is enabled, released exactly
61// once on every Run exit. *gtk.Application satisfies it through embedding.
62type residencyApplication interface {
63 Hold()
64 Release()
65 Quit()
66}
67
68// ResidencyController owns one application hold and the idle timer for
69// bounded opt-in residency. It maps engine-neutral policy decisions to
70// scheduler operations; the clock and timer handles are injected so tests
71// use fakes while production uses the GLib main context. It never sleeps.
72type ResidencyController struct {
73 policy *usecase.RuntimeResidency
74 clock func() time.Time
75 schedule func(d time.Duration, generation uint64)
76 cancel func()
77 quit func()
78
79 enabled bool
80 lastWindows int
81 quitting bool
82 held bool
83
84 // Native quiescence inputs. Sources report busy/clear transitions;
85 // expiry commits to quit only after admitted work drains and every
86 // source settles. All methods must run on the GTK thread; cross-thread
87 // settle callbacks arrive through dispatchToGTK.
88 nativeBusy map[string]bool
89 pendingQuit bool
90 quiescent func() bool
91 dispatchToGTK func(func())
92 // seal stops new admissions before a quit commits, closing the race
93 // between the quiescence recheck and admitted work starting.
94 seal func()
95 // unseal reopens admission when a deferred quit is invalidated (e.g.
96 // a reopened window); without it the surviving process would stay
97 // permanently deaf to relay requests.
98 unseal func()
99
100 timerCallback *glib.SourceFunc
101 timerTag uint
102}
103
104// NewResidencyController builds the UI-side residency owner. A zero timeout
105// keeps the controller disabled: decisions then always resolve to the
106// pre-existing last-window exit behavior.
107func NewResidencyController(
108 timeout time.Duration,
109 clock func() time.Time,
110 schedule func(time.Duration, uint64),
111 cancel, quit func(),
112) *ResidencyController {
113 if clock == nil {
114 clock = time.Now
115 }
116 return &ResidencyController{
117 policy: usecase.NewRuntimeResidency(timeout),
118 enabled: timeout > 0,
119 clock: clock,
120 schedule: schedule,
121 cancel: cancel,
122 quit: quit,
123 }
124}
125
126// Enabled reports whether opt-in residency is active (nonzero latch).
127// A disabled controller resolves every empty state to the pre-existing
128// last-window exit behavior.
129func (c *ResidencyController) Enabled() bool {
130 return c != nil && c.enabled
131}
132
133// AcquireHold takes exactly one application hold for enabled residency.
134// It is a no-op when disabled, already held, or given a nil application.
135// Pair every acquisition with ReleaseHold, typically via defer in Run so
136// all exits (including activation failure) match it exactly once.
137func (c *ResidencyController) AcquireHold(app residencyApplication) {
138 if c == nil || !c.enabled || c.held || app == nil {
139 return
140 }
141 app.Hold()
142 c.held = true
143}
144
145// ReleaseHold releases a previously acquired hold exactly once.
146func (c *ResidencyController) ReleaseHold(app residencyApplication) {
147 if c == nil || !c.held || app == nil {
148 return
149 }
150 c.held = false
151 app.Release()
152}
153
154// SetWindowCount converges the policy to the current user-window count.
155// Opens and closes funnel through here so duplicate or reordered events
156// cannot strand the count. It returns the final decision for this update.
157func (c *ResidencyController) SetWindowCount(count int) usecase.ResidencyDecision {
158 if c == nil {
159 return usecase.ResidencyDecision{Action: usecase.ResidencyQuit}
160 }
161 now := c.clock()
162 var decision usecase.ResidencyDecision
163 events := false
164 for c.lastWindows < count {
165 c.lastWindows++
166 events = true
167 // A reopened window invalidates any deferred quit, and with it
168 // the admission seal: the surviving process keeps serving relay.
169 if c.pendingQuit {
170 c.pendingQuit = false
171 if c.unseal != nil {
172 c.unseal()
173 }
174 }
175 decision = c.apply(c.policy.WindowOpened(now))
176 }
177 for c.lastWindows > count {
178 c.lastWindows--
179 events = true
180 decision = c.apply(c.policy.WindowClosed(now))
181 }
182 if count == 0 && !events {
183 // No windows were ever reported (e.g. activation failure):
184 // evaluate once so the process reaches bounded idle or orderly
185 // shutdown instead of idling forever on the residency hold.
186 // An already-armed idle state stays put instead of re-arming.
187 if c.policy.IdleArmed() {
188 return usecase.ResidencyDecision{Action: usecase.ResidencyNone, Generation: c.policy.Generation()}
189 }
190 decision = c.apply(c.policy.WindowClosed(now))
191 }
192 return decision
193}
194
195// Evaluate forces one idle evaluation for paths that never report windows
196// (e.g. initial-shell failure). It returns the resulting decision.
197func (c *ResidencyController) Evaluate() usecase.ResidencyDecision {
198 if c == nil {
199 return usecase.ResidencyDecision{Action: usecase.ResidencyQuit}
200 }
201 return c.apply(c.policy.WindowClosed(c.clock()))
202}
203
204// NoteExplicitQuit records an explicit quit or signal: the timer is
205// canceled, the policy resolves Quit immediately, and the deadline is never
206// waited on. It never invokes the quit function itself.
207func (c *ResidencyController) NoteExplicitQuit() {
208 if c == nil {
209 return
210 }
211 if c.cancel != nil {
212 c.cancel()
213 }
214 c.policy.ShutdownRequested()
215}
216
217// OnTimerFired processes a fired idle timer for the given generation.
218// Stale generations are ignored by the policy.
219func (c *ResidencyController) OnTimerFired(generation uint64) {
220 if c == nil {
221 return
222 }
223 c.apply(c.policy.HandleExpiry(c.clock(), generation))
224}
225
226func (c *ResidencyController) apply(decision usecase.ResidencyDecision) usecase.ResidencyDecision {
227 switch decision.Action {
228 case usecase.ResidencyArm:
229 if c.cancel != nil {
230 c.cancel()
231 }
232 if c.schedule != nil {
233 // Delay derives from the injected clock, never wall time, so
234 // fake clocks in tests observe the policy deadline exactly.
235 delay := decision.Deadline.Sub(c.clock())
236 if delay < 0 {
237 delay = 0
238 }
239 c.schedule(delay, decision.Generation)
240 }
241 case usecase.ResidencyCancel:
242 if c.cancel != nil {
243 c.cancel()
244 }
245 case usecase.ResidencyQuit:
246 c.requestQuit()
247 }
248 return decision
249}
250
251// requestQuit commits to quit only when every native source has settled.
252// Otherwise it defers: the next settling source rechecks via MaybeQuit.
253// Disabled residency quits unconditionally, preserving pre-existing behavior.
254func (c *ResidencyController) requestQuit() {
255 if !c.enabled {
256 c.pendingQuit = false
257 c.doQuit()
258 return
259 }
260 // Seal admission first so no new work starts between the recheck and
261 // the quit. Draining leases then settle through MaybeQuit.
262 if c.seal != nil {
263 c.seal()
264 }
265 if c.quiescent != nil && !c.quiescent() {
266 c.pendingQuit = true
267 return
268 }
269 c.pendingQuit = false
270 c.doQuit()
271}
272
273// MaybeQuit rechecks a deferred quit after sources settle. It is invoked
274// from settle callbacks (already dispatched to GTK) and busy transitions.
275// A reopened window invalidates the deferral: quitting then would bypass
276// the fresh idle interval, so admission reopens with it.
277func (c *ResidencyController) MaybeQuit() {
278 if c == nil || !c.pendingQuit || c.quitting {
279 return
280 }
281 if c.lastWindows > 0 {
282 c.pendingQuit = false
283 if c.unseal != nil {
284 c.unseal()
285 }
286 return
287 }
288 if c.quiescent != nil && !c.quiescent() {
289 return
290 }
291 c.pendingQuit = false
292 c.doQuit()
293}
294
295// SetQuiescentFunc installs the composed quiescence predicate consulted
296// before an expiry commits to quit. A nil predicate means always settled.
297func (c *ResidencyController) SetQuiescentFunc(fn func() bool) {
298 if c == nil {
299 return
300 }
301 c.quiescent = fn
302}
303
304// SetDispatchToGTK installs the cross-thread dispatch used by settle
305// callbacks from CEF, relay, and persistence threads. A nil dispatch
306// invokes callbacks directly (tests only).
307func (c *ResidencyController) SetDispatchToGTK(fn func(func())) {
308 if c == nil {
309 return
310 }
311 c.dispatchToGTK = fn
312}
313
314// SetSealFunc installs the admission seal invoked before an enabled quit
315// commits, so no new relay work starts between the quiescence recheck and
316// the quit.
317func (c *ResidencyController) SetSealFunc(fn func()) {
318 if c == nil {
319 return
320 }
321 c.seal = fn
322}
323
324// SetUnsealFunc installs the admission reopen invoked when a deferred quit
325// is invalidated by a reopened window.
326func (c *ResidencyController) SetUnsealFunc(fn func()) {
327 if c == nil {
328 return
329 }
330 c.unseal = fn
331}
332
333// DispatchToGTK runs fn on the GTK thread through the installed dispatch,
334// or directly when none is installed. Settle callbacks must use this.
335func (c *ResidencyController) DispatchToGTK(fn func()) {
336 if c == nil || fn == nil {
337 return
338 }
339 if c.dispatchToGTK != nil {
340 c.dispatchToGTK(fn)
341 return
342 }
343 fn()
344}
345
346// SetNativeBusy records one native source busy (true) or settled (false).
347// Transitions drive policy work events so expiry never fires while required
348// work is outstanding; settling also rechecks a deferred quit.
349func (c *ResidencyController) SetNativeBusy(source string, busy bool) usecase.ResidencyDecision {
350 if c == nil {
351 return usecase.ResidencyDecision{Action: usecase.ResidencyNone}
352 }
353 if c.nativeBusy == nil {
354 c.nativeBusy = make(map[string]bool)
355 }
356 if c.nativeBusy[source] == busy {
357 return usecase.ResidencyDecision{Action: usecase.ResidencyNone}
358 }
359 c.nativeBusy[source] = busy
360 var decision usecase.ResidencyDecision
361 if busy {
362 decision = c.apply(c.policy.WorkStarted(c.clock()))
363 } else {
364 decision = c.apply(c.policy.WorkFinished(c.clock()))
365 c.MaybeQuit()
366 }
367 return decision
368}
369
370func (c *ResidencyController) doQuit() {
371 if c.quitting {
372 return
373 }
374 c.quitting = true
375 if c.cancel != nil {
376 c.cancel()
377 }
378 if c.quit != nil {
379 c.quit()
380 }
381}
382
383// scheduleGLibTimeout arms a one-shot GLib timeout for the idle deadline.
384// The callback is retained on the controller until it fires or is
385// canceled, and stale generations are rejected by the policy on fire.
386func (c *ResidencyController) scheduleGLibTimeout(d time.Duration, generation uint64) {
387 c.cancelGLibTimeout()
388 ms := uint(d.Milliseconds())
389 if ms == 0 {
390 ms = 1
391 }
392 callback := glib.SourceFunc(func(_ uintptr) bool {
393 // GLib retires this source on return; drop our tag on entry so a
394 // later cancel can never remove an expired or reused source ID.
395 // The callback stays retained through return.
396 c.timerTag = 0
397 c.OnTimerFired(generation)
398 c.timerCallback = nil
399 return false
400 })
401 c.timerCallback = &callback
402 c.timerTag = glib.TimeoutAdd(ms, &callback, 0)
403}
404
405// cancelGLibTimeout drops a pending idle timer and releases its callback.
406func (c *ResidencyController) cancelGLibTimeout() {
407 if c.timerTag != 0 {
408 glib.SourceRemove(c.timerTag)
409 c.timerTag = 0
410 }
411 c.timerCallback = nil
412}
413
414// LatchResidencyTimeoutForApp latches the bounded-residency timeout once at
415// startup. Only CEF uses it, and only when nonzero; later config changes
416// are restart-required instead of partially hot-applied.
417func LatchResidencyTimeoutForApp(app *App, timeoutMs int, isCEF bool) {
418 if app == nil || !isCEF {
419 return
420 }
421 app.SetResidencyTimeout(ResidencyTimeoutFromMillis(timeoutMs))
422}
423
424// residencyShouldQuitAfterLastWindow reports whether the app must quit now
425// that no user windows remain. A nil controller (before Run) preserves
426// unconditional Quit.
427func (a *App) residencyShouldQuitAfterLastWindow() bool {
428 if a == nil || a.residency == nil {
429 return true
430 }
431 return a.residency.SetWindowCount(len(a.browserWindows)).Action == usecase.ResidencyQuit
432}
433
434// residencyAfterWindowRemoved converges residency accounting after every
435// removal path (close-request, tab-empty, eject, failures) and quits when
436// the policy decides the empty state must exit. Disabled or missing
437// residency preserves unconditional Quit.
438func (a *App) residencyAfterWindowRemoved() {
439 if a == nil {
440 return
441 }
442 decision := a.residencyNoteWindowsChanged()
443 if len(a.browserWindows) == 0 && decision.Action == usecase.ResidencyQuit {
444 a.Quit()
445 }
446}
447
448// CloseAllWindows closes every user window through the standard removal
449// path, converging residency exactly like interactive closes. It runs on
450// the main thread via the window-URL dispatch.
451func (a *App) CloseAllWindows(ctx context.Context) error {
452 return a.dispatchWindowURLWork(ctx, "ui.close_all_windows", "", func(context.Context) error {
453 ids := make([]string, 0, len(a.browserWindows))
454 for id := range a.browserWindows {
455 ids = append(ids, id)
456 }
457 for _, id := range ids {
458 bw, ok := a.browserWindows[id]
459 if !ok || bw == nil {
460 continue
461 }
462 a.removeBrowserWindow(bw.id)
463 if bw.mainWindow != nil {
464 bw.mainWindow.Destroy()
465 }
466 }
467 return nil
468 })
469}
470
471// residencyNoteWindowsChanged converges residency accounting to the current
472// window count after opens, closes, and activation outcomes.
473func (a *App) residencyNoteWindowsChanged() usecase.ResidencyDecision {
474 if a == nil || a.residency == nil {
475 return usecase.ResidencyDecision{Action: usecase.ResidencyQuit}
476 }
477 return a.residency.SetWindowCount(len(a.browserWindows))
478}
479
480// relayAdmissionProvider exposes a relay admission boundary without
481// extending the port interface. It matches desktop's provider structurally.
482// The boundary type keeps application code off the concrete gate.
483type relayAdmissionProvider interface {
484 AdmissionGate() port.AdmissionBoundary
485}
486
487// residencyQuiescent composes every native quiescence input: CEF runtime
488// activity, snapshot persistence drain, and relay admission leases.
489func (a *App) residencyQuiescent() bool {
490 if a == nil {
491 return true
492 }
493 if act := port.EngineRuntimeActivity(a.engine); act != nil && !act.Snapshot().Quiescent() {
494 return false
495 }
496 if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil && drain.Active() {
497 return false
498 }
499 if a.deps != nil {
500 if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
501 if gate := provider.AdmissionGate(); gate != nil && gate.Active() > 0 {
502 return false
503 }
504 }
505 }
506 return true
507}
508
509// subscribeResidencyQuiescence wires native settle callbacks into the
510// residency controller. CEF activity transitions drive policy work events;
511// persistence and relay settle paths recheck a deferred quit. Every
512// callback crosses into GTK through the controller dispatch: no GTK work
513// runs on CEF callbacks and no per-frame polling observes quiescence.
514func (a *App) subscribeResidencyQuiescence() {
515 r := a.residency
516 if a == nil || r == nil || !r.Enabled() {
517 return
518 }
519 dispatch := func(fn func()) { r.DispatchToGTK(fn) }
520 if act := port.EngineRuntimeActivity(a.engine); act != nil {
521 unsubscribe := act.Subscribe(func(snapshot port.RuntimeActivitySnapshot) {
522 busy := !snapshot.Quiescent()
523 dispatch(func() { r.SetNativeBusy("engine-activity", busy) })
524 })
525 a.residencyUnsubscribe = append(a.residencyUnsubscribe, unsubscribe)
526 }
527 if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil {
528 if notifier, ok := drain.(interface{ OnSettled(func()) }); ok {
529 notifier.OnSettled(func() { dispatch(func() { r.MaybeQuit() }) })
530 }
531 }
532 if a.deps != nil {
533 if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
534 if gate := provider.AdmissionGate(); gate != nil {
535 gate.OnDrained(func() { dispatch(func() { r.MaybeQuit() }) })
536 }
537 }
538 }
539}
540
541// unsubscribeResidencyQuiescence drops native subscriptions at shutdown.
542func (a *App) unsubscribeResidencyQuiescence() {
543 if a == nil {
544 return
545 }
546 for _, unsubscribe := range a.residencyUnsubscribe {
547 if unsubscribe != nil {
548 unsubscribe()
549 }
550 }
551 a.residencyUnsubscribe = nil
552}
553
554// closeRelayAdmission stops relay admission before snapshot/update
555// teardown so no new work starts while persistence drains.
556func (a *App) closeRelayAdmission() {
557 if a == nil || a.deps == nil {
558 return
559 }
560 if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
561 if gate := provider.AdmissionGate(); gate != nil {
562 gate.Close()
563 }
564 }
565}
566
567// drainForShutdownOffThread settles admission leases and persistence
568// before GTK shutdown, while the loop can still service admitted work.
569// It runs only off the GTK thread (see Quit); every wait is bounded and
570// expiry only logs.
571func (a *App) drainForShutdownOffThread() {
572 if a == nil {
573 return
574 }
575 a.closeRelayAdmission()
576 if a.deps != nil {
577 if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
578 if gate := provider.AdmissionGate(); gate != nil && gate.Active() > 0 {
579 drainCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
580 if !gate.WaitDrained(drainCtx) {
581 logging.FromContext(context.Background()).Warn().Int("admitted", gate.Active()).Msg("relay admission did not drain before shutdown")
582 }
583 cancel()
584 }
585 }
586 }
587 if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil {
588 drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
589 if err := drain.WaitSettled(drainCtx); err != nil {
590 logging.FromContext(context.Background()).Warn().Err(err).Msg("persistence did not settle before shutdown")
591 }
592 cancel()
593 }
594}