detached dcda339

feat(cef): resident runtime with bounded prewarming gates (Plan 02) (#411)

* feat(cef): add bounded residency policy with generation guard

* feat(cef): add startup-only residency timeout config

* feat(cef): integrate residency hold and close paths

* feat(cef): integrate residency lifecycle with quiescence

* fix(cef): resolve lint for residency lifecycle

* feat(cef): converge close paths and add reopen diagnostics

* fix(cef): address senior review blockers on residency

* fix(cef): address Copilot review on residency stack
commit
dcda33944e48da407ac880c0f71f15ee3c5ee016
author
bnema · · 2026-09-08 10:52 UTC
committer
GitHub · · 2026-09-08 10:52 UTC
parent
b889462
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
33 files · +2960 -64
M cmd/dumber/main.go +13 -7
@@ -266,6 +266,16 @@ func main() {
266266 cmd.Execute()
267267 }
268268
269+func setAlreadyRunningRelaunchHandler(engine port.Engine, app *ui.App, ctx context.Context, log *zerolog.Logger) {
270+ if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
271+ relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
272+ if err := app.OpenExternalURL(ctx, url); err != nil {
273+ log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
274+ }
275+ })
276+ }
277+}
278+
269279 func runGUI(cfg *config.Config, timing startupTiming) int {
270280 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
271281
@@ -333,13 +343,9 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
333343 log.Error().Err(err).Msg("failed to create application")
334344 return 1
335345 }
336- if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
337- relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
338- if err := app.OpenExternalURL(ctx, url); err != nil {
339- log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
340- }
341- })
342- }
346+ // Latch the bounded-residency timeout once at startup (CEF only).
347+ ui.LatchResidencyTimeoutForApp(app, cfg.Engine.CEF.IdleRuntimeTimeoutMs, cfg.Engine.ResolveEngineType() == config.EngineTypeCEF)
348+ setAlreadyRunningRelaunchHandler(engine, app, ctx, log)
343349 timer.Mark("ui_deps")
344350 timer.Log(ctx)
345351
@@ -266,6 +266,16 @@ func main() {
266 cmd.Execute()266 cmd.Execute()
267 }267 }
268 268
269+func setAlreadyRunningRelaunchHandler(engine port.Engine, app *ui.App, ctx context.Context, log *zerolog.Logger) {
270+ if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
271+ relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
272+ if err := app.OpenExternalURL(ctx, url); err != nil {
273+ log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
274+ }
275+ })
276+ }
277+}
278+
269 func runGUI(cfg *config.Config, timing startupTiming) int {279 func runGUI(cfg *config.Config, timing startupTiming) int {
270 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)280 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
271 281
@@ -333,13 +343,9 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
333 log.Error().Err(err).Msg("failed to create application")343 log.Error().Err(err).Msg("failed to create application")
334 return 1344 return 1
335 }345 }
336- if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {346+ // Latch the bounded-residency timeout once at startup (CEF only).
337- relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {347+ ui.LatchResidencyTimeoutForApp(app, cfg.Engine.CEF.IdleRuntimeTimeoutMs, cfg.Engine.ResolveEngineType() == config.EngineTypeCEF)
338- if err := app.OpenExternalURL(ctx, url); err != nil {348+ setAlreadyRunningRelaunchHandler(engine, app, ctx, log)
339- log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
340- }
341- })
342- }
343 timer.Mark("ui_deps")349 timer.Mark("ui_deps")
344 timer.Log(ctx)350 timer.Log(ctx)
345 351
M docs/reference/configuration.md +1 -0
@@ -66,6 +66,7 @@
6666 | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
6767 | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
6868 | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 |
69+| `engine.cef.idle_runtime_timeout_ms` | int | `0` | 0..300000, restart-required |
6970 | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 |
7071 | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 |
7172 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 |
@@ -66,6 +66,7 @@
66 | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |66 | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
67 | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |67 | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
68 | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 |68 | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 |
69+| `engine.cef.idle_runtime_timeout_ms` | int | `0` | 0..300000, restart-required |
69 | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 |70 | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 |
70 | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 |71 | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 |
71 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 |72 | `engine.cef.input.scroll_horizontal_multiplier` | float | `1.0` | > 0 |
A internal/application/port/runtime_activity.go +70 -0
@@ -0,0 +1,70 @@
1+package port
2+
3+import "context"
4+
5+// RuntimeActivitySnapshot is an engine-neutral view of outstanding native
6+// runtime work that must settle before the process may idle out. Counts only:
7+// generations and acknowledgement correlation live with the implementation.
8+type RuntimeActivitySnapshot struct {
9+ // PendingCreations counts accepted browser creations not yet resolved
10+ // through OnAfterCreated or a definitive failure.
11+ PendingCreations int
12+ // ActiveViews counts registered live views, including views whose
13+ // native close has started but whose GTK teardown is outstanding.
14+ ActiveViews int
15+ // PendingCleanup counts native-closed views whose queued GTK
16+ // bridge/popup cleanup has not completed.
17+ PendingCleanup int
18+ // ActiveDownloads counts downloads with a terminal event outstanding.
19+ ActiveDownloads int
20+}
21+
22+// Quiescent reports whether no native work is outstanding.
23+func (s RuntimeActivitySnapshot) Quiescent() bool {
24+ return s.PendingCreations == 0 && s.ActiveViews == 0 && s.PendingCleanup == 0 && s.ActiveDownloads == 0
25+}
26+
27+// RuntimeActivity is the application-port boundary for native runtime
28+// quiescence. The initial snapshot and subscription are race-safe and
29+// sequenced: Subscribe delivers the current snapshot synchronously, then all
30+// later transitions in order. Unsubscribe at shutdown.
31+type RuntimeActivity interface {
32+ Snapshot() RuntimeActivitySnapshot
33+ Subscribe(func(RuntimeActivitySnapshot)) (unsubscribe func())
34+}
35+
36+// PersistenceDrain is the narrow activity/drain boundary for persistence
37+// owners whose saves cross the GTK/database boundary. Active covers pending
38+// debounce, in-flight capture/execution, and unsettled terminal failure.
39+// WaitSettled blocks until settled or ctx ends; it must never be called on
40+// the GTK thread when capture dispatches there.
41+type PersistenceDrain interface {
42+ Active() bool
43+ WaitSettled(ctx context.Context) error
44+}
45+
46+// AdmissionBoundary is the admission/work-lease contract shared by the relay
47+// listener and the shutdown/residency policy. Infrastructure provides the
48+// implementation; application code depends only on this boundary.
49+type AdmissionBoundary interface {
50+ // Acquire takes one work lease, reporting false when admission is
51+ // closed. Callers send the existing error response instead of
52+ // acknowledging.
53+ Acquire() bool
54+ // Release returns one work lease on dispatch settlement.
55+ Release()
56+ // Close atomically stops admission; in-flight leases drain.
57+ Close()
58+ // Reopen resumes admission after an invalidated seal.
59+ Reopen()
60+ // Closed reports whether admission is stopped.
61+ Closed() bool
62+ // Active reports the number of held leases.
63+ Active() int
64+ // OnDrained registers a callback for every transition to zero leases.
65+ // Callbacks run without the gate lock and must not reenter it.
66+ OnDrained(func())
67+ // WaitDrained blocks until no leases are held or ctx ends, reporting
68+ // false on expiry so shutdown never waits forever.
69+ WaitDrained(ctx context.Context) bool
70+}
@@ -0,0 +1,70 @@
1+package port
2+
3+import "context"
4+
5+// RuntimeActivitySnapshot is an engine-neutral view of outstanding native
6+// runtime work that must settle before the process may idle out. Counts only:
7+// generations and acknowledgement correlation live with the implementation.
8+type RuntimeActivitySnapshot struct {
9+ // PendingCreations counts accepted browser creations not yet resolved
10+ // through OnAfterCreated or a definitive failure.
11+ PendingCreations int
12+ // ActiveViews counts registered live views, including views whose
13+ // native close has started but whose GTK teardown is outstanding.
14+ ActiveViews int
15+ // PendingCleanup counts native-closed views whose queued GTK
16+ // bridge/popup cleanup has not completed.
17+ PendingCleanup int
18+ // ActiveDownloads counts downloads with a terminal event outstanding.
19+ ActiveDownloads int
20+}
21+
22+// Quiescent reports whether no native work is outstanding.
23+func (s RuntimeActivitySnapshot) Quiescent() bool {
24+ return s.PendingCreations == 0 && s.ActiveViews == 0 && s.PendingCleanup == 0 && s.ActiveDownloads == 0
25+}
26+
27+// RuntimeActivity is the application-port boundary for native runtime
28+// quiescence. The initial snapshot and subscription are race-safe and
29+// sequenced: Subscribe delivers the current snapshot synchronously, then all
30+// later transitions in order. Unsubscribe at shutdown.
31+type RuntimeActivity interface {
32+ Snapshot() RuntimeActivitySnapshot
33+ Subscribe(func(RuntimeActivitySnapshot)) (unsubscribe func())
34+}
35+
36+// PersistenceDrain is the narrow activity/drain boundary for persistence
37+// owners whose saves cross the GTK/database boundary. Active covers pending
38+// debounce, in-flight capture/execution, and unsettled terminal failure.
39+// WaitSettled blocks until settled or ctx ends; it must never be called on
40+// the GTK thread when capture dispatches there.
41+type PersistenceDrain interface {
42+ Active() bool
43+ WaitSettled(ctx context.Context) error
44+}
45+
46+// AdmissionBoundary is the admission/work-lease contract shared by the relay
47+// listener and the shutdown/residency policy. Infrastructure provides the
48+// implementation; application code depends only on this boundary.
49+type AdmissionBoundary interface {
50+ // Acquire takes one work lease, reporting false when admission is
51+ // closed. Callers send the existing error response instead of
52+ // acknowledging.
53+ Acquire() bool
54+ // Release returns one work lease on dispatch settlement.
55+ Release()
56+ // Close atomically stops admission; in-flight leases drain.
57+ Close()
58+ // Reopen resumes admission after an invalidated seal.
59+ Reopen()
60+ // Closed reports whether admission is stopped.
61+ Closed() bool
62+ // Active reports the number of held leases.
63+ Active() int
64+ // OnDrained registers a callback for every transition to zero leases.
65+ // Callbacks run without the gate lock and must not reenter it.
66+ OnDrained(func())
67+ // WaitDrained blocks until no leases are held or ctx ends, reporting
68+ // false on expiry so shutdown never waits forever.
69+ WaitDrained(ctx context.Context) bool
70+}
A internal/application/usecase/runtime_residency.go +166 -0
@@ -0,0 +1,166 @@
1+package usecase
2+
3+import "time"
4+
5+// ResidencyAction is the single decision a RuntimeResidency event produces.
6+// The UI boundary maps it to scheduler operations: clock and timer handles
7+// stay outside this controller, which never sleeps.
8+type ResidencyAction int
9+
10+const (
11+ // ResidencyNone requires no scheduler change.
12+ ResidencyNone ResidencyAction = iota
13+ // ResidencyArm starts (or restarts) the idle deadline.
14+ ResidencyArm
15+ // ResidencyCancel drops a previously armed idle deadline.
16+ ResidencyCancel
17+ // ResidencyQuit ends the process through the orderly shutdown path.
18+ ResidencyQuit
19+)
20+
21+// ResidencyDecision is the outcome of one residency event.
22+type ResidencyDecision struct {
23+ Action ResidencyAction
24+ Deadline time.Time
25+ Generation uint64
26+}
27+
28+// RuntimeResidency is a testable bounded-residency policy: after the last
29+// user window closes it arms a deadline instead of quitting immediately, so
30+// a fast reopen can reuse the retained runtime. It tracks only engine-neutral
31+// counts; native GTK holds, timers, and quiescence signals live in UI
32+// adapters. The zero timeout preserves the existing last-window exit.
33+type RuntimeResidency struct {
34+ timeout time.Duration
35+ windows int
36+ activeWork int
37+ shutdown bool
38+ armed bool
39+ deadline time.Time
40+ generation uint64
41+}
42+
43+// NewRuntimeResidency returns a policy with the given idle timeout. Negative
44+// values clamp to zero (disabled). The accepted configuration range is
45+// 0..300000ms; enforcement of that range belongs to config validation.
46+func NewRuntimeResidency(timeout time.Duration) *RuntimeResidency {
47+ if timeout < 0 {
48+ timeout = 0
49+ }
50+ return &RuntimeResidency{timeout: timeout}
51+}
52+
53+// WindowOpened records a user window (including native browser/auth popups
54+// that still belong to the application) and cancels any armed deadline.
55+func (r *RuntimeResidency) WindowOpened(_ time.Time) ResidencyDecision {
56+ r.windows++
57+ if r.armed {
58+ return r.cancel()
59+ }
60+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
61+}
62+
63+// WindowClosed records a user-window close. Duplicate closes are idempotent
64+// and never drive the count negative.
65+func (r *RuntimeResidency) WindowClosed(now time.Time) ResidencyDecision {
66+ if r.windows > 0 {
67+ r.windows--
68+ }
69+ return r.evaluateIdle(now)
70+}
71+
72+// OpenFailed records a failed reopen attempt. Idle eligibility is restored
73+// rather than stranding the policy: a failed open can never leak the
74+// application hold indefinitely.
75+func (r *RuntimeResidency) OpenFailed(now time.Time) ResidencyDecision {
76+ if r.windows > 0 {
77+ r.windows--
78+ }
79+ return r.evaluateIdle(now)
80+}
81+
82+// WorkStarted records newly accepted native work. An armed deadline is
83+// canceled: expiry must never fire while required work is outstanding.
84+func (r *RuntimeResidency) WorkStarted(_ time.Time) ResidencyDecision {
85+ r.activeWork++
86+ if r.armed {
87+ return r.cancel()
88+ }
89+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
90+}
91+
92+// WorkFinished records settled work and re-evaluates idle eligibility.
93+func (r *RuntimeResidency) WorkFinished(now time.Time) ResidencyDecision {
94+ if r.activeWork > 0 {
95+ r.activeWork--
96+ }
97+ return r.evaluateIdle(now)
98+}
99+
100+// ShutdownRequested records an explicit quit or signal. It always decides
101+// Quit immediately and never waits for the idle deadline.
102+func (r *RuntimeResidency) ShutdownRequested() ResidencyDecision {
103+ r.shutdown = true
104+ r.armed = false
105+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
106+}
107+
108+// IdleArmed reports whether an idle deadline is currently armed.
109+func (r *RuntimeResidency) IdleArmed() bool {
110+ return r.armed
111+}
112+
113+// HandleExpiry processes a fired idle timer. Stale generations (superseded by
114+// a later arm/cancel) are ignored. Expiry quits only when still quiescent;
115+// otherwise it stands down and lets the next event re-arm.
116+func (r *RuntimeResidency) HandleExpiry(_ time.Time, generation uint64) ResidencyDecision {
117+ if !r.armed || generation != r.generation {
118+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
119+ }
120+ if r.windows > 0 || r.activeWork > 0 || r.shutdown {
121+ r.armed = false
122+ if r.shutdown {
123+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
124+ }
125+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
126+ }
127+ r.armed = false
128+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
129+}
130+
131+// Armed reports whether an idle deadline is currently armed. The UI boundary
132+// uses it only for diagnostics; scheduling follows decisions.
133+func (r *RuntimeResidency) Armed() bool { return r.armed }
134+
135+// Generation returns the current timer generation for expiry correlation.
136+func (r *RuntimeResidency) Generation() uint64 { return r.generation }
137+
138+func (r *RuntimeResidency) cancel() ResidencyDecision {
139+ r.armed = false
140+ r.generation++
141+ return ResidencyDecision{Action: ResidencyCancel, Generation: r.generation}
142+}
143+
144+func (r *RuntimeResidency) evaluateIdle(now time.Time) ResidencyDecision {
145+ if r.shutdown {
146+ r.armed = false
147+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
148+ }
149+ if r.windows > 0 {
150+ if r.armed {
151+ return r.cancel()
152+ }
153+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
154+ }
155+ if r.timeout <= 0 {
156+ r.armed = false
157+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
158+ }
159+ if r.activeWork > 0 {
160+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
161+ }
162+ r.armed = true
163+ r.deadline = now.Add(r.timeout)
164+ r.generation++
165+ return ResidencyDecision{Action: ResidencyArm, Deadline: r.deadline, Generation: r.generation}
166+}
@@ -0,0 +1,166 @@
1+package usecase
2+
3+import "time"
4+
5+// ResidencyAction is the single decision a RuntimeResidency event produces.
6+// The UI boundary maps it to scheduler operations: clock and timer handles
7+// stay outside this controller, which never sleeps.
8+type ResidencyAction int
9+
10+const (
11+ // ResidencyNone requires no scheduler change.
12+ ResidencyNone ResidencyAction = iota
13+ // ResidencyArm starts (or restarts) the idle deadline.
14+ ResidencyArm
15+ // ResidencyCancel drops a previously armed idle deadline.
16+ ResidencyCancel
17+ // ResidencyQuit ends the process through the orderly shutdown path.
18+ ResidencyQuit
19+)
20+
21+// ResidencyDecision is the outcome of one residency event.
22+type ResidencyDecision struct {
23+ Action ResidencyAction
24+ Deadline time.Time
25+ Generation uint64
26+}
27+
28+// RuntimeResidency is a testable bounded-residency policy: after the last
29+// user window closes it arms a deadline instead of quitting immediately, so
30+// a fast reopen can reuse the retained runtime. It tracks only engine-neutral
31+// counts; native GTK holds, timers, and quiescence signals live in UI
32+// adapters. The zero timeout preserves the existing last-window exit.
33+type RuntimeResidency struct {
34+ timeout time.Duration
35+ windows int
36+ activeWork int
37+ shutdown bool
38+ armed bool
39+ deadline time.Time
40+ generation uint64
41+}
42+
43+// NewRuntimeResidency returns a policy with the given idle timeout. Negative
44+// values clamp to zero (disabled). The accepted configuration range is
45+// 0..300000ms; enforcement of that range belongs to config validation.
46+func NewRuntimeResidency(timeout time.Duration) *RuntimeResidency {
47+ if timeout < 0 {
48+ timeout = 0
49+ }
50+ return &RuntimeResidency{timeout: timeout}
51+}
52+
53+// WindowOpened records a user window (including native browser/auth popups
54+// that still belong to the application) and cancels any armed deadline.
55+func (r *RuntimeResidency) WindowOpened(_ time.Time) ResidencyDecision {
56+ r.windows++
57+ if r.armed {
58+ return r.cancel()
59+ }
60+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
61+}
62+
63+// WindowClosed records a user-window close. Duplicate closes are idempotent
64+// and never drive the count negative.
65+func (r *RuntimeResidency) WindowClosed(now time.Time) ResidencyDecision {
66+ if r.windows > 0 {
67+ r.windows--
68+ }
69+ return r.evaluateIdle(now)
70+}
71+
72+// OpenFailed records a failed reopen attempt. Idle eligibility is restored
73+// rather than stranding the policy: a failed open can never leak the
74+// application hold indefinitely.
75+func (r *RuntimeResidency) OpenFailed(now time.Time) ResidencyDecision {
76+ if r.windows > 0 {
77+ r.windows--
78+ }
79+ return r.evaluateIdle(now)
80+}
81+
82+// WorkStarted records newly accepted native work. An armed deadline is
83+// canceled: expiry must never fire while required work is outstanding.
84+func (r *RuntimeResidency) WorkStarted(_ time.Time) ResidencyDecision {
85+ r.activeWork++
86+ if r.armed {
87+ return r.cancel()
88+ }
89+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
90+}
91+
92+// WorkFinished records settled work and re-evaluates idle eligibility.
93+func (r *RuntimeResidency) WorkFinished(now time.Time) ResidencyDecision {
94+ if r.activeWork > 0 {
95+ r.activeWork--
96+ }
97+ return r.evaluateIdle(now)
98+}
99+
100+// ShutdownRequested records an explicit quit or signal. It always decides
101+// Quit immediately and never waits for the idle deadline.
102+func (r *RuntimeResidency) ShutdownRequested() ResidencyDecision {
103+ r.shutdown = true
104+ r.armed = false
105+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
106+}
107+
108+// IdleArmed reports whether an idle deadline is currently armed.
109+func (r *RuntimeResidency) IdleArmed() bool {
110+ return r.armed
111+}
112+
113+// HandleExpiry processes a fired idle timer. Stale generations (superseded by
114+// a later arm/cancel) are ignored. Expiry quits only when still quiescent;
115+// otherwise it stands down and lets the next event re-arm.
116+func (r *RuntimeResidency) HandleExpiry(_ time.Time, generation uint64) ResidencyDecision {
117+ if !r.armed || generation != r.generation {
118+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
119+ }
120+ if r.windows > 0 || r.activeWork > 0 || r.shutdown {
121+ r.armed = false
122+ if r.shutdown {
123+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
124+ }
125+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
126+ }
127+ r.armed = false
128+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
129+}
130+
131+// Armed reports whether an idle deadline is currently armed. The UI boundary
132+// uses it only for diagnostics; scheduling follows decisions.
133+func (r *RuntimeResidency) Armed() bool { return r.armed }
134+
135+// Generation returns the current timer generation for expiry correlation.
136+func (r *RuntimeResidency) Generation() uint64 { return r.generation }
137+
138+func (r *RuntimeResidency) cancel() ResidencyDecision {
139+ r.armed = false
140+ r.generation++
141+ return ResidencyDecision{Action: ResidencyCancel, Generation: r.generation}
142+}
143+
144+func (r *RuntimeResidency) evaluateIdle(now time.Time) ResidencyDecision {
145+ if r.shutdown {
146+ r.armed = false
147+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
148+ }
149+ if r.windows > 0 {
150+ if r.armed {
151+ return r.cancel()
152+ }
153+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
154+ }
155+ if r.timeout <= 0 {
156+ r.armed = false
157+ return ResidencyDecision{Action: ResidencyQuit, Generation: r.generation}
158+ }
159+ if r.activeWork > 0 {
160+ return ResidencyDecision{Action: ResidencyNone, Generation: r.generation}
161+ }
162+ r.armed = true
163+ r.deadline = now.Add(r.timeout)
164+ r.generation++
165+ return ResidencyDecision{Action: ResidencyArm, Deadline: r.deadline, Generation: r.generation}
166+}
A internal/application/usecase/runtime_residency_test.go +133 -0
@@ -0,0 +1,133 @@
1+package usecase
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRuntimeResidencyDisabledQuitsOnLastWindow(t *testing.T) {
11+ r := NewRuntimeResidency(0)
12+ r.WindowOpened(time.Now())
13+ decision := r.WindowClosed(time.Now())
14+ require.Equal(t, ResidencyQuit, decision.Action)
15+ require.False(t, r.Armed())
16+}
17+
18+func TestRuntimeResidencyNegativeTimeoutDisables(t *testing.T) {
19+ r := NewRuntimeResidency(-time.Second)
20+ r.WindowOpened(time.Now())
21+ require.Equal(t, ResidencyQuit, r.WindowClosed(time.Now()).Action)
22+}
23+
24+func TestRuntimeResidencyFirstIdleArmsDeadline(t *testing.T) {
25+ now := time.Now()
26+ r := NewRuntimeResidency(time.Minute)
27+ r.WindowOpened(now)
28+ decision := r.WindowClosed(now)
29+ require.Equal(t, ResidencyArm, decision.Action)
30+ require.Equal(t, now.Add(time.Minute), decision.Deadline)
31+ require.True(t, r.Armed())
32+ gen := decision.Generation
33+
34+ expiry := r.HandleExpiry(now.Add(2*time.Minute), gen)
35+ require.Equal(t, ResidencyQuit, expiry.Action)
36+ require.False(t, r.Armed())
37+}
38+
39+func TestRuntimeResidencyReopenCancelsDeadline(t *testing.T) {
40+ now := time.Now()
41+ r := NewRuntimeResidency(time.Minute)
42+ r.WindowOpened(now)
43+ arm := r.WindowClosed(now)
44+ require.Equal(t, ResidencyArm, arm.Action)
45+
46+ cancel := r.WindowOpened(now)
47+ require.Equal(t, ResidencyCancel, cancel.Action)
48+ require.False(t, r.Armed())
49+
50+ stale := r.HandleExpiry(now.Add(2*time.Minute), arm.Generation)
51+ require.Equal(t, ResidencyNone, stale.Action)
52+}
53+
54+func TestRuntimeResidencyFailedReopenRestoresIdle(t *testing.T) {
55+ now := time.Now()
56+ r := NewRuntimeResidency(time.Minute)
57+ r.WindowOpened(now)
58+ require.Equal(t, ResidencyArm, r.WindowClosed(now).Action)
59+
60+ // Optimistic open followed by failure: the hold must not leak, idle
61+ // eligibility returns.
62+ r.WindowOpened(now)
63+ decision := r.OpenFailed(now)
64+ require.Equal(t, ResidencyArm, decision.Action)
65+ require.True(t, r.Armed())
66+}
67+
68+func TestRuntimeResidencyPopupRemainingPreventsIdle(t *testing.T) {
69+ now := time.Now()
70+ r := NewRuntimeResidency(time.Minute)
71+ r.WindowOpened(now)
72+ r.WindowOpened(now)
73+ decision := r.WindowClosed(now)
74+ require.Equal(t, ResidencyNone, decision.Action)
75+ require.False(t, r.Armed())
76+}
77+
78+func TestRuntimeResidencyDuplicateCloseIsIdempotent(t *testing.T) {
79+ r := NewRuntimeResidency(0)
80+ decision := r.WindowClosed(time.Now())
81+ require.Equal(t, ResidencyQuit, decision.Action)
82+ again := r.WindowClosed(time.Now())
83+ require.Equal(t, ResidencyQuit, again.Action)
84+}
85+
86+func TestRuntimeResidencyStaleExpiryIgnored(t *testing.T) {
87+ now := time.Now()
88+ r := NewRuntimeResidency(time.Minute)
89+ r.WindowOpened(now)
90+ first := r.WindowClosed(now)
91+ r.WindowOpened(now)
92+ second := r.WindowClosed(now)
93+ require.NotEqual(t, first.Generation, second.Generation)
94+
95+ require.Equal(t, ResidencyNone, r.HandleExpiry(now, first.Generation).Action)
96+ require.True(t, r.Armed())
97+ require.Equal(t, ResidencyQuit, r.HandleExpiry(now, second.Generation).Action)
98+}
99+
100+func TestRuntimeResidencyExplicitQuitNeverWaits(t *testing.T) {
101+ now := time.Now()
102+ r := NewRuntimeResidency(time.Minute)
103+ r.WindowOpened(now)
104+ require.Equal(t, ResidencyArm, r.WindowClosed(now).Action)
105+ require.True(t, r.Armed())
106+
107+ decision := r.ShutdownRequested()
108+ require.Equal(t, ResidencyQuit, decision.Action)
109+ require.False(t, r.Armed())
110+}
111+
112+func TestRuntimeResidencyWorkBlocksIdleUntilDrained(t *testing.T) {
113+ now := time.Now()
114+ r := NewRuntimeResidency(time.Minute)
115+ r.WindowOpened(now)
116+ r.WorkStarted(now)
117+ decision := r.WindowClosed(now)
118+ require.Equal(t, ResidencyNone, decision.Action)
119+ require.False(t, r.Armed())
120+
121+ drained := r.WorkFinished(now)
122+ require.Equal(t, ResidencyArm, drained.Action)
123+ require.True(t, r.Armed())
124+}
125+
126+func TestRuntimeResidencyWorkCancelsArmedDeadline(t *testing.T) {
127+ now := time.Now()
128+ r := NewRuntimeResidency(time.Minute)
129+ r.WindowOpened(now)
130+ require.Equal(t, ResidencyArm, r.WindowClosed(now).Action)
131+ require.Equal(t, ResidencyCancel, r.WorkStarted(now).Action)
132+ require.False(t, r.Armed())
133+}
@@ -0,0 +1,133 @@
1+package usecase
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRuntimeResidencyDisabledQuitsOnLastWindow(t *testing.T) {
11+ r := NewRuntimeResidency(0)
12+ r.WindowOpened(time.Now())
13+ decision := r.WindowClosed(time.Now())
14+ require.Equal(t, ResidencyQuit, decision.Action)
15+ require.False(t, r.Armed())
16+}
17+
18+func TestRuntimeResidencyNegativeTimeoutDisables(t *testing.T) {
19+ r := NewRuntimeResidency(-time.Second)
20+ r.WindowOpened(time.Now())
21+ require.Equal(t, ResidencyQuit, r.WindowClosed(time.Now()).Action)
22+}
23+
24+func TestRuntimeResidencyFirstIdleArmsDeadline(t *testing.T) {
25+ now := time.Now()
26+ r := NewRuntimeResidency(time.Minute)
27+ r.WindowOpened(now)
28+ decision := r.WindowClosed(now)
29+ require.Equal(t, ResidencyArm, decision.Action)
30+ require.Equal(t, now.Add(time.Minute), decision.Deadline)
31+ require.True(t, r.Armed())
32+ gen := decision.Generation
33+
34+ expiry := r.HandleExpiry(now.Add(2*time.Minute), gen)
35+ require.Equal(t, ResidencyQuit, expiry.Action)
36+ require.False(t, r.Armed())
37+}
38+
39+func TestRuntimeResidencyReopenCancelsDeadline(t *testing.T) {
40+ now := time.Now()
41+ r := NewRuntimeResidency(time.Minute)
42+ r.WindowOpened(now)
43+ arm := r.WindowClosed(now)
44+ require.Equal(t, ResidencyArm, arm.Action)
45+
46+ cancel := r.WindowOpened(now)
47+ require.Equal(t, ResidencyCancel, cancel.Action)
48+ require.False(t, r.Armed())
49+
50+ stale := r.HandleExpiry(now.Add(2*time.Minute), arm.Generation)
51+ require.Equal(t, ResidencyNone, stale.Action)
52+}
53+
54+func TestRuntimeResidencyFailedReopenRestoresIdle(t *testing.T) {
55+ now := time.Now()
56+ r := NewRuntimeResidency(time.Minute)
57+ r.WindowOpened(now)
58+ require.Equal(t, ResidencyArm, r.WindowClosed(now).Action)
59+
60+ // Optimistic open followed by failure: the hold must not leak, idle
61+ // eligibility returns.
62+ r.WindowOpened(now)
63+ decision := r.OpenFailed(now)
64+ require.Equal(t, ResidencyArm, decision.Action)
65+ require.True(t, r.Armed())
66+}
67+
68+func TestRuntimeResidencyPopupRemainingPreventsIdle(t *testing.T) {
69+ now := time.Now()
70+ r := NewRuntimeResidency(time.Minute)
71+ r.WindowOpened(now)
72+ r.WindowOpened(now)
73+ decision := r.WindowClosed(now)
74+ require.Equal(t, ResidencyNone, decision.Action)
75+ require.False(t, r.Armed())
76+}
77+
78+func TestRuntimeResidencyDuplicateCloseIsIdempotent(t *testing.T) {
79+ r := NewRuntimeResidency(0)
80+ decision := r.WindowClosed(time.Now())
81+ require.Equal(t, ResidencyQuit, decision.Action)
82+ again := r.WindowClosed(time.Now())
83+ require.Equal(t, ResidencyQuit, again.Action)
84+}
85+
86+func TestRuntimeResidencyStaleExpiryIgnored(t *testing.T) {
87+ now := time.Now()
88+ r := NewRuntimeResidency(time.Minute)
89+ r.WindowOpened(now)
90+ first := r.WindowClosed(now)
91+ r.WindowOpened(now)
92+ second := r.WindowClosed(now)
93+ require.NotEqual(t, first.Generation, second.Generation)
94+
95+ require.Equal(t, ResidencyNone, r.HandleExpiry(now, first.Generation).Action)
96+ require.True(t, r.Armed())
97+ require.Equal(t, ResidencyQuit, r.HandleExpiry(now, second.Generation).Action)
98+}
99+
100+func TestRuntimeResidencyExplicitQuitNeverWaits(t *testing.T) {
101+ now := time.Now()
102+ r := NewRuntimeResidency(time.Minute)
103+ r.WindowOpened(now)
104+ require.Equal(t, ResidencyArm, r.WindowClosed(now).Action)
105+ require.True(t, r.Armed())
106+
107+ decision := r.ShutdownRequested()
108+ require.Equal(t, ResidencyQuit, decision.Action)
109+ require.False(t, r.Armed())
110+}
111+
112+func TestRuntimeResidencyWorkBlocksIdleUntilDrained(t *testing.T) {
113+ now := time.Now()
114+ r := NewRuntimeResidency(time.Minute)
115+ r.WindowOpened(now)
116+ r.WorkStarted(now)
117+ decision := r.WindowClosed(now)
118+ require.Equal(t, ResidencyNone, decision.Action)
119+ require.False(t, r.Armed())
120+
121+ drained := r.WorkFinished(now)
122+ require.Equal(t, ResidencyArm, drained.Action)
123+ require.True(t, r.Armed())
124+}
125+
126+func TestRuntimeResidencyWorkCancelsArmedDeadline(t *testing.T) {
127+ now := time.Now()
128+ r := NewRuntimeResidency(time.Minute)
129+ r.WindowOpened(now)
130+ require.Equal(t, ResidencyArm, r.WindowClosed(now).Action)
131+ require.Equal(t, ResidencyCancel, r.WorkStarted(now).Action)
132+ require.False(t, r.Armed())
133+}
M internal/infrastructure/cef/download_handler.go +21 -1
@@ -24,6 +24,9 @@ type downloadHandler struct {
2424 mu sync.Mutex
2525 active map[uint32]cefDownloadState
2626 finished map[uint32]struct{} // IDs that already emitted a terminal event
27+ // activity tracks download starts against their terminal events for
28+ // the quiescence boundary. Nil-safe: unset in unit-test literals.
29+ activity *RuntimeActivityTracker
2730 }
2831
2932 type cefDownloadState struct {
@@ -102,11 +105,13 @@ func newDownloadHandler(
102105 downloadPath string,
103106 eventHandler port.DownloadEventHandler,
104107 preparer port.DownloadPreparer,
108+ activity *RuntimeActivityTracker,
105109 ) *downloadHandler {
106110 return &downloadHandler{
107111 runtime: downloadruntime.New(downloadPath, eventHandler, preparer),
108112 active: make(map[uint32]cefDownloadState),
109113 finished: make(map[uint32]struct{}),
114+ activity: activity,
110115 }
111116 }
112117
@@ -116,7 +121,7 @@ func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
116121
117122 func (h *downloadHandler) onBeforeDownload(
118123 ctx context.Context,
119- _ purecef.Browser,
124+ browser purecef.Browser,
120125 downloadItem purecef.DownloadItem,
121126 suggestedName string,
122127 callback purecef.BeforeDownloadCallback,
@@ -124,6 +129,14 @@ func (h *downloadHandler) onBeforeDownload(
124129 if downloadItem == nil || callback == nil {
125130 return false
126131 }
132+ // Retain browser ownership the previous code discarded: activity is
133+ // keyed by (browser identity, CEF download ID), never by filename or
134+ // destination, which can collide or change mid-download.
135+ var browserID int32
136+ if browser != nil {
137+ browserID = browser.GetIdentifier()
138+ }
139+ h.activity.NoteDownloadStarted(browserID, downloadItem.GetID())
127140
128141 log := logging.FromContext(ctx)
129142 output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem})
@@ -149,6 +162,7 @@ func (h *downloadHandler) onBeforeDownload(
149162
150163 func (h *downloadHandler) onDownloadUpdated(
151164 ctx context.Context,
165+ browser purecef.Browser,
152166 downloadItem purecef.DownloadItem,
153167 callback purecef.DownloadItemCallback,
154168 ) {
@@ -156,6 +170,10 @@ func (h *downloadHandler) onDownloadUpdated(
156170 if downloadItem == nil {
157171 return
158172 }
173+ var browserID int32
174+ if browser != nil {
175+ browserID = browser.GetIdentifier()
176+ }
159177
160178 id := downloadItem.GetID()
161179
@@ -175,11 +193,13 @@ func (h *downloadHandler) onDownloadUpdated(
175193 if !h.markFinished(id) {
176194 return
177195 }
196+ h.activity.NoteDownloadTerminal(browserID, id)
178197 h.runtime.EmitFinished(ctx, state.filename, state.destination)
179198 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
180199 if !h.markFinished(id) {
181200 return
182201 }
202+ h.activity.NoteDownloadTerminal(browserID, id)
183203 var err error
184204 if downloadItem.IsInterrupted() {
185205 err = cefDownloadInterruptError{
@@ -24,6 +24,9 @@ type downloadHandler struct {
24 mu sync.Mutex24 mu sync.Mutex
25 active map[uint32]cefDownloadState25 active map[uint32]cefDownloadState
26 finished map[uint32]struct{} // IDs that already emitted a terminal event26 finished map[uint32]struct{} // IDs that already emitted a terminal event
27+ // activity tracks download starts against their terminal events for
28+ // the quiescence boundary. Nil-safe: unset in unit-test literals.
29+ activity *RuntimeActivityTracker
27 }30 }
28 31
29 type cefDownloadState struct {32 type cefDownloadState struct {
@@ -102,11 +105,13 @@ func newDownloadHandler(
102 downloadPath string,105 downloadPath string,
103 eventHandler port.DownloadEventHandler,106 eventHandler port.DownloadEventHandler,
104 preparer port.DownloadPreparer,107 preparer port.DownloadPreparer,
108+ activity *RuntimeActivityTracker,
105 ) *downloadHandler {109 ) *downloadHandler {
106 return &downloadHandler{110 return &downloadHandler{
107 runtime: downloadruntime.New(downloadPath, eventHandler, preparer),111 runtime: downloadruntime.New(downloadPath, eventHandler, preparer),
108 active: make(map[uint32]cefDownloadState),112 active: make(map[uint32]cefDownloadState),
109 finished: make(map[uint32]struct{}),113 finished: make(map[uint32]struct{}),
114+ activity: activity,
110 }115 }
111 }116 }
112 117
@@ -116,7 +121,7 @@ func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
116 121
117 func (h *downloadHandler) onBeforeDownload(122 func (h *downloadHandler) onBeforeDownload(
118 ctx context.Context,123 ctx context.Context,
119- _ purecef.Browser,124+ browser purecef.Browser,
120 downloadItem purecef.DownloadItem,125 downloadItem purecef.DownloadItem,
121 suggestedName string,126 suggestedName string,
122 callback purecef.BeforeDownloadCallback,127 callback purecef.BeforeDownloadCallback,
@@ -124,6 +129,14 @@ func (h *downloadHandler) onBeforeDownload(
124 if downloadItem == nil || callback == nil {129 if downloadItem == nil || callback == nil {
125 return false130 return false
126 }131 }
132+ // Retain browser ownership the previous code discarded: activity is
133+ // keyed by (browser identity, CEF download ID), never by filename or
134+ // destination, which can collide or change mid-download.
135+ var browserID int32
136+ if browser != nil {
137+ browserID = browser.GetIdentifier()
138+ }
139+ h.activity.NoteDownloadStarted(browserID, downloadItem.GetID())
127 140
128 log := logging.FromContext(ctx)141 log := logging.FromContext(ctx)
129 output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem})142 output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem})
@@ -149,6 +162,7 @@ func (h *downloadHandler) onBeforeDownload(
149 162
150 func (h *downloadHandler) onDownloadUpdated(163 func (h *downloadHandler) onDownloadUpdated(
151 ctx context.Context,164 ctx context.Context,
165+ browser purecef.Browser,
152 downloadItem purecef.DownloadItem,166 downloadItem purecef.DownloadItem,
153 callback purecef.DownloadItemCallback,167 callback purecef.DownloadItemCallback,
154 ) {168 ) {
@@ -156,6 +170,10 @@ func (h *downloadHandler) onDownloadUpdated(
156 if downloadItem == nil {170 if downloadItem == nil {
157 return171 return
158 }172 }
173+ var browserID int32
174+ if browser != nil {
175+ browserID = browser.GetIdentifier()
176+ }
159 177
160 id := downloadItem.GetID()178 id := downloadItem.GetID()
161 179
@@ -175,11 +193,13 @@ func (h *downloadHandler) onDownloadUpdated(
175 if !h.markFinished(id) {193 if !h.markFinished(id) {
176 return194 return
177 }195 }
196+ h.activity.NoteDownloadTerminal(browserID, id)
178 h.runtime.EmitFinished(ctx, state.filename, state.destination)197 h.runtime.EmitFinished(ctx, state.filename, state.destination)
179 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():198 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
180 if !h.markFinished(id) {199 if !h.markFinished(id) {
181 return200 return
182 }201 }
202+ h.activity.NoteDownloadTerminal(browserID, id)
183 var err error203 var err error
184 if downloadItem.IsInterrupted() {204 if downloadItem.IsInterrupted() {
185 err = cefDownloadInterruptError{205 err = cefDownloadInterruptError{
M internal/infrastructure/cef/download_handler_test.go +41 -11
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
1011 "github.com/stretchr/testify/require"
1112
1213 "github.com/bnema/dumber/internal/application/port"
@@ -74,7 +75,7 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
7475 preparer := usecase.NewPrepareDownloadUseCase(nil)
7576 events := &captureDownloadEvents{}
7677 downloadDir := t.TempDir()
77- handler := newDownloadHandler(downloadDir, events, preparer)
78+ handler := newDownloadHandler(downloadDir, events, preparer, nil)
7879 callback := &stubBeforeDownloadCallback{}
7980
8081 item := stubDownloadItem{
@@ -93,9 +94,9 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
9394
9495 item.complete = true
9596 item.fullPath = callback.path
96- handler.onDownloadUpdated(ctx, item, nil)
97+ handler.onDownloadUpdated(ctx, nil, item, nil)
9798 // Repeated completion updates should not emit duplicate finished events.
98- handler.onDownloadUpdated(ctx, item, nil)
99+ handler.onDownloadUpdated(ctx, nil, item, nil)
99100
100101 require.Len(t, events.events, 2)
101102 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
@@ -112,7 +113,7 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
112113
113114 func TestCanDownload_AllowsAllMethods(t *testing.T) {
114115 preparer := usecase.NewPrepareDownloadUseCase(nil)
115- handler := newDownloadHandler("/tmp/downloads", nil, preparer)
116+ handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
116117
117118 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
118119 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
@@ -122,7 +123,7 @@ func TestCanDownload_AllowsAllMethods(t *testing.T) {
122123
123124 func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
124125 preparer := usecase.NewPrepareDownloadUseCase(nil)
125- handler := newDownloadHandler("/tmp/downloads", nil, preparer)
126+ handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
126127
127128 // Simulate a download that was tracked through onBeforeDownload.
128129 handler.mu.Lock()
@@ -149,7 +150,7 @@ func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
149150 ctx := context.Background()
150151 preparer := usecase.NewPrepareDownloadUseCase(nil)
151152 events := &captureDownloadEvents{}
152- handler := newDownloadHandler("/tmp/downloads", events, preparer)
153+ handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
153154 callback := &stubBeforeDownloadCallback{}
154155
155156 item := stubDownloadItem{
@@ -165,12 +166,12 @@ func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
165166 item.percentComplete = 12
166167 item.receivedBytes = 12
167168 item.totalBytes = 100
168- handler.onDownloadUpdated(ctx, item, nil)
169- handler.onDownloadUpdated(ctx, item, nil)
169+ handler.onDownloadUpdated(ctx, nil, item, nil)
170+ handler.onDownloadUpdated(ctx, nil, item, nil)
170171
171172 item.percentComplete = 13
172173 item.receivedBytes = 13
173- handler.onDownloadUpdated(ctx, item, nil)
174+ handler.onDownloadUpdated(ctx, nil, item, nil)
174175
175176 require.Len(t, events.events, 3)
176177 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
@@ -184,7 +185,7 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
184185 ctx := context.Background()
185186 preparer := usecase.NewPrepareDownloadUseCase(nil)
186187 events := &captureDownloadEvents{}
187- handler := newDownloadHandler("/tmp/downloads", events, preparer)
188+ handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
188189 callback := &stubBeforeDownloadCallback{}
189190
190191 item := stubDownloadItem{
@@ -199,7 +200,7 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
199200 item.fullPath = callback.path
200201 item.interrupted = true
201202 item.reason = purecef.DownloadInterruptReasonServerBadContent
202- handler.onDownloadUpdated(ctx, item, nil)
203+ handler.onDownloadUpdated(ctx, nil, item, nil)
203204
204205 require.Len(t, events.events, 2)
205206 require.Error(t, events.events[1].Error)
@@ -207,3 +208,32 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
207208 require.ErrorContains(t, events.events[1].Error, expectedCode)
208209 require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
209210 }
211+
212+func TestDownloadHandlerTracksOwnersByBrowserAndID(t *testing.T) {
213+ ctx := context.Background()
214+ preparer := usecase.NewPrepareDownloadUseCase(nil)
215+ events := &captureDownloadEvents{}
216+ tracker := NewRuntimeActivityTracker()
217+ handler := newDownloadHandler(t.TempDir(), events, preparer, tracker)
218+ callback := &stubBeforeDownloadCallback{}
219+
220+ first := cefmocks.NewMockBrowser(t)
221+ first.EXPECT().GetIdentifier().Return(int32(7))
222+ second := cefmocks.NewMockBrowser(t)
223+ second.EXPECT().GetIdentifier().Return(int32(9))
224+
225+ // Same filename from two owners are distinct activity entries.
226+ require.True(t, handler.onBeforeDownload(ctx, first,
227+ stubDownloadItem{id: 11, url: "https://example.com/a.bin", suggested: "a.bin"}, "a.bin", callback))
228+ require.True(t, handler.onBeforeDownload(ctx, second,
229+ stubDownloadItem{id: 12, url: "https://other.example/a.bin", suggested: "a.bin"}, "a.bin", callback))
230+ require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
231+
232+ // Terminal for one ID leaves the other owner outstanding.
233+ handler.onDownloadUpdated(ctx, first, stubDownloadItem{id: 11, complete: true, fullPath: "/tmp/a.bin"}, nil)
234+ require.Equal(t, 1, tracker.Snapshot().ActiveDownloads)
235+
236+ // Lost owners reconcile through the native lifecycle, never removal.
237+ tracker.DropBrowser(9)
238+ require.True(t, tracker.Snapshot().Quiescent())
239+}
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 8
9 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 12
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
@@ -74,7 +75,7 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
74 preparer := usecase.NewPrepareDownloadUseCase(nil)75 preparer := usecase.NewPrepareDownloadUseCase(nil)
75 events := &captureDownloadEvents{}76 events := &captureDownloadEvents{}
76 downloadDir := t.TempDir()77 downloadDir := t.TempDir()
77- handler := newDownloadHandler(downloadDir, events, preparer)78+ handler := newDownloadHandler(downloadDir, events, preparer, nil)
78 callback := &stubBeforeDownloadCallback{}79 callback := &stubBeforeDownloadCallback{}
79 80
80 item := stubDownloadItem{81 item := stubDownloadItem{
@@ -93,9 +94,9 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
93 94
94 item.complete = true95 item.complete = true
95 item.fullPath = callback.path96 item.fullPath = callback.path
96- handler.onDownloadUpdated(ctx, item, nil)97+ handler.onDownloadUpdated(ctx, nil, item, nil)
97 // Repeated completion updates should not emit duplicate finished events.98 // Repeated completion updates should not emit duplicate finished events.
98- handler.onDownloadUpdated(ctx, item, nil)99+ handler.onDownloadUpdated(ctx, nil, item, nil)
99 100
100 require.Len(t, events.events, 2)101 require.Len(t, events.events, 2)
101 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)102 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
@@ -112,7 +113,7 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) {
112 113
113 func TestCanDownload_AllowsAllMethods(t *testing.T) {114 func TestCanDownload_AllowsAllMethods(t *testing.T) {
114 preparer := usecase.NewPrepareDownloadUseCase(nil)115 preparer := usecase.NewPrepareDownloadUseCase(nil)
115- handler := newDownloadHandler("/tmp/downloads", nil, preparer)116+ handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
116 117
117 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))118 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
118 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))119 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
@@ -122,7 +123,7 @@ func TestCanDownload_AllowsAllMethods(t *testing.T) {
122 123
123 func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {124 func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
124 preparer := usecase.NewPrepareDownloadUseCase(nil)125 preparer := usecase.NewPrepareDownloadUseCase(nil)
125- handler := newDownloadHandler("/tmp/downloads", nil, preparer)126+ handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
126 127
127 // Simulate a download that was tracked through onBeforeDownload.128 // Simulate a download that was tracked through onBeforeDownload.
128 handler.mu.Lock()129 handler.mu.Lock()
@@ -149,7 +150,7 @@ func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
149 ctx := context.Background()150 ctx := context.Background()
150 preparer := usecase.NewPrepareDownloadUseCase(nil)151 preparer := usecase.NewPrepareDownloadUseCase(nil)
151 events := &captureDownloadEvents{}152 events := &captureDownloadEvents{}
152- handler := newDownloadHandler("/tmp/downloads", events, preparer)153+ handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
153 callback := &stubBeforeDownloadCallback{}154 callback := &stubBeforeDownloadCallback{}
154 155
155 item := stubDownloadItem{156 item := stubDownloadItem{
@@ -165,12 +166,12 @@ func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
165 item.percentComplete = 12166 item.percentComplete = 12
166 item.receivedBytes = 12167 item.receivedBytes = 12
167 item.totalBytes = 100168 item.totalBytes = 100
168- handler.onDownloadUpdated(ctx, item, nil)169+ handler.onDownloadUpdated(ctx, nil, item, nil)
169- handler.onDownloadUpdated(ctx, item, nil)170+ handler.onDownloadUpdated(ctx, nil, item, nil)
170 171
171 item.percentComplete = 13172 item.percentComplete = 13
172 item.receivedBytes = 13173 item.receivedBytes = 13
173- handler.onDownloadUpdated(ctx, item, nil)174+ handler.onDownloadUpdated(ctx, nil, item, nil)
174 175
175 require.Len(t, events.events, 3)176 require.Len(t, events.events, 3)
176 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)177 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
@@ -184,7 +185,7 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
184 ctx := context.Background()185 ctx := context.Background()
185 preparer := usecase.NewPrepareDownloadUseCase(nil)186 preparer := usecase.NewPrepareDownloadUseCase(nil)
186 events := &captureDownloadEvents{}187 events := &captureDownloadEvents{}
187- handler := newDownloadHandler("/tmp/downloads", events, preparer)188+ handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
188 callback := &stubBeforeDownloadCallback{}189 callback := &stubBeforeDownloadCallback{}
189 190
190 item := stubDownloadItem{191 item := stubDownloadItem{
@@ -199,7 +200,7 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
199 item.fullPath = callback.path200 item.fullPath = callback.path
200 item.interrupted = true201 item.interrupted = true
201 item.reason = purecef.DownloadInterruptReasonServerBadContent202 item.reason = purecef.DownloadInterruptReasonServerBadContent
202- handler.onDownloadUpdated(ctx, item, nil)203+ handler.onDownloadUpdated(ctx, nil, item, nil)
203 204
204 require.Len(t, events.events, 2)205 require.Len(t, events.events, 2)
205 require.Error(t, events.events[1].Error)206 require.Error(t, events.events[1].Error)
@@ -207,3 +208,32 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
207 require.ErrorContains(t, events.events[1].Error, expectedCode)208 require.ErrorContains(t, events.events[1].Error, expectedCode)
208 require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")209 require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
209 }210 }
211+
212+func TestDownloadHandlerTracksOwnersByBrowserAndID(t *testing.T) {
213+ ctx := context.Background()
214+ preparer := usecase.NewPrepareDownloadUseCase(nil)
215+ events := &captureDownloadEvents{}
216+ tracker := NewRuntimeActivityTracker()
217+ handler := newDownloadHandler(t.TempDir(), events, preparer, tracker)
218+ callback := &stubBeforeDownloadCallback{}
219+
220+ first := cefmocks.NewMockBrowser(t)
221+ first.EXPECT().GetIdentifier().Return(int32(7))
222+ second := cefmocks.NewMockBrowser(t)
223+ second.EXPECT().GetIdentifier().Return(int32(9))
224+
225+ // Same filename from two owners are distinct activity entries.
226+ require.True(t, handler.onBeforeDownload(ctx, first,
227+ stubDownloadItem{id: 11, url: "https://example.com/a.bin", suggested: "a.bin"}, "a.bin", callback))
228+ require.True(t, handler.onBeforeDownload(ctx, second,
229+ stubDownloadItem{id: 12, url: "https://other.example/a.bin", suggested: "a.bin"}, "a.bin", callback))
230+ require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
231+
232+ // Terminal for one ID leaves the other owner outstanding.
233+ handler.onDownloadUpdated(ctx, first, stubDownloadItem{id: 11, complete: true, fullPath: "/tmp/a.bin"}, nil)
234+ require.Equal(t, 1, tracker.Snapshot().ActiveDownloads)
235+
236+ // Lost owners reconcile through the native lifecycle, never removal.
237+ tracker.DropBrowser(9)
238+ require.True(t, tracker.Snapshot().Quiescent())
239+}
M internal/infrastructure/cef/engine.go +29 -8
@@ -62,6 +62,11 @@ type Engine struct {
6262 browserWebViews sync.Map // map[int32]*WebView
6363 activeCount atomic.Int32
6464
65+ // activity tracks outstanding native runtime work for the residency
66+ // quiescence boundary. Initialized by NewEngine; nil in unit-test
67+ // literals, where tracker methods are safe no-ops.
68+ activity *RuntimeActivityTracker
69+
6570 // shutdownNotify is signaled when the active webview count reaches 0
6671 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
6772 shutdownNotify chan struct{}
@@ -87,6 +92,16 @@ type Engine struct {
8792 browserCreateComplete atomic.Bool
8893 }
8994
95+// RuntimeActivity exposes the engine's outstanding-work tracker through
96+// the application port. UI subscribes at startup and unsubscribes at
97+// shutdown; the initial snapshot is race-safe and sequenced.
98+func (e *Engine) RuntimeActivity() port.RuntimeActivity {
99+ if e == nil || e.activity == nil {
100+ return NewRuntimeActivityTracker()
101+ }
102+ return e.activity
103+}
104+
90105 func (e *Engine) Factory() port.WebViewFactory {
91106 return &webViewFactoryAdapter{factory: e.factory}
92107 }
@@ -162,6 +177,7 @@ func (e *Engine) recordGPURelaunch() {
162177 func (e *Engine) registerWebView(wv *WebView) {
163178 e.activeWebViews.Store(wv.id, wv)
164179 e.activeCount.Add(1)
180+ e.activity.NoteViewRegistered()
165181 }
166182
167183 func (e *Engine) lookupWebView(id port.WebViewID) *WebView {
@@ -204,6 +220,7 @@ func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
204220 func (e *Engine) unregisterWebView(wv *WebView, browserID int32) {
205221 e.activeWebViews.Delete(wv.id)
206222 e.unbindBrowserWebView(browserID, wv)
223+ e.activity.DropBrowser(browserID)
207224 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
208225 select {
209226 case e.shutdownNotify <- struct{}{}:
@@ -304,6 +321,7 @@ func (e *Engine) closeActiveWebViews() {
304321 func (e *Engine) destroyClosedWebViewBridges(webViews []*WebView) {
305322 for _, wv := range webViews {
306323 if wv != nil && wv.destroyed.Load() {
324+ // Completion accounting lands in destroyViewBridgeOnGTKThread.
307325 wv.destroyViewBridgeOnGTKSync()
308326 }
309327 }
@@ -340,7 +358,7 @@ func (e *Engine) ConfigureDownloads(
340358 return fmt.Errorf("cef: download preparer is required")
341359 }
342360 e.downloadMu.Lock()
343- e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)
361+ e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer, e.activity)
344362 e.downloadMu.Unlock()
345363 return nil
346364 }
@@ -503,18 +521,21 @@ func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
503521 Int32("height", height).
504522 Int32("result", result).
505523 Msg("cef: BrowserHostCreateBrowser returned")
506- if result != 1 {
507- logging.FromContext(e.ctx).Warn().
508- Uint64("request_count", count).
509- Int32("width", width).
510- Int32("height", height).
511- Int32("result", result).
512- Msg("cef: BrowserHostCreateBrowser returned non-success")
524+ if result == 1 {
525+ e.activity.NoteCreationAccepted()
526+ return
513527 }
528+ logging.FromContext(e.ctx).Warn().
529+ Uint64("request_count", count).
530+ Int32("width", width).
531+ Int32("height", height).
532+ Int32("result", result).
533+ Msg("cef: BrowserHostCreateBrowser returned non-success")
514534 }
515535
516536 func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
517537 count := e.browserAfterCreated.Add(1)
538+ e.activity.NoteCreationResolved()
518539 if count >= e.browserCreateRequests.Load() {
519540 e.browserCreateComplete.Store(true)
520541 }
@@ -62,6 +62,11 @@ type Engine struct {
62 browserWebViews sync.Map // map[int32]*WebView62 browserWebViews sync.Map // map[int32]*WebView
63 activeCount atomic.Int3263 activeCount atomic.Int32
64 64
65+ // activity tracks outstanding native runtime work for the residency
66+ // quiescence boundary. Initialized by NewEngine; nil in unit-test
67+ // literals, where tracker methods are safe no-ops.
68+ activity *RuntimeActivityTracker
69+
65 // shutdownNotify is signaled when the active webview count reaches 070 // shutdownNotify is signaled when the active webview count reaches 0
66 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.71 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
67 shutdownNotify chan struct{}72 shutdownNotify chan struct{}
@@ -87,6 +92,16 @@ type Engine struct {
87 browserCreateComplete atomic.Bool92 browserCreateComplete atomic.Bool
88 }93 }
89 94
95+// RuntimeActivity exposes the engine's outstanding-work tracker through
96+// the application port. UI subscribes at startup and unsubscribes at
97+// shutdown; the initial snapshot is race-safe and sequenced.
98+func (e *Engine) RuntimeActivity() port.RuntimeActivity {
99+ if e == nil || e.activity == nil {
100+ return NewRuntimeActivityTracker()
101+ }
102+ return e.activity
103+}
104+
90 func (e *Engine) Factory() port.WebViewFactory {105 func (e *Engine) Factory() port.WebViewFactory {
91 return &webViewFactoryAdapter{factory: e.factory}106 return &webViewFactoryAdapter{factory: e.factory}
92 }107 }
@@ -162,6 +177,7 @@ func (e *Engine) recordGPURelaunch() {
162 func (e *Engine) registerWebView(wv *WebView) {177 func (e *Engine) registerWebView(wv *WebView) {
163 e.activeWebViews.Store(wv.id, wv)178 e.activeWebViews.Store(wv.id, wv)
164 e.activeCount.Add(1)179 e.activeCount.Add(1)
180+ e.activity.NoteViewRegistered()
165 }181 }
166 182
167 func (e *Engine) lookupWebView(id port.WebViewID) *WebView {183 func (e *Engine) lookupWebView(id port.WebViewID) *WebView {
@@ -204,6 +220,7 @@ func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) {
204 func (e *Engine) unregisterWebView(wv *WebView, browserID int32) {220 func (e *Engine) unregisterWebView(wv *WebView, browserID int32) {
205 e.activeWebViews.Delete(wv.id)221 e.activeWebViews.Delete(wv.id)
206 e.unbindBrowserWebView(browserID, wv)222 e.unbindBrowserWebView(browserID, wv)
223+ e.activity.DropBrowser(browserID)
207 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {224 if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil {
208 select {225 select {
209 case e.shutdownNotify <- struct{}{}:226 case e.shutdownNotify <- struct{}{}:
@@ -304,6 +321,7 @@ func (e *Engine) closeActiveWebViews() {
304 func (e *Engine) destroyClosedWebViewBridges(webViews []*WebView) {321 func (e *Engine) destroyClosedWebViewBridges(webViews []*WebView) {
305 for _, wv := range webViews {322 for _, wv := range webViews {
306 if wv != nil && wv.destroyed.Load() {323 if wv != nil && wv.destroyed.Load() {
324+ // Completion accounting lands in destroyViewBridgeOnGTKThread.
307 wv.destroyViewBridgeOnGTKSync()325 wv.destroyViewBridgeOnGTKSync()
308 }326 }
309 }327 }
@@ -340,7 +358,7 @@ func (e *Engine) ConfigureDownloads(
340 return fmt.Errorf("cef: download preparer is required")358 return fmt.Errorf("cef: download preparer is required")
341 }359 }
342 e.downloadMu.Lock()360 e.downloadMu.Lock()
343- e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)361+ e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer, e.activity)
344 e.downloadMu.Unlock()362 e.downloadMu.Unlock()
345 return nil363 return nil
346 }364 }
@@ -503,18 +521,21 @@ func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
503 Int32("height", height).521 Int32("height", height).
504 Int32("result", result).522 Int32("result", result).
505 Msg("cef: BrowserHostCreateBrowser returned")523 Msg("cef: BrowserHostCreateBrowser returned")
506- if result != 1 {524+ if result == 1 {
507- logging.FromContext(e.ctx).Warn().525+ e.activity.NoteCreationAccepted()
508- Uint64("request_count", count).526+ return
509- Int32("width", width).
510- Int32("height", height).
511- Int32("result", result).
512- Msg("cef: BrowserHostCreateBrowser returned non-success")
513 }527 }
528+ logging.FromContext(e.ctx).Warn().
529+ Uint64("request_count", count).
530+ Int32("width", width).
531+ Int32("height", height).
532+ Int32("result", result).
533+ Msg("cef: BrowserHostCreateBrowser returned non-success")
514 }534 }
515 535
516 func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {536 func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
517 count := e.browserAfterCreated.Add(1)537 count := e.browserAfterCreated.Add(1)
538+ e.activity.NoteCreationResolved()
518 if count >= e.browserCreateRequests.Load() {539 if count >= e.browserCreateRequests.Load() {
519 e.browserCreateComplete.Store(true)540 e.browserCreateComplete.Store(true)
520 }541 }
M internal/infrastructure/cef/engine_init.go +1 -0
@@ -84,6 +84,7 @@ func NewEngine(
8484 ctxMenuRenderer: deps.ContextMenuRenderer,
8585 clipboard: deps.Clipboard,
8686 resolver: deps.ImageDataResolver,
87+ activity: NewRuntimeActivityTracker(),
8788 }
8889
8990 logger.Info().
@@ -84,6 +84,7 @@ func NewEngine(
84 ctxMenuRenderer: deps.ContextMenuRenderer,84 ctxMenuRenderer: deps.ContextMenuRenderer,
85 clipboard: deps.Clipboard,85 clipboard: deps.Clipboard,
86 resolver: deps.ImageDataResolver,86 resolver: deps.ImageDataResolver,
87+ activity: NewRuntimeActivityTracker(),
87 }88 }
88 89
89 logger.Info().90 logger.Info().
M internal/infrastructure/cef/pool.go +13 -0
@@ -110,6 +110,19 @@ func (p *WebViewPool) Size() int {
110110 return len(p.pool)
111111 }
112112
113+// pooledViewBrowserReady reports whether a pooled reserve holds a live
114+// browser. Wrapper-only reserves (allocated struct, no OnAfterCreated yet)
115+// are never reported browser-ready. Viewport validity and first blank frame
116+// require native validation and stay outside this predicate.
117+func pooledViewBrowserReady(wv *WebView) bool {
118+ if wv == nil || wv.destroyed.Load() {
119+ return false
120+ }
121+ wv.mu.RLock()
122+ defer wv.mu.RUnlock()
123+ return wv.browser != nil
124+}
125+
113126 // Close shuts down the pool and destroys all pooled WebViews.
114127 func (p *WebViewPool) Close() {
115128 p.mu.Lock()
@@ -110,6 +110,19 @@ func (p *WebViewPool) Size() int {
110 return len(p.pool)110 return len(p.pool)
111 }111 }
112 112
113+// pooledViewBrowserReady reports whether a pooled reserve holds a live
114+// browser. Wrapper-only reserves (allocated struct, no OnAfterCreated yet)
115+// are never reported browser-ready. Viewport validity and first blank frame
116+// require native validation and stay outside this predicate.
117+func pooledViewBrowserReady(wv *WebView) bool {
118+ if wv == nil || wv.destroyed.Load() {
119+ return false
120+ }
121+ wv.mu.RLock()
122+ defer wv.mu.RUnlock()
123+ return wv.browser != nil
124+}
125+
113 // Close shuts down the pool and destroys all pooled WebViews.126 // Close shuts down the pool and destroys all pooled WebViews.
114 func (p *WebViewPool) Close() {127 func (p *WebViewPool) Close() {
115 p.mu.Lock()128 p.mu.Lock()
A internal/infrastructure/cef/pool_test.go +39 -0
@@ -0,0 +1,39 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestPoolZeroCountCreatesNothing(t *testing.T) {
12+ pool := &WebViewPool{}
13+ pool.Prewarm(0)
14+ pool.Prewarm(-3)
15+ pool.PrewarmAsync(context.Background(), 0)
16+ require.Equal(t, 0, pool.Size())
17+}
18+
19+func TestPoolReserveReadinessRequiresBrowser(t *testing.T) {
20+ // A wrapper-only reserve (no browser yet) is never browser-ready.
21+ require.False(t, pooledViewBrowserReady(nil))
22+ require.False(t, pooledViewBrowserReady(&WebView{}))
23+
24+ browser := cefmocks.NewMockBrowser(t)
25+ wv := &WebView{ctx: context.Background(), browser: browser}
26+ require.True(t, pooledViewBrowserReady(wv))
27+
28+ wv.Destroy()
29+ require.False(t, pooledViewBrowserReady(wv), "destroyed views leave the ready set")
30+}
31+
32+func TestPoolReleaseDestroysAndIgnoresNil(t *testing.T) {
33+ pool := &WebViewPool{}
34+ require.NotPanics(t, func() { pool.Release(nil) })
35+
36+ wv := &WebView{ctx: context.Background()}
37+ pool.Release(wv)
38+ require.True(t, wv.destroyed.Load(), "Release destroys user-loaded views")
39+}
@@ -0,0 +1,39 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestPoolZeroCountCreatesNothing(t *testing.T) {
12+ pool := &WebViewPool{}
13+ pool.Prewarm(0)
14+ pool.Prewarm(-3)
15+ pool.PrewarmAsync(context.Background(), 0)
16+ require.Equal(t, 0, pool.Size())
17+}
18+
19+func TestPoolReserveReadinessRequiresBrowser(t *testing.T) {
20+ // A wrapper-only reserve (no browser yet) is never browser-ready.
21+ require.False(t, pooledViewBrowserReady(nil))
22+ require.False(t, pooledViewBrowserReady(&WebView{}))
23+
24+ browser := cefmocks.NewMockBrowser(t)
25+ wv := &WebView{ctx: context.Background(), browser: browser}
26+ require.True(t, pooledViewBrowserReady(wv))
27+
28+ wv.Destroy()
29+ require.False(t, pooledViewBrowserReady(wv), "destroyed views leave the ready set")
30+}
31+
32+func TestPoolReleaseDestroysAndIgnoresNil(t *testing.T) {
33+ pool := &WebViewPool{}
34+ require.NotPanics(t, func() { pool.Release(nil) })
35+
36+ wv := &WebView{ctx: context.Background()}
37+ pool.Release(wv)
38+ require.True(t, wv.destroyed.Load(), "Release destroys user-loaded views")
39+}
A internal/infrastructure/cef/runtime_activity.go +207 -0
@@ -0,0 +1,207 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+// downloadOwner identifies one download by its owning browser and the CEF
10+// download ID. Filename and destination are never identity: they can collide
11+// or change mid-download.
12+type downloadOwner struct {
13+ browserID int32
14+ downloadID uint32
15+}
16+
17+// RuntimeActivityTracker implements port.RuntimeActivity for the CEF engine.
18+// It counts accepted creations through OnAfterCreated/failure, registered
19+// live views, outstanding GTK cleanup, and unterminated downloads. All
20+// methods are safe for concurrent use from CEF callbacks. Delivery is
21+// serialized under the state lock, so the initial snapshot and every later
22+// transition arrive in order; subscribers must not reenter the tracker.
23+// A nil tracker is a safe no-op sink.
24+type RuntimeActivityTracker struct {
25+ mu sync.Mutex
26+ pending int
27+ active int
28+ cleanup int
29+ downloads map[downloadOwner]struct{}
30+ subscribers map[uint64]func(port.RuntimeActivitySnapshot)
31+ nextSub uint64
32+}
33+
34+// NewRuntimeActivityTracker returns an idle tracker.
35+func NewRuntimeActivityTracker() *RuntimeActivityTracker {
36+ return &RuntimeActivityTracker{
37+ downloads: make(map[downloadOwner]struct{}),
38+ subscribers: make(map[uint64]func(port.RuntimeActivitySnapshot)),
39+ }
40+}
41+
42+// Snapshot returns the current outstanding-work counts.
43+func (t *RuntimeActivityTracker) Snapshot() port.RuntimeActivitySnapshot {
44+ if t == nil {
45+ return port.RuntimeActivitySnapshot{}
46+ }
47+ t.mu.Lock()
48+ defer t.mu.Unlock()
49+ return t.snapshotLocked()
50+}
51+
52+// Subscribe delivers the current snapshot synchronously, then every later
53+// transition in subscription order. Delivery holds the state lock, so
54+// callbacks must not call back into the tracker. The returned function
55+// unsubscribes.
56+func (t *RuntimeActivityTracker) Subscribe(fn func(port.RuntimeActivitySnapshot)) (unsubscribe func()) {
57+ if t == nil || fn == nil {
58+ return func() {}
59+ }
60+ t.mu.Lock()
61+ defer t.mu.Unlock()
62+ id := t.nextSub
63+ t.nextSub++
64+ t.subscribers[id] = fn
65+ fn(t.snapshotLocked())
66+ return func() {
67+ t.mu.Lock()
68+ defer t.mu.Unlock()
69+ delete(t.subscribers, id)
70+ }
71+}
72+
73+// NoteCreationAccepted records one accepted browser creation.
74+func (t *RuntimeActivityTracker) NoteCreationAccepted() {
75+ if t == nil {
76+ return
77+ }
78+ t.mu.Lock()
79+ defer t.mu.Unlock()
80+ t.pending++
81+ t.notifyLocked()
82+}
83+
84+// NoteCreationResolved records one creation resolved through OnAfterCreated
85+// (registered) or a definitive failure. Over-resolution is clamped at zero.
86+func (t *RuntimeActivityTracker) NoteCreationResolved() {
87+ if t == nil {
88+ return
89+ }
90+ t.mu.Lock()
91+ defer t.mu.Unlock()
92+ if t.pending > 0 {
93+ t.pending--
94+ }
95+ t.notifyLocked()
96+}
97+
98+// NoteViewRegistered records one live view registration.
99+func (t *RuntimeActivityTracker) NoteViewRegistered() {
100+ if t == nil {
101+ return
102+ }
103+ t.mu.Lock()
104+ defer t.mu.Unlock()
105+ t.active++
106+ t.notifyLocked()
107+}
108+
109+// NoteViewCloseStarted records one native close: the view leaves the active
110+// set and enters GTK-teardown outstanding.
111+func (t *RuntimeActivityTracker) NoteViewCloseStarted() {
112+ if t == nil {
113+ return
114+ }
115+ t.mu.Lock()
116+ defer t.mu.Unlock()
117+ if t.active > 0 {
118+ t.active--
119+ }
120+ t.cleanup++
121+ t.notifyLocked()
122+}
123+
124+// NoteCleanupCompleted records one finished GTK bridge/popup cleanup.
125+func (t *RuntimeActivityTracker) NoteCleanupCompleted() {
126+ if t == nil {
127+ return
128+ }
129+ t.mu.Lock()
130+ defer t.mu.Unlock()
131+ if t.cleanup > 0 {
132+ t.cleanup--
133+ }
134+ t.notifyLocked()
135+}
136+
137+// NoteDownloadStarted records one download start keyed by owning browser and
138+// CEF download ID.
139+func (t *RuntimeActivityTracker) NoteDownloadStarted(browserID int32, downloadID uint32) {
140+ if t == nil {
141+ return
142+ }
143+ t.mu.Lock()
144+ defer t.mu.Unlock()
145+ t.downloads[downloadOwner{browserID: browserID, downloadID: downloadID}] = struct{}{}
146+ t.notifyLocked()
147+}
148+
149+// NoteDownloadProgress is observed without effect: only start and terminal
150+// events change activity. Progress without a start never creates work.
151+func (t *RuntimeActivityTracker) NoteDownloadProgress(_ int32, _ uint32) {}
152+
153+// NoteDownloadTerminal records one terminal download event (complete,
154+// cancel, or failure) for the exact owning browser. Duplicate terminals are
155+// idempotent; terminals for unknown owners change nothing, so one browser
156+// completing a download ID never clears another browser's same ID.
157+func (t *RuntimeActivityTracker) NoteDownloadTerminal(browserID int32, downloadID uint32) {
158+ if t == nil {
159+ return
160+ }
161+ t.mu.Lock()
162+ defer t.mu.Unlock()
163+ delete(t.downloads, downloadOwner{browserID: browserID, downloadID: downloadID})
164+ t.notifyLocked()
165+}
166+
167+// DropBrowser reconciles owner destruction through the native lifecycle:
168+// entries owned by a destroyed browser can never receive terminal events.
169+// It is never driven by window removal alone.
170+func (t *RuntimeActivityTracker) DropBrowser(browserID int32) {
171+ if t == nil {
172+ return
173+ }
174+ t.mu.Lock()
175+ defer t.mu.Unlock()
176+ changed := false
177+ for owner := range t.downloads {
178+ if owner.browserID == browserID {
179+ delete(t.downloads, owner)
180+ changed = true
181+ }
182+ }
183+ if changed {
184+ t.notifyLocked()
185+ }
186+}
187+
188+func (t *RuntimeActivityTracker) snapshotLocked() port.RuntimeActivitySnapshot {
189+ return port.RuntimeActivitySnapshot{
190+ PendingCreations: t.pending,
191+ ActiveViews: t.active,
192+ PendingCleanup: t.cleanup,
193+ ActiveDownloads: len(t.downloads),
194+ }
195+}
196+
197+// notifyLocked delivers the current snapshot to subscribers in subscription
198+// order. The caller must hold t.mu and must not reenter the tracker from a
199+// callback.
200+func (t *RuntimeActivityTracker) notifyLocked() {
201+ snapshot := t.snapshotLocked()
202+ for id := uint64(0); id < t.nextSub; id++ {
203+ if fn, ok := t.subscribers[id]; ok {
204+ fn(snapshot)
205+ }
206+ }
207+}
@@ -0,0 +1,207 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+// downloadOwner identifies one download by its owning browser and the CEF
10+// download ID. Filename and destination are never identity: they can collide
11+// or change mid-download.
12+type downloadOwner struct {
13+ browserID int32
14+ downloadID uint32
15+}
16+
17+// RuntimeActivityTracker implements port.RuntimeActivity for the CEF engine.
18+// It counts accepted creations through OnAfterCreated/failure, registered
19+// live views, outstanding GTK cleanup, and unterminated downloads. All
20+// methods are safe for concurrent use from CEF callbacks. Delivery is
21+// serialized under the state lock, so the initial snapshot and every later
22+// transition arrive in order; subscribers must not reenter the tracker.
23+// A nil tracker is a safe no-op sink.
24+type RuntimeActivityTracker struct {
25+ mu sync.Mutex
26+ pending int
27+ active int
28+ cleanup int
29+ downloads map[downloadOwner]struct{}
30+ subscribers map[uint64]func(port.RuntimeActivitySnapshot)
31+ nextSub uint64
32+}
33+
34+// NewRuntimeActivityTracker returns an idle tracker.
35+func NewRuntimeActivityTracker() *RuntimeActivityTracker {
36+ return &RuntimeActivityTracker{
37+ downloads: make(map[downloadOwner]struct{}),
38+ subscribers: make(map[uint64]func(port.RuntimeActivitySnapshot)),
39+ }
40+}
41+
42+// Snapshot returns the current outstanding-work counts.
43+func (t *RuntimeActivityTracker) Snapshot() port.RuntimeActivitySnapshot {
44+ if t == nil {
45+ return port.RuntimeActivitySnapshot{}
46+ }
47+ t.mu.Lock()
48+ defer t.mu.Unlock()
49+ return t.snapshotLocked()
50+}
51+
52+// Subscribe delivers the current snapshot synchronously, then every later
53+// transition in subscription order. Delivery holds the state lock, so
54+// callbacks must not call back into the tracker. The returned function
55+// unsubscribes.
56+func (t *RuntimeActivityTracker) Subscribe(fn func(port.RuntimeActivitySnapshot)) (unsubscribe func()) {
57+ if t == nil || fn == nil {
58+ return func() {}
59+ }
60+ t.mu.Lock()
61+ defer t.mu.Unlock()
62+ id := t.nextSub
63+ t.nextSub++
64+ t.subscribers[id] = fn
65+ fn(t.snapshotLocked())
66+ return func() {
67+ t.mu.Lock()
68+ defer t.mu.Unlock()
69+ delete(t.subscribers, id)
70+ }
71+}
72+
73+// NoteCreationAccepted records one accepted browser creation.
74+func (t *RuntimeActivityTracker) NoteCreationAccepted() {
75+ if t == nil {
76+ return
77+ }
78+ t.mu.Lock()
79+ defer t.mu.Unlock()
80+ t.pending++
81+ t.notifyLocked()
82+}
83+
84+// NoteCreationResolved records one creation resolved through OnAfterCreated
85+// (registered) or a definitive failure. Over-resolution is clamped at zero.
86+func (t *RuntimeActivityTracker) NoteCreationResolved() {
87+ if t == nil {
88+ return
89+ }
90+ t.mu.Lock()
91+ defer t.mu.Unlock()
92+ if t.pending > 0 {
93+ t.pending--
94+ }
95+ t.notifyLocked()
96+}
97+
98+// NoteViewRegistered records one live view registration.
99+func (t *RuntimeActivityTracker) NoteViewRegistered() {
100+ if t == nil {
101+ return
102+ }
103+ t.mu.Lock()
104+ defer t.mu.Unlock()
105+ t.active++
106+ t.notifyLocked()
107+}
108+
109+// NoteViewCloseStarted records one native close: the view leaves the active
110+// set and enters GTK-teardown outstanding.
111+func (t *RuntimeActivityTracker) NoteViewCloseStarted() {
112+ if t == nil {
113+ return
114+ }
115+ t.mu.Lock()
116+ defer t.mu.Unlock()
117+ if t.active > 0 {
118+ t.active--
119+ }
120+ t.cleanup++
121+ t.notifyLocked()
122+}
123+
124+// NoteCleanupCompleted records one finished GTK bridge/popup cleanup.
125+func (t *RuntimeActivityTracker) NoteCleanupCompleted() {
126+ if t == nil {
127+ return
128+ }
129+ t.mu.Lock()
130+ defer t.mu.Unlock()
131+ if t.cleanup > 0 {
132+ t.cleanup--
133+ }
134+ t.notifyLocked()
135+}
136+
137+// NoteDownloadStarted records one download start keyed by owning browser and
138+// CEF download ID.
139+func (t *RuntimeActivityTracker) NoteDownloadStarted(browserID int32, downloadID uint32) {
140+ if t == nil {
141+ return
142+ }
143+ t.mu.Lock()
144+ defer t.mu.Unlock()
145+ t.downloads[downloadOwner{browserID: browserID, downloadID: downloadID}] = struct{}{}
146+ t.notifyLocked()
147+}
148+
149+// NoteDownloadProgress is observed without effect: only start and terminal
150+// events change activity. Progress without a start never creates work.
151+func (t *RuntimeActivityTracker) NoteDownloadProgress(_ int32, _ uint32) {}
152+
153+// NoteDownloadTerminal records one terminal download event (complete,
154+// cancel, or failure) for the exact owning browser. Duplicate terminals are
155+// idempotent; terminals for unknown owners change nothing, so one browser
156+// completing a download ID never clears another browser's same ID.
157+func (t *RuntimeActivityTracker) NoteDownloadTerminal(browserID int32, downloadID uint32) {
158+ if t == nil {
159+ return
160+ }
161+ t.mu.Lock()
162+ defer t.mu.Unlock()
163+ delete(t.downloads, downloadOwner{browserID: browserID, downloadID: downloadID})
164+ t.notifyLocked()
165+}
166+
167+// DropBrowser reconciles owner destruction through the native lifecycle:
168+// entries owned by a destroyed browser can never receive terminal events.
169+// It is never driven by window removal alone.
170+func (t *RuntimeActivityTracker) DropBrowser(browserID int32) {
171+ if t == nil {
172+ return
173+ }
174+ t.mu.Lock()
175+ defer t.mu.Unlock()
176+ changed := false
177+ for owner := range t.downloads {
178+ if owner.browserID == browserID {
179+ delete(t.downloads, owner)
180+ changed = true
181+ }
182+ }
183+ if changed {
184+ t.notifyLocked()
185+ }
186+}
187+
188+func (t *RuntimeActivityTracker) snapshotLocked() port.RuntimeActivitySnapshot {
189+ return port.RuntimeActivitySnapshot{
190+ PendingCreations: t.pending,
191+ ActiveViews: t.active,
192+ PendingCleanup: t.cleanup,
193+ ActiveDownloads: len(t.downloads),
194+ }
195+}
196+
197+// notifyLocked delivers the current snapshot to subscribers in subscription
198+// order. The caller must hold t.mu and must not reenter the tracker from a
199+// callback.
200+func (t *RuntimeActivityTracker) notifyLocked() {
201+ snapshot := t.snapshotLocked()
202+ for id := uint64(0); id < t.nextSub; id++ {
203+ if fn, ok := t.subscribers[id]; ok {
204+ fn(snapshot)
205+ }
206+ }
207+}
A internal/infrastructure/cef/runtime_activity_test.go +114 -0
@@ -0,0 +1,114 @@
1+package cef
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRuntimeActivityTrackerStartsQuiescent(t *testing.T) {
12+ tracker := NewRuntimeActivityTracker()
13+ require.True(t, tracker.Snapshot().Quiescent())
14+}
15+
16+func TestRuntimeActivityTrackerCreationLifecycle(t *testing.T) {
17+ tracker := NewRuntimeActivityTracker()
18+ tracker.NoteCreationAccepted()
19+ require.Equal(t, 1, tracker.Snapshot().PendingCreations)
20+ require.False(t, tracker.Snapshot().Quiescent())
21+
22+ tracker.NoteCreationResolved()
23+ require.True(t, tracker.Snapshot().Quiescent())
24+
25+ // Over-resolution clamps instead of going negative.
26+ tracker.NoteCreationResolved()
27+ require.Equal(t, 0, tracker.Snapshot().PendingCreations)
28+}
29+
30+func TestRuntimeActivityTrackerViewLifecycle(t *testing.T) {
31+ tracker := NewRuntimeActivityTracker()
32+ tracker.NoteViewRegistered()
33+ tracker.NoteViewRegistered()
34+ require.Equal(t, 2, tracker.Snapshot().ActiveViews)
35+
36+ tracker.NoteViewCloseStarted()
37+ snapshot := tracker.Snapshot()
38+ require.Equal(t, 1, snapshot.ActiveViews)
39+ require.Equal(t, 1, snapshot.PendingCleanup)
40+ require.False(t, snapshot.Quiescent())
41+
42+ tracker.NoteCleanupCompleted()
43+ tracker.NoteViewCloseStarted()
44+ tracker.NoteCleanupCompleted()
45+ require.True(t, tracker.Snapshot().Quiescent())
46+}
47+
48+func TestRuntimeActivityTrackerDownloads(t *testing.T) {
49+ tracker := NewRuntimeActivityTracker()
50+
51+ // Same filename on two browsers are distinct owners.
52+ tracker.NoteDownloadStarted(7, 100)
53+ tracker.NoteDownloadStarted(9, 100)
54+ require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
55+
56+ // Progress without a start never creates work.
57+ tracker.NoteDownloadProgress(7, 999)
58+ require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
59+
60+ // Terminal resolves only the exact owner sharing the CEF download ID.
61+ tracker.NoteDownloadTerminal(7, 100)
62+ require.Equal(t, 1, tracker.Snapshot().ActiveDownloads)
63+
64+ tracker.NoteDownloadTerminal(9, 100)
65+ require.Equal(t, 0, tracker.Snapshot().ActiveDownloads)
66+
67+ // Duplicate terminals are idempotent; unknown owners change nothing.
68+ tracker.NoteDownloadTerminal(9, 100)
69+ tracker.NoteDownloadTerminal(7, 424242)
70+ require.True(t, tracker.Snapshot().Quiescent())
71+}
72+
73+func TestRuntimeActivityTrackerDropBrowser(t *testing.T) {
74+ tracker := NewRuntimeActivityTracker()
75+ tracker.NoteDownloadStarted(7, 100)
76+ tracker.NoteDownloadStarted(9, 200)
77+ tracker.DropBrowser(7)
78+ snapshot := tracker.Snapshot()
79+ require.Equal(t, 1, snapshot.ActiveDownloads)
80+
81+ tracker.NoteDownloadTerminal(9, 200)
82+ require.True(t, tracker.Snapshot().Quiescent())
83+
84+ // Dropping an unknown browser notifies nobody and changes nothing.
85+ tracker.DropBrowser(12345)
86+ require.True(t, tracker.Snapshot().Quiescent())
87+}
88+
89+func TestRuntimeActivityTrackerSubscribeSequenced(t *testing.T) {
90+ tracker := NewRuntimeActivityTracker()
91+ var mu sync.Mutex
92+ var seen []port.RuntimeActivitySnapshot
93+ unsubscribe := tracker.Subscribe(func(snapshot port.RuntimeActivitySnapshot) {
94+ mu.Lock()
95+ seen = append(seen, snapshot)
96+ mu.Unlock()
97+ })
98+ // Initial delivery is synchronous and quiescent.
99+ mu.Lock()
100+ require.Len(t, seen, 1)
101+ require.True(t, seen[0].Quiescent())
102+ mu.Unlock()
103+
104+ tracker.NoteCreationAccepted()
105+ tracker.NoteCreationResolved()
106+ unsubscribe()
107+ tracker.NoteViewRegistered()
108+
109+ mu.Lock()
110+ defer mu.Unlock()
111+ require.Len(t, seen, 3, "unsubscribe stops delivery")
112+ require.Equal(t, 1, seen[1].PendingCreations)
113+ require.True(t, seen[2].Quiescent())
114+}
@@ -0,0 +1,114 @@
1+package cef
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRuntimeActivityTrackerStartsQuiescent(t *testing.T) {
12+ tracker := NewRuntimeActivityTracker()
13+ require.True(t, tracker.Snapshot().Quiescent())
14+}
15+
16+func TestRuntimeActivityTrackerCreationLifecycle(t *testing.T) {
17+ tracker := NewRuntimeActivityTracker()
18+ tracker.NoteCreationAccepted()
19+ require.Equal(t, 1, tracker.Snapshot().PendingCreations)
20+ require.False(t, tracker.Snapshot().Quiescent())
21+
22+ tracker.NoteCreationResolved()
23+ require.True(t, tracker.Snapshot().Quiescent())
24+
25+ // Over-resolution clamps instead of going negative.
26+ tracker.NoteCreationResolved()
27+ require.Equal(t, 0, tracker.Snapshot().PendingCreations)
28+}
29+
30+func TestRuntimeActivityTrackerViewLifecycle(t *testing.T) {
31+ tracker := NewRuntimeActivityTracker()
32+ tracker.NoteViewRegistered()
33+ tracker.NoteViewRegistered()
34+ require.Equal(t, 2, tracker.Snapshot().ActiveViews)
35+
36+ tracker.NoteViewCloseStarted()
37+ snapshot := tracker.Snapshot()
38+ require.Equal(t, 1, snapshot.ActiveViews)
39+ require.Equal(t, 1, snapshot.PendingCleanup)
40+ require.False(t, snapshot.Quiescent())
41+
42+ tracker.NoteCleanupCompleted()
43+ tracker.NoteViewCloseStarted()
44+ tracker.NoteCleanupCompleted()
45+ require.True(t, tracker.Snapshot().Quiescent())
46+}
47+
48+func TestRuntimeActivityTrackerDownloads(t *testing.T) {
49+ tracker := NewRuntimeActivityTracker()
50+
51+ // Same filename on two browsers are distinct owners.
52+ tracker.NoteDownloadStarted(7, 100)
53+ tracker.NoteDownloadStarted(9, 100)
54+ require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
55+
56+ // Progress without a start never creates work.
57+ tracker.NoteDownloadProgress(7, 999)
58+ require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
59+
60+ // Terminal resolves only the exact owner sharing the CEF download ID.
61+ tracker.NoteDownloadTerminal(7, 100)
62+ require.Equal(t, 1, tracker.Snapshot().ActiveDownloads)
63+
64+ tracker.NoteDownloadTerminal(9, 100)
65+ require.Equal(t, 0, tracker.Snapshot().ActiveDownloads)
66+
67+ // Duplicate terminals are idempotent; unknown owners change nothing.
68+ tracker.NoteDownloadTerminal(9, 100)
69+ tracker.NoteDownloadTerminal(7, 424242)
70+ require.True(t, tracker.Snapshot().Quiescent())
71+}
72+
73+func TestRuntimeActivityTrackerDropBrowser(t *testing.T) {
74+ tracker := NewRuntimeActivityTracker()
75+ tracker.NoteDownloadStarted(7, 100)
76+ tracker.NoteDownloadStarted(9, 200)
77+ tracker.DropBrowser(7)
78+ snapshot := tracker.Snapshot()
79+ require.Equal(t, 1, snapshot.ActiveDownloads)
80+
81+ tracker.NoteDownloadTerminal(9, 200)
82+ require.True(t, tracker.Snapshot().Quiescent())
83+
84+ // Dropping an unknown browser notifies nobody and changes nothing.
85+ tracker.DropBrowser(12345)
86+ require.True(t, tracker.Snapshot().Quiescent())
87+}
88+
89+func TestRuntimeActivityTrackerSubscribeSequenced(t *testing.T) {
90+ tracker := NewRuntimeActivityTracker()
91+ var mu sync.Mutex
92+ var seen []port.RuntimeActivitySnapshot
93+ unsubscribe := tracker.Subscribe(func(snapshot port.RuntimeActivitySnapshot) {
94+ mu.Lock()
95+ seen = append(seen, snapshot)
96+ mu.Unlock()
97+ })
98+ // Initial delivery is synchronous and quiescent.
99+ mu.Lock()
100+ require.Len(t, seen, 1)
101+ require.True(t, seen[0].Quiescent())
102+ mu.Unlock()
103+
104+ tracker.NoteCreationAccepted()
105+ tracker.NoteCreationResolved()
106+ unsubscribe()
107+ tracker.NoteViewRegistered()
108+
109+ mu.Lock()
110+ defer mu.Unlock()
111+ require.Len(t, seen, 3, "unsubscribe stops delivery")
112+ require.Equal(t, 1, seen[1].PendingCreations)
113+ require.True(t, seen[2].Quiescent())
114+}
M internal/infrastructure/cef/webview.go +30 -2
@@ -278,8 +278,12 @@ type WebView struct {
278278 loadDiagLastLoadStateAt time.Time
279279
280280 // Atomic state.
281- destroyed atomic.Bool
282- fullscreen atomic.Bool
281+ destroyed atomic.Bool
282+ fullscreen atomic.Bool
283+ // bridgeTeardownNoted gates quiescence accounting exactly once per
284+ // view: every bridge teardown is scheduled through the Sync/Async
285+ // wrappers below and completes in destroyViewBridgeOnGTKThread.
286+ bridgeTeardownNoted atomic.Bool
283287 generation atomic.Uint64
284288 audioPlaying atomic.Bool
285289 zoomFactor atomic.Value // float64, initialized to 1.0
@@ -1318,6 +1322,7 @@ func (wv *WebView) destroyViewBridgeOnGTKSync() {
13181322 if wv == nil || wv.viewBridge == nil {
13191323 return
13201324 }
1325+ wv.noteBridgeTeardownScheduled()
13211326 wv.runOnGTKSyncLabelAllowLateStart("cef.destroy_view_bridge", true, func() {
13221327 wv.destroyViewBridgeOnGTKThread()
13231328 })
@@ -1327,11 +1332,29 @@ func (wv *WebView) destroyViewBridgeOnGTKAsync() {
13271332 if wv == nil || wv.viewBridge == nil {
13281333 return
13291334 }
1335+ wv.noteBridgeTeardownScheduled()
13301336 wv.runOnGTK(func() {
13311337 wv.destroyViewBridgeOnGTKThread()
13321338 })
13331339 }
13341340
1341+// noteBridgeTeardownScheduled records one outstanding GTK teardown for the
1342+// quiescence boundary. Exactly one caller wins per view however often the
1343+// Sync/Async wrappers race; the matching completion lands in
1344+// destroyViewBridgeOnGTKThread, which runs exactly once per view because it
1345+// nils the bridge on completion.
1346+func (wv *WebView) noteBridgeTeardownScheduled() {
1347+ if wv == nil {
1348+ return
1349+ }
1350+ if !wv.bridgeTeardownNoted.CompareAndSwap(false, true) {
1351+ return
1352+ }
1353+ if wv.engine != nil {
1354+ wv.engine.activity.NoteViewCloseStarted()
1355+ }
1356+}
1357+
13351358 func (wv *WebView) destroyViewBridgeOnGTKThread() {
13361359 if wv == nil || wv.viewBridge == nil {
13371360 return
@@ -1350,6 +1373,11 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
13501373 _ = wv.viewBridge.Destroy()
13511374 wv.viewBridge = nil
13521375 wv.nativeWidget = nil
1376+ // The bridge actually tore down here (second arrivals return early on
1377+ // the nil guard above), completing the outstanding teardown exactly once.
1378+ if wv.engine != nil {
1379+ wv.engine.activity.NoteCleanupCompleted()
1380+ }
13531381 }
13541382
13551383 // ---------------------------------------------------------------------------
@@ -278,8 +278,12 @@ type WebView struct {
278 loadDiagLastLoadStateAt time.Time278 loadDiagLastLoadStateAt time.Time
279 279
280 // Atomic state.280 // Atomic state.
281- destroyed atomic.Bool281+ destroyed atomic.Bool
282- fullscreen atomic.Bool282+ fullscreen atomic.Bool
283+ // bridgeTeardownNoted gates quiescence accounting exactly once per
284+ // view: every bridge teardown is scheduled through the Sync/Async
285+ // wrappers below and completes in destroyViewBridgeOnGTKThread.
286+ bridgeTeardownNoted atomic.Bool
283 generation atomic.Uint64287 generation atomic.Uint64
284 audioPlaying atomic.Bool288 audioPlaying atomic.Bool
285 zoomFactor atomic.Value // float64, initialized to 1.0289 zoomFactor atomic.Value // float64, initialized to 1.0
@@ -1318,6 +1322,7 @@ func (wv *WebView) destroyViewBridgeOnGTKSync() {
1318 if wv == nil || wv.viewBridge == nil {1322 if wv == nil || wv.viewBridge == nil {
1319 return1323 return
1320 }1324 }
1325+ wv.noteBridgeTeardownScheduled()
1321 wv.runOnGTKSyncLabelAllowLateStart("cef.destroy_view_bridge", true, func() {1326 wv.runOnGTKSyncLabelAllowLateStart("cef.destroy_view_bridge", true, func() {
1322 wv.destroyViewBridgeOnGTKThread()1327 wv.destroyViewBridgeOnGTKThread()
1323 })1328 })
@@ -1327,11 +1332,29 @@ func (wv *WebView) destroyViewBridgeOnGTKAsync() {
1327 if wv == nil || wv.viewBridge == nil {1332 if wv == nil || wv.viewBridge == nil {
1328 return1333 return
1329 }1334 }
1335+ wv.noteBridgeTeardownScheduled()
1330 wv.runOnGTK(func() {1336 wv.runOnGTK(func() {
1331 wv.destroyViewBridgeOnGTKThread()1337 wv.destroyViewBridgeOnGTKThread()
1332 })1338 })
1333 }1339 }
1334 1340
1341+// noteBridgeTeardownScheduled records one outstanding GTK teardown for the
1342+// quiescence boundary. Exactly one caller wins per view however often the
1343+// Sync/Async wrappers race; the matching completion lands in
1344+// destroyViewBridgeOnGTKThread, which runs exactly once per view because it
1345+// nils the bridge on completion.
1346+func (wv *WebView) noteBridgeTeardownScheduled() {
1347+ if wv == nil {
1348+ return
1349+ }
1350+ if !wv.bridgeTeardownNoted.CompareAndSwap(false, true) {
1351+ return
1352+ }
1353+ if wv.engine != nil {
1354+ wv.engine.activity.NoteViewCloseStarted()
1355+ }
1356+}
1357+
1335 func (wv *WebView) destroyViewBridgeOnGTKThread() {1358 func (wv *WebView) destroyViewBridgeOnGTKThread() {
1336 if wv == nil || wv.viewBridge == nil {1359 if wv == nil || wv.viewBridge == nil {
1337 return1360 return
@@ -1350,6 +1373,11 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
1350 _ = wv.viewBridge.Destroy()1373 _ = wv.viewBridge.Destroy()
1351 wv.viewBridge = nil1374 wv.viewBridge = nil
1352 wv.nativeWidget = nil1375 wv.nativeWidget = nil
1376+ // The bridge actually tore down here (second arrivals return early on
1377+ // the nil guard above), completing the outstanding teardown exactly once.
1378+ if wv.engine != nil {
1379+ wv.engine.activity.NoteCleanupCompleted()
1380+ }
1353 }1381 }
1354 1382
1355 // ---------------------------------------------------------------------------1383 // ---------------------------------------------------------------------------
M internal/infrastructure/cef/webview_handlers.go +15 -2
@@ -828,6 +828,11 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
828828 log := logging.FromContext(h.wv.ctx)
829829 if browser == nil {
830830 log.Warn().Msg("cef: OnAfterCreated called with nil browser")
831+ if h.wv.engine != nil {
832+ // No browser will ever complete this creation: resolve the
833+ // pending count so quiescence cannot strand on it.
834+ h.wv.engine.activity.NoteCreationResolved()
835+ }
831836 return
832837 }
833838 browserID := browser.GetIdentifier()
@@ -836,6 +841,11 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
836841 host := browser.GetHost()
837842 if host == nil {
838843 log.Warn().Msg("cef: OnAfterCreated returned nil host")
844+ if h.wv.engine != nil {
845+ // No view will ever attach to this browser: resolve the
846+ // pending count so quiescence cannot strand on it.
847+ h.wv.engine.activity.NoteCreationResolved()
848+ }
839849 return
840850 }
841851
@@ -845,6 +855,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
845855 Int32("browser_id", browserID).
846856 Int32("existing_browser_id", state.duplicateBrowserID).
847857 Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
858+ if h.wv.engine != nil {
859+ h.wv.engine.activity.NoteCreationResolved()
860+ }
848861 host.CloseBrowser(1)
849862 return
850863 }
@@ -1083,7 +1096,7 @@ func (h *handlerSet) OnBeforeDownload(
10831096 }
10841097
10851098 func (h *handlerSet) OnDownloadUpdated(
1086- _ purecef.Browser,
1099+ browser purecef.Browser,
10871100 downloadItem purecef.DownloadItem,
10881101 callback purecef.DownloadItemCallback,
10891102 ) {
@@ -1091,7 +1104,7 @@ func (h *handlerSet) OnDownloadUpdated(
10911104 if handler == nil {
10921105 return
10931106 }
1094- handler.onDownloadUpdated(h.currentContext(), downloadItem, callback)
1107+ handler.onDownloadUpdated(h.currentContext(), browser, downloadItem, callback)
10951108 }
10961109
10971110 func (h *handlerSet) downloadHandler() *downloadHandler {
@@ -828,6 +828,11 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
828 log := logging.FromContext(h.wv.ctx)828 log := logging.FromContext(h.wv.ctx)
829 if browser == nil {829 if browser == nil {
830 log.Warn().Msg("cef: OnAfterCreated called with nil browser")830 log.Warn().Msg("cef: OnAfterCreated called with nil browser")
831+ if h.wv.engine != nil {
832+ // No browser will ever complete this creation: resolve the
833+ // pending count so quiescence cannot strand on it.
834+ h.wv.engine.activity.NoteCreationResolved()
835+ }
831 return836 return
832 }837 }
833 browserID := browser.GetIdentifier()838 browserID := browser.GetIdentifier()
@@ -836,6 +841,11 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
836 host := browser.GetHost()841 host := browser.GetHost()
837 if host == nil {842 if host == nil {
838 log.Warn().Msg("cef: OnAfterCreated returned nil host")843 log.Warn().Msg("cef: OnAfterCreated returned nil host")
844+ if h.wv.engine != nil {
845+ // No view will ever attach to this browser: resolve the
846+ // pending count so quiescence cannot strand on it.
847+ h.wv.engine.activity.NoteCreationResolved()
848+ }
839 return849 return
840 }850 }
841 851
@@ -845,6 +855,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
845 Int32("browser_id", browserID).855 Int32("browser_id", browserID).
846 Int32("existing_browser_id", state.duplicateBrowserID).856 Int32("existing_browser_id", state.duplicateBrowserID).
847 Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")857 Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
858+ if h.wv.engine != nil {
859+ h.wv.engine.activity.NoteCreationResolved()
860+ }
848 host.CloseBrowser(1)861 host.CloseBrowser(1)
849 return862 return
850 }863 }
@@ -1083,7 +1096,7 @@ func (h *handlerSet) OnBeforeDownload(
1083 }1096 }
1084 1097
1085 func (h *handlerSet) OnDownloadUpdated(1098 func (h *handlerSet) OnDownloadUpdated(
1086- _ purecef.Browser,1099+ browser purecef.Browser,
1087 downloadItem purecef.DownloadItem,1100 downloadItem purecef.DownloadItem,
1088 callback purecef.DownloadItemCallback,1101 callback purecef.DownloadItemCallback,
1089 ) {1102 ) {
@@ -1091,7 +1104,7 @@ func (h *handlerSet) OnDownloadUpdated(
1091 if handler == nil {1104 if handler == nil {
1092 return1105 return
1093 }1106 }
1094- handler.onDownloadUpdated(h.currentContext(), downloadItem, callback)1107+ handler.onDownloadUpdated(h.currentContext(), browser, downloadItem, callback)
1095 }1108 }
1096 1109
1097 func (h *handlerSet) downloadHandler() *downloadHandler {1110 func (h *handlerSet) downloadHandler() *downloadHandler {
M internal/infrastructure/config/engine.go +5 -0
@@ -112,6 +112,11 @@ type CEFEngineConfig struct {
112112 WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` //nolint:lll // struct tags exceed lll limit
113113 // Input controls GTK input translation before events are forwarded to CEF.
114114 Input CEFInputConfig `mapstructure:"input" toml:"input" yaml:"input"`
115+ // IdleRuntimeTimeoutMs bounds opt-in CEF runtime residency after the last
116+ // user window closes. Integer milliseconds, default 0 (disabled),
117+ // accepted range 0..300000. Read once at process startup; changes require
118+ // a restart and are never partially hot-applied.
119+ IdleRuntimeTimeoutMs int `mapstructure:"idle_runtime_timeout_ms" toml:"idle_runtime_timeout_ms" yaml:"idle_runtime_timeout_ms"`
115120 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
116121 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
117122 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
@@ -112,6 +112,11 @@ type CEFEngineConfig struct {
112 WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` //nolint:lll // struct tags exceed lll limit112 WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` //nolint:lll // struct tags exceed lll limit
113 // Input controls GTK input translation before events are forwarded to CEF.113 // Input controls GTK input translation before events are forwarded to CEF.
114 Input CEFInputConfig `mapstructure:"input" toml:"input" yaml:"input"`114 Input CEFInputConfig `mapstructure:"input" toml:"input" yaml:"input"`
115+ // IdleRuntimeTimeoutMs bounds opt-in CEF runtime residency after the last
116+ // user window closes. Integer milliseconds, default 0 (disabled),
117+ // accepted range 0..300000. Read once at process startup; changes require
118+ // a restart and are never partially hot-applied.
119+ IdleRuntimeTimeoutMs int `mapstructure:"idle_runtime_timeout_ms" toml:"idle_runtime_timeout_ms" yaml:"idle_runtime_timeout_ms"`
115 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.120 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
116 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`121 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
117 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.122 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
A internal/infrastructure/config/idle_runtime_timeout_test.go +65 -0
@@ -0,0 +1,65 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/spf13/viper"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestIdleRuntimeTimeoutDefaultsToDisabled(t *testing.T) {
13+ require.Equal(t, 0, DefaultConfig().Engine.CEF.IdleRuntimeTimeoutMs)
14+}
15+
16+func TestIdleRuntimeTimeoutFileOverride(t *testing.T) {
17+ dir := t.TempDir()
18+ configPath := filepath.Join(dir, "config.toml")
19+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nidle_runtime_timeout_ms = 60000\n"), 0o600))
20+
21+ m := &Manager{viper: viper.New()}
22+ m.viper.SetConfigFile(configPath)
23+ m.viper.SetConfigType("toml")
24+ m.configureAutomaticEnv()
25+ m.setDefaults()
26+ require.NoError(t, m.viper.ReadInConfig())
27+
28+ require.Equal(t, 60000, m.viper.GetInt("engine.cef.idle_runtime_timeout_ms"))
29+}
30+
31+func TestIdleRuntimeTimeoutEnvMapping(t *testing.T) {
32+ dir := t.TempDir()
33+ configPath := filepath.Join(dir, "config.toml")
34+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\n"), 0o600))
35+ t.Setenv("DUMBER_ENGINE_CEF_IDLE_RUNTIME_TIMEOUT_MS", "45000")
36+
37+ m := &Manager{viper: viper.New()}
38+ m.viper.SetConfigFile(configPath)
39+ m.viper.SetConfigType("toml")
40+ m.configureAutomaticEnv()
41+ m.setDefaults()
42+ require.NoError(t, m.viper.ReadInConfig())
43+
44+ require.Equal(t, 45000, m.viper.GetInt("engine.cef.idle_runtime_timeout_ms"))
45+}
46+
47+func TestIdleRuntimeTimeoutStableAcrossUnrelatedReload(t *testing.T) {
48+ dir := t.TempDir()
49+ configPath := filepath.Join(dir, "config.toml")
50+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nidle_runtime_timeout_ms = 60000\n"), 0o600))
51+
52+ load := func() int {
53+ m := &Manager{viper: viper.New()}
54+ m.viper.SetConfigFile(configPath)
55+ m.viper.SetConfigType("toml")
56+ m.configureAutomaticEnv()
57+ m.setDefaults()
58+ require.NoError(t, m.viper.ReadInConfig())
59+ return m.viper.GetInt("engine.cef.idle_runtime_timeout_ms")
60+ }
61+ require.Equal(t, 60000, load())
62+
63+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nidle_runtime_timeout_ms = 60000\nlog_severity = 3\n"), 0o600))
64+ require.Equal(t, 60000, load(), "unrelated reload must not change the effective timeout")
65+}
@@ -0,0 +1,65 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/spf13/viper"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestIdleRuntimeTimeoutDefaultsToDisabled(t *testing.T) {
13+ require.Equal(t, 0, DefaultConfig().Engine.CEF.IdleRuntimeTimeoutMs)
14+}
15+
16+func TestIdleRuntimeTimeoutFileOverride(t *testing.T) {
17+ dir := t.TempDir()
18+ configPath := filepath.Join(dir, "config.toml")
19+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nidle_runtime_timeout_ms = 60000\n"), 0o600))
20+
21+ m := &Manager{viper: viper.New()}
22+ m.viper.SetConfigFile(configPath)
23+ m.viper.SetConfigType("toml")
24+ m.configureAutomaticEnv()
25+ m.setDefaults()
26+ require.NoError(t, m.viper.ReadInConfig())
27+
28+ require.Equal(t, 60000, m.viper.GetInt("engine.cef.idle_runtime_timeout_ms"))
29+}
30+
31+func TestIdleRuntimeTimeoutEnvMapping(t *testing.T) {
32+ dir := t.TempDir()
33+ configPath := filepath.Join(dir, "config.toml")
34+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\n"), 0o600))
35+ t.Setenv("DUMBER_ENGINE_CEF_IDLE_RUNTIME_TIMEOUT_MS", "45000")
36+
37+ m := &Manager{viper: viper.New()}
38+ m.viper.SetConfigFile(configPath)
39+ m.viper.SetConfigType("toml")
40+ m.configureAutomaticEnv()
41+ m.setDefaults()
42+ require.NoError(t, m.viper.ReadInConfig())
43+
44+ require.Equal(t, 45000, m.viper.GetInt("engine.cef.idle_runtime_timeout_ms"))
45+}
46+
47+func TestIdleRuntimeTimeoutStableAcrossUnrelatedReload(t *testing.T) {
48+ dir := t.TempDir()
49+ configPath := filepath.Join(dir, "config.toml")
50+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nidle_runtime_timeout_ms = 60000\n"), 0o600))
51+
52+ load := func() int {
53+ m := &Manager{viper: viper.New()}
54+ m.viper.SetConfigFile(configPath)
55+ m.viper.SetConfigType("toml")
56+ m.configureAutomaticEnv()
57+ m.setDefaults()
58+ require.NoError(t, m.viper.ReadInConfig())
59+ return m.viper.GetInt("engine.cef.idle_runtime_timeout_ms")
60+ }
61+ require.Equal(t, 60000, load())
62+
63+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nidle_runtime_timeout_ms = 60000\nlog_severity = 3\n"), 0o600))
64+ require.Equal(t, 60000, load(), "unrelated reload must not change the effective timeout")
65+}
M internal/infrastructure/config/loader.go +1 -0
@@ -808,6 +808,7 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
808808 m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
809809 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
810810 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
811+ m.viper.SetDefault("engine.cef.idle_runtime_timeout_ms", ce.IdleRuntimeTimeoutMs)
811812 m.setCEFInputDefaults(ce.Input)
812813
813814 wk := e.WebKit
@@ -808,6 +808,7 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
808 m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())808 m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
809 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)809 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
810 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)810 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
811+ m.viper.SetDefault("engine.cef.idle_runtime_timeout_ms", ce.IdleRuntimeTimeoutMs)
811 m.setCEFInputDefaults(ce.Input)812 m.setCEFInputDefaults(ce.Input)
812 813
813 wk := e.WebKit814 wk := e.WebKit
M internal/infrastructure/config/schema_provider.go +8 -0
@@ -1117,6 +1117,14 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
11171117 Range: ">=0",
11181118 Section: SectionPerformance,
11191119 },
1120+ {
1121+ Key: "engine.cef.idle_runtime_timeout_ms",
1122+ Type: "int",
1123+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.IdleRuntimeTimeoutMs),
1124+ Description: "Bounded opt-in CEF runtime residency in milliseconds after the last window closes; 0 disables, changes require restart",
1125+ Range: "0..300000",
1126+ Section: SectionPerformance,
1127+ },
11201128 {
11211129 Key: "engine.cef.input.scroll_wheel_multiplier",
11221130 Type: "float64",
@@ -1117,6 +1117,14 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
1117 Range: ">=0",1117 Range: ">=0",
1118 Section: SectionPerformance,1118 Section: SectionPerformance,
1119 },1119 },
1120+ {
1121+ Key: "engine.cef.idle_runtime_timeout_ms",
1122+ Type: "int",
1123+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.IdleRuntimeTimeoutMs),
1124+ Description: "Bounded opt-in CEF runtime residency in milliseconds after the last window closes; 0 disables, changes require restart",
1125+ Range: "0..300000",
1126+ Section: SectionPerformance,
1127+ },
1120 {1128 {
1121 Key: "engine.cef.input.scroll_wheel_multiplier",1129 Key: "engine.cef.input.scroll_wheel_multiplier",
1122 Type: "float64",1130 Type: "float64",
M internal/infrastructure/config/validation.go +3 -0
@@ -501,6 +501,9 @@ func validateCEF(config *Config) []string {
501501 if config.Engine.CEF.WindowlessFrameRateMax < 0 {
502502 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
503503 }
504+ if config.Engine.CEF.IdleRuntimeTimeoutMs < 0 || config.Engine.CEF.IdleRuntimeTimeoutMs > 300000 {
505+ validationErrors = append(validationErrors, "engine.cef.idle_runtime_timeout_ms must be within 0..300000")
506+ }
504507
505508 input := config.Engine.CEF.Input
506509 if invalidPositiveFloat(input.ScrollWheelMultiplier) {
@@ -501,6 +501,9 @@ func validateCEF(config *Config) []string {
501 if config.Engine.CEF.WindowlessFrameRateMax < 0 {501 if config.Engine.CEF.WindowlessFrameRateMax < 0 {
502 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")502 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
503 }503 }
504+ if config.Engine.CEF.IdleRuntimeTimeoutMs < 0 || config.Engine.CEF.IdleRuntimeTimeoutMs > 300000 {
505+ validationErrors = append(validationErrors, "engine.cef.idle_runtime_timeout_ms must be within 0..300000")
506+ }
504 507
505 input := config.Engine.CEF.Input508 input := config.Engine.CEF.Input
506 if invalidPositiveFloat(input.ScrollWheelMultiplier) {509 if invalidPositiveFloat(input.ScrollWheelMultiplier) {
M internal/infrastructure/config/validation_test.go +30 -0
@@ -197,6 +197,36 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
197197 wantErr: true,
198198 wantText: "engine.cef.windowless_frame_rate",
199199 },
200+ {
201+ name: "zero idle residency timeout disables",
202+ mutate: func(cfg *Config) {
203+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = 0
204+ },
205+ wantErr: false,
206+ },
207+ {
208+ name: "maximum idle residency timeout accepted",
209+ mutate: func(cfg *Config) {
210+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = 300000
211+ },
212+ wantErr: false,
213+ },
214+ {
215+ name: "negative idle residency timeout",
216+ mutate: func(cfg *Config) {
217+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = -1
218+ },
219+ wantErr: true,
220+ wantText: "engine.cef.idle_runtime_timeout_ms",
221+ },
222+ {
223+ name: "oversized idle residency timeout",
224+ mutate: func(cfg *Config) {
225+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = 300001
226+ },
227+ wantErr: true,
228+ wantText: "engine.cef.idle_runtime_timeout_ms",
229+ },
200230 {
201231 name: "zero wheel multiplier",
202232 mutate: func(cfg *Config) {
@@ -197,6 +197,36 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
197 wantErr: true,197 wantErr: true,
198 wantText: "engine.cef.windowless_frame_rate",198 wantText: "engine.cef.windowless_frame_rate",
199 },199 },
200+ {
201+ name: "zero idle residency timeout disables",
202+ mutate: func(cfg *Config) {
203+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = 0
204+ },
205+ wantErr: false,
206+ },
207+ {
208+ name: "maximum idle residency timeout accepted",
209+ mutate: func(cfg *Config) {
210+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = 300000
211+ },
212+ wantErr: false,
213+ },
214+ {
215+ name: "negative idle residency timeout",
216+ mutate: func(cfg *Config) {
217+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = -1
218+ },
219+ wantErr: true,
220+ wantText: "engine.cef.idle_runtime_timeout_ms",
221+ },
222+ {
223+ name: "oversized idle residency timeout",
224+ mutate: func(cfg *Config) {
225+ cfg.Engine.CEF.IdleRuntimeTimeoutMs = 300001
226+ },
227+ wantErr: true,
228+ wantText: "engine.cef.idle_runtime_timeout_ms",
229+ },
200 {230 {
201 name: "zero wheel multiplier",231 name: "zero wheel multiplier",
202 mutate: func(cfg *Config) {232 mutate: func(cfg *Config) {
M internal/infrastructure/desktop/browser_launch_relay.go +109 -3
@@ -17,8 +17,10 @@ import (
1717 "time"
1818
1919 "github.com/bnema/dumber/internal/application/port"
20+ "github.com/bnema/dumber/internal/infrastructure/process"
2021 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
2122 "github.com/bnema/dumber/internal/logging"
23+ "github.com/rs/zerolog"
2224 )
2325
2426 const browserLaunchSocketName = "browser-launch.sock"
@@ -36,6 +38,11 @@ var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not
3638
3739 type browserLaunchRelay struct {
3840 ipc runtimeprofile.IPCPaths
41+ // admission is the shared admission/work-lease boundary: a lease is
42+ // acquired before sending Accepted and released when dispatch settles.
43+ // Shutdown closes it so losing requests get an error response instead
44+ // of an acknowledgement. Initialized eagerly; never nil.
45+ admission *process.AdmissionGate
3946 }
4047
4148 type browserLaunchRequest struct {
@@ -49,8 +56,26 @@ type browserLaunchAction string
4956 const (
5057 browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
5158 browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
59+ // browserLaunchActionCloseAllWindows is a diagnostic-only action for the
60+ // residency harness: it closes every user window through the standard
61+ // removal path. It is honored only when the OWNING process sets
62+ // DUMBER_DIAGNOSTIC_WINDOW_CLOSE=1; otherwise it is rejected before
63+ // acknowledgement. It is never a general unauthenticated shutdown
64+ // command: the relay socket itself remains owner-only (0700, uid).
65+ browserLaunchActionCloseAllWindows browserLaunchAction = "close-all-windows"
5266 )
5367
68+// diagnosticWindowCloseEnvVar gates the diagnostic close action on the
69+// owning (server) process environment.
70+const diagnosticWindowCloseEnvVar = "DUMBER_DIAGNOSTIC_WINDOW_CLOSE"
71+
72+// windowCloseOpener is implemented by openers that support the diagnostic
73+// close action. It is matched structurally so the port interface is
74+// unchanged.
75+type windowCloseOpener interface {
76+ CloseAllWindows(ctx context.Context) error
77+}
78+
5479 type browserLaunchResponse struct {
5580 RequestID string `json:"request_id,omitempty"`
5681 Accepted bool `json:"accepted,omitempty"`
@@ -60,6 +85,7 @@ type browserLaunchResponse struct {
6085 type browserLaunchRelayListener struct {
6186 listener *net.UnixListener
6287 socketPath string
88+ admission *process.AdmissionGate
6389 once sync.Once
6490 err error
6591 }
@@ -73,7 +99,24 @@ var newBrowserLaunchRequestID = func() string {
7399 }
74100
75101 func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
76- return &browserLaunchRelay{ipc: ipc}
102+ return &browserLaunchRelay{ipc: ipc, admission: process.NewAdmissionGate()}
103+}
104+
105+// AdmissionGateProvider exposes the relay's shared admission boundary
106+// without extending the port interface or regenerating mocks.
107+type AdmissionGateProvider interface {
108+ AdmissionGate() *process.AdmissionGate
109+}
110+
111+// Compile-time check: the shared gate implements the application boundary.
112+var _ port.AdmissionBoundary = (*process.AdmissionGate)(nil)
113+
114+// AdmissionGate returns the shared admission/work-lease boundary.
115+func (r *browserLaunchRelay) AdmissionGate() *process.AdmissionGate {
116+ if r == nil || r.admission == nil {
117+ return process.NewAdmissionGate()
118+ }
119+ return r.admission
77120 }
78121
79122 func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
@@ -269,7 +312,7 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
269312 }
270313 }
271314
272- relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
315+ relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath, admission: r.AdmissionGate()}
273316 go relayListener.serve(ctx, opener)
274317
275318 return relayListener, nil
@@ -322,7 +365,48 @@ func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.Brow
322365 }
323366 }
324367
325-func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
368+// admitRelayConnection acquires one admission lease for the decoded request
369+// and enforces pre-acknowledgement eligibility. It returns nil after sending
370+// an error response (closed admission or unauthorized diagnostic action);
371+// the caller must return without acknowledging. An admitted lease is released
372+// by the caller on early failure and by dispatch completion otherwise.
373+func admitRelayConnection(
374+ conn *net.UnixConn,
375+ configured *process.AdmissionGate,
376+ request browserLaunchRequest,
377+ requestID string,
378+ log *zerolog.Logger,
379+) *process.AdmissionGate {
380+ gate := configured
381+ if gate == nil {
382+ gate = process.NewAdmissionGate()
383+ }
384+ refuse := func(errorBody string) *process.AdmissionGate {
385+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
386+ return nil
387+ }
388+ _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Error: errorBody})
389+ return nil
390+ }
391+ if !gate.Acquire() {
392+ log.Debug().
393+ Str("request_id", requestID).
394+ Msg("browser launch relay refused request after admission closed")
395+ return refuse("shutting down")
396+ }
397+ // The diagnostic close action is rejected before acknowledgement
398+ // unless the owning process explicitly enables it.
399+ if request.Action == browserLaunchActionCloseAllWindows && os.Getenv(diagnosticWindowCloseEnvVar) != "1" {
400+ gate.Release()
401+ log.Warn().
402+ Str("request_id", requestID).
403+ Msg("browser launch relay rejected diagnostic close without owner opt-in")
404+ return refuse("diagnostic close not enabled")
405+ }
406+ return gate
407+}
408+
409+func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
326410 defer func() { _ = conn.Close() }()
327411 log := logging.FromContext(ctx)
328412 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
@@ -342,7 +426,17 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
342426 Str("url_host", safeURLHost(request.URL)).
343427 Msg("browser launch relay request received")
344428
429+ // Admit before acknowledgement: losing requests receive the existing
430+ // error response here, never an acknowledgement for work that will not
431+ // run. Acknowledgement still precedes expensive UI dispatch below, and
432+ // unconfirmed delivery keeps its no-duplicate-fallback semantics.
433+ gate := admitRelayConnection(conn, l.admission, request, requestID, log)
434+ if gate == nil {
435+ return
436+ }
437+
345438 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
439+ gate.Release()
346440 return
347441 }
348442 if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil {
@@ -350,6 +444,7 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
350444 Str("request_id", requestID).
351445 Str("url_host", safeURLHost(request.URL)).
352446 Msg("failed to encode browser launch response")
447+ gate.Release()
353448 return
354449 }
355450 log.Debug().
@@ -358,6 +453,11 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
358453 Msg("browser launch relay request accepted")
359454
360455 go func() {
456+ // The lease spans acknowledgement through dispatch completion or
457+ // definitive factory/dispatch failure. Factory/dispatch failure
458+ // retains the documented existing failure semantics and never
459+ // triggers duplicate fallback.
460+ defer gate.Release()
361461 if opener == nil {
362462 log.Warn().
363463 Str("request_id", requestID).
@@ -376,6 +476,12 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
376476 err = opener.OpenExternalURL(ctx, request.URL)
377477 case browserLaunchActionOpenFreshWindow:
378478 err = opener.OpenFreshWindow(ctx, request.URL)
479+ case browserLaunchActionCloseAllWindows:
480+ if closer, ok := opener.(windowCloseOpener); ok {
481+ err = closer.CloseAllWindows(ctx)
482+ } else {
483+ err = errors.New("browser launch relay opener does not support diagnostic close")
484+ }
379485 default:
380486 log.Warn().
381487 Str("request_id", requestID).
@@ -17,8 +17,10 @@ import (
17 "time"17 "time"
18 18
19 "github.com/bnema/dumber/internal/application/port"19 "github.com/bnema/dumber/internal/application/port"
20+ "github.com/bnema/dumber/internal/infrastructure/process"
20 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"21 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
21 "github.com/bnema/dumber/internal/logging"22 "github.com/bnema/dumber/internal/logging"
23+ "github.com/rs/zerolog"
22 )24 )
23 25
24 const browserLaunchSocketName = "browser-launch.sock"26 const browserLaunchSocketName = "browser-launch.sock"
@@ -36,6 +38,11 @@ var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not
36 38
37 type browserLaunchRelay struct {39 type browserLaunchRelay struct {
38 ipc runtimeprofile.IPCPaths40 ipc runtimeprofile.IPCPaths
41+ // admission is the shared admission/work-lease boundary: a lease is
42+ // acquired before sending Accepted and released when dispatch settles.
43+ // Shutdown closes it so losing requests get an error response instead
44+ // of an acknowledgement. Initialized eagerly; never nil.
45+ admission *process.AdmissionGate
39 }46 }
40 47
41 type browserLaunchRequest struct {48 type browserLaunchRequest struct {
@@ -49,8 +56,26 @@ type browserLaunchAction string
49 const (56 const (
50 browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"57 browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
51 browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"58 browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
59+ // browserLaunchActionCloseAllWindows is a diagnostic-only action for the
60+ // residency harness: it closes every user window through the standard
61+ // removal path. It is honored only when the OWNING process sets
62+ // DUMBER_DIAGNOSTIC_WINDOW_CLOSE=1; otherwise it is rejected before
63+ // acknowledgement. It is never a general unauthenticated shutdown
64+ // command: the relay socket itself remains owner-only (0700, uid).
65+ browserLaunchActionCloseAllWindows browserLaunchAction = "close-all-windows"
52 )66 )
53 67
68+// diagnosticWindowCloseEnvVar gates the diagnostic close action on the
69+// owning (server) process environment.
70+const diagnosticWindowCloseEnvVar = "DUMBER_DIAGNOSTIC_WINDOW_CLOSE"
71+
72+// windowCloseOpener is implemented by openers that support the diagnostic
73+// close action. It is matched structurally so the port interface is
74+// unchanged.
75+type windowCloseOpener interface {
76+ CloseAllWindows(ctx context.Context) error
77+}
78+
54 type browserLaunchResponse struct {79 type browserLaunchResponse struct {
55 RequestID string `json:"request_id,omitempty"`80 RequestID string `json:"request_id,omitempty"`
56 Accepted bool `json:"accepted,omitempty"`81 Accepted bool `json:"accepted,omitempty"`
@@ -60,6 +85,7 @@ type browserLaunchResponse struct {
60 type browserLaunchRelayListener struct {85 type browserLaunchRelayListener struct {
61 listener *net.UnixListener86 listener *net.UnixListener
62 socketPath string87 socketPath string
88+ admission *process.AdmissionGate
63 once sync.Once89 once sync.Once
64 err error90 err error
65 }91 }
@@ -73,7 +99,24 @@ var newBrowserLaunchRequestID = func() string {
73 }99 }
74 100
75 func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {101 func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
76- return &browserLaunchRelay{ipc: ipc}102+ return &browserLaunchRelay{ipc: ipc, admission: process.NewAdmissionGate()}
103+}
104+
105+// AdmissionGateProvider exposes the relay's shared admission boundary
106+// without extending the port interface or regenerating mocks.
107+type AdmissionGateProvider interface {
108+ AdmissionGate() *process.AdmissionGate
109+}
110+
111+// Compile-time check: the shared gate implements the application boundary.
112+var _ port.AdmissionBoundary = (*process.AdmissionGate)(nil)
113+
114+// AdmissionGate returns the shared admission/work-lease boundary.
115+func (r *browserLaunchRelay) AdmissionGate() *process.AdmissionGate {
116+ if r == nil || r.admission == nil {
117+ return process.NewAdmissionGate()
118+ }
119+ return r.admission
77 }120 }
78 121
79 func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {122 func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
@@ -269,7 +312,7 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
269 }312 }
270 }313 }
271 314
272- relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}315+ relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath, admission: r.AdmissionGate()}
273 go relayListener.serve(ctx, opener)316 go relayListener.serve(ctx, opener)
274 317
275 return relayListener, nil318 return relayListener, nil
@@ -322,7 +365,48 @@ func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.Brow
322 }365 }
323 }366 }
324 367
325-func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {368+// admitRelayConnection acquires one admission lease for the decoded request
369+// and enforces pre-acknowledgement eligibility. It returns nil after sending
370+// an error response (closed admission or unauthorized diagnostic action);
371+// the caller must return without acknowledging. An admitted lease is released
372+// by the caller on early failure and by dispatch completion otherwise.
373+func admitRelayConnection(
374+ conn *net.UnixConn,
375+ configured *process.AdmissionGate,
376+ request browserLaunchRequest,
377+ requestID string,
378+ log *zerolog.Logger,
379+) *process.AdmissionGate {
380+ gate := configured
381+ if gate == nil {
382+ gate = process.NewAdmissionGate()
383+ }
384+ refuse := func(errorBody string) *process.AdmissionGate {
385+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
386+ return nil
387+ }
388+ _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Error: errorBody})
389+ return nil
390+ }
391+ if !gate.Acquire() {
392+ log.Debug().
393+ Str("request_id", requestID).
394+ Msg("browser launch relay refused request after admission closed")
395+ return refuse("shutting down")
396+ }
397+ // The diagnostic close action is rejected before acknowledgement
398+ // unless the owning process explicitly enables it.
399+ if request.Action == browserLaunchActionCloseAllWindows && os.Getenv(diagnosticWindowCloseEnvVar) != "1" {
400+ gate.Release()
401+ log.Warn().
402+ Str("request_id", requestID).
403+ Msg("browser launch relay rejected diagnostic close without owner opt-in")
404+ return refuse("diagnostic close not enabled")
405+ }
406+ return gate
407+}
408+
409+func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
326 defer func() { _ = conn.Close() }()410 defer func() { _ = conn.Close() }()
327 log := logging.FromContext(ctx)411 log := logging.FromContext(ctx)
328 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {412 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
@@ -342,7 +426,17 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
342 Str("url_host", safeURLHost(request.URL)).426 Str("url_host", safeURLHost(request.URL)).
343 Msg("browser launch relay request received")427 Msg("browser launch relay request received")
344 428
429+ // Admit before acknowledgement: losing requests receive the existing
430+ // error response here, never an acknowledgement for work that will not
431+ // run. Acknowledgement still precedes expensive UI dispatch below, and
432+ // unconfirmed delivery keeps its no-duplicate-fallback semantics.
433+ gate := admitRelayConnection(conn, l.admission, request, requestID, log)
434+ if gate == nil {
435+ return
436+ }
437+
345 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {438 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
439+ gate.Release()
346 return440 return
347 }441 }
348 if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil {442 if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil {
@@ -350,6 +444,7 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
350 Str("request_id", requestID).444 Str("request_id", requestID).
351 Str("url_host", safeURLHost(request.URL)).445 Str("url_host", safeURLHost(request.URL)).
352 Msg("failed to encode browser launch response")446 Msg("failed to encode browser launch response")
447+ gate.Release()
353 return448 return
354 }449 }
355 log.Debug().450 log.Debug().
@@ -358,6 +453,11 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
358 Msg("browser launch relay request accepted")453 Msg("browser launch relay request accepted")
359 454
360 go func() {455 go func() {
456+ // The lease spans acknowledgement through dispatch completion or
457+ // definitive factory/dispatch failure. Factory/dispatch failure
458+ // retains the documented existing failure semantics and never
459+ // triggers duplicate fallback.
460+ defer gate.Release()
361 if opener == nil {461 if opener == nil {
362 log.Warn().462 log.Warn().
363 Str("request_id", requestID).463 Str("request_id", requestID).
@@ -376,6 +476,12 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
376 err = opener.OpenExternalURL(ctx, request.URL)476 err = opener.OpenExternalURL(ctx, request.URL)
377 case browserLaunchActionOpenFreshWindow:477 case browserLaunchActionOpenFreshWindow:
378 err = opener.OpenFreshWindow(ctx, request.URL)478 err = opener.OpenFreshWindow(ctx, request.URL)
479+ case browserLaunchActionCloseAllWindows:
480+ if closer, ok := opener.(windowCloseOpener); ok {
481+ err = closer.CloseAllWindows(ctx)
482+ } else {
483+ err = errors.New("browser launch relay opener does not support diagnostic close")
484+ }
379 default:485 default:
380 log.Warn().486 log.Warn().
381 Str("request_id", requestID).487 Str("request_id", requestID).
M internal/infrastructure/desktop/browser_launch_relay_test.go +157 -0
@@ -10,6 +10,8 @@ import (
1010 "testing"
1111 "time"
1212
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/infrastructure/process"
1315 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1416 "github.com/stretchr/testify/assert"
1517 "github.com/stretchr/testify/require"
@@ -648,3 +650,158 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
648650 t.Fatal("expected opener to receive the URL")
649651 }
650652 }
653+
654+type blockingRelayOpener struct {
655+ release chan struct{}
656+ received chan string
657+}
658+
659+func (o *blockingRelayOpener) OpenExternalURL(_ context.Context, url string) error {
660+ o.received <- url
661+ <-o.release
662+ return nil
663+}
664+
665+func (o *blockingRelayOpener) OpenFreshWindow(_ context.Context, url string) error {
666+ return o.OpenExternalURL(context.Background(), url)
667+}
668+
669+func startLeaseTestListener(t *testing.T, opener port.BrowserWindowOpener) *browserLaunchRelay {
670+ t.Helper()
671+ ipc := testIPC(shortTempDir(t))
672+ require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
673+ relay := NewBrowserLaunchRelay(ipc).(*browserLaunchRelay)
674+ ctx, cancel := context.WithCancel(context.Background())
675+ t.Cleanup(cancel)
676+ closer, err := relay.Listen(ctx, opener)
677+ require.NoError(t, err)
678+ t.Cleanup(func() { _ = closer.Close() })
679+ return relay
680+}
681+
682+func waitForLeaseCount(t *testing.T, gate *process.AdmissionGate, want int) {
683+ t.Helper()
684+ deadline := time.Now().Add(3 * time.Second)
685+ for time.Now().Before(deadline) {
686+ if gate.Active() == want {
687+ return
688+ }
689+ time.Sleep(5 * time.Millisecond)
690+ }
691+ t.Fatalf("lease count = %d, want %d", gate.Active(), want)
692+}
693+
694+func TestBrowserLaunchRelay_HoldsLeaseThroughDispatch(t *testing.T) {
695+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 2)}
696+ relay := startLeaseTestListener(t, opener)
697+ gate := relay.AdmissionGate()
698+
699+ delivered := make(chan error, 1)
700+ go func() {
701+ _, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/lease")
702+ delivered <- err
703+ }()
704+ select {
705+ case url := <-opener.received:
706+ require.Equal(t, "https://example.com/lease", url)
707+ case <-time.After(3 * time.Second):
708+ t.Fatal("opener never received the request")
709+ }
710+ // Acknowledged and dispatched: the lease is held until dispatch settles.
711+ waitForLeaseCount(t, gate, 1)
712+ select {
713+ case err := <-delivered:
714+ require.NoError(t, err)
715+ case <-time.After(3 * time.Second):
716+ t.Fatal("client never received acknowledgement")
717+ }
718+ close(opener.release)
719+ waitForLeaseCount(t, gate, 0)
720+}
721+
722+func TestBrowserLaunchRelay_SimultaneousOpensHoldLeases(t *testing.T) {
723+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 4)}
724+ relay := startLeaseTestListener(t, opener)
725+ gate := relay.AdmissionGate()
726+
727+ for range 2 {
728+ go func() {
729+ _, _ = relay.DeliverOpenExternalURL(context.Background(), "https://example.com/parallel")
730+ }()
731+ }
732+ for range 2 {
733+ select {
734+ case <-opener.received:
735+ case <-time.After(3 * time.Second):
736+ t.Fatal("opener never received a parallel request")
737+ }
738+ }
739+ waitForLeaseCount(t, gate, 2)
740+ close(opener.release)
741+ waitForLeaseCount(t, gate, 0)
742+}
743+
744+func TestBrowserLaunchRelay_ClosedAdmissionSendsErrorResponse(t *testing.T) {
745+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
746+ relay := startLeaseTestListener(t, opener)
747+ gate := relay.AdmissionGate()
748+ close(opener.release)
749+
750+ gate.Close()
751+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/late")
752+ require.Error(t, err, "losing request must receive the error response")
753+ require.Contains(t, err.Error(), "shutting down")
754+ require.Equal(t, 0, gate.Active(), "refused requests hold no lease")
755+ select {
756+ case url := <-opener.received:
757+ t.Fatalf("refused request reached the opener: %s", url)
758+ case <-time.After(300 * time.Millisecond):
759+ }
760+ _ = delivered
761+}
762+
763+type closeTrackingOpener struct {
764+ *blockingRelayOpener
765+ closed chan struct{}
766+}
767+
768+func (o *closeTrackingOpener) CloseAllWindows(_ context.Context) error {
769+ close(o.closed)
770+ return nil
771+}
772+
773+func sendRelayAction(t *testing.T, socketPath, action string) browserLaunchResponse {
774+ t.Helper()
775+ conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"})
776+ require.NoError(t, err)
777+ defer func() { _ = conn.Close() }()
778+ require.NoError(t, conn.SetDeadline(time.Now().Add(3*time.Second)))
779+ require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "diag-1", Action: browserLaunchAction(action)}))
780+ var response browserLaunchResponse
781+ require.NoError(t, json.NewDecoder(conn).Decode(&response))
782+ return response
783+}
784+
785+func TestBrowserLaunchRelay_DiagnosticCloseRejectedWithoutOptIn(t *testing.T) {
786+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
787+ close(opener.release)
788+ relay := startLeaseTestListener(t, opener)
789+ response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
790+ require.False(t, response.Accepted, "rejected close must not acknowledge")
791+ require.Contains(t, response.Error, "diagnostic close not enabled")
792+}
793+
794+func TestBrowserLaunchRelay_DiagnosticCloseDispatchedWithOptIn(t *testing.T) {
795+ t.Setenv(diagnosticWindowCloseEnvVar, "1")
796+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
797+ close(opener.release)
798+ tracker := &closeTrackingOpener{blockingRelayOpener: opener, closed: make(chan struct{})}
799+ relay := startLeaseTestListener(t, tracker)
800+ response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
801+ require.True(t, response.Accepted)
802+ select {
803+ case <-tracker.closed:
804+ case <-time.After(3 * time.Second):
805+ t.Fatal("diagnostic close never reached the opener")
806+ }
807+}
@@ -10,6 +10,8 @@ import (
10 "testing"10 "testing"
11 "time"11 "time"
12 12
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/infrastructure/process"
13 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"15 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
14 "github.com/stretchr/testify/assert"16 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
@@ -648,3 +650,158 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
648 t.Fatal("expected opener to receive the URL")650 t.Fatal("expected opener to receive the URL")
649 }651 }
650 }652 }
653+
654+type blockingRelayOpener struct {
655+ release chan struct{}
656+ received chan string
657+}
658+
659+func (o *blockingRelayOpener) OpenExternalURL(_ context.Context, url string) error {
660+ o.received <- url
661+ <-o.release
662+ return nil
663+}
664+
665+func (o *blockingRelayOpener) OpenFreshWindow(_ context.Context, url string) error {
666+ return o.OpenExternalURL(context.Background(), url)
667+}
668+
669+func startLeaseTestListener(t *testing.T, opener port.BrowserWindowOpener) *browserLaunchRelay {
670+ t.Helper()
671+ ipc := testIPC(shortTempDir(t))
672+ require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
673+ relay := NewBrowserLaunchRelay(ipc).(*browserLaunchRelay)
674+ ctx, cancel := context.WithCancel(context.Background())
675+ t.Cleanup(cancel)
676+ closer, err := relay.Listen(ctx, opener)
677+ require.NoError(t, err)
678+ t.Cleanup(func() { _ = closer.Close() })
679+ return relay
680+}
681+
682+func waitForLeaseCount(t *testing.T, gate *process.AdmissionGate, want int) {
683+ t.Helper()
684+ deadline := time.Now().Add(3 * time.Second)
685+ for time.Now().Before(deadline) {
686+ if gate.Active() == want {
687+ return
688+ }
689+ time.Sleep(5 * time.Millisecond)
690+ }
691+ t.Fatalf("lease count = %d, want %d", gate.Active(), want)
692+}
693+
694+func TestBrowserLaunchRelay_HoldsLeaseThroughDispatch(t *testing.T) {
695+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 2)}
696+ relay := startLeaseTestListener(t, opener)
697+ gate := relay.AdmissionGate()
698+
699+ delivered := make(chan error, 1)
700+ go func() {
701+ _, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/lease")
702+ delivered <- err
703+ }()
704+ select {
705+ case url := <-opener.received:
706+ require.Equal(t, "https://example.com/lease", url)
707+ case <-time.After(3 * time.Second):
708+ t.Fatal("opener never received the request")
709+ }
710+ // Acknowledged and dispatched: the lease is held until dispatch settles.
711+ waitForLeaseCount(t, gate, 1)
712+ select {
713+ case err := <-delivered:
714+ require.NoError(t, err)
715+ case <-time.After(3 * time.Second):
716+ t.Fatal("client never received acknowledgement")
717+ }
718+ close(opener.release)
719+ waitForLeaseCount(t, gate, 0)
720+}
721+
722+func TestBrowserLaunchRelay_SimultaneousOpensHoldLeases(t *testing.T) {
723+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 4)}
724+ relay := startLeaseTestListener(t, opener)
725+ gate := relay.AdmissionGate()
726+
727+ for range 2 {
728+ go func() {
729+ _, _ = relay.DeliverOpenExternalURL(context.Background(), "https://example.com/parallel")
730+ }()
731+ }
732+ for range 2 {
733+ select {
734+ case <-opener.received:
735+ case <-time.After(3 * time.Second):
736+ t.Fatal("opener never received a parallel request")
737+ }
738+ }
739+ waitForLeaseCount(t, gate, 2)
740+ close(opener.release)
741+ waitForLeaseCount(t, gate, 0)
742+}
743+
744+func TestBrowserLaunchRelay_ClosedAdmissionSendsErrorResponse(t *testing.T) {
745+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
746+ relay := startLeaseTestListener(t, opener)
747+ gate := relay.AdmissionGate()
748+ close(opener.release)
749+
750+ gate.Close()
751+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/late")
752+ require.Error(t, err, "losing request must receive the error response")
753+ require.Contains(t, err.Error(), "shutting down")
754+ require.Equal(t, 0, gate.Active(), "refused requests hold no lease")
755+ select {
756+ case url := <-opener.received:
757+ t.Fatalf("refused request reached the opener: %s", url)
758+ case <-time.After(300 * time.Millisecond):
759+ }
760+ _ = delivered
761+}
762+
763+type closeTrackingOpener struct {
764+ *blockingRelayOpener
765+ closed chan struct{}
766+}
767+
768+func (o *closeTrackingOpener) CloseAllWindows(_ context.Context) error {
769+ close(o.closed)
770+ return nil
771+}
772+
773+func sendRelayAction(t *testing.T, socketPath, action string) browserLaunchResponse {
774+ t.Helper()
775+ conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"})
776+ require.NoError(t, err)
777+ defer func() { _ = conn.Close() }()
778+ require.NoError(t, conn.SetDeadline(time.Now().Add(3*time.Second)))
779+ require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "diag-1", Action: browserLaunchAction(action)}))
780+ var response browserLaunchResponse
781+ require.NoError(t, json.NewDecoder(conn).Decode(&response))
782+ return response
783+}
784+
785+func TestBrowserLaunchRelay_DiagnosticCloseRejectedWithoutOptIn(t *testing.T) {
786+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
787+ close(opener.release)
788+ relay := startLeaseTestListener(t, opener)
789+ response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
790+ require.False(t, response.Accepted, "rejected close must not acknowledge")
791+ require.Contains(t, response.Error, "diagnostic close not enabled")
792+}
793+
794+func TestBrowserLaunchRelay_DiagnosticCloseDispatchedWithOptIn(t *testing.T) {
795+ t.Setenv(diagnosticWindowCloseEnvVar, "1")
796+ opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
797+ close(opener.release)
798+ tracker := &closeTrackingOpener{blockingRelayOpener: opener, closed: make(chan struct{})}
799+ relay := startLeaseTestListener(t, tracker)
800+ response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
801+ require.True(t, response.Accepted)
802+ select {
803+ case <-tracker.closed:
804+ case <-time.After(3 * time.Second):
805+ t.Fatal("diagnostic close never reached the opener")
806+ }
807+}
A internal/infrastructure/process/admission_gate.go +119 -0
@@ -0,0 +1,119 @@
1+package process
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+)
8+
9+// AdmissionGate is a narrow thread-safe admission/work-lease boundary shared
10+// by the relay listener and the shutdown/residency policy. A lease must be
11+// acquired before sending an acknowledgement; shutdown atomically stops
12+// admission while admitted work drains. It never blocks the holder: waiting
13+// uses WaitDrained with a caller-provided bounded context.
14+type AdmissionGate struct {
15+ mu sync.Mutex
16+ closed bool
17+ active int
18+ onDrained []func()
19+}
20+
21+// NewAdmissionGate returns an open gate with no active leases.
22+func NewAdmissionGate() *AdmissionGate {
23+ return &AdmissionGate{}
24+}
25+
26+// Acquire takes one work lease. It reports false when admission is closed;
27+// the caller must then send the existing error response before any
28+// acknowledgement and must not dispatch.
29+func (g *AdmissionGate) Acquire() bool {
30+ g.mu.Lock()
31+ defer g.mu.Unlock()
32+ if g.closed {
33+ return false
34+ }
35+ g.active++
36+ return true
37+}
38+
39+// Release returns one work lease. The final release notifies drain
40+// subscribers.
41+func (g *AdmissionGate) Release() {
42+ g.mu.Lock()
43+ var notify []func()
44+ if g.active > 0 {
45+ g.active--
46+ }
47+ if g.active == 0 {
48+ notify = append([]func(){}, g.onDrained...)
49+ }
50+ g.mu.Unlock()
51+ for _, fn := range notify {
52+ fn()
53+ }
54+}
55+
56+// Close atomically stops admission. In-flight leases are unaffected; they
57+// drain through Release.
58+func (g *AdmissionGate) Close() {
59+ g.mu.Lock()
60+ defer g.mu.Unlock()
61+ g.closed = true
62+}
63+
64+// Reopen resumes admission after a seal that no longer applies: an
65+// invalidated deferred quit (e.g. a reopened window) must not leave the
66+// surviving process permanently deaf to relay requests.
67+func (g *AdmissionGate) Reopen() {
68+ g.mu.Lock()
69+ defer g.mu.Unlock()
70+ g.closed = false
71+}
72+
73+// Closed reports whether admission is stopped.
74+func (g *AdmissionGate) Closed() bool {
75+ g.mu.Lock()
76+ defer g.mu.Unlock()
77+ return g.closed
78+}
79+
80+// Active reports the number of held leases.
81+func (g *AdmissionGate) Active() int {
82+ g.mu.Lock()
83+ defer g.mu.Unlock()
84+ return g.active
85+}
86+
87+// OnDrained registers a callback invoked (without holding the gate lock)
88+// every time the active count reaches zero. Registration is not idempotent:
89+// each call appends. There is no unsubscribe; gates are process-lifetime
90+// owners. Callbacks must not call back into the gate.
91+func (g *AdmissionGate) OnDrained(fn func()) {
92+ if fn == nil {
93+ return
94+ }
95+ g.mu.Lock()
96+ defer g.mu.Unlock()
97+ g.onDrained = append(g.onDrained, fn)
98+}
99+
100+// WaitDrained blocks until no leases are held or ctx ends. It reports false
101+// on context expiry so shutdown never waits forever. The short poll runs
102+// only on shutdown/drain paths, never on any frame or event loop.
103+func (g *AdmissionGate) WaitDrained(ctx context.Context) bool {
104+ ticker := time.NewTicker(10 * time.Millisecond)
105+ defer ticker.Stop()
106+ for {
107+ g.mu.Lock()
108+ drained := g.active == 0
109+ g.mu.Unlock()
110+ if drained {
111+ return true
112+ }
113+ select {
114+ case <-ctx.Done():
115+ return false
116+ case <-ticker.C:
117+ }
118+ }
119+}
@@ -0,0 +1,119 @@
1+package process
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+)
8+
9+// AdmissionGate is a narrow thread-safe admission/work-lease boundary shared
10+// by the relay listener and the shutdown/residency policy. A lease must be
11+// acquired before sending an acknowledgement; shutdown atomically stops
12+// admission while admitted work drains. It never blocks the holder: waiting
13+// uses WaitDrained with a caller-provided bounded context.
14+type AdmissionGate struct {
15+ mu sync.Mutex
16+ closed bool
17+ active int
18+ onDrained []func()
19+}
20+
21+// NewAdmissionGate returns an open gate with no active leases.
22+func NewAdmissionGate() *AdmissionGate {
23+ return &AdmissionGate{}
24+}
25+
26+// Acquire takes one work lease. It reports false when admission is closed;
27+// the caller must then send the existing error response before any
28+// acknowledgement and must not dispatch.
29+func (g *AdmissionGate) Acquire() bool {
30+ g.mu.Lock()
31+ defer g.mu.Unlock()
32+ if g.closed {
33+ return false
34+ }
35+ g.active++
36+ return true
37+}
38+
39+// Release returns one work lease. The final release notifies drain
40+// subscribers.
41+func (g *AdmissionGate) Release() {
42+ g.mu.Lock()
43+ var notify []func()
44+ if g.active > 0 {
45+ g.active--
46+ }
47+ if g.active == 0 {
48+ notify = append([]func(){}, g.onDrained...)
49+ }
50+ g.mu.Unlock()
51+ for _, fn := range notify {
52+ fn()
53+ }
54+}
55+
56+// Close atomically stops admission. In-flight leases are unaffected; they
57+// drain through Release.
58+func (g *AdmissionGate) Close() {
59+ g.mu.Lock()
60+ defer g.mu.Unlock()
61+ g.closed = true
62+}
63+
64+// Reopen resumes admission after a seal that no longer applies: an
65+// invalidated deferred quit (e.g. a reopened window) must not leave the
66+// surviving process permanently deaf to relay requests.
67+func (g *AdmissionGate) Reopen() {
68+ g.mu.Lock()
69+ defer g.mu.Unlock()
70+ g.closed = false
71+}
72+
73+// Closed reports whether admission is stopped.
74+func (g *AdmissionGate) Closed() bool {
75+ g.mu.Lock()
76+ defer g.mu.Unlock()
77+ return g.closed
78+}
79+
80+// Active reports the number of held leases.
81+func (g *AdmissionGate) Active() int {
82+ g.mu.Lock()
83+ defer g.mu.Unlock()
84+ return g.active
85+}
86+
87+// OnDrained registers a callback invoked (without holding the gate lock)
88+// every time the active count reaches zero. Registration is not idempotent:
89+// each call appends. There is no unsubscribe; gates are process-lifetime
90+// owners. Callbacks must not call back into the gate.
91+func (g *AdmissionGate) OnDrained(fn func()) {
92+ if fn == nil {
93+ return
94+ }
95+ g.mu.Lock()
96+ defer g.mu.Unlock()
97+ g.onDrained = append(g.onDrained, fn)
98+}
99+
100+// WaitDrained blocks until no leases are held or ctx ends. It reports false
101+// on context expiry so shutdown never waits forever. The short poll runs
102+// only on shutdown/drain paths, never on any frame or event loop.
103+func (g *AdmissionGate) WaitDrained(ctx context.Context) bool {
104+ ticker := time.NewTicker(10 * time.Millisecond)
105+ defer ticker.Stop()
106+ for {
107+ g.mu.Lock()
108+ drained := g.active == 0
109+ g.mu.Unlock()
110+ if drained {
111+ return true
112+ }
113+ select {
114+ case <-ctx.Done():
115+ return false
116+ case <-ticker.C:
117+ }
118+ }
119+}
A internal/infrastructure/process/admission_gate_test.go +96 -0
@@ -0,0 +1,96 @@
1+package process
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdmissionGateAcquireRelease(t *testing.T) {
13+ gate := NewAdmissionGate()
14+ require.True(t, gate.Acquire())
15+ require.True(t, gate.Acquire())
16+ require.Equal(t, 2, gate.Active())
17+ gate.Release()
18+ require.Equal(t, 1, gate.Active())
19+ gate.Release()
20+ require.Equal(t, 0, gate.Active())
21+}
22+
23+func TestAdmissionGateCloseStopsAdmission(t *testing.T) {
24+ gate := NewAdmissionGate()
25+ require.True(t, gate.Acquire())
26+ gate.Close()
27+ require.True(t, gate.Closed())
28+ require.False(t, gate.Acquire(), "closed gate must refuse new leases")
29+ require.Equal(t, 1, gate.Active(), "in-flight lease survives close")
30+ gate.Release()
31+ require.Equal(t, 0, gate.Active())
32+}
33+
34+func TestAdmissionGateWaitDrained(t *testing.T) {
35+ gate := NewAdmissionGate()
36+ gate.Acquire()
37+ done := make(chan bool, 1)
38+ go func() {
39+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
40+ defer cancel()
41+ done <- gate.WaitDrained(ctx)
42+ }()
43+ select {
44+ case <-done:
45+ t.Fatal("drain must not complete while a lease is held")
46+ case <-time.After(50 * time.Millisecond):
47+ }
48+ gate.Release()
49+ select {
50+ case drained := <-done:
51+ require.True(t, drained)
52+ case <-time.After(5 * time.Second):
53+ t.Fatal("drain must complete after release")
54+ }
55+}
56+
57+func TestAdmissionGateWaitDrainedExpiry(t *testing.T) {
58+ gate := NewAdmissionGate()
59+ gate.Acquire()
60+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
61+ defer cancel()
62+ require.False(t, gate.WaitDrained(ctx), "expiry must not hang shutdown")
63+ gate.Release()
64+}
65+
66+func TestAdmissionGateOnDrained(t *testing.T) {
67+ gate := NewAdmissionGate()
68+ var mu sync.Mutex
69+ calls := 0
70+ gate.OnDrained(func() {
71+ mu.Lock()
72+ calls++
73+ mu.Unlock()
74+ })
75+ gate.Acquire()
76+ gate.Release()
77+ mu.Lock()
78+ defer mu.Unlock()
79+ require.Equal(t, 1, calls)
80+}
81+
82+func TestAdmissionGateConcurrent(t *testing.T) {
83+ gate := NewAdmissionGate()
84+ var wg sync.WaitGroup
85+ for range 32 {
86+ wg.Add(1)
87+ go func() {
88+ defer wg.Done()
89+ if gate.Acquire() {
90+ gate.Release()
91+ }
92+ }()
93+ }
94+ wg.Wait()
95+ require.Equal(t, 0, gate.Active())
96+}
@@ -0,0 +1,96 @@
1+package process
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdmissionGateAcquireRelease(t *testing.T) {
13+ gate := NewAdmissionGate()
14+ require.True(t, gate.Acquire())
15+ require.True(t, gate.Acquire())
16+ require.Equal(t, 2, gate.Active())
17+ gate.Release()
18+ require.Equal(t, 1, gate.Active())
19+ gate.Release()
20+ require.Equal(t, 0, gate.Active())
21+}
22+
23+func TestAdmissionGateCloseStopsAdmission(t *testing.T) {
24+ gate := NewAdmissionGate()
25+ require.True(t, gate.Acquire())
26+ gate.Close()
27+ require.True(t, gate.Closed())
28+ require.False(t, gate.Acquire(), "closed gate must refuse new leases")
29+ require.Equal(t, 1, gate.Active(), "in-flight lease survives close")
30+ gate.Release()
31+ require.Equal(t, 0, gate.Active())
32+}
33+
34+func TestAdmissionGateWaitDrained(t *testing.T) {
35+ gate := NewAdmissionGate()
36+ gate.Acquire()
37+ done := make(chan bool, 1)
38+ go func() {
39+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
40+ defer cancel()
41+ done <- gate.WaitDrained(ctx)
42+ }()
43+ select {
44+ case <-done:
45+ t.Fatal("drain must not complete while a lease is held")
46+ case <-time.After(50 * time.Millisecond):
47+ }
48+ gate.Release()
49+ select {
50+ case drained := <-done:
51+ require.True(t, drained)
52+ case <-time.After(5 * time.Second):
53+ t.Fatal("drain must complete after release")
54+ }
55+}
56+
57+func TestAdmissionGateWaitDrainedExpiry(t *testing.T) {
58+ gate := NewAdmissionGate()
59+ gate.Acquire()
60+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
61+ defer cancel()
62+ require.False(t, gate.WaitDrained(ctx), "expiry must not hang shutdown")
63+ gate.Release()
64+}
65+
66+func TestAdmissionGateOnDrained(t *testing.T) {
67+ gate := NewAdmissionGate()
68+ var mu sync.Mutex
69+ calls := 0
70+ gate.OnDrained(func() {
71+ mu.Lock()
72+ calls++
73+ mu.Unlock()
74+ })
75+ gate.Acquire()
76+ gate.Release()
77+ mu.Lock()
78+ defer mu.Unlock()
79+ require.Equal(t, 1, calls)
80+}
81+
82+func TestAdmissionGateConcurrent(t *testing.T) {
83+ gate := NewAdmissionGate()
84+ var wg sync.WaitGroup
85+ for range 32 {
86+ wg.Add(1)
87+ go func() {
88+ defer wg.Done()
89+ if gate.Acquire() {
90+ gate.Release()
91+ }
92+ }()
93+ }
94+ wg.Wait()
95+ require.Equal(t, 0, gate.Active())
96+}
M internal/infrastructure/snapshot/service.go +150 -13
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "strings"
88 "sync"
9+ "sync/atomic"
910 "time"
1011
1112 "github.com/bnema/dumber/internal/application/port"
@@ -23,6 +24,9 @@ const (
2324 // Compile-time interface check.
2425 var _ port.SnapshotService = (*Service)(nil)
2526
27+// Compile-time drain boundary check.
28+var _ port.PersistenceDrain = (*Service)(nil)
29+
2630 // Service handles debounced session state snapshots.
2731 type Service struct {
2832 snapshotUC *usecase.SnapshotSessionUseCase
@@ -31,12 +35,24 @@ type Service struct {
3135 retries int
3236 retryDelay time.Duration
3337
34- mu sync.Mutex
35- timer *time.Timer
36- dirty bool
37- ready bool // true when session is persisted to DB and snapshots can be saved
38- ctx context.Context
39- cancel context.CancelFunc
38+ mu sync.Mutex
39+ timer *time.Timer
40+ timerGen uint64
41+ // stopped latches Stop: no new debounce work schedules afterwards.
42+ stopped bool
43+ dirty bool
44+ ready bool // true when session is persisted to DB and snapshots can be saved
45+ ctx context.Context
46+ cancel context.CancelFunc
47+
48+ // Drain boundary for the residency quiescence contract. inflight
49+ // counts an executing capture/database save; lastErr records the
50+ // terminal failure of the most recent save while dirty is preserved
51+ // for reporting and later retry. Failed writes are settled-but-failed:
52+ // the drain terminates instead of looping forever on dirty.
53+ inflight atomic.Int32
54+ lastErr error
55+ onSettled []func()
4056 }
4157
4258 // NewService creates a new snapshot service.
@@ -91,7 +107,11 @@ func (s *Service) SetReady() {
91107 }()
92108 }
93109
94-// Stop stops the service and saves final state.
110+// Stop stops the service and saves final state, then joins an
111+// already-running save within a bounded wait so shutdown observes the
112+// drain barrier instead of racing it. The wait never blocks forever.
113+// Latching stopped also prevents any later debounce from scheduling work
114+// on the dead service.
95115 func (s *Service) Stop(ctx context.Context) error {
96116 s.mu.Lock()
97117 if s.cancel != nil {
@@ -101,27 +121,52 @@ func (s *Service) Stop(ctx context.Context) error {
101121 s.timer.Stop()
102122 s.timer = nil
103123 }
124+ s.stopped = true
104125 s.mu.Unlock()
105126
106127 // Final save on shutdown
107- return s.SaveNow(ctx)
128+ err := s.SaveNow(ctx)
129+ drainCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
130+ defer cancel()
131+ if waitErr := s.WaitSettled(drainCtx); waitErr != nil {
132+ logging.FromContext(ctx).Warn().Err(waitErr).Msg("snapshot drain did not settle before shutdown")
133+ }
134+ return err
108135 }
109136
110137 // MarkDirty signals that state has changed.
111-// Debounces saves to avoid excessive DB writes.
138+// Debounces saves to avoid excessive DB writes. It is a no-op after Stop
139+// so shutdown never resurrects debounce work on the dead service.
112140 func (s *Service) MarkDirty() {
113141 s.mu.Lock()
114142 defer s.mu.Unlock()
115-
143+ if s.stopped {
144+ return
145+ }
116146 s.dirty = true
117147
118148 // Reset or create timer
119149 if s.timer != nil {
120150 s.timer.Stop()
121151 }
122-
152+ s.timerGen++
153+ gen := s.timerGen
123154 s.timer = time.AfterFunc(s.interval, func() {
124155 s.mu.Lock()
156+ // Retire the owning timer as it fires: a non-nil timer means
157+ // debounce work is outstanding, so leaving it set would strand
158+ // the drain after the save settles. The generation check keeps
159+ // a superseded timer from clearing its replacement, and a
160+ // superseded or stopped timer saves nothing: the replacement
161+ // timer or Stop's own final save owns that work.
162+ if s.timerGen != gen || s.stopped {
163+ if s.timerGen == gen {
164+ s.timer = nil
165+ }
166+ s.mu.Unlock()
167+ return
168+ }
169+ s.timer = nil
125170 ctx := s.ctx
126171 s.mu.Unlock()
127172
@@ -160,14 +205,28 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
160205 s.mu.Unlock()
161206 return nil
162207 }
208+ // Claim the dirty work atomically: concurrent timer and SaveNow paths
209+ // serialize here, so exactly one save runs and shutdown joins rather
210+ // than duplicates the active save.
211+ if !s.dirty {
212+ s.mu.Unlock()
213+ return nil
214+ }
163215 // Only clear dirty when we're actually going to save
164216 s.dirty = false
217+ s.lastErr = nil
218+ s.inflight.Add(1)
165219 s.mu.Unlock()
166220
221+ defer func() {
222+ s.inflight.Add(-1)
223+ s.notifyIfSettled()
224+ }()
225+
167226 sessionID := s.provider.GetSessionID()
168227
169228 if sessionID == "" {
170- s.markDirty()
229+ s.MarkDirty()
171230 return nil
172231 }
173232
@@ -175,7 +234,7 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
175234 // A nil window list with no active index means the snapshot is truly unavailable,
176235 // not merely empty. Keep the session dirty so a later capture can retry.
177236 if windows == nil && activeWindowIndex < 0 {
178- s.markDirty()
237+ s.MarkDirty()
179238 logging.FromContext(ctx).Warn().Msg("window snapshot unavailable; keeping session snapshot dirty")
180239 return nil
181240 }
@@ -196,12 +255,90 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
196255
197256 if err := s.executeWithRetry(ctx, input); err != nil {
198257 s.markDirty()
258+ s.setTerminalError(err)
199259 return err
200260 }
201261
202262 return nil
203263 }
204264
265+// Active reports whether snapshot work is outstanding: pending debounce,
266+// an armed timer, or an in-flight capture/database save. A terminally
267+// failed save is settled-but-failed: dirty is preserved for reporting and
268+// later retry while lastErr records it, so the drain always terminates and
269+// never loops forever merely because dirty remains set.
270+func (s *Service) Active() bool {
271+ s.mu.Lock()
272+ defer s.mu.Unlock()
273+ if s.inflight.Load() > 0 {
274+ return true
275+ }
276+ if s.timer != nil {
277+ return true
278+ }
279+ if !s.dirty {
280+ return false
281+ }
282+ return s.lastErr == nil
283+}
284+
285+// LastError returns the terminal error of the most recent save, or nil.
286+// Dirty is preserved alongside it for reporting and later retry.
287+func (s *Service) LastError() error {
288+ s.mu.Lock()
289+ defer s.mu.Unlock()
290+ return s.lastErr
291+}
292+
293+// OnSettled registers a callback invoked when the service reaches a
294+// settled state after a save completes. Callbacks run on the completing
295+// goroutine and must be non-blocking; the UI wraps them with GTK dispatch.
296+// There is no unsubscribe; services are process-lifetime owners.
297+func (s *Service) OnSettled(fn func()) {
298+ if fn == nil {
299+ return
300+ }
301+ s.mu.Lock()
302+ defer s.mu.Unlock()
303+ s.onSettled = append(s.onSettled, fn)
304+}
305+
306+// WaitSettled blocks until no snapshot work is outstanding or ctx ends.
307+// It reports ctx.Err() on expiry so shutdown never waits forever. It must
308+// never be called on the GTK thread when capture dispatches there.
309+func (s *Service) WaitSettled(ctx context.Context) error {
310+ ticker := time.NewTicker(10 * time.Millisecond)
311+ defer ticker.Stop()
312+ for {
313+ if !s.Active() {
314+ return nil
315+ }
316+ select {
317+ case <-ctx.Done():
318+ return ctx.Err()
319+ case <-ticker.C:
320+ }
321+ }
322+}
323+
324+func (s *Service) setTerminalError(err error) {
325+ s.mu.Lock()
326+ defer s.mu.Unlock()
327+ s.lastErr = err
328+}
329+
330+func (s *Service) notifyIfSettled() {
331+ if s.Active() {
332+ return
333+ }
334+ s.mu.Lock()
335+ callbacks := append([]func(){}, s.onSettled...)
336+ s.mu.Unlock()
337+ for _, fn := range callbacks {
338+ fn()
339+ }
340+}
341+
205342 func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
206343 log := logging.FromContext(ctx)
207344 maxAttempts := s.retries + 1
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "strings"7 "strings"
8 "sync"8 "sync"
9+ "sync/atomic"
9 "time"10 "time"
10 11
11 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
@@ -23,6 +24,9 @@ const (
23 // Compile-time interface check.24 // Compile-time interface check.
24 var _ port.SnapshotService = (*Service)(nil)25 var _ port.SnapshotService = (*Service)(nil)
25 26
27+// Compile-time drain boundary check.
28+var _ port.PersistenceDrain = (*Service)(nil)
29+
26 // Service handles debounced session state snapshots.30 // Service handles debounced session state snapshots.
27 type Service struct {31 type Service struct {
28 snapshotUC *usecase.SnapshotSessionUseCase32 snapshotUC *usecase.SnapshotSessionUseCase
@@ -31,12 +35,24 @@ type Service struct {
31 retries int35 retries int
32 retryDelay time.Duration36 retryDelay time.Duration
33 37
34- mu sync.Mutex38+ mu sync.Mutex
35- timer *time.Timer39+ timer *time.Timer
36- dirty bool40+ timerGen uint64
37- ready bool // true when session is persisted to DB and snapshots can be saved41+ // stopped latches Stop: no new debounce work schedules afterwards.
38- ctx context.Context42+ stopped bool
39- cancel context.CancelFunc43+ dirty bool
44+ ready bool // true when session is persisted to DB and snapshots can be saved
45+ ctx context.Context
46+ cancel context.CancelFunc
47+
48+ // Drain boundary for the residency quiescence contract. inflight
49+ // counts an executing capture/database save; lastErr records the
50+ // terminal failure of the most recent save while dirty is preserved
51+ // for reporting and later retry. Failed writes are settled-but-failed:
52+ // the drain terminates instead of looping forever on dirty.
53+ inflight atomic.Int32
54+ lastErr error
55+ onSettled []func()
40 }56 }
41 57
42 // NewService creates a new snapshot service.58 // NewService creates a new snapshot service.
@@ -91,7 +107,11 @@ func (s *Service) SetReady() {
91 }()107 }()
92 }108 }
93 109
94-// Stop stops the service and saves final state.110+// Stop stops the service and saves final state, then joins an
111+// already-running save within a bounded wait so shutdown observes the
112+// drain barrier instead of racing it. The wait never blocks forever.
113+// Latching stopped also prevents any later debounce from scheduling work
114+// on the dead service.
95 func (s *Service) Stop(ctx context.Context) error {115 func (s *Service) Stop(ctx context.Context) error {
96 s.mu.Lock()116 s.mu.Lock()
97 if s.cancel != nil {117 if s.cancel != nil {
@@ -101,27 +121,52 @@ func (s *Service) Stop(ctx context.Context) error {
101 s.timer.Stop()121 s.timer.Stop()
102 s.timer = nil122 s.timer = nil
103 }123 }
124+ s.stopped = true
104 s.mu.Unlock()125 s.mu.Unlock()
105 126
106 // Final save on shutdown127 // Final save on shutdown
107- return s.SaveNow(ctx)128+ err := s.SaveNow(ctx)
129+ drainCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
130+ defer cancel()
131+ if waitErr := s.WaitSettled(drainCtx); waitErr != nil {
132+ logging.FromContext(ctx).Warn().Err(waitErr).Msg("snapshot drain did not settle before shutdown")
133+ }
134+ return err
108 }135 }
109 136
110 // MarkDirty signals that state has changed.137 // MarkDirty signals that state has changed.
111-// Debounces saves to avoid excessive DB writes.138+// Debounces saves to avoid excessive DB writes. It is a no-op after Stop
139+// so shutdown never resurrects debounce work on the dead service.
112 func (s *Service) MarkDirty() {140 func (s *Service) MarkDirty() {
113 s.mu.Lock()141 s.mu.Lock()
114 defer s.mu.Unlock()142 defer s.mu.Unlock()
115-143+ if s.stopped {
144+ return
145+ }
116 s.dirty = true146 s.dirty = true
117 147
118 // Reset or create timer148 // Reset or create timer
119 if s.timer != nil {149 if s.timer != nil {
120 s.timer.Stop()150 s.timer.Stop()
121 }151 }
122-152+ s.timerGen++
153+ gen := s.timerGen
123 s.timer = time.AfterFunc(s.interval, func() {154 s.timer = time.AfterFunc(s.interval, func() {
124 s.mu.Lock()155 s.mu.Lock()
156+ // Retire the owning timer as it fires: a non-nil timer means
157+ // debounce work is outstanding, so leaving it set would strand
158+ // the drain after the save settles. The generation check keeps
159+ // a superseded timer from clearing its replacement, and a
160+ // superseded or stopped timer saves nothing: the replacement
161+ // timer or Stop's own final save owns that work.
162+ if s.timerGen != gen || s.stopped {
163+ if s.timerGen == gen {
164+ s.timer = nil
165+ }
166+ s.mu.Unlock()
167+ return
168+ }
169+ s.timer = nil
125 ctx := s.ctx170 ctx := s.ctx
126 s.mu.Unlock()171 s.mu.Unlock()
127 172
@@ -160,14 +205,28 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
160 s.mu.Unlock()205 s.mu.Unlock()
161 return nil206 return nil
162 }207 }
208+ // Claim the dirty work atomically: concurrent timer and SaveNow paths
209+ // serialize here, so exactly one save runs and shutdown joins rather
210+ // than duplicates the active save.
211+ if !s.dirty {
212+ s.mu.Unlock()
213+ return nil
214+ }
163 // Only clear dirty when we're actually going to save215 // Only clear dirty when we're actually going to save
164 s.dirty = false216 s.dirty = false
217+ s.lastErr = nil
218+ s.inflight.Add(1)
165 s.mu.Unlock()219 s.mu.Unlock()
166 220
221+ defer func() {
222+ s.inflight.Add(-1)
223+ s.notifyIfSettled()
224+ }()
225+
167 sessionID := s.provider.GetSessionID()226 sessionID := s.provider.GetSessionID()
168 227
169 if sessionID == "" {228 if sessionID == "" {
170- s.markDirty()229+ s.MarkDirty()
171 return nil230 return nil
172 }231 }
173 232
@@ -175,7 +234,7 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
175 // A nil window list with no active index means the snapshot is truly unavailable,234 // A nil window list with no active index means the snapshot is truly unavailable,
176 // not merely empty. Keep the session dirty so a later capture can retry.235 // not merely empty. Keep the session dirty so a later capture can retry.
177 if windows == nil && activeWindowIndex < 0 {236 if windows == nil && activeWindowIndex < 0 {
178- s.markDirty()237+ s.MarkDirty()
179 logging.FromContext(ctx).Warn().Msg("window snapshot unavailable; keeping session snapshot dirty")238 logging.FromContext(ctx).Warn().Msg("window snapshot unavailable; keeping session snapshot dirty")
180 return nil239 return nil
181 }240 }
@@ -196,12 +255,90 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
196 255
197 if err := s.executeWithRetry(ctx, input); err != nil {256 if err := s.executeWithRetry(ctx, input); err != nil {
198 s.markDirty()257 s.markDirty()
258+ s.setTerminalError(err)
199 return err259 return err
200 }260 }
201 261
202 return nil262 return nil
203 }263 }
204 264
265+// Active reports whether snapshot work is outstanding: pending debounce,
266+// an armed timer, or an in-flight capture/database save. A terminally
267+// failed save is settled-but-failed: dirty is preserved for reporting and
268+// later retry while lastErr records it, so the drain always terminates and
269+// never loops forever merely because dirty remains set.
270+func (s *Service) Active() bool {
271+ s.mu.Lock()
272+ defer s.mu.Unlock()
273+ if s.inflight.Load() > 0 {
274+ return true
275+ }
276+ if s.timer != nil {
277+ return true
278+ }
279+ if !s.dirty {
280+ return false
281+ }
282+ return s.lastErr == nil
283+}
284+
285+// LastError returns the terminal error of the most recent save, or nil.
286+// Dirty is preserved alongside it for reporting and later retry.
287+func (s *Service) LastError() error {
288+ s.mu.Lock()
289+ defer s.mu.Unlock()
290+ return s.lastErr
291+}
292+
293+// OnSettled registers a callback invoked when the service reaches a
294+// settled state after a save completes. Callbacks run on the completing
295+// goroutine and must be non-blocking; the UI wraps them with GTK dispatch.
296+// There is no unsubscribe; services are process-lifetime owners.
297+func (s *Service) OnSettled(fn func()) {
298+ if fn == nil {
299+ return
300+ }
301+ s.mu.Lock()
302+ defer s.mu.Unlock()
303+ s.onSettled = append(s.onSettled, fn)
304+}
305+
306+// WaitSettled blocks until no snapshot work is outstanding or ctx ends.
307+// It reports ctx.Err() on expiry so shutdown never waits forever. It must
308+// never be called on the GTK thread when capture dispatches there.
309+func (s *Service) WaitSettled(ctx context.Context) error {
310+ ticker := time.NewTicker(10 * time.Millisecond)
311+ defer ticker.Stop()
312+ for {
313+ if !s.Active() {
314+ return nil
315+ }
316+ select {
317+ case <-ctx.Done():
318+ return ctx.Err()
319+ case <-ticker.C:
320+ }
321+ }
322+}
323+
324+func (s *Service) setTerminalError(err error) {
325+ s.mu.Lock()
326+ defer s.mu.Unlock()
327+ s.lastErr = err
328+}
329+
330+func (s *Service) notifyIfSettled() {
331+ if s.Active() {
332+ return
333+ }
334+ s.mu.Lock()
335+ callbacks := append([]func(){}, s.onSettled...)
336+ s.mu.Unlock()
337+ for _, fn := range callbacks {
338+ fn()
339+ }
340+}
341+
205 func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {342 func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
206 log := logging.FromContext(ctx)343 log := logging.FromContext(ctx)
207 maxAttempts := s.retries + 1344 maxAttempts := s.retries + 1
M internal/infrastructure/snapshot/service_test.go +167 -0
@@ -3,6 +3,7 @@ package snapshot
33 import (
44 "context"
55 "errors"
6+ "sync/atomic"
67 "testing"
78 "time"
89
@@ -27,6 +28,80 @@ func newWindowStateProvider(
2728 return provider
2829 }
2930
31+func TestService_DrainSettlesAfterDebounceAndSave(t *testing.T) {
32+ repo := repomocks.NewMockSessionStateRepository(t)
33+ repo.EXPECT().
34+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
35+ Return(nil).Once()
36+ uc := usecase.NewSnapshotSessionUseCase(repo)
37+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_drain", nil, 0), 60000)
38+ svc.ready = true
39+ require.False(t, svc.Active())
40+
41+ svc.MarkDirty()
42+ require.True(t, svc.Active(), "pending debounce is outstanding work")
43+
44+ require.NoError(t, svc.SaveNow(context.Background()))
45+ require.False(t, svc.Active())
46+ require.NoError(t, svc.LastError())
47+}
48+
49+func TestService_FailedSaveIsSettledButFailed(t *testing.T) {
50+ closedErr := errors.New("database is closed")
51+ repo := repomocks.NewMockSessionStateRepository(t)
52+ calls := 0
53+ repo.EXPECT().
54+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
55+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
56+ calls++
57+ return closedErr
58+ })
59+ uc := usecase.NewSnapshotSessionUseCase(repo)
60+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_closed", nil, 0), 60000)
61+ svc.ready = true
62+ svc.dirty = true
63+
64+ err := svc.SaveNow(context.Background())
65+ require.ErrorIs(t, err, closedErr)
66+ require.Equal(t, 1, calls, "no write retries after terminal database failure")
67+ require.True(t, svc.dirty, "dirty preserved for reporting and later retry")
68+ require.ErrorIs(t, svc.LastError(), closedErr)
69+ require.False(t, svc.Active(), "terminal failure settles the drain")
70+ require.NoError(t, svc.WaitSettled(context.Background()))
71+}
72+
73+func TestService_WaitSettledExpiry(t *testing.T) {
74+ repo := repomocks.NewMockSessionStateRepository(t)
75+ uc := usecase.NewSnapshotSessionUseCase(repo)
76+ svc := NewService(uc, mocks.NewMockWindowStateProvider(t), 60000)
77+ svc.MarkDirty()
78+ require.True(t, svc.Active())
79+
80+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
81+ defer cancel()
82+ require.Error(t, svc.WaitSettled(ctx), "drain must expire instead of looping forever")
83+}
84+
85+func TestService_OnSettledFiresAfterSave(t *testing.T) {
86+ repo := repomocks.NewMockSessionStateRepository(t)
87+ repo.EXPECT().
88+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
89+ Return(nil).Once()
90+ uc := usecase.NewSnapshotSessionUseCase(repo)
91+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_settled", nil, 0), 60000)
92+ svc.ready = true
93+ svc.dirty = true
94+ settled := make(chan struct{}, 1)
95+ svc.OnSettled(func() { settled <- struct{}{} })
96+
97+ require.NoError(t, svc.SaveNow(context.Background()))
98+ select {
99+ case <-settled:
100+ case <-time.After(5 * time.Second):
101+ t.Fatal("settled callback must fire after save completes")
102+ }
103+}
104+
30105 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
31106 repo := repomocks.NewMockSessionStateRepository(t)
32107 calls := 0
@@ -251,3 +326,95 @@ func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
251326 require.NoError(t, err)
252327 assert.False(t, svc.dirty)
253328 }
329+
330+func TestService_DebounceTimerClearsOnFire(t *testing.T) {
331+ repo := repomocks.NewMockSessionStateRepository(t)
332+ repo.EXPECT().
333+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
334+ Return(nil).Once()
335+ uc := usecase.NewSnapshotSessionUseCase(repo)
336+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fire", nil, 0), 1)
337+ svc.ready = true
338+ svc.ctx = context.Background()
339+ svc.MarkDirty()
340+ require.True(t, svc.Active())
341+ // The fired timer retires itself; the save settles the drain.
342+ require.Eventually(t, func() bool { return !svc.Active() }, 5*time.Second, 5*time.Millisecond)
343+}
344+
345+func TestService_StopJoinsInFlightSave(t *testing.T) {
346+ released := make(chan struct{})
347+ var calls atomic.Int32
348+ repo := repomocks.NewMockSessionStateRepository(t)
349+ repo.EXPECT().
350+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
351+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
352+ calls.Add(1)
353+ <-released
354+ return nil
355+ })
356+ uc := usecase.NewSnapshotSessionUseCase(repo)
357+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_join", nil, 0), 1)
358+ svc.ready = true
359+ svc.dirty = true
360+ saveDone := make(chan error, 1)
361+ go func() { saveDone <- svc.saveSnapshot(context.Background()) }()
362+ // The in-flight save reached the database: dirty is cleared and the
363+ // provider was consumed exactly once, so Stop cannot start a second.
364+ require.Eventually(t, func() bool { return calls.Load() == 1 }, 5*time.Second, 5*time.Millisecond)
365+ stopDone := make(chan error, 1)
366+ go func() { stopDone <- svc.Stop(context.Background()) }()
367+ select {
368+ case err := <-stopDone:
369+ t.Fatalf("Stop returned while a save was still in flight: %v", err)
370+ case <-time.After(200 * time.Millisecond):
371+ }
372+ close(released)
373+ select {
374+ case err := <-saveDone:
375+ require.NoError(t, err)
376+ case <-time.After(5 * time.Second):
377+ t.Fatal("in-flight save never completed")
378+ }
379+ select {
380+ case err := <-stopDone:
381+ require.NoError(t, err)
382+ case <-time.After(15 * time.Second):
383+ t.Fatal("Stop did not join the in-flight save")
384+ }
385+ require.False(t, svc.Active())
386+ require.Equal(t, int32(1), calls.Load(), "Stop must join, not duplicate, the save")
387+}
388+
389+func TestService_MarkDirtyNoOpAfterStop(t *testing.T) {
390+ repo := repomocks.NewMockSessionStateRepository(t)
391+ uc := usecase.NewSnapshotSessionUseCase(repo)
392+ svc := NewService(uc, mocks.NewMockWindowStateProvider(t), 1)
393+ require.NoError(t, svc.Stop(context.Background()))
394+ svc.MarkDirty()
395+ require.False(t, svc.Active(), "stopped service schedules nothing")
396+ time.Sleep(20 * time.Millisecond)
397+ require.False(t, svc.Active())
398+}
399+
400+func TestService_SupersededTimerSavesNothing(t *testing.T) {
401+ var calls atomic.Int32
402+ repo := repomocks.NewMockSessionStateRepository(t)
403+ repo.EXPECT().
404+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
405+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
406+ calls.Add(1)
407+ return nil
408+ })
409+ uc := usecase.NewSnapshotSessionUseCase(repo)
410+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_superseded", nil, 0), 1)
411+ svc.ready = true
412+ svc.MarkDirty()
413+ // Stop consumes the pending work in its own final save and retires
414+ // the debounce timer; the stopped timer callback must save nothing.
415+ require.NoError(t, svc.Stop(context.Background()))
416+ require.Equal(t, int32(1), calls.Load())
417+ time.Sleep(50 * time.Millisecond)
418+ require.Equal(t, int32(1), calls.Load(), "stopped timer must not save")
419+ require.False(t, svc.Active())
420+}
@@ -3,6 +3,7 @@ package snapshot
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "sync/atomic"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -27,6 +28,80 @@ func newWindowStateProvider(
27 return provider28 return provider
28 }29 }
29 30
31+func TestService_DrainSettlesAfterDebounceAndSave(t *testing.T) {
32+ repo := repomocks.NewMockSessionStateRepository(t)
33+ repo.EXPECT().
34+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
35+ Return(nil).Once()
36+ uc := usecase.NewSnapshotSessionUseCase(repo)
37+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_drain", nil, 0), 60000)
38+ svc.ready = true
39+ require.False(t, svc.Active())
40+
41+ svc.MarkDirty()
42+ require.True(t, svc.Active(), "pending debounce is outstanding work")
43+
44+ require.NoError(t, svc.SaveNow(context.Background()))
45+ require.False(t, svc.Active())
46+ require.NoError(t, svc.LastError())
47+}
48+
49+func TestService_FailedSaveIsSettledButFailed(t *testing.T) {
50+ closedErr := errors.New("database is closed")
51+ repo := repomocks.NewMockSessionStateRepository(t)
52+ calls := 0
53+ repo.EXPECT().
54+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
55+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
56+ calls++
57+ return closedErr
58+ })
59+ uc := usecase.NewSnapshotSessionUseCase(repo)
60+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_closed", nil, 0), 60000)
61+ svc.ready = true
62+ svc.dirty = true
63+
64+ err := svc.SaveNow(context.Background())
65+ require.ErrorIs(t, err, closedErr)
66+ require.Equal(t, 1, calls, "no write retries after terminal database failure")
67+ require.True(t, svc.dirty, "dirty preserved for reporting and later retry")
68+ require.ErrorIs(t, svc.LastError(), closedErr)
69+ require.False(t, svc.Active(), "terminal failure settles the drain")
70+ require.NoError(t, svc.WaitSettled(context.Background()))
71+}
72+
73+func TestService_WaitSettledExpiry(t *testing.T) {
74+ repo := repomocks.NewMockSessionStateRepository(t)
75+ uc := usecase.NewSnapshotSessionUseCase(repo)
76+ svc := NewService(uc, mocks.NewMockWindowStateProvider(t), 60000)
77+ svc.MarkDirty()
78+ require.True(t, svc.Active())
79+
80+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
81+ defer cancel()
82+ require.Error(t, svc.WaitSettled(ctx), "drain must expire instead of looping forever")
83+}
84+
85+func TestService_OnSettledFiresAfterSave(t *testing.T) {
86+ repo := repomocks.NewMockSessionStateRepository(t)
87+ repo.EXPECT().
88+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
89+ Return(nil).Once()
90+ uc := usecase.NewSnapshotSessionUseCase(repo)
91+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_settled", nil, 0), 60000)
92+ svc.ready = true
93+ svc.dirty = true
94+ settled := make(chan struct{}, 1)
95+ svc.OnSettled(func() { settled <- struct{}{} })
96+
97+ require.NoError(t, svc.SaveNow(context.Background()))
98+ select {
99+ case <-settled:
100+ case <-time.After(5 * time.Second):
101+ t.Fatal("settled callback must fire after save completes")
102+ }
103+}
104+
30 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {105 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
31 repo := repomocks.NewMockSessionStateRepository(t)106 repo := repomocks.NewMockSessionStateRepository(t)
32 calls := 0107 calls := 0
@@ -251,3 +326,95 @@ func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
251 require.NoError(t, err)326 require.NoError(t, err)
252 assert.False(t, svc.dirty)327 assert.False(t, svc.dirty)
253 }328 }
329+
330+func TestService_DebounceTimerClearsOnFire(t *testing.T) {
331+ repo := repomocks.NewMockSessionStateRepository(t)
332+ repo.EXPECT().
333+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
334+ Return(nil).Once()
335+ uc := usecase.NewSnapshotSessionUseCase(repo)
336+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fire", nil, 0), 1)
337+ svc.ready = true
338+ svc.ctx = context.Background()
339+ svc.MarkDirty()
340+ require.True(t, svc.Active())
341+ // The fired timer retires itself; the save settles the drain.
342+ require.Eventually(t, func() bool { return !svc.Active() }, 5*time.Second, 5*time.Millisecond)
343+}
344+
345+func TestService_StopJoinsInFlightSave(t *testing.T) {
346+ released := make(chan struct{})
347+ var calls atomic.Int32
348+ repo := repomocks.NewMockSessionStateRepository(t)
349+ repo.EXPECT().
350+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
351+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
352+ calls.Add(1)
353+ <-released
354+ return nil
355+ })
356+ uc := usecase.NewSnapshotSessionUseCase(repo)
357+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_join", nil, 0), 1)
358+ svc.ready = true
359+ svc.dirty = true
360+ saveDone := make(chan error, 1)
361+ go func() { saveDone <- svc.saveSnapshot(context.Background()) }()
362+ // The in-flight save reached the database: dirty is cleared and the
363+ // provider was consumed exactly once, so Stop cannot start a second.
364+ require.Eventually(t, func() bool { return calls.Load() == 1 }, 5*time.Second, 5*time.Millisecond)
365+ stopDone := make(chan error, 1)
366+ go func() { stopDone <- svc.Stop(context.Background()) }()
367+ select {
368+ case err := <-stopDone:
369+ t.Fatalf("Stop returned while a save was still in flight: %v", err)
370+ case <-time.After(200 * time.Millisecond):
371+ }
372+ close(released)
373+ select {
374+ case err := <-saveDone:
375+ require.NoError(t, err)
376+ case <-time.After(5 * time.Second):
377+ t.Fatal("in-flight save never completed")
378+ }
379+ select {
380+ case err := <-stopDone:
381+ require.NoError(t, err)
382+ case <-time.After(15 * time.Second):
383+ t.Fatal("Stop did not join the in-flight save")
384+ }
385+ require.False(t, svc.Active())
386+ require.Equal(t, int32(1), calls.Load(), "Stop must join, not duplicate, the save")
387+}
388+
389+func TestService_MarkDirtyNoOpAfterStop(t *testing.T) {
390+ repo := repomocks.NewMockSessionStateRepository(t)
391+ uc := usecase.NewSnapshotSessionUseCase(repo)
392+ svc := NewService(uc, mocks.NewMockWindowStateProvider(t), 1)
393+ require.NoError(t, svc.Stop(context.Background()))
394+ svc.MarkDirty()
395+ require.False(t, svc.Active(), "stopped service schedules nothing")
396+ time.Sleep(20 * time.Millisecond)
397+ require.False(t, svc.Active())
398+}
399+
400+func TestService_SupersededTimerSavesNothing(t *testing.T) {
401+ var calls atomic.Int32
402+ repo := repomocks.NewMockSessionStateRepository(t)
403+ repo.EXPECT().
404+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
405+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
406+ calls.Add(1)
407+ return nil
408+ })
409+ uc := usecase.NewSnapshotSessionUseCase(repo)
410+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_superseded", nil, 0), 1)
411+ svc.ready = true
412+ svc.MarkDirty()
413+ // Stop consumes the pending work in its own final save and retires
414+ // the debounce timer; the stopped timer callback must save nothing.
415+ require.NoError(t, svc.Stop(context.Background()))
416+ require.Equal(t, int32(1), calls.Load())
417+ time.Sleep(50 * time.Millisecond)
418+ require.Equal(t, int32(1), calls.Load(), "stopped timer must not save")
419+ require.False(t, svc.Active())
420+}
M internal/ui/app.go +76 -4
@@ -139,6 +139,17 @@ type App struct {
139139 // Engine
140140 engine port.Engine
141141
142+ // residencyTimeout is the effective bounded-residency idle timeout
143+ // latched once at process startup. Live config snapshots are replaced
144+ // wholesale, so this field is never re-read from them; a changed value
145+ // is reported as restart-required instead of partially hot-applied.
146+ residencyTimeout time.Duration
147+ // residency owns the opt-in idle hold and deadline. Always non-nil
148+ // after Run starts; nil beforehand (e.g. in tests without Run).
149+ residency *ResidencyController
150+ // residencyUnsubscribe drops native quiescence subscriptions at shutdown.
151+ residencyUnsubscribe []func()
152+
142153 // Web content (managed by content.Coordinator)
143154 faviconAdapter *adapter.FaviconAdapter
144155
@@ -341,6 +352,34 @@ func (a *App) Run(ctx context.Context, args []string) int {
341352 defer a.gtkApp.Unref()
342353 a.dispatchOnMainThread = a.runOnMainThread
343354
355+ // Bounded opt-in residency owns exactly one application hold, taken
356+ // only when enabled and released once on every Run exit (including
357+ // activation failure before the shutdown signal).
358+ a.residency = NewResidencyController(a.residencyTimeout, time.Now, nil, nil, a.Quit)
359+ a.residency.schedule = a.residency.scheduleGLibTimeout
360+ a.residency.cancel = a.residency.cancelGLibTimeout
361+ a.residency.SetDispatchToGTK(func(f func()) {
362+ callback := glib.SourceFunc(func(_ uintptr) bool {
363+ f()
364+ return false
365+ })
366+ glib.IdleAdd(&callback, 0)
367+ })
368+ a.residency.SetQuiescentFunc(a.residencyQuiescent)
369+ a.residency.SetSealFunc(a.closeRelayAdmission)
370+ a.residency.SetUnsealFunc(func() {
371+ if a.deps == nil {
372+ return
373+ }
374+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
375+ if gate := provider.AdmissionGate(); gate != nil {
376+ gate.Reopen()
377+ }
378+ }
379+ })
380+ a.residency.AcquireHold(a.gtkApp)
381+ defer a.residency.ReleaseHold(a.gtkApp)
382+
344383 // Connect activate signal
345384 activateCb := func(_ gio.Application) {
346385 a.onActivate(ctx)
@@ -372,6 +411,12 @@ func (a *App) onActivate(ctx context.Context) {
372411
373412 if err := a.openInitialBrowserWindowShell(ctx, a.initialWindowURL()); err != nil {
374413 log.Error().Err(err).Msg("failed to create main window")
414+ // Reach bounded idle or orderly shutdown instead of lingering on
415+ // the residency hold with no windows: disabled residency quits here
416+ // exactly as before (GTK auto-exits holderless), enabled arms.
417+ if a.residencyShouldQuitAfterLastWindow() {
418+ a.Quit()
419+ }
375420 return
376421 }
377422
@@ -388,6 +433,7 @@ func (a *App) onActivate(ctx context.Context) {
388433 a.wireSessionManagerShortcut()
389434 a.initSnapshotService(ctx)
390435 a.initUpdateCoordinator(ctx)
436+ a.subscribeResidencyQuiescence()
391437 a.createInitialTab(ctx)
392438 a.finalizeActivation(ctx)
393439 }
@@ -3065,6 +3111,19 @@ func (a *App) onShutdown(ctx context.Context) {
30653111 log := logging.FromContext(ctx)
30663112 log.Debug().Msg("GTK application shutting down")
30673113
3114+ // Stop relay admission before snapshot/update teardown so no new work
3115+ // starts while persistence drains. Admitted leases keep their sockets
3116+ // until dispatch settles; off-thread quits already drained beforehand
3117+ // (see Quit), and on the GTK thread itself a drain wait is impossible,
3118+ // so teardown relies on close-then-teardown ordering here.
3119+ a.closeRelayAdmission()
3120+ a.unsubscribeResidencyQuiescence()
3121+
3122+ // Persistence drain barriers, in teardown order. The snapshot service
3123+ // owns debounced session writes (Stop performs the final synchronous
3124+ // save); the history recorder flushes synchronously on Close. All
3125+ // other persistence owners are synchronous use-case calls with no
3126+ // background work, so no additional drain exists.
30683127 // Save final session state before shutdown
30693128 if a.snapshotService != nil {
30703129 if err := a.snapshotService.Stop(ctx); err != nil {
@@ -3240,10 +3299,8 @@ func (a *App) initTabCoordinator(ctx context.Context) {
32403299 bw.mainWindow.Destroy()
32413300 }
32423301 }
3243- // Quit the app only when all browser windows are gone.
3244- if len(a.browserWindows) == 0 {
3245- a.Quit()
3246- }
3302+ // Quitting is decided inside removeBrowserWindow through residency;
3303+ // nothing further to do here.
32473304 })
32483305 // Wire popup tab WebView attachment
32493306 a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab)
@@ -3779,11 +3836,26 @@ func (a *App) Quit() {
37793836 return
37803837 }
37813838 quit := func() {
3839+ // The whole residency/GTK quit transition runs on the GTK thread:
3840+ // canceling the idle timer touches GLib sources, which is only
3841+ // safe on its owner thread. Explicit quits and signals never wait
3842+ // for the idle deadline and must not bypass orderly cleanup.
3843+ if a.residency != nil {
3844+ a.residency.NoteExplicitQuit()
3845+ }
37823846 if a.gtkApp != nil {
37833847 a.gtkApp.Quit()
37843848 }
37853849 }
37863850 glibCtx := glib.MainContextDefault()
3851+ if glibCtx == nil || !glibCtx.IsOwner() {
3852+ // Off-thread quits (signals, relay threads) drain admission and
3853+ // persistence BEFORE entering GTK shutdown, while the loop can
3854+ // still service admitted work. On the GTK thread itself this wait
3855+ // is impossible, so shutdown relies on close-then-teardown
3856+ // ordering instead; the dispatched closure below never blocks.
3857+ a.drainForShutdownOffThread()
3858+ }
37873859 if glibCtx != nil && glibCtx.IsOwner() {
37883860 quit()
37893861 return
@@ -139,6 +139,17 @@ type App struct {
139 // Engine139 // Engine
140 engine port.Engine140 engine port.Engine
141 141
142+ // residencyTimeout is the effective bounded-residency idle timeout
143+ // latched once at process startup. Live config snapshots are replaced
144+ // wholesale, so this field is never re-read from them; a changed value
145+ // is reported as restart-required instead of partially hot-applied.
146+ residencyTimeout time.Duration
147+ // residency owns the opt-in idle hold and deadline. Always non-nil
148+ // after Run starts; nil beforehand (e.g. in tests without Run).
149+ residency *ResidencyController
150+ // residencyUnsubscribe drops native quiescence subscriptions at shutdown.
151+ residencyUnsubscribe []func()
152+
142 // Web content (managed by content.Coordinator)153 // Web content (managed by content.Coordinator)
143 faviconAdapter *adapter.FaviconAdapter154 faviconAdapter *adapter.FaviconAdapter
144 155
@@ -341,6 +352,34 @@ func (a *App) Run(ctx context.Context, args []string) int {
341 defer a.gtkApp.Unref()352 defer a.gtkApp.Unref()
342 a.dispatchOnMainThread = a.runOnMainThread353 a.dispatchOnMainThread = a.runOnMainThread
343 354
355+ // Bounded opt-in residency owns exactly one application hold, taken
356+ // only when enabled and released once on every Run exit (including
357+ // activation failure before the shutdown signal).
358+ a.residency = NewResidencyController(a.residencyTimeout, time.Now, nil, nil, a.Quit)
359+ a.residency.schedule = a.residency.scheduleGLibTimeout
360+ a.residency.cancel = a.residency.cancelGLibTimeout
361+ a.residency.SetDispatchToGTK(func(f func()) {
362+ callback := glib.SourceFunc(func(_ uintptr) bool {
363+ f()
364+ return false
365+ })
366+ glib.IdleAdd(&callback, 0)
367+ })
368+ a.residency.SetQuiescentFunc(a.residencyQuiescent)
369+ a.residency.SetSealFunc(a.closeRelayAdmission)
370+ a.residency.SetUnsealFunc(func() {
371+ if a.deps == nil {
372+ return
373+ }
374+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
375+ if gate := provider.AdmissionGate(); gate != nil {
376+ gate.Reopen()
377+ }
378+ }
379+ })
380+ a.residency.AcquireHold(a.gtkApp)
381+ defer a.residency.ReleaseHold(a.gtkApp)
382+
344 // Connect activate signal383 // Connect activate signal
345 activateCb := func(_ gio.Application) {384 activateCb := func(_ gio.Application) {
346 a.onActivate(ctx)385 a.onActivate(ctx)
@@ -372,6 +411,12 @@ func (a *App) onActivate(ctx context.Context) {
372 411
373 if err := a.openInitialBrowserWindowShell(ctx, a.initialWindowURL()); err != nil {412 if err := a.openInitialBrowserWindowShell(ctx, a.initialWindowURL()); err != nil {
374 log.Error().Err(err).Msg("failed to create main window")413 log.Error().Err(err).Msg("failed to create main window")
414+ // Reach bounded idle or orderly shutdown instead of lingering on
415+ // the residency hold with no windows: disabled residency quits here
416+ // exactly as before (GTK auto-exits holderless), enabled arms.
417+ if a.residencyShouldQuitAfterLastWindow() {
418+ a.Quit()
419+ }
375 return420 return
376 }421 }
377 422
@@ -388,6 +433,7 @@ func (a *App) onActivate(ctx context.Context) {
388 a.wireSessionManagerShortcut()433 a.wireSessionManagerShortcut()
389 a.initSnapshotService(ctx)434 a.initSnapshotService(ctx)
390 a.initUpdateCoordinator(ctx)435 a.initUpdateCoordinator(ctx)
436+ a.subscribeResidencyQuiescence()
391 a.createInitialTab(ctx)437 a.createInitialTab(ctx)
392 a.finalizeActivation(ctx)438 a.finalizeActivation(ctx)
393 }439 }
@@ -3065,6 +3111,19 @@ func (a *App) onShutdown(ctx context.Context) {
3065 log := logging.FromContext(ctx)3111 log := logging.FromContext(ctx)
3066 log.Debug().Msg("GTK application shutting down")3112 log.Debug().Msg("GTK application shutting down")
3067 3113
3114+ // Stop relay admission before snapshot/update teardown so no new work
3115+ // starts while persistence drains. Admitted leases keep their sockets
3116+ // until dispatch settles; off-thread quits already drained beforehand
3117+ // (see Quit), and on the GTK thread itself a drain wait is impossible,
3118+ // so teardown relies on close-then-teardown ordering here.
3119+ a.closeRelayAdmission()
3120+ a.unsubscribeResidencyQuiescence()
3121+
3122+ // Persistence drain barriers, in teardown order. The snapshot service
3123+ // owns debounced session writes (Stop performs the final synchronous
3124+ // save); the history recorder flushes synchronously on Close. All
3125+ // other persistence owners are synchronous use-case calls with no
3126+ // background work, so no additional drain exists.
3068 // Save final session state before shutdown3127 // Save final session state before shutdown
3069 if a.snapshotService != nil {3128 if a.snapshotService != nil {
3070 if err := a.snapshotService.Stop(ctx); err != nil {3129 if err := a.snapshotService.Stop(ctx); err != nil {
@@ -3240,10 +3299,8 @@ func (a *App) initTabCoordinator(ctx context.Context) {
3240 bw.mainWindow.Destroy()3299 bw.mainWindow.Destroy()
3241 }3300 }
3242 }3301 }
3243- // Quit the app only when all browser windows are gone.3302+ // Quitting is decided inside removeBrowserWindow through residency;
3244- if len(a.browserWindows) == 0 {3303+ // nothing further to do here.
3245- a.Quit()
3246- }
3247 })3304 })
3248 // Wire popup tab WebView attachment3305 // Wire popup tab WebView attachment
3249 a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab)3306 a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab)
@@ -3779,11 +3836,26 @@ func (a *App) Quit() {
3779 return3836 return
3780 }3837 }
3781 quit := func() {3838 quit := func() {
3839+ // The whole residency/GTK quit transition runs on the GTK thread:
3840+ // canceling the idle timer touches GLib sources, which is only
3841+ // safe on its owner thread. Explicit quits and signals never wait
3842+ // for the idle deadline and must not bypass orderly cleanup.
3843+ if a.residency != nil {
3844+ a.residency.NoteExplicitQuit()
3845+ }
3782 if a.gtkApp != nil {3846 if a.gtkApp != nil {
3783 a.gtkApp.Quit()3847 a.gtkApp.Quit()
3784 }3848 }
3785 }3849 }
3786 glibCtx := glib.MainContextDefault()3850 glibCtx := glib.MainContextDefault()
3851+ if glibCtx == nil || !glibCtx.IsOwner() {
3852+ // Off-thread quits (signals, relay threads) drain admission and
3853+ // persistence BEFORE entering GTK shutdown, while the loop can
3854+ // still service admitted work. On the GTK thread itself this wait
3855+ // is impossible, so shutdown relies on close-then-teardown
3856+ // ordering instead; the dispatched closure below never blocks.
3857+ a.drainForShutdownOffThread()
3858+ }
3787 if glibCtx != nil && glibCtx.IsOwner() {3859 if glibCtx != nil && glibCtx.IsOwner() {
3788 quit()3860 quit()
3789 return3861 return
M internal/ui/browser_window.go +2 -0
@@ -343,6 +343,7 @@ func (a *App) registerBrowserWindow(bw *browserWindow) {
343343 return
344344 }
345345 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
346+ a.residencyNoteWindowsChanged()
346347 }
347348
348349 func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
@@ -392,6 +393,7 @@ func (a *App) removeBrowserWindow(id string) {
392393 if wasMainWindow {
393394 a.clearMainBrowserWindowAfterRemoval(fallback)
394395 }
396+ a.residencyAfterWindowRemoved()
395397 }
396398
397399 func (a *App) releaseNativePopupsForBrowserWindow(windowID string) {
@@ -343,6 +343,7 @@ func (a *App) registerBrowserWindow(bw *browserWindow) {
343 return343 return
344 }344 }
345 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)345 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
346+ a.residencyNoteWindowsChanged()
346 }347 }
347 348
348 func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {349 func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
@@ -392,6 +393,7 @@ func (a *App) removeBrowserWindow(id string) {
392 if wasMainWindow {393 if wasMainWindow {
393 a.clearMainBrowserWindowAfterRemoval(fallback)394 a.clearMainBrowserWindowAfterRemoval(fallback)
394 }395 }
396+ a.residencyAfterWindowRemoved()
395 }397 }
396 398
397 func (a *App) releaseNativePopupsForBrowserWindow(windowID string) {399 func (a *App) releaseNativePopupsForBrowserWindow(windowID string) {
A internal/ui/runtime_residency.go +598 -0
@@ -0,0 +1,598 @@
1+package ui
2+
3+import (
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.
14+const 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.
21+func 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.
34+func (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.
42+func (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.
52+func (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.
62+type 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.
72+type 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.
107+func 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.
129+func (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.
137+func (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.
146+func (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.
157+func (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.
197+func (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.
207+func (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.
219+func (c *ResidencyController) OnTimerFired(generation uint64) {
220+ if c == nil {
221+ return
222+ }
223+ c.apply(c.policy.HandleExpiry(c.clock(), generation))
224+}
225+
226+func (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.
254+func (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.
277+func (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.
297+func (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).
307+func (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.
317+func (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.
326+func (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.
335+func (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.
349+func (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+
370+func (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.
386+func (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.
406+func (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.
417+func 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.
427+func (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.
438+func (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.
451+func (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.
473+func (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.
483+type 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.
489+func (a *App) residencyQuiescent() bool {
490+ if a == nil {
491+ return true
492+ }
493+ if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil {
494+ if act := eng.RuntimeActivity(); act != nil && !act.Snapshot().Quiescent() {
495+ return false
496+ }
497+ }
498+ if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil && drain.Active() {
499+ return false
500+ }
501+ if a.deps != nil {
502+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
503+ if gate := provider.AdmissionGate(); gate != nil && gate.Active() > 0 {
504+ return false
505+ }
506+ }
507+ }
508+ return true
509+}
510+
511+// subscribeResidencyQuiescence wires native settle callbacks into the
512+// residency controller. CEF activity transitions drive policy work events;
513+// persistence and relay settle paths recheck a deferred quit. Every
514+// callback crosses into GTK through the controller dispatch: no GTK work
515+// runs on CEF callbacks and no per-frame polling observes quiescence.
516+func (a *App) subscribeResidencyQuiescence() {
517+ r := a.residency
518+ if a == nil || r == nil || !r.Enabled() {
519+ return
520+ }
521+ dispatch := func(fn func()) { r.DispatchToGTK(fn) }
522+ if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil {
523+ if act := eng.RuntimeActivity(); act != nil {
524+ unsubscribe := act.Subscribe(func(snapshot port.RuntimeActivitySnapshot) {
525+ busy := !snapshot.Quiescent()
526+ dispatch(func() { r.SetNativeBusy("cef-activity", busy) })
527+ })
528+ a.residencyUnsubscribe = append(a.residencyUnsubscribe, unsubscribe)
529+ }
530+ }
531+ if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil {
532+ if notifier, ok := drain.(interface{ OnSettled(func()) }); ok {
533+ notifier.OnSettled(func() { dispatch(func() { r.MaybeQuit() }) })
534+ }
535+ }
536+ if a.deps != nil {
537+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
538+ if gate := provider.AdmissionGate(); gate != nil {
539+ gate.OnDrained(func() { dispatch(func() { r.MaybeQuit() }) })
540+ }
541+ }
542+ }
543+}
544+
545+// unsubscribeResidencyQuiescence drops native subscriptions at shutdown.
546+func (a *App) unsubscribeResidencyQuiescence() {
547+ if a == nil {
548+ return
549+ }
550+ for _, unsubscribe := range a.residencyUnsubscribe {
551+ if unsubscribe != nil {
552+ unsubscribe()
553+ }
554+ }
555+ a.residencyUnsubscribe = nil
556+}
557+
558+// closeRelayAdmission stops relay admission before snapshot/update
559+// teardown so no new work starts while persistence drains.
560+func (a *App) closeRelayAdmission() {
561+ if a == nil || a.deps == nil {
562+ return
563+ }
564+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
565+ if gate := provider.AdmissionGate(); gate != nil {
566+ gate.Close()
567+ }
568+ }
569+}
570+
571+// drainForShutdownOffThread settles admission leases and persistence
572+// before GTK shutdown, while the loop can still service admitted work.
573+// It runs only off the GTK thread (see Quit); every wait is bounded and
574+// expiry only logs.
575+func (a *App) drainForShutdownOffThread() {
576+ if a == nil {
577+ return
578+ }
579+ a.closeRelayAdmission()
580+ if a.deps != nil {
581+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
582+ if gate := provider.AdmissionGate(); gate != nil && gate.Active() > 0 {
583+ drainCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
584+ if !gate.WaitDrained(drainCtx) {
585+ logging.FromContext(context.Background()).Warn().Int("admitted", gate.Active()).Msg("relay admission did not drain before shutdown")
586+ }
587+ cancel()
588+ }
589+ }
590+ }
591+ if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil {
592+ drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
593+ if err := drain.WaitSettled(drainCtx); err != nil {
594+ logging.FromContext(context.Background()).Warn().Err(err).Msg("persistence did not settle before shutdown")
595+ }
596+ cancel()
597+ }
598+}
@@ -0,0 +1,598 @@
1+package ui
2+
3+import (
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.
14+const 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.
21+func 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.
34+func (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.
42+func (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.
52+func (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.
62+type 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.
72+type 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.
107+func 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.
129+func (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.
137+func (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.
146+func (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.
157+func (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.
197+func (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.
207+func (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.
219+func (c *ResidencyController) OnTimerFired(generation uint64) {
220+ if c == nil {
221+ return
222+ }
223+ c.apply(c.policy.HandleExpiry(c.clock(), generation))
224+}
225+
226+func (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.
254+func (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.
277+func (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.
297+func (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).
307+func (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.
317+func (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.
326+func (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.
335+func (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.
349+func (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+
370+func (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.
386+func (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.
406+func (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.
417+func 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.
427+func (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.
438+func (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.
451+func (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.
473+func (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.
483+type 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.
489+func (a *App) residencyQuiescent() bool {
490+ if a == nil {
491+ return true
492+ }
493+ if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil {
494+ if act := eng.RuntimeActivity(); act != nil && !act.Snapshot().Quiescent() {
495+ return false
496+ }
497+ }
498+ if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil && drain.Active() {
499+ return false
500+ }
501+ if a.deps != nil {
502+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
503+ if gate := provider.AdmissionGate(); gate != nil && gate.Active() > 0 {
504+ return false
505+ }
506+ }
507+ }
508+ return true
509+}
510+
511+// subscribeResidencyQuiescence wires native settle callbacks into the
512+// residency controller. CEF activity transitions drive policy work events;
513+// persistence and relay settle paths recheck a deferred quit. Every
514+// callback crosses into GTK through the controller dispatch: no GTK work
515+// runs on CEF callbacks and no per-frame polling observes quiescence.
516+func (a *App) subscribeResidencyQuiescence() {
517+ r := a.residency
518+ if a == nil || r == nil || !r.Enabled() {
519+ return
520+ }
521+ dispatch := func(fn func()) { r.DispatchToGTK(fn) }
522+ if eng, ok := a.engine.(interface{ RuntimeActivity() port.RuntimeActivity }); ok && eng != nil {
523+ if act := eng.RuntimeActivity(); act != nil {
524+ unsubscribe := act.Subscribe(func(snapshot port.RuntimeActivitySnapshot) {
525+ busy := !snapshot.Quiescent()
526+ dispatch(func() { r.SetNativeBusy("cef-activity", busy) })
527+ })
528+ a.residencyUnsubscribe = append(a.residencyUnsubscribe, unsubscribe)
529+ }
530+ }
531+ if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil {
532+ if notifier, ok := drain.(interface{ OnSettled(func()) }); ok {
533+ notifier.OnSettled(func() { dispatch(func() { r.MaybeQuit() }) })
534+ }
535+ }
536+ if a.deps != nil {
537+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
538+ if gate := provider.AdmissionGate(); gate != nil {
539+ gate.OnDrained(func() { dispatch(func() { r.MaybeQuit() }) })
540+ }
541+ }
542+ }
543+}
544+
545+// unsubscribeResidencyQuiescence drops native subscriptions at shutdown.
546+func (a *App) unsubscribeResidencyQuiescence() {
547+ if a == nil {
548+ return
549+ }
550+ for _, unsubscribe := range a.residencyUnsubscribe {
551+ if unsubscribe != nil {
552+ unsubscribe()
553+ }
554+ }
555+ a.residencyUnsubscribe = nil
556+}
557+
558+// closeRelayAdmission stops relay admission before snapshot/update
559+// teardown so no new work starts while persistence drains.
560+func (a *App) closeRelayAdmission() {
561+ if a == nil || a.deps == nil {
562+ return
563+ }
564+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
565+ if gate := provider.AdmissionGate(); gate != nil {
566+ gate.Close()
567+ }
568+ }
569+}
570+
571+// drainForShutdownOffThread settles admission leases and persistence
572+// before GTK shutdown, while the loop can still service admitted work.
573+// It runs only off the GTK thread (see Quit); every wait is bounded and
574+// expiry only logs.
575+func (a *App) drainForShutdownOffThread() {
576+ if a == nil {
577+ return
578+ }
579+ a.closeRelayAdmission()
580+ if a.deps != nil {
581+ if provider, ok := a.deps.BrowserLaunchRelay.(relayAdmissionProvider); ok && provider != nil {
582+ if gate := provider.AdmissionGate(); gate != nil && gate.Active() > 0 {
583+ drainCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
584+ if !gate.WaitDrained(drainCtx) {
585+ logging.FromContext(context.Background()).Warn().Int("admitted", gate.Active()).Msg("relay admission did not drain before shutdown")
586+ }
587+ cancel()
588+ }
589+ }
590+ }
591+ if drain, ok := a.snapshotService.(port.PersistenceDrain); ok && drain != nil {
592+ drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
593+ if err := drain.WaitSettled(drainCtx); err != nil {
594+ logging.FromContext(context.Background()).Warn().Err(err).Msg("persistence did not settle before shutdown")
595+ }
596+ cancel()
597+ }
598+}
A internal/ui/runtime_residency_test.go +252 -0
@@ -0,0 +1,252 @@
1+package ui
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResidencyTimeoutFromMillis(t *testing.T) {
12+ require.Equal(t, time.Duration(0), ResidencyTimeoutFromMillis(0))
13+ require.Equal(t, time.Duration(0), ResidencyTimeoutFromMillis(-5))
14+ require.Equal(t, 60*time.Second, ResidencyTimeoutFromMillis(60000))
15+ require.Equal(t, 300000*time.Millisecond, ResidencyTimeoutFromMillis(300000))
16+ require.Equal(t, 300000*time.Millisecond, ResidencyTimeoutFromMillis(999999))
17+}
18+
19+func TestResidencyTimeoutLatchAndRestartRequired(t *testing.T) {
20+ app := &App{}
21+ require.Equal(t, time.Duration(0), app.LatchedResidencyTimeout())
22+
23+ app.SetResidencyTimeout(60 * time.Second)
24+ require.Equal(t, 60*time.Second, app.LatchedResidencyTimeout())
25+ require.False(t, app.ResidencyTimeoutRestartRequired(60000))
26+ require.True(t, app.ResidencyTimeoutRestartRequired(0))
27+ require.True(t, app.ResidencyTimeoutRestartRequired(120000))
28+
29+ // A nil app never panics and reports no change.
30+ var nilApp *App
31+ nilApp.SetResidencyTimeout(time.Minute)
32+ require.Equal(t, time.Duration(0), nilApp.LatchedResidencyTimeout())
33+ require.False(t, nilApp.ResidencyTimeoutRestartRequired(60000))
34+}
35+
36+type residencyFakeApp struct {
37+ holds int
38+ releases int
39+ quits int
40+}
41+
42+func (f *residencyFakeApp) Hold() { f.holds++ }
43+func (f *residencyFakeApp) Release() { f.releases++ }
44+func (f *residencyFakeApp) Quit() { f.quits++ }
45+
46+type residencyFakeScheduler struct {
47+ scheduled []time.Duration
48+ generations []uint64
49+ cancels int
50+}
51+
52+func (f *residencyFakeScheduler) schedule(d time.Duration, generation uint64) {
53+ f.scheduled = append(f.scheduled, d)
54+ f.generations = append(f.generations, generation)
55+}
56+
57+func (f *residencyFakeScheduler) cancel() { f.cancels++ }
58+
59+func newResidencyTestController(timeout time.Duration) (*ResidencyController, *residencyFakeApp, *residencyFakeScheduler, *int) {
60+ now := time.Now()
61+ clock := func() time.Time { return now }
62+ app := &residencyFakeApp{}
63+ sched := &residencyFakeScheduler{}
64+ quits := 0
65+ ctl := NewResidencyController(timeout, clock, sched.schedule, sched.cancel, func() { quits++ })
66+ return ctl, app, sched, &quits
67+}
68+
69+func TestResidencyControllerDisabledPreservesQuit(t *testing.T) {
70+ ctl, app, sched, quits := newResidencyTestController(0)
71+ require.False(t, ctl.Enabled())
72+ ctl.AcquireHold(app)
73+ require.Equal(t, 0, app.holds, "disabled residency takes no hold")
74+ ctl.ReleaseHold(app)
75+ require.Equal(t, 0, app.releases)
76+
77+ ctl.SetWindowCount(1)
78+ decision := ctl.SetWindowCount(0)
79+ require.Equal(t, usecase.ResidencyQuit, decision.Action)
80+ require.Equal(t, 1, *quits)
81+ require.Empty(t, sched.scheduled)
82+}
83+
84+func TestResidencyControllerHoldMatchesReleaseOnce(t *testing.T) {
85+ ctl, app, _, quits := newResidencyTestController(time.Minute)
86+ require.True(t, ctl.Enabled())
87+ ctl.AcquireHold(app)
88+ ctl.AcquireHold(app)
89+ require.Equal(t, 1, app.holds, "exactly one hold")
90+ ctl.ReleaseHold(app)
91+ ctl.ReleaseHold(app)
92+ require.Equal(t, 1, app.releases, "exactly one release")
93+ require.Equal(t, 0, *quits, "hold cycling never quits")
94+}
95+
96+func TestResidencyControllerLastWindowArmsAndExpiryQuits(t *testing.T) {
97+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
98+ ctl.SetWindowCount(1)
99+ decision := ctl.SetWindowCount(0)
100+ require.Equal(t, usecase.ResidencyArm, decision.Action)
101+ require.NotEmpty(t, sched.scheduled)
102+ armed := sched.generations[len(sched.generations)-1]
103+ ctl.OnTimerFired(armed)
104+ require.Equal(t, 1, *quits)
105+}
106+
107+func TestResidencyControllerReopenCancelsTimer(t *testing.T) {
108+ ctl, _, sched, _ := newResidencyTestController(time.Minute)
109+ ctl.SetWindowCount(1)
110+ ctl.SetWindowCount(0)
111+ require.NotEmpty(t, sched.scheduled)
112+ armed := sched.generations[len(sched.generations)-1]
113+ cancels := sched.cancels
114+ ctl.SetWindowCount(1)
115+ require.Greater(t, sched.cancels, cancels, "reopen cancels the armed deadline")
116+ ctl.OnTimerFired(armed)
117+}
118+
119+func TestResidencyControllerExplicitQuitBypassesDeadline(t *testing.T) {
120+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
121+ ctl.SetWindowCount(1)
122+ ctl.SetWindowCount(0)
123+ require.NotEmpty(t, sched.scheduled)
124+ armed := sched.generations[len(sched.generations)-1]
125+ ctl.NoteExplicitQuit()
126+ require.Equal(t, 0, *quits, "noting quit never invokes quit itself")
127+ ctl.OnTimerFired(armed)
128+ require.Equal(t, 0, *quits, "deadline never fires after explicit quit")
129+}
130+
131+func TestResidencyControllerNilSafe(t *testing.T) {
132+ var ctl *ResidencyController
133+ require.False(t, ctl.Enabled())
134+ require.Equal(t, usecase.ResidencyQuit, ctl.SetWindowCount(0).Action)
135+ require.Equal(t, usecase.ResidencyQuit, ctl.Evaluate().Action)
136+ ctl.NoteExplicitQuit()
137+ ctl.OnTimerFired(7)
138+ ctl.AcquireHold(nil)
139+ ctl.ReleaseHold(nil)
140+ ctl.SetQuiescentFunc(nil)
141+ ctl.SetDispatchToGTK(nil)
142+ ctl.DispatchToGTK(nil)
143+ ctl.MaybeQuit()
144+ require.Equal(t, usecase.ResidencyNone, ctl.SetNativeBusy("x", true).Action)
145+}
146+
147+func TestResidencyControllerDefersQuitUntilSettled(t *testing.T) {
148+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
149+ quiescent := true
150+ ctl.SetQuiescentFunc(func() bool { return quiescent })
151+ ctl.SetWindowCount(1)
152+ decision := ctl.SetWindowCount(0)
153+ require.Equal(t, usecase.ResidencyArm, decision.Action)
154+ armed := sched.generations[len(sched.generations)-1]
155+
156+ quiescent = false
157+ ctl.OnTimerFired(armed)
158+ require.Equal(t, 0, *quits, "expiry defers while native work is outstanding")
159+
160+ quiescent = true
161+ ctl.MaybeQuit()
162+ require.Equal(t, 1, *quits, "settle recheck commits the deferred quit")
163+ ctl.MaybeQuit()
164+ require.Equal(t, 1, *quits, "quit commits exactly once")
165+}
166+
167+func TestResidencyControllerNativeBusyDrivesPolicy(t *testing.T) {
168+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
169+ ctl.SetWindowCount(1)
170+
171+ decision := ctl.SetNativeBusy("cef-activity", true)
172+ require.Equal(t, usecase.ResidencyNone, decision.Action)
173+ decision = ctl.SetWindowCount(0)
174+ require.Equal(t, usecase.ResidencyNone, decision.Action, "close with native work outstanding arms nothing")
175+ require.Empty(t, sched.scheduled)
176+
177+ decision = ctl.SetNativeBusy("cef-activity", false)
178+ require.Equal(t, usecase.ResidencyArm, decision.Action, "settle re-arms idle")
179+ require.NotEmpty(t, sched.scheduled)
180+ require.Equal(t, 0, *quits)
181+}
182+
183+func TestResidencyControllerDuplicateBusyIsSilent(t *testing.T) {
184+ ctl, _, sched, _ := newResidencyTestController(time.Minute)
185+ ctl.SetNativeBusy("downloads", true)
186+ decision := ctl.SetNativeBusy("downloads", true)
187+ require.Equal(t, usecase.ResidencyNone, decision.Action)
188+ require.Empty(t, sched.scheduled)
189+}
190+
191+func TestResidencyControllerDispatchRoutesCallbacks(t *testing.T) {
192+ ctl, _, _, quits := newResidencyTestController(time.Minute)
193+ var routed []string
194+ ctl.SetDispatchToGTK(func(fn func()) {
195+ routed = append(routed, "gtk")
196+ fn()
197+ })
198+ ctl.DispatchToGTK(func() { routed = append(routed, "cb") })
199+ require.Equal(t, []string{"gtk", "cb"}, routed)
200+ require.Equal(t, 0, *quits)
201+}
202+
203+func TestResidencyControllerSealsBeforeCommit(t *testing.T) {
204+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
205+ sealed := 0
206+ unsealed := 0
207+ ctl.SetSealFunc(func() { sealed++ })
208+ ctl.SetUnsealFunc(func() { unsealed++ })
209+ ctl.SetQuiescentFunc(func() bool { return true })
210+ ctl.SetWindowCount(1)
211+ ctl.SetWindowCount(0)
212+ armed := sched.generations[len(sched.generations)-1]
213+ // Quiescent expiry seals admission, then commits the quit.
214+ ctl.OnTimerFired(armed)
215+ require.Equal(t, 1, sealed)
216+ require.Equal(t, 0, unsealed)
217+ require.Equal(t, 1, *quits)
218+}
219+
220+func TestResidencyControllerReopenInvalidatesDeferredQuit(t *testing.T) {
221+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
222+ quiescent := true
223+ unsealed := 0
224+ ctl.SetUnsealFunc(func() { unsealed++ })
225+ ctl.SetQuiescentFunc(func() bool { return quiescent })
226+ ctl.SetWindowCount(1)
227+ ctl.SetWindowCount(0)
228+ armed := sched.generations[len(sched.generations)-1]
229+ quiescent = false
230+ ctl.OnTimerFired(armed)
231+ require.Equal(t, 0, *quits, "quit defers while unsettled")
232+ // A reopened window invalidates the deferral.
233+ ctl.SetWindowCount(1)
234+ quiescent = true
235+ ctl.MaybeQuit()
236+ require.Equal(t, 0, *quits, "obsolete quit must not run after reopen")
237+ require.Equal(t, 1, unsealed, "invalidation reopens admission")
238+}
239+
240+func TestResidencyControllerDuplicateEmptyStaysArmed(t *testing.T) {
241+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
242+ ctl.SetWindowCount(1)
243+ first := ctl.SetWindowCount(0)
244+ require.Equal(t, usecase.ResidencyArm, first.Action)
245+ require.Len(t, sched.scheduled, 1)
246+ // A repeated empty update (e.g. tab-empty after window removal)
247+ // stays on the armed deadline instead of re-arming.
248+ again := ctl.SetWindowCount(0)
249+ require.Equal(t, usecase.ResidencyNone, again.Action)
250+ require.Len(t, sched.scheduled, 1, "no duplicate timer")
251+ require.Equal(t, 0, *quits)
252+}
@@ -0,0 +1,252 @@
1+package ui
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResidencyTimeoutFromMillis(t *testing.T) {
12+ require.Equal(t, time.Duration(0), ResidencyTimeoutFromMillis(0))
13+ require.Equal(t, time.Duration(0), ResidencyTimeoutFromMillis(-5))
14+ require.Equal(t, 60*time.Second, ResidencyTimeoutFromMillis(60000))
15+ require.Equal(t, 300000*time.Millisecond, ResidencyTimeoutFromMillis(300000))
16+ require.Equal(t, 300000*time.Millisecond, ResidencyTimeoutFromMillis(999999))
17+}
18+
19+func TestResidencyTimeoutLatchAndRestartRequired(t *testing.T) {
20+ app := &App{}
21+ require.Equal(t, time.Duration(0), app.LatchedResidencyTimeout())
22+
23+ app.SetResidencyTimeout(60 * time.Second)
24+ require.Equal(t, 60*time.Second, app.LatchedResidencyTimeout())
25+ require.False(t, app.ResidencyTimeoutRestartRequired(60000))
26+ require.True(t, app.ResidencyTimeoutRestartRequired(0))
27+ require.True(t, app.ResidencyTimeoutRestartRequired(120000))
28+
29+ // A nil app never panics and reports no change.
30+ var nilApp *App
31+ nilApp.SetResidencyTimeout(time.Minute)
32+ require.Equal(t, time.Duration(0), nilApp.LatchedResidencyTimeout())
33+ require.False(t, nilApp.ResidencyTimeoutRestartRequired(60000))
34+}
35+
36+type residencyFakeApp struct {
37+ holds int
38+ releases int
39+ quits int
40+}
41+
42+func (f *residencyFakeApp) Hold() { f.holds++ }
43+func (f *residencyFakeApp) Release() { f.releases++ }
44+func (f *residencyFakeApp) Quit() { f.quits++ }
45+
46+type residencyFakeScheduler struct {
47+ scheduled []time.Duration
48+ generations []uint64
49+ cancels int
50+}
51+
52+func (f *residencyFakeScheduler) schedule(d time.Duration, generation uint64) {
53+ f.scheduled = append(f.scheduled, d)
54+ f.generations = append(f.generations, generation)
55+}
56+
57+func (f *residencyFakeScheduler) cancel() { f.cancels++ }
58+
59+func newResidencyTestController(timeout time.Duration) (*ResidencyController, *residencyFakeApp, *residencyFakeScheduler, *int) {
60+ now := time.Now()
61+ clock := func() time.Time { return now }
62+ app := &residencyFakeApp{}
63+ sched := &residencyFakeScheduler{}
64+ quits := 0
65+ ctl := NewResidencyController(timeout, clock, sched.schedule, sched.cancel, func() { quits++ })
66+ return ctl, app, sched, &quits
67+}
68+
69+func TestResidencyControllerDisabledPreservesQuit(t *testing.T) {
70+ ctl, app, sched, quits := newResidencyTestController(0)
71+ require.False(t, ctl.Enabled())
72+ ctl.AcquireHold(app)
73+ require.Equal(t, 0, app.holds, "disabled residency takes no hold")
74+ ctl.ReleaseHold(app)
75+ require.Equal(t, 0, app.releases)
76+
77+ ctl.SetWindowCount(1)
78+ decision := ctl.SetWindowCount(0)
79+ require.Equal(t, usecase.ResidencyQuit, decision.Action)
80+ require.Equal(t, 1, *quits)
81+ require.Empty(t, sched.scheduled)
82+}
83+
84+func TestResidencyControllerHoldMatchesReleaseOnce(t *testing.T) {
85+ ctl, app, _, quits := newResidencyTestController(time.Minute)
86+ require.True(t, ctl.Enabled())
87+ ctl.AcquireHold(app)
88+ ctl.AcquireHold(app)
89+ require.Equal(t, 1, app.holds, "exactly one hold")
90+ ctl.ReleaseHold(app)
91+ ctl.ReleaseHold(app)
92+ require.Equal(t, 1, app.releases, "exactly one release")
93+ require.Equal(t, 0, *quits, "hold cycling never quits")
94+}
95+
96+func TestResidencyControllerLastWindowArmsAndExpiryQuits(t *testing.T) {
97+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
98+ ctl.SetWindowCount(1)
99+ decision := ctl.SetWindowCount(0)
100+ require.Equal(t, usecase.ResidencyArm, decision.Action)
101+ require.NotEmpty(t, sched.scheduled)
102+ armed := sched.generations[len(sched.generations)-1]
103+ ctl.OnTimerFired(armed)
104+ require.Equal(t, 1, *quits)
105+}
106+
107+func TestResidencyControllerReopenCancelsTimer(t *testing.T) {
108+ ctl, _, sched, _ := newResidencyTestController(time.Minute)
109+ ctl.SetWindowCount(1)
110+ ctl.SetWindowCount(0)
111+ require.NotEmpty(t, sched.scheduled)
112+ armed := sched.generations[len(sched.generations)-1]
113+ cancels := sched.cancels
114+ ctl.SetWindowCount(1)
115+ require.Greater(t, sched.cancels, cancels, "reopen cancels the armed deadline")
116+ ctl.OnTimerFired(armed)
117+}
118+
119+func TestResidencyControllerExplicitQuitBypassesDeadline(t *testing.T) {
120+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
121+ ctl.SetWindowCount(1)
122+ ctl.SetWindowCount(0)
123+ require.NotEmpty(t, sched.scheduled)
124+ armed := sched.generations[len(sched.generations)-1]
125+ ctl.NoteExplicitQuit()
126+ require.Equal(t, 0, *quits, "noting quit never invokes quit itself")
127+ ctl.OnTimerFired(armed)
128+ require.Equal(t, 0, *quits, "deadline never fires after explicit quit")
129+}
130+
131+func TestResidencyControllerNilSafe(t *testing.T) {
132+ var ctl *ResidencyController
133+ require.False(t, ctl.Enabled())
134+ require.Equal(t, usecase.ResidencyQuit, ctl.SetWindowCount(0).Action)
135+ require.Equal(t, usecase.ResidencyQuit, ctl.Evaluate().Action)
136+ ctl.NoteExplicitQuit()
137+ ctl.OnTimerFired(7)
138+ ctl.AcquireHold(nil)
139+ ctl.ReleaseHold(nil)
140+ ctl.SetQuiescentFunc(nil)
141+ ctl.SetDispatchToGTK(nil)
142+ ctl.DispatchToGTK(nil)
143+ ctl.MaybeQuit()
144+ require.Equal(t, usecase.ResidencyNone, ctl.SetNativeBusy("x", true).Action)
145+}
146+
147+func TestResidencyControllerDefersQuitUntilSettled(t *testing.T) {
148+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
149+ quiescent := true
150+ ctl.SetQuiescentFunc(func() bool { return quiescent })
151+ ctl.SetWindowCount(1)
152+ decision := ctl.SetWindowCount(0)
153+ require.Equal(t, usecase.ResidencyArm, decision.Action)
154+ armed := sched.generations[len(sched.generations)-1]
155+
156+ quiescent = false
157+ ctl.OnTimerFired(armed)
158+ require.Equal(t, 0, *quits, "expiry defers while native work is outstanding")
159+
160+ quiescent = true
161+ ctl.MaybeQuit()
162+ require.Equal(t, 1, *quits, "settle recheck commits the deferred quit")
163+ ctl.MaybeQuit()
164+ require.Equal(t, 1, *quits, "quit commits exactly once")
165+}
166+
167+func TestResidencyControllerNativeBusyDrivesPolicy(t *testing.T) {
168+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
169+ ctl.SetWindowCount(1)
170+
171+ decision := ctl.SetNativeBusy("cef-activity", true)
172+ require.Equal(t, usecase.ResidencyNone, decision.Action)
173+ decision = ctl.SetWindowCount(0)
174+ require.Equal(t, usecase.ResidencyNone, decision.Action, "close with native work outstanding arms nothing")
175+ require.Empty(t, sched.scheduled)
176+
177+ decision = ctl.SetNativeBusy("cef-activity", false)
178+ require.Equal(t, usecase.ResidencyArm, decision.Action, "settle re-arms idle")
179+ require.NotEmpty(t, sched.scheduled)
180+ require.Equal(t, 0, *quits)
181+}
182+
183+func TestResidencyControllerDuplicateBusyIsSilent(t *testing.T) {
184+ ctl, _, sched, _ := newResidencyTestController(time.Minute)
185+ ctl.SetNativeBusy("downloads", true)
186+ decision := ctl.SetNativeBusy("downloads", true)
187+ require.Equal(t, usecase.ResidencyNone, decision.Action)
188+ require.Empty(t, sched.scheduled)
189+}
190+
191+func TestResidencyControllerDispatchRoutesCallbacks(t *testing.T) {
192+ ctl, _, _, quits := newResidencyTestController(time.Minute)
193+ var routed []string
194+ ctl.SetDispatchToGTK(func(fn func()) {
195+ routed = append(routed, "gtk")
196+ fn()
197+ })
198+ ctl.DispatchToGTK(func() { routed = append(routed, "cb") })
199+ require.Equal(t, []string{"gtk", "cb"}, routed)
200+ require.Equal(t, 0, *quits)
201+}
202+
203+func TestResidencyControllerSealsBeforeCommit(t *testing.T) {
204+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
205+ sealed := 0
206+ unsealed := 0
207+ ctl.SetSealFunc(func() { sealed++ })
208+ ctl.SetUnsealFunc(func() { unsealed++ })
209+ ctl.SetQuiescentFunc(func() bool { return true })
210+ ctl.SetWindowCount(1)
211+ ctl.SetWindowCount(0)
212+ armed := sched.generations[len(sched.generations)-1]
213+ // Quiescent expiry seals admission, then commits the quit.
214+ ctl.OnTimerFired(armed)
215+ require.Equal(t, 1, sealed)
216+ require.Equal(t, 0, unsealed)
217+ require.Equal(t, 1, *quits)
218+}
219+
220+func TestResidencyControllerReopenInvalidatesDeferredQuit(t *testing.T) {
221+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
222+ quiescent := true
223+ unsealed := 0
224+ ctl.SetUnsealFunc(func() { unsealed++ })
225+ ctl.SetQuiescentFunc(func() bool { return quiescent })
226+ ctl.SetWindowCount(1)
227+ ctl.SetWindowCount(0)
228+ armed := sched.generations[len(sched.generations)-1]
229+ quiescent = false
230+ ctl.OnTimerFired(armed)
231+ require.Equal(t, 0, *quits, "quit defers while unsettled")
232+ // A reopened window invalidates the deferral.
233+ ctl.SetWindowCount(1)
234+ quiescent = true
235+ ctl.MaybeQuit()
236+ require.Equal(t, 0, *quits, "obsolete quit must not run after reopen")
237+ require.Equal(t, 1, unsealed, "invalidation reopens admission")
238+}
239+
240+func TestResidencyControllerDuplicateEmptyStaysArmed(t *testing.T) {
241+ ctl, _, sched, quits := newResidencyTestController(time.Minute)
242+ ctl.SetWindowCount(1)
243+ first := ctl.SetWindowCount(0)
244+ require.Equal(t, usecase.ResidencyArm, first.Action)
245+ require.Len(t, sched.scheduled, 1)
246+ // A repeated empty update (e.g. tab-empty after window removal)
247+ // stays on the armed deadline instead of re-arming.
248+ again := ctl.SetWindowCount(0)
249+ require.Equal(t, usecase.ResidencyNone, again.Action)
250+ require.Len(t, sched.scheduled, 1, "no duplicate timer")
251+ require.Equal(t, 0, *quits)
252+}
M scripts/measure_cef_startup.py +156 -13
@@ -8,6 +8,10 @@ Scenarios:
88 relay-window a real long-lived owner browser holds the relay; samples
99 launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that
1010 same isolated profile
11+ reopen-window like relay-window, but each sample first closes all owner
12+ windows through the diagnostic relay action, then reopens
13+ through the relay; verifies the same owner process serves
14+ the reopen without a second CEF initialization
1115
1216 Fixtures (loopback only): static, delayed, redirect, cache.
1317
@@ -281,7 +285,7 @@ def wait_for_relay_socket(runtime_dir, timeout_seconds=30.0):
281285 return None
282286
283287
284-def start_relay_owner(binary, cef_dir, scenario_dir):
288+def start_relay_owner(binary, cef_dir, scenario_dir, diagnostic_close=False):
285289 dirs = {
286290 "config": os.path.join(scenario_dir, "owner-config"),
287291 "data": os.path.join(scenario_dir, "owner-data"),
@@ -291,6 +295,12 @@ def start_relay_owner(binary, cef_dir, scenario_dir):
291295 "root_cache": os.path.join(scenario_dir, "shared-profile"),
292296 }
293297 env = scenario_env(cef_dir, dirs)
298+ if diagnostic_close:
299+ # Owned window-close mechanism for the reopen scenario only: the
300+ # relay honors it solely in processes carrying this variable. It is
301+ # never a general unauthenticated shutdown command.
302+ env = dict(env)
303+ env["DUMBER_DIAGNOSTIC_WINDOW_CLOSE"] = "1"
294304 try:
295305 proc = subprocess.Popen(
296306 [binary, "browse", "about:blank"],
@@ -299,18 +309,19 @@ def start_relay_owner(binary, cef_dir, scenario_dir):
299309 env=env,
300310 )
301311 except OSError:
302- return None, None
312+ return None, None, None
303313 time.sleep(0.5)
304314 if proc.poll() is not None:
305- return None, None
306- if wait_for_relay_socket(dirs["runtime"]) is None:
315+ return None, None, None
316+ socket_path = wait_for_relay_socket(dirs["runtime"])
317+ if socket_path is None:
307318 proc.terminate()
308319 try:
309320 proc.wait(timeout=5)
310321 except subprocess.TimeoutExpired:
311322 proc.kill()
312- return None, None
313- return proc, dirs
323+ return None, None, None
324+ return proc, dirs, socket_path
314325
315326
316327 def stop_proc(proc):
@@ -324,8 +335,98 @@ def stop_proc(proc):
324335 proc.wait(timeout=5)
325336
326337
338+def send_diagnostic_close(socket_path, timeout_seconds=10.0):
339+ """Ask the relay owner to close all windows. Returns True only on an
340+ explicit acknowledgement. Never a general shutdown command: owners
341+ honor it solely with DUMBER_DIAGNOSTIC_WINDOW_CLOSE=1."""
342+ request = json.dumps({"request_id": secrets.token_hex(8), "action": "close-all-windows"}) + "\n"
343+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
344+ sock.settimeout(timeout_seconds)
345+ try:
346+ sock.connect(socket_path)
347+ sock.sendall(request.encode())
348+ received = b""
349+ while b"\n" not in received:
350+ chunk = sock.recv(4096)
351+ if not chunk:
352+ break
353+ received += chunk
354+ try:
355+ response = json.loads(received.decode())
356+ except ValueError:
357+ return False
358+ return bool(response.get("accepted")) and not response.get("error")
359+ except OSError:
360+ return False
361+ finally:
362+ sock.close()
363+
364+
365+def child_pids(root_pid):
366+ """Direct child pids of root_pid via /proc (stdlib only)."""
367+ children = []
368+ try:
369+ entries = os.listdir("/proc")
370+ except OSError:
371+ return children
372+ for entry in entries:
373+ if not entry.isdigit():
374+ continue
375+ try:
376+ with open("/proc/%s/stat" % entry) as handle:
377+ parts = handle.read().rsplit(")", 1)[1].split()
378+ if int(parts[1]) == root_pid:
379+ children.append(int(entry))
380+ except (OSError, ValueError, IndexError):
381+ continue
382+ return children
383+
384+
385+def proc_starttime(pid):
386+ """Process start time (jiffies since boot) via /proc, or None.
387+ Combined with the pid it forms an independently observed identity that
388+ survives Popen-handle reuse and detects pid recycling."""
389+ try:
390+ with open("/proc/%d/stat" % pid) as handle:
391+ parts = handle.read().rsplit(")", 1)[1].split()
392+ return int(parts[19])
393+ except (OSError, ValueError, IndexError):
394+ return None
395+
396+
397+def wait_for_child_quiescence(owner_pid, settle_seconds=2.0, timeout_seconds=15.0):
398+ """Wait until the owner's child count is stable across settle_seconds.
399+ Window close is asynchronous: the relay acknowledgement only means the
400+ request was accepted, so reopening must wait for an observable steady
401+ state instead of a fixed sleep. Returns the stable child count."""
402+ end = time.monotonic() + timeout_seconds
403+ last_change = time.monotonic()
404+ last_count = len(child_pids(owner_pid))
405+ while time.monotonic() < end:
406+ time.sleep(0.5)
407+ count = len(child_pids(owner_pid))
408+ if count != last_count:
409+ last_count = count
410+ last_change = time.monotonic()
411+ elif time.monotonic() - last_change >= settle_seconds:
412+ return count
413+ return len(child_pids(owner_pid))
414+
415+
416+def proc_rss_kb(pid):
417+ """Resident memory of pid in KiB via /proc, or None."""
418+ try:
419+ with open("/proc/%d/status" % pid) as handle:
420+ for line in handle:
421+ if line.startswith("VmRSS:"):
422+ return int(line.split()[1])
423+ except (OSError, ValueError, IndexError):
424+ pass
425+ return None
426+
427+
327428 def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_dirs=None,
328- shared_root_cache=None):
429+ shared_root_cache=None, owner_socket=None):
329430 run_token = secrets.token_hex(8)
330431 nav_token = secrets.token_hex(8)
331432 cutoff = post_nav_cutoff_seconds()
@@ -345,7 +446,7 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
345446 url = "http://127.0.0.1:%d/redirect?run=%s&nav=%s" % (port, run_token, nav_token)
346447 else:
347448 url = "http://127.0.0.1:%d/?run=%s&nav=%s" % (port, run_token, nav_token)
348- if scenario == "relay-window" and owner_dirs is not None:
449+ if scenario in ("relay-window", "reopen-window") and owner_dirs is not None:
349450 dirs = dict(owner_dirs)
350451 extra = {"DUMBER_BROWSER_FRESH_WINDOW": "1"}
351452 else:
@@ -360,6 +461,24 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
360461 extra = {}
361462 env = scenario_env(cef_dir, dirs, extra)
362463 owner_alive_at_start = owner is not None and owner.poll() is None
464+ reopen = None
465+ if scenario == "reopen-window" and owner is not None and owner_socket:
466+ owner_pid = owner.pid
467+ owner_start = proc_starttime(owner_pid)
468+ children_before = child_pids(owner_pid)
469+ rss_before = proc_rss_kb(owner_pid)
470+ acknowledged = send_diagnostic_close(owner_socket)
471+ # The acknowledgement only means accepted: wait for an
472+ # observable steady state before measuring the reopen.
473+ quiescent_children = wait_for_child_quiescence(owner_pid)
474+ reopen = {
475+ "owner_pid": owner_pid,
476+ "owner_starttime": owner_start,
477+ "close_acknowledged": acknowledged,
478+ "browser_child_count_before": len(children_before),
479+ "browser_child_count_quiescent": quiescent_children,
480+ "owner_rss_kb_before": rss_before,
481+ }
363482 spawn_ts = time.monotonic()
364483 proc = subprocess.Popen(
365484 [binary, "browse", url],
@@ -429,11 +548,11 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
429548 observation_ms = int((decision_ts - spawn_ts) * 1000)
430549 expected_document_requests = 2 if fixture == "redirect" else 1
431550 complete = document_count == expected_document_requests and len(fcp) > 0
432- if scenario == "relay-window":
551+ if scenario in ("relay-window", "reopen-window"):
433552 relayed = bool(owner_alive_at_start and self_exited)
434553 else:
435554 relayed = False
436- return {
555+ result = {
437556 "run": run_index,
438557 "scenario": scenario,
439558 "fixture": fixture,
@@ -447,8 +566,25 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
447566 "startup_milestones": len(milestones),
448567 "complete": complete,
449568 "relayed": relayed,
450- "fallback_spawn": scenario == "relay-window" and not relayed,
569+ "fallback_spawn": scenario in ("relay-window", "reopen-window") and not relayed,
451570 }
571+ if reopen is not None:
572+ # Owner identity is verified independently of the Popen handle:
573+ # the same pid with a different start time is a recycled pid,
574+ # not the same process. Unverifiable identity counts as different.
575+ start_now = proc_starttime(reopen["owner_pid"]) if owner is not None else None
576+ owner_alive = owner is not None and owner.poll() is None
577+ same = bool(
578+ owner_alive
579+ and reopen["owner_starttime"] is not None
580+ and start_now == reopen["owner_starttime"]
581+ )
582+ reopen["owner_alive_after"] = owner_alive
583+ reopen["same_process"] = same
584+ reopen["browser_child_count_after"] = len(child_pids(owner.pid)) if owner_alive else 0
585+ reopen["owner_rss_kb_after"] = proc_rss_kb(owner.pid) if owner_alive else None
586+ result["reopen"] = reopen
587+ return result
452588 finally:
453589 server.shutdown()
454590 server.server_close()
@@ -463,7 +599,7 @@ def main():
463599 parser.add_argument(
464600 "--scenario",
465601 required=True,
466- choices=("process-warm", "profile-fresh", "relay-window"),
602+ choices=("process-warm", "profile-fresh", "relay-window", "reopen-window"),
467603 )
468604 parser.add_argument(
469605 "--fixture",
@@ -501,6 +637,7 @@ def main():
501637 os.makedirs(args.output)
502638 owner = None
503639 owner_dirs = None
640+ owner_socket = None
504641 shared_root_cache = None
505642 warmup_discarded = False
506643 if args.scenario == "process-warm":
@@ -509,7 +646,12 @@ def main():
509646 if args.scenario == "relay-window":
510647 scenario_dir = os.path.join(args.output, "relay-scenario")
511648 os.makedirs(scenario_dir)
512- owner, owner_dirs = start_relay_owner(args.binary, args.cef_dir, scenario_dir)
649+ owner, owner_dirs, owner_socket = start_relay_owner(args.binary, args.cef_dir, scenario_dir)
650+ if args.scenario == "reopen-window":
651+ scenario_dir = os.path.join(args.output, "reopen-scenario")
652+ os.makedirs(scenario_dir)
653+ owner, owner_dirs, owner_socket = start_relay_owner(
654+ args.binary, args.cef_dir, scenario_dir, diagnostic_close=True)
513655
514656 results = []
515657 try:
@@ -528,6 +670,7 @@ def main():
528670 result = run_sample(
529671 args.binary, args.cef_dir, args.scenario, args.fixture, index,
530672 owner=owner, owner_dirs=owner_dirs, shared_root_cache=shared_root_cache,
673+ owner_socket=owner_socket,
531674 )
532675 results.append(result)
533676 with open(os.path.join(args.output, "run-%02d.json" % index), "w") as handle:
@@ -8,6 +8,10 @@ Scenarios:
8 relay-window a real long-lived owner browser holds the relay; samples8 relay-window a real long-lived owner browser holds the relay; samples
9 launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that9 launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that
10 same isolated profile10 same isolated profile
11+ reopen-window like relay-window, but each sample first closes all owner
12+ windows through the diagnostic relay action, then reopens
13+ through the relay; verifies the same owner process serves
14+ the reopen without a second CEF initialization
11 15
12 Fixtures (loopback only): static, delayed, redirect, cache.16 Fixtures (loopback only): static, delayed, redirect, cache.
13 17
@@ -281,7 +285,7 @@ def wait_for_relay_socket(runtime_dir, timeout_seconds=30.0):
281 return None285 return None
282 286
283 287
284-def start_relay_owner(binary, cef_dir, scenario_dir):288+def start_relay_owner(binary, cef_dir, scenario_dir, diagnostic_close=False):
285 dirs = {289 dirs = {
286 "config": os.path.join(scenario_dir, "owner-config"),290 "config": os.path.join(scenario_dir, "owner-config"),
287 "data": os.path.join(scenario_dir, "owner-data"),291 "data": os.path.join(scenario_dir, "owner-data"),
@@ -291,6 +295,12 @@ def start_relay_owner(binary, cef_dir, scenario_dir):
291 "root_cache": os.path.join(scenario_dir, "shared-profile"),295 "root_cache": os.path.join(scenario_dir, "shared-profile"),
292 }296 }
293 env = scenario_env(cef_dir, dirs)297 env = scenario_env(cef_dir, dirs)
298+ if diagnostic_close:
299+ # Owned window-close mechanism for the reopen scenario only: the
300+ # relay honors it solely in processes carrying this variable. It is
301+ # never a general unauthenticated shutdown command.
302+ env = dict(env)
303+ env["DUMBER_DIAGNOSTIC_WINDOW_CLOSE"] = "1"
294 try:304 try:
295 proc = subprocess.Popen(305 proc = subprocess.Popen(
296 [binary, "browse", "about:blank"],306 [binary, "browse", "about:blank"],
@@ -299,18 +309,19 @@ def start_relay_owner(binary, cef_dir, scenario_dir):
299 env=env,309 env=env,
300 )310 )
301 except OSError:311 except OSError:
302- return None, None312+ return None, None, None
303 time.sleep(0.5)313 time.sleep(0.5)
304 if proc.poll() is not None:314 if proc.poll() is not None:
305- return None, None315+ return None, None, None
306- if wait_for_relay_socket(dirs["runtime"]) is None:316+ socket_path = wait_for_relay_socket(dirs["runtime"])
317+ if socket_path is None:
307 proc.terminate()318 proc.terminate()
308 try:319 try:
309 proc.wait(timeout=5)320 proc.wait(timeout=5)
310 except subprocess.TimeoutExpired:321 except subprocess.TimeoutExpired:
311 proc.kill()322 proc.kill()
312- return None, None323+ return None, None, None
313- return proc, dirs324+ return proc, dirs, socket_path
314 325
315 326
316 def stop_proc(proc):327 def stop_proc(proc):
@@ -324,8 +335,98 @@ def stop_proc(proc):
324 proc.wait(timeout=5)335 proc.wait(timeout=5)
325 336
326 337
338+def send_diagnostic_close(socket_path, timeout_seconds=10.0):
339+ """Ask the relay owner to close all windows. Returns True only on an
340+ explicit acknowledgement. Never a general shutdown command: owners
341+ honor it solely with DUMBER_DIAGNOSTIC_WINDOW_CLOSE=1."""
342+ request = json.dumps({"request_id": secrets.token_hex(8), "action": "close-all-windows"}) + "\n"
343+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
344+ sock.settimeout(timeout_seconds)
345+ try:
346+ sock.connect(socket_path)
347+ sock.sendall(request.encode())
348+ received = b""
349+ while b"\n" not in received:
350+ chunk = sock.recv(4096)
351+ if not chunk:
352+ break
353+ received += chunk
354+ try:
355+ response = json.loads(received.decode())
356+ except ValueError:
357+ return False
358+ return bool(response.get("accepted")) and not response.get("error")
359+ except OSError:
360+ return False
361+ finally:
362+ sock.close()
363+
364+
365+def child_pids(root_pid):
366+ """Direct child pids of root_pid via /proc (stdlib only)."""
367+ children = []
368+ try:
369+ entries = os.listdir("/proc")
370+ except OSError:
371+ return children
372+ for entry in entries:
373+ if not entry.isdigit():
374+ continue
375+ try:
376+ with open("/proc/%s/stat" % entry) as handle:
377+ parts = handle.read().rsplit(")", 1)[1].split()
378+ if int(parts[1]) == root_pid:
379+ children.append(int(entry))
380+ except (OSError, ValueError, IndexError):
381+ continue
382+ return children
383+
384+
385+def proc_starttime(pid):
386+ """Process start time (jiffies since boot) via /proc, or None.
387+ Combined with the pid it forms an independently observed identity that
388+ survives Popen-handle reuse and detects pid recycling."""
389+ try:
390+ with open("/proc/%d/stat" % pid) as handle:
391+ parts = handle.read().rsplit(")", 1)[1].split()
392+ return int(parts[19])
393+ except (OSError, ValueError, IndexError):
394+ return None
395+
396+
397+def wait_for_child_quiescence(owner_pid, settle_seconds=2.0, timeout_seconds=15.0):
398+ """Wait until the owner's child count is stable across settle_seconds.
399+ Window close is asynchronous: the relay acknowledgement only means the
400+ request was accepted, so reopening must wait for an observable steady
401+ state instead of a fixed sleep. Returns the stable child count."""
402+ end = time.monotonic() + timeout_seconds
403+ last_change = time.monotonic()
404+ last_count = len(child_pids(owner_pid))
405+ while time.monotonic() < end:
406+ time.sleep(0.5)
407+ count = len(child_pids(owner_pid))
408+ if count != last_count:
409+ last_count = count
410+ last_change = time.monotonic()
411+ elif time.monotonic() - last_change >= settle_seconds:
412+ return count
413+ return len(child_pids(owner_pid))
414+
415+
416+def proc_rss_kb(pid):
417+ """Resident memory of pid in KiB via /proc, or None."""
418+ try:
419+ with open("/proc/%d/status" % pid) as handle:
420+ for line in handle:
421+ if line.startswith("VmRSS:"):
422+ return int(line.split()[1])
423+ except (OSError, ValueError, IndexError):
424+ pass
425+ return None
426+
427+
327 def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_dirs=None,428 def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_dirs=None,
328- shared_root_cache=None):429+ shared_root_cache=None, owner_socket=None):
329 run_token = secrets.token_hex(8)430 run_token = secrets.token_hex(8)
330 nav_token = secrets.token_hex(8)431 nav_token = secrets.token_hex(8)
331 cutoff = post_nav_cutoff_seconds()432 cutoff = post_nav_cutoff_seconds()
@@ -345,7 +446,7 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
345 url = "http://127.0.0.1:%d/redirect?run=%s&nav=%s" % (port, run_token, nav_token)446 url = "http://127.0.0.1:%d/redirect?run=%s&nav=%s" % (port, run_token, nav_token)
346 else:447 else:
347 url = "http://127.0.0.1:%d/?run=%s&nav=%s" % (port, run_token, nav_token)448 url = "http://127.0.0.1:%d/?run=%s&nav=%s" % (port, run_token, nav_token)
348- if scenario == "relay-window" and owner_dirs is not None:449+ if scenario in ("relay-window", "reopen-window") and owner_dirs is not None:
349 dirs = dict(owner_dirs)450 dirs = dict(owner_dirs)
350 extra = {"DUMBER_BROWSER_FRESH_WINDOW": "1"}451 extra = {"DUMBER_BROWSER_FRESH_WINDOW": "1"}
351 else:452 else:
@@ -360,6 +461,24 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
360 extra = {}461 extra = {}
361 env = scenario_env(cef_dir, dirs, extra)462 env = scenario_env(cef_dir, dirs, extra)
362 owner_alive_at_start = owner is not None and owner.poll() is None463 owner_alive_at_start = owner is not None and owner.poll() is None
464+ reopen = None
465+ if scenario == "reopen-window" and owner is not None and owner_socket:
466+ owner_pid = owner.pid
467+ owner_start = proc_starttime(owner_pid)
468+ children_before = child_pids(owner_pid)
469+ rss_before = proc_rss_kb(owner_pid)
470+ acknowledged = send_diagnostic_close(owner_socket)
471+ # The acknowledgement only means accepted: wait for an
472+ # observable steady state before measuring the reopen.
473+ quiescent_children = wait_for_child_quiescence(owner_pid)
474+ reopen = {
475+ "owner_pid": owner_pid,
476+ "owner_starttime": owner_start,
477+ "close_acknowledged": acknowledged,
478+ "browser_child_count_before": len(children_before),
479+ "browser_child_count_quiescent": quiescent_children,
480+ "owner_rss_kb_before": rss_before,
481+ }
363 spawn_ts = time.monotonic()482 spawn_ts = time.monotonic()
364 proc = subprocess.Popen(483 proc = subprocess.Popen(
365 [binary, "browse", url],484 [binary, "browse", url],
@@ -429,11 +548,11 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
429 observation_ms = int((decision_ts - spawn_ts) * 1000)548 observation_ms = int((decision_ts - spawn_ts) * 1000)
430 expected_document_requests = 2 if fixture == "redirect" else 1549 expected_document_requests = 2 if fixture == "redirect" else 1
431 complete = document_count == expected_document_requests and len(fcp) > 0550 complete = document_count == expected_document_requests and len(fcp) > 0
432- if scenario == "relay-window":551+ if scenario in ("relay-window", "reopen-window"):
433 relayed = bool(owner_alive_at_start and self_exited)552 relayed = bool(owner_alive_at_start and self_exited)
434 else:553 else:
435 relayed = False554 relayed = False
436- return {555+ result = {
437 "run": run_index,556 "run": run_index,
438 "scenario": scenario,557 "scenario": scenario,
439 "fixture": fixture,558 "fixture": fixture,
@@ -447,8 +566,25 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_
447 "startup_milestones": len(milestones),566 "startup_milestones": len(milestones),
448 "complete": complete,567 "complete": complete,
449 "relayed": relayed,568 "relayed": relayed,
450- "fallback_spawn": scenario == "relay-window" and not relayed,569+ "fallback_spawn": scenario in ("relay-window", "reopen-window") and not relayed,
451 }570 }
571+ if reopen is not None:
572+ # Owner identity is verified independently of the Popen handle:
573+ # the same pid with a different start time is a recycled pid,
574+ # not the same process. Unverifiable identity counts as different.
575+ start_now = proc_starttime(reopen["owner_pid"]) if owner is not None else None
576+ owner_alive = owner is not None and owner.poll() is None
577+ same = bool(
578+ owner_alive
579+ and reopen["owner_starttime"] is not None
580+ and start_now == reopen["owner_starttime"]
581+ )
582+ reopen["owner_alive_after"] = owner_alive
583+ reopen["same_process"] = same
584+ reopen["browser_child_count_after"] = len(child_pids(owner.pid)) if owner_alive else 0
585+ reopen["owner_rss_kb_after"] = proc_rss_kb(owner.pid) if owner_alive else None
586+ result["reopen"] = reopen
587+ return result
452 finally:588 finally:
453 server.shutdown()589 server.shutdown()
454 server.server_close()590 server.server_close()
@@ -463,7 +599,7 @@ def main():
463 parser.add_argument(599 parser.add_argument(
464 "--scenario",600 "--scenario",
465 required=True,601 required=True,
466- choices=("process-warm", "profile-fresh", "relay-window"),602+ choices=("process-warm", "profile-fresh", "relay-window", "reopen-window"),
467 )603 )
468 parser.add_argument(604 parser.add_argument(
469 "--fixture",605 "--fixture",
@@ -501,6 +637,7 @@ def main():
501 os.makedirs(args.output)637 os.makedirs(args.output)
502 owner = None638 owner = None
503 owner_dirs = None639 owner_dirs = None
640+ owner_socket = None
504 shared_root_cache = None641 shared_root_cache = None
505 warmup_discarded = False642 warmup_discarded = False
506 if args.scenario == "process-warm":643 if args.scenario == "process-warm":
@@ -509,7 +646,12 @@ def main():
509 if args.scenario == "relay-window":646 if args.scenario == "relay-window":
510 scenario_dir = os.path.join(args.output, "relay-scenario")647 scenario_dir = os.path.join(args.output, "relay-scenario")
511 os.makedirs(scenario_dir)648 os.makedirs(scenario_dir)
512- owner, owner_dirs = start_relay_owner(args.binary, args.cef_dir, scenario_dir)649+ owner, owner_dirs, owner_socket = start_relay_owner(args.binary, args.cef_dir, scenario_dir)
650+ if args.scenario == "reopen-window":
651+ scenario_dir = os.path.join(args.output, "reopen-scenario")
652+ os.makedirs(scenario_dir)
653+ owner, owner_dirs, owner_socket = start_relay_owner(
654+ args.binary, args.cef_dir, scenario_dir, diagnostic_close=True)
513 655
514 results = []656 results = []
515 try:657 try:
@@ -528,6 +670,7 @@ def main():
528 result = run_sample(670 result = run_sample(
529 args.binary, args.cef_dir, args.scenario, args.fixture, index,671 args.binary, args.cef_dir, args.scenario, args.fixture, index,
530 owner=owner, owner_dirs=owner_dirs, shared_root_cache=shared_root_cache,672 owner=owner, owner_dirs=owner_dirs, shared_root_cache=shared_root_cache,
673+ owner_socket=owner_socket,
531 )674 )
532 results.append(result)675 results.append(result)
533 with open(os.path.join(args.output, "run-%02d.json" % index), "w") as handle:676 with open(os.path.join(args.output, "run-%02d.json" % index), "w") as handle:
M scripts/test_measure_cef_startup.py +73 -0
@@ -211,5 +211,78 @@ class FakeBinaryLifetimeTest(unittest.TestCase):
211211 self.assertLess(result["observation_spawn_to_summary_ms"], 15000)
212212
213213
214+class ReopenWindowTest(unittest.TestCase):
215+ def setUp(self):
216+ self.temp = tempfile.mkdtemp()
217+ self.cef_dir = os.path.join(self.temp, "cef")
218+ os.makedirs(self.cef_dir)
219+
220+ def test_diagnostic_close_acknowledged(self):
221+ import json
222+ import socket
223+ socket_path = os.path.join(self.temp, "test.sock")
224+ listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
225+ listener.bind(socket_path)
226+ listener.listen(1)
227+ listener.settimeout(5)
228+
229+ def serve_once():
230+ conn, _ = listener.accept()
231+ with conn:
232+ conn.settimeout(5)
233+ data = b""
234+ while b"\n" not in data:
235+ chunk = conn.recv(4096)
236+ if not chunk:
237+ break
238+ data += chunk
239+ request = json.loads(data.decode())
240+ assert request["action"] == "close-all-windows"
241+ conn.sendall(json.dumps({"request_id": request["request_id"],
242+ "accepted": True}).encode() + b"\n")
243+
244+ thread = threading.Thread(target=serve_once)
245+ thread.daemon = True
246+ thread.start()
247+ try:
248+ self.assertTrue(harness.send_diagnostic_close(socket_path))
249+ finally:
250+ thread.join(timeout=5)
251+ listener.close()
252+
253+ def test_diagnostic_close_refused_without_server(self):
254+ self.assertFalse(harness.send_diagnostic_close(os.path.join(self.temp, "missing.sock"), 1.0))
255+
256+ def test_proc_helpers_observe_current_process(self):
257+ self.assertIsInstance(harness.child_pids(os.getpid()), list)
258+ rss = harness.proc_rss_kb(os.getpid())
259+ self.assertIsNotNone(rss)
260+ self.assertGreater(rss, 0)
261+ self.assertIsNone(harness.proc_rss_kb(2 ** 30))
262+
263+ def test_proc_starttime_identifies_current_process(self):
264+ first = harness.proc_starttime(os.getpid())
265+ self.assertIsNotNone(first)
266+ self.assertGreaterEqual(first, 0)
267+ self.assertEqual(first, harness.proc_starttime(os.getpid()))
268+ self.assertIsNone(harness.proc_starttime(2 ** 30))
269+
270+ def test_child_quiescence_returns_stable_count(self):
271+ count = harness.wait_for_child_quiescence(os.getpid(), settle_seconds=0.2, timeout_seconds=5.0)
272+ self.assertIsInstance(count, int)
273+ self.assertGreaterEqual(count, 0)
274+
275+ def test_reopen_without_owner_falls_back(self):
276+ binary = os.path.join(self.temp, "fakebin")
277+ with open(binary, "w") as handle:
278+ handle.write("#!/bin/sh\nexit 0\n")
279+ os.chmod(binary, 0o755)
280+ result = harness.run_sample(binary, self.cef_dir, "reopen-window", "static", 1,
281+ owner=None, owner_dirs=None, owner_socket=None)
282+ self.assertTrue(result["fallback_spawn"])
283+ self.assertFalse(result["relayed"])
284+ self.assertNotIn("reopen", result)
285+
286+
214287 if __name__ == "__main__":
215288 unittest.main()
@@ -211,5 +211,78 @@ class FakeBinaryLifetimeTest(unittest.TestCase):
211 self.assertLess(result["observation_spawn_to_summary_ms"], 15000)211 self.assertLess(result["observation_spawn_to_summary_ms"], 15000)
212 212
213 213
214+class ReopenWindowTest(unittest.TestCase):
215+ def setUp(self):
216+ self.temp = tempfile.mkdtemp()
217+ self.cef_dir = os.path.join(self.temp, "cef")
218+ os.makedirs(self.cef_dir)
219+
220+ def test_diagnostic_close_acknowledged(self):
221+ import json
222+ import socket
223+ socket_path = os.path.join(self.temp, "test.sock")
224+ listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
225+ listener.bind(socket_path)
226+ listener.listen(1)
227+ listener.settimeout(5)
228+
229+ def serve_once():
230+ conn, _ = listener.accept()
231+ with conn:
232+ conn.settimeout(5)
233+ data = b""
234+ while b"\n" not in data:
235+ chunk = conn.recv(4096)
236+ if not chunk:
237+ break
238+ data += chunk
239+ request = json.loads(data.decode())
240+ assert request["action"] == "close-all-windows"
241+ conn.sendall(json.dumps({"request_id": request["request_id"],
242+ "accepted": True}).encode() + b"\n")
243+
244+ thread = threading.Thread(target=serve_once)
245+ thread.daemon = True
246+ thread.start()
247+ try:
248+ self.assertTrue(harness.send_diagnostic_close(socket_path))
249+ finally:
250+ thread.join(timeout=5)
251+ listener.close()
252+
253+ def test_diagnostic_close_refused_without_server(self):
254+ self.assertFalse(harness.send_diagnostic_close(os.path.join(self.temp, "missing.sock"), 1.0))
255+
256+ def test_proc_helpers_observe_current_process(self):
257+ self.assertIsInstance(harness.child_pids(os.getpid()), list)
258+ rss = harness.proc_rss_kb(os.getpid())
259+ self.assertIsNotNone(rss)
260+ self.assertGreater(rss, 0)
261+ self.assertIsNone(harness.proc_rss_kb(2 ** 30))
262+
263+ def test_proc_starttime_identifies_current_process(self):
264+ first = harness.proc_starttime(os.getpid())
265+ self.assertIsNotNone(first)
266+ self.assertGreaterEqual(first, 0)
267+ self.assertEqual(first, harness.proc_starttime(os.getpid()))
268+ self.assertIsNone(harness.proc_starttime(2 ** 30))
269+
270+ def test_child_quiescence_returns_stable_count(self):
271+ count = harness.wait_for_child_quiescence(os.getpid(), settle_seconds=0.2, timeout_seconds=5.0)
272+ self.assertIsInstance(count, int)
273+ self.assertGreaterEqual(count, 0)
274+
275+ def test_reopen_without_owner_falls_back(self):
276+ binary = os.path.join(self.temp, "fakebin")
277+ with open(binary, "w") as handle:
278+ handle.write("#!/bin/sh\nexit 0\n")
279+ os.chmod(binary, 0o755)
280+ result = harness.run_sample(binary, self.cef_dir, "reopen-window", "static", 1,
281+ owner=None, owner_dirs=None, owner_socket=None)
282+ self.assertTrue(result["fallback_spawn"])
283+ self.assertFalse(result["relayed"])
284+ self.assertNotIn("reopen", result)
285+
286+
214 if __name__ == "__main__":287 if __name__ == "__main__":
215 unittest.main()288 unittest.main()

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