runtime_residency.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close