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 Unverified
B5690EEEBB952194The key is unknown
33 files · +2960 -64
M cmd/dumber/main.go +13 -7
| @@ -266,6 +266,16 @@ func main() { | |||
| 266 | 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 | 279 | func runGUI(cfg *config.Config, timing startupTiming) int { | |
| 270 | 280 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 271 | 281 | ||
| @@ -333,13 +343,9 @@ func runGUI(cfg *config.Config, timing startupTiming) int { | |||
| 333 | 343 | log.Error().Err(err).Msg("failed to create application") | |
| 334 | 344 | return 1 | |
| 335 | 345 | } | |
| 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) | |
| 343 | 349 | timer.Mark("ui_deps") | |
| 344 | 350 | timer.Log(ctx) | |
| 345 | 351 | ||
| @@ -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 1 | 344 | 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 @@ | |||
| 66 | 66 | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | | | |
| 67 | 67 | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | |
| 68 | 68 | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | | |
| 69 | + | | `engine.cef.idle_runtime_timeout_ms` | int | `0` | 0..300000, restart-required | | |
| 69 | 70 | | `engine.cef.input.scroll_wheel_multiplier` | float | `1.0` | > 0 | | |
| 70 | 71 | | `engine.cef.input.scroll_precise_multiplier` | float | `2.5` | > 0 | | |
| 71 | 72 | | `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 { | |||
| 24 | 24 | mu sync.Mutex | |
| 25 | 25 | active map[uint32]cefDownloadState | |
| 26 | 26 | 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 | 32 | type cefDownloadState struct { | |
| @@ -102,11 +105,13 @@ func newDownloadHandler( | |||
| 102 | 105 | downloadPath string, | |
| 103 | 106 | eventHandler port.DownloadEventHandler, | |
| 104 | 107 | preparer port.DownloadPreparer, | |
| 108 | + | activity *RuntimeActivityTracker, | |
| 105 | 109 | ) *downloadHandler { | |
| 106 | 110 | return &downloadHandler{ | |
| 107 | 111 | runtime: downloadruntime.New(downloadPath, eventHandler, preparer), | |
| 108 | 112 | active: make(map[uint32]cefDownloadState), | |
| 109 | 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 | 122 | func (h *downloadHandler) onBeforeDownload( | |
| 118 | 123 | ctx context.Context, | |
| 119 | - | _ purecef.Browser, | |
| 124 | + | browser purecef.Browser, | |
| 120 | 125 | downloadItem purecef.DownloadItem, | |
| 121 | 126 | suggestedName string, | |
| 122 | 127 | callback purecef.BeforeDownloadCallback, | |
| @@ -124,6 +129,14 @@ func (h *downloadHandler) onBeforeDownload( | |||
| 124 | 129 | if downloadItem == nil || callback == nil { | |
| 125 | 130 | 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 | 141 | log := logging.FromContext(ctx) | |
| 129 | 142 | output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem}) | |
| @@ -149,6 +162,7 @@ func (h *downloadHandler) onBeforeDownload( | |||
| 149 | 162 | ||
| 150 | 163 | func (h *downloadHandler) onDownloadUpdated( | |
| 151 | 164 | ctx context.Context, | |
| 165 | + | browser purecef.Browser, | |
| 152 | 166 | downloadItem purecef.DownloadItem, | |
| 153 | 167 | callback purecef.DownloadItemCallback, | |
| 154 | 168 | ) { | |
| @@ -156,6 +170,10 @@ func (h *downloadHandler) onDownloadUpdated( | |||
| 156 | 170 | if downloadItem == nil { | |
| 157 | 171 | return | |
| 158 | 172 | } | |
| 173 | + | var browserID int32 | |
| 174 | + | if browser != nil { | |
| 175 | + | browserID = browser.GetIdentifier() | |
| 176 | + | } | |
| 159 | 177 | ||
| 160 | 178 | id := downloadItem.GetID() | |
| 161 | 179 | ||
| @@ -175,11 +193,13 @@ func (h *downloadHandler) onDownloadUpdated( | |||
| 175 | 193 | if !h.markFinished(id) { | |
| 176 | 194 | return | |
| 177 | 195 | } | |
| 196 | + | h.activity.NoteDownloadTerminal(browserID, id) | |
| 178 | 197 | h.runtime.EmitFinished(ctx, state.filename, state.destination) | |
| 179 | 198 | case downloadItem.IsCanceled() || downloadItem.IsInterrupted(): | |
| 180 | 199 | if !h.markFinished(id) { | |
| 181 | 200 | return | |
| 182 | 201 | } | |
| 202 | + | h.activity.NoteDownloadTerminal(browserID, id) | |
| 183 | 203 | var err error | |
| 184 | 204 | if downloadItem.IsInterrupted() { | |
| 185 | 205 | err = cefDownloadInterruptError{ | |
| @@ -24,6 +24,9 @@ type downloadHandler struct { | |||||
| 24 | mu sync.Mutex | 24 | mu sync.Mutex | ||
| 25 | active map[uint32]cefDownloadState | 25 | active map[uint32]cefDownloadState | ||
| 26 | finished map[uint32]struct{} // IDs that already emitted a terminal event | 26 | 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 false | 130 | 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 | return | 171 | 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 | return | 194 | 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 | return | 200 | return | ||
| 182 | } | 201 | } | ||
| 202 | + | h.activity.NoteDownloadTerminal(browserID, id) | |||
| 183 | var err error | 203 | 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 ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -74,7 +75,7 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) { | |||
| 74 | 75 | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 75 | 76 | events := &captureDownloadEvents{} | |
| 76 | 77 | downloadDir := t.TempDir() | |
| 77 | - | handler := newDownloadHandler(downloadDir, events, preparer) | |
| 78 | + | handler := newDownloadHandler(downloadDir, events, preparer, nil) | |
| 78 | 79 | callback := &stubBeforeDownloadCallback{} | |
| 79 | 80 | ||
| 80 | 81 | item := stubDownloadItem{ | |
| @@ -93,9 +94,9 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) { | |||
| 93 | 94 | ||
| 94 | 95 | item.complete = true | |
| 95 | 96 | item.fullPath = callback.path | |
| 96 | - | handler.onDownloadUpdated(ctx, item, nil) | |
| 97 | + | handler.onDownloadUpdated(ctx, nil, item, nil) | |
| 97 | 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 | 101 | require.Len(t, events.events, 2) | |
| 101 | 102 | require.Equal(t, port.DownloadEventFinished, events.events[1].Type) | |
| @@ -112,7 +113,7 @@ func TestCEFDownloadHandlerLifecycle(t *testing.T) { | |||
| 112 | 113 | ||
| 113 | 114 | func TestCanDownload_AllowsAllMethods(t *testing.T) { | |
| 114 | 115 | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 115 | - | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |
| 116 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil) | |
| 116 | 117 | ||
| 117 | 118 | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET")) | |
| 118 | 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 | 124 | func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |
| 124 | 125 | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 125 | - | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |
| 126 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil) | |
| 126 | 127 | ||
| 127 | 128 | // Simulate a download that was tracked through onBeforeDownload. | |
| 128 | 129 | handler.mu.Lock() | |
| @@ -149,7 +150,7 @@ func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) { | |||
| 149 | 150 | ctx := context.Background() | |
| 150 | 151 | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 151 | 152 | events := &captureDownloadEvents{} | |
| 152 | - | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |
| 153 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer, nil) | |
| 153 | 154 | callback := &stubBeforeDownloadCallback{} | |
| 154 | 155 | ||
| 155 | 156 | item := stubDownloadItem{ | |
| @@ -165,12 +166,12 @@ func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) { | |||
| 165 | 166 | item.percentComplete = 12 | |
| 166 | 167 | item.receivedBytes = 12 | |
| 167 | 168 | 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) | |
| 170 | 171 | ||
| 171 | 172 | item.percentComplete = 13 | |
| 172 | 173 | item.receivedBytes = 13 | |
| 173 | - | handler.onDownloadUpdated(ctx, item, nil) | |
| 174 | + | handler.onDownloadUpdated(ctx, nil, item, nil) | |
| 174 | 175 | ||
| 175 | 176 | require.Len(t, events.events, 3) | |
| 176 | 177 | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| @@ -184,7 +185,7 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | |||
| 184 | 185 | ctx := context.Background() | |
| 185 | 186 | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 186 | 187 | events := &captureDownloadEvents{} | |
| 187 | - | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |
| 188 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer, nil) | |
| 188 | 189 | callback := &stubBeforeDownloadCallback{} | |
| 189 | 190 | ||
| 190 | 191 | item := stubDownloadItem{ | |
| @@ -199,7 +200,7 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | |||
| 199 | 200 | item.fullPath = callback.path | |
| 200 | 201 | item.interrupted = true | |
| 201 | 202 | item.reason = purecef.DownloadInterruptReasonServerBadContent | |
| 202 | - | handler.onDownloadUpdated(ctx, item, nil) | |
| 203 | + | handler.onDownloadUpdated(ctx, nil, item, nil) | |
| 203 | 204 | ||
| 204 | 205 | require.Len(t, events.events, 2) | |
| 205 | 206 | require.Error(t, events.events[1].Error) | |
| @@ -207,3 +208,32 @@ func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | |||
| 207 | 208 | require.ErrorContains(t, events.events[1].Error, expectedCode) | |
| 208 | 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 | + | } | |
| @@ -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 = true | 95 | item.complete = true | ||
| 95 | item.fullPath = callback.path | 96 | 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 = 12 | 166 | item.percentComplete = 12 | ||
| 166 | item.receivedBytes = 12 | 167 | item.receivedBytes = 12 | ||
| 167 | item.totalBytes = 100 | 168 | 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 = 13 | 172 | item.percentComplete = 13 | ||
| 172 | item.receivedBytes = 13 | 173 | 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.path | 200 | item.fullPath = callback.path | ||
| 200 | item.interrupted = true | 201 | item.interrupted = true | ||
| 201 | item.reason = purecef.DownloadInterruptReasonServerBadContent | 202 | 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 { | |||
| 62 | 62 | browserWebViews sync.Map // map[int32]*WebView | |
| 63 | 63 | 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 | 70 | // shutdownNotify is signaled when the active webview count reaches 0 | |
| 66 | 71 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| 67 | 72 | shutdownNotify chan struct{} | |
| @@ -87,6 +92,16 @@ type Engine struct { | |||
| 87 | 92 | 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 | 105 | func (e *Engine) Factory() port.WebViewFactory { | |
| 91 | 106 | return &webViewFactoryAdapter{factory: e.factory} | |
| 92 | 107 | } | |
| @@ -162,6 +177,7 @@ func (e *Engine) recordGPURelaunch() { | |||
| 162 | 177 | func (e *Engine) registerWebView(wv *WebView) { | |
| 163 | 178 | e.activeWebViews.Store(wv.id, wv) | |
| 164 | 179 | e.activeCount.Add(1) | |
| 180 | + | e.activity.NoteViewRegistered() | |
| 165 | 181 | } | |
| 166 | 182 | ||
| 167 | 183 | func (e *Engine) lookupWebView(id port.WebViewID) *WebView { | |
| @@ -204,6 +220,7 @@ func (e *Engine) unbindBrowserWebView(browserID int32, wv *WebView) { | |||
| 204 | 220 | func (e *Engine) unregisterWebView(wv *WebView, browserID int32) { | |
| 205 | 221 | e.activeWebViews.Delete(wv.id) | |
| 206 | 222 | e.unbindBrowserWebView(browserID, wv) | |
| 223 | + | e.activity.DropBrowser(browserID) | |
| 207 | 224 | if e.activeCount.Add(-1) == 0 && e.shutdownNotify != nil { | |
| 208 | 225 | select { | |
| 209 | 226 | case e.shutdownNotify <- struct{}{}: | |
| @@ -304,6 +321,7 @@ func (e *Engine) closeActiveWebViews() { | |||
| 304 | 321 | func (e *Engine) destroyClosedWebViewBridges(webViews []*WebView) { | |
| 305 | 322 | for _, wv := range webViews { | |
| 306 | 323 | if wv != nil && wv.destroyed.Load() { | |
| 324 | + | // Completion accounting lands in destroyViewBridgeOnGTKThread. | |
| 307 | 325 | wv.destroyViewBridgeOnGTKSync() | |
| 308 | 326 | } | |
| 309 | 327 | } | |
| @@ -340,7 +358,7 @@ func (e *Engine) ConfigureDownloads( | |||
| 340 | 358 | return fmt.Errorf("cef: download preparer is required") | |
| 341 | 359 | } | |
| 342 | 360 | e.downloadMu.Lock() | |
| 343 | - | e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer) | |
| 361 | + | e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer, e.activity) | |
| 344 | 362 | e.downloadMu.Unlock() | |
| 345 | 363 | return nil | |
| 346 | 364 | } | |
| @@ -503,18 +521,21 @@ func (e *Engine) recordBrowserCreateRequest(width, height, result int32) { | |||
| 503 | 521 | Int32("height", height). | |
| 504 | 522 | Int32("result", result). | |
| 505 | 523 | 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 | |
| 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 | 536 | func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) { | |
| 517 | 537 | count := e.browserAfterCreated.Add(1) | |
| 538 | + | e.activity.NoteCreationResolved() | |
| 518 | 539 | if count >= e.browserCreateRequests.Load() { | |
| 519 | 540 | e.browserCreateComplete.Store(true) | |
| 520 | 541 | } | |
| @@ -62,6 +62,11 @@ type Engine struct { | |||||
| 62 | browserWebViews sync.Map // map[int32]*WebView | 62 | browserWebViews sync.Map // map[int32]*WebView | ||
| 63 | activeCount atomic.Int32 | 63 | 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 0 | 70 | // 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.Bool | 92 | 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 nil | 363 | 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( | |||
| 84 | 84 | ctxMenuRenderer: deps.ContextMenuRenderer, | |
| 85 | 85 | clipboard: deps.Clipboard, | |
| 86 | 86 | resolver: deps.ImageDataResolver, | |
| 87 | + | activity: NewRuntimeActivityTracker(), | |
| 87 | 88 | } | |
| 88 | 89 | ||
| 89 | 90 | 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 { | |||
| 110 | 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 | 126 | // Close shuts down the pool and destroys all pooled WebViews. | |
| 114 | 127 | func (p *WebViewPool) Close() { | |
| 115 | 128 | 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 { | |||
| 278 | 278 | loadDiagLastLoadStateAt time.Time | |
| 279 | 279 | ||
| 280 | 280 | // 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 | |
| 283 | 287 | generation atomic.Uint64 | |
| 284 | 288 | audioPlaying atomic.Bool | |
| 285 | 289 | zoomFactor atomic.Value // float64, initialized to 1.0 | |
| @@ -1318,6 +1322,7 @@ func (wv *WebView) destroyViewBridgeOnGTKSync() { | |||
| 1318 | 1322 | if wv == nil || wv.viewBridge == nil { | |
| 1319 | 1323 | return | |
| 1320 | 1324 | } | |
| 1325 | + | wv.noteBridgeTeardownScheduled() | |
| 1321 | 1326 | wv.runOnGTKSyncLabelAllowLateStart("cef.destroy_view_bridge", true, func() { | |
| 1322 | 1327 | wv.destroyViewBridgeOnGTKThread() | |
| 1323 | 1328 | }) | |
| @@ -1327,11 +1332,29 @@ func (wv *WebView) destroyViewBridgeOnGTKAsync() { | |||
| 1327 | 1332 | if wv == nil || wv.viewBridge == nil { | |
| 1328 | 1333 | return | |
| 1329 | 1334 | } | |
| 1335 | + | wv.noteBridgeTeardownScheduled() | |
| 1330 | 1336 | wv.runOnGTK(func() { | |
| 1331 | 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 | 1358 | func (wv *WebView) destroyViewBridgeOnGTKThread() { | |
| 1336 | 1359 | if wv == nil || wv.viewBridge == nil { | |
| 1337 | 1360 | return | |
| @@ -1350,6 +1373,11 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||
| 1350 | 1373 | _ = wv.viewBridge.Destroy() | |
| 1351 | 1374 | wv.viewBridge = nil | |
| 1352 | 1375 | 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 | // --------------------------------------------------------------------------- | |
| @@ -278,8 +278,12 @@ type WebView struct { | |||||
| 278 | loadDiagLastLoadStateAt time.Time | 278 | loadDiagLastLoadStateAt time.Time | ||
| 279 | 279 | ||||
| 280 | // Atomic state. | 280 | // Atomic state. | ||
| 281 | - | destroyed atomic.Bool | 281 | + | destroyed atomic.Bool |
| 282 | - | fullscreen 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 | |||
| 283 | generation atomic.Uint64 | 287 | generation atomic.Uint64 | ||
| 284 | audioPlaying atomic.Bool | 288 | audioPlaying atomic.Bool | ||
| 285 | zoomFactor atomic.Value // float64, initialized to 1.0 | 289 | 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 | return | 1323 | 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 | return | 1333 | 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 | return | 1360 | return | ||
| @@ -1350,6 +1373,11 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||||
| 1350 | _ = wv.viewBridge.Destroy() | 1373 | _ = wv.viewBridge.Destroy() | ||
| 1351 | wv.viewBridge = nil | 1374 | wv.viewBridge = nil | ||
| 1352 | wv.nativeWidget = nil | 1375 | 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) { | |||
| 828 | 828 | log := logging.FromContext(h.wv.ctx) | |
| 829 | 829 | if browser == nil { | |
| 830 | 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 | 836 | return | |
| 832 | 837 | } | |
| 833 | 838 | browserID := browser.GetIdentifier() | |
| @@ -836,6 +841,11 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 836 | 841 | host := browser.GetHost() | |
| 837 | 842 | if host == nil { | |
| 838 | 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 | 849 | return | |
| 840 | 850 | } | |
| 841 | 851 | ||
| @@ -845,6 +855,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 845 | 855 | Int32("browser_id", browserID). | |
| 846 | 856 | Int32("existing_browser_id", state.duplicateBrowserID). | |
| 847 | 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 | 861 | host.CloseBrowser(1) | |
| 849 | 862 | return | |
| 850 | 863 | } | |
| @@ -1083,7 +1096,7 @@ func (h *handlerSet) OnBeforeDownload( | |||
| 1083 | 1096 | } | |
| 1084 | 1097 | ||
| 1085 | 1098 | func (h *handlerSet) OnDownloadUpdated( | |
| 1086 | - | _ purecef.Browser, | |
| 1099 | + | browser purecef.Browser, | |
| 1087 | 1100 | downloadItem purecef.DownloadItem, | |
| 1088 | 1101 | callback purecef.DownloadItemCallback, | |
| 1089 | 1102 | ) { | |
| @@ -1091,7 +1104,7 @@ func (h *handlerSet) OnDownloadUpdated( | |||
| 1091 | 1104 | if handler == nil { | |
| 1092 | 1105 | return | |
| 1093 | 1106 | } | |
| 1094 | - | handler.onDownloadUpdated(h.currentContext(), downloadItem, callback) | |
| 1107 | + | handler.onDownloadUpdated(h.currentContext(), browser, downloadItem, callback) | |
| 1095 | 1108 | } | |
| 1096 | 1109 | ||
| 1097 | 1110 | 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 | return | 836 | 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 | return | 849 | 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 | return | 862 | 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 | return | 1105 | 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 { | |||
| 112 | 112 | 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 | 113 | // Input controls GTK input translation before events are forwarded to CEF. | |
| 114 | 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 | 120 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | |
| 116 | 121 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | |
| 117 | 122 | // 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 limit | 112 | 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) { | |||
| 808 | 808 | m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax()) | |
| 809 | 809 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | |
| 810 | 810 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | |
| 811 | + | m.viper.SetDefault("engine.cef.idle_runtime_timeout_ms", ce.IdleRuntimeTimeoutMs) | |
| 811 | 812 | m.setCEFInputDefaults(ce.Input) | |
| 812 | 813 | ||
| 813 | 814 | 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.WebKit | 814 | wk := e.WebKit | ||
M internal/infrastructure/config/schema_provider.go +8 -0
| @@ -1117,6 +1117,14 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 1117 | 1117 | Range: ">=0", | |
| 1118 | 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 | 1129 | Key: "engine.cef.input.scroll_wheel_multiplier", | |
| 1122 | 1130 | 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 { | |||
| 501 | 501 | if config.Engine.CEF.WindowlessFrameRateMax < 0 { | |
| 502 | 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 | 508 | input := config.Engine.CEF.Input | |
| 506 | 509 | 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.Input | 508 | 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) { | |||
| 197 | 197 | wantErr: true, | |
| 198 | 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 | 231 | name: "zero wheel multiplier", | |
| 202 | 232 | 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 ( | |||
| 17 | 17 | "time" | |
| 18 | 18 | ||
| 19 | 19 | "github.com/bnema/dumber/internal/application/port" | |
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/process" | |
| 20 | 21 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 21 | 22 | "github.com/bnema/dumber/internal/logging" | |
| 23 | + | "github.com/rs/zerolog" | |
| 22 | 24 | ) | |
| 23 | 25 | ||
| 24 | 26 | const browserLaunchSocketName = "browser-launch.sock" | |
| @@ -36,6 +38,11 @@ var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not | |||
| 36 | 38 | ||
| 37 | 39 | type browserLaunchRelay struct { | |
| 38 | 40 | 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 | 48 | type browserLaunchRequest struct { | |
| @@ -49,8 +56,26 @@ type browserLaunchAction string | |||
| 49 | 56 | const ( | |
| 50 | 57 | browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url" | |
| 51 | 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 | 79 | type browserLaunchResponse struct { | |
| 55 | 80 | RequestID string `json:"request_id,omitempty"` | |
| 56 | 81 | Accepted bool `json:"accepted,omitempty"` | |
| @@ -60,6 +85,7 @@ type browserLaunchResponse struct { | |||
| 60 | 85 | type browserLaunchRelayListener struct { | |
| 61 | 86 | listener *net.UnixListener | |
| 62 | 87 | socketPath string | |
| 88 | + | admission *process.AdmissionGate | |
| 63 | 89 | once sync.Once | |
| 64 | 90 | err error | |
| 65 | 91 | } | |
| @@ -73,7 +99,24 @@ var newBrowserLaunchRequestID = func() string { | |||
| 73 | 99 | } | |
| 74 | 100 | ||
| 75 | 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 | 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 | 316 | go relayListener.serve(ctx, opener) | |
| 274 | 317 | ||
| 275 | 318 | 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 | 410 | defer func() { _ = conn.Close() }() | |
| 327 | 411 | log := logging.FromContext(ctx) | |
| 328 | 412 | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| @@ -342,7 +426,17 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n | |||
| 342 | 426 | Str("url_host", safeURLHost(request.URL)). | |
| 343 | 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 | 438 | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| 439 | + | gate.Release() | |
| 346 | 440 | return | |
| 347 | 441 | } | |
| 348 | 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 | 444 | Str("request_id", requestID). | |
| 351 | 445 | Str("url_host", safeURLHost(request.URL)). | |
| 352 | 446 | Msg("failed to encode browser launch response") | |
| 447 | + | gate.Release() | |
| 353 | 448 | return | |
| 354 | 449 | } | |
| 355 | 450 | log.Debug(). | |
| @@ -358,6 +453,11 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n | |||
| 358 | 453 | Msg("browser launch relay request accepted") | |
| 359 | 454 | ||
| 360 | 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 | 461 | if opener == nil { | |
| 362 | 462 | log.Warn(). | |
| 363 | 463 | Str("request_id", requestID). | |
| @@ -376,6 +476,12 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n | |||
| 376 | 476 | err = opener.OpenExternalURL(ctx, request.URL) | |
| 377 | 477 | case browserLaunchActionOpenFreshWindow: | |
| 378 | 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 | 485 | default: | |
| 380 | 486 | log.Warn(). | |
| 381 | 487 | 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.IPCPaths | 40 | 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.UnixListener | 86 | listener *net.UnixListener | ||
| 62 | socketPath string | 87 | socketPath string | ||
| 88 | + | admission *process.AdmissionGate | |||
| 63 | once sync.Once | 89 | once sync.Once | ||
| 64 | err error | 90 | 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, nil | 318 | 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 | return | 440 | 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 | return | 448 | 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 ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/process" | |
| 13 | 15 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 14 | 16 | "github.com/stretchr/testify/assert" | |
| 15 | 17 | "github.com/stretchr/testify/require" | |
| @@ -648,3 +650,158 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |||
| 648 | 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 | + | } | |
| @@ -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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -23,6 +24,9 @@ const ( | |||
| 23 | 24 | // Compile-time interface check. | |
| 24 | 25 | var _ port.SnapshotService = (*Service)(nil) | |
| 25 | 26 | ||
| 27 | + | // Compile-time drain boundary check. | |
| 28 | + | var _ port.PersistenceDrain = (*Service)(nil) | |
| 29 | + | ||
| 26 | 30 | // Service handles debounced session state snapshots. | |
| 27 | 31 | type Service struct { | |
| 28 | 32 | snapshotUC *usecase.SnapshotSessionUseCase | |
| @@ -31,12 +35,24 @@ type Service struct { | |||
| 31 | 35 | retries int | |
| 32 | 36 | retryDelay time.Duration | |
| 33 | 37 | ||
| 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() | |
| 40 | 56 | } | |
| 41 | 57 | ||
| 42 | 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 | 115 | func (s *Service) Stop(ctx context.Context) error { | |
| 96 | 116 | s.mu.Lock() | |
| 97 | 117 | if s.cancel != nil { | |
| @@ -101,27 +121,52 @@ func (s *Service) Stop(ctx context.Context) error { | |||
| 101 | 121 | s.timer.Stop() | |
| 102 | 122 | s.timer = nil | |
| 103 | 123 | } | |
| 124 | + | s.stopped = true | |
| 104 | 125 | s.mu.Unlock() | |
| 105 | 126 | ||
| 106 | 127 | // 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 | 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 | 140 | func (s *Service) MarkDirty() { | |
| 113 | 141 | s.mu.Lock() | |
| 114 | 142 | defer s.mu.Unlock() | |
| 115 | - | ||
| 143 | + | if s.stopped { | |
| 144 | + | return | |
| 145 | + | } | |
| 116 | 146 | s.dirty = true | |
| 117 | 147 | ||
| 118 | 148 | // Reset or create timer | |
| 119 | 149 | if s.timer != nil { | |
| 120 | 150 | s.timer.Stop() | |
| 121 | 151 | } | |
| 122 | - | ||
| 152 | + | s.timerGen++ | |
| 153 | + | gen := s.timerGen | |
| 123 | 154 | s.timer = time.AfterFunc(s.interval, func() { | |
| 124 | 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 | 170 | ctx := s.ctx | |
| 126 | 171 | s.mu.Unlock() | |
| 127 | 172 | ||
| @@ -160,14 +205,28 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||
| 160 | 205 | s.mu.Unlock() | |
| 161 | 206 | 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 | 215 | // Only clear dirty when we're actually going to save | |
| 164 | 216 | s.dirty = false | |
| 217 | + | s.lastErr = nil | |
| 218 | + | s.inflight.Add(1) | |
| 165 | 219 | s.mu.Unlock() | |
| 166 | 220 | ||
| 221 | + | defer func() { | |
| 222 | + | s.inflight.Add(-1) | |
| 223 | + | s.notifyIfSettled() | |
| 224 | + | }() | |
| 225 | + | ||
| 167 | 226 | sessionID := s.provider.GetSessionID() | |
| 168 | 227 | ||
| 169 | 228 | if sessionID == "" { | |
| 170 | - | s.markDirty() | |
| 229 | + | s.MarkDirty() | |
| 171 | 230 | return nil | |
| 172 | 231 | } | |
| 173 | 232 | ||
| @@ -175,7 +234,7 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||
| 175 | 234 | // A nil window list with no active index means the snapshot is truly unavailable, | |
| 176 | 235 | // not merely empty. Keep the session dirty so a later capture can retry. | |
| 177 | 236 | if windows == nil && activeWindowIndex < 0 { | |
| 178 | - | s.markDirty() | |
| 237 | + | s.MarkDirty() | |
| 179 | 238 | logging.FromContext(ctx).Warn().Msg("window snapshot unavailable; keeping session snapshot dirty") | |
| 180 | 239 | return nil | |
| 181 | 240 | } | |
| @@ -196,12 +255,90 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||
| 196 | 255 | ||
| 197 | 256 | if err := s.executeWithRetry(ctx, input); err != nil { | |
| 198 | 257 | s.markDirty() | |
| 258 | + | s.setTerminalError(err) | |
| 199 | 259 | return err | |
| 200 | 260 | } | |
| 201 | 261 | ||
| 202 | 262 | 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 | 342 | func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error { | |
| 206 | 343 | log := logging.FromContext(ctx) | |
| 207 | 344 | 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.SnapshotSessionUseCase | 32 | snapshotUC *usecase.SnapshotSessionUseCase | ||
| @@ -31,12 +35,24 @@ type Service struct { | |||||
| 31 | retries int | 35 | retries int | ||
| 32 | retryDelay time.Duration | 36 | retryDelay time.Duration | ||
| 33 | 37 | ||||
| 34 | - | mu sync.Mutex | 38 | + | mu sync.Mutex |
| 35 | - | timer *time.Timer | 39 | + | timer *time.Timer |
| 36 | - | dirty bool | 40 | + | timerGen uint64 |
| 37 | - | ready bool // true when session is persisted to DB and snapshots can be saved | 41 | + | // stopped latches Stop: no new debounce work schedules afterwards. |
| 38 | - | ctx context.Context | 42 | + | stopped bool |
| 39 | - | cancel context.CancelFunc | 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() | |||
| 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 = nil | 122 | s.timer = nil | ||
| 103 | } | 123 | } | ||
| 124 | + | s.stopped = true | |||
| 104 | s.mu.Unlock() | 125 | s.mu.Unlock() | ||
| 105 | 126 | ||||
| 106 | // Final save on shutdown | 127 | // 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 = true | 146 | s.dirty = true | ||
| 117 | 147 | ||||
| 118 | // Reset or create timer | 148 | // 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.ctx | 170 | 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 nil | 206 | 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 save | 215 | // Only clear dirty when we're actually going to save | ||
| 164 | s.dirty = false | 216 | 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 nil | 230 | 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 nil | 239 | 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 err | 259 | return err | ||
| 200 | } | 260 | } | ||
| 201 | 261 | ||||
| 202 | return nil | 262 | 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 + 1 | 344 | maxAttempts := s.retries + 1 | ||
M internal/infrastructure/snapshot/service_test.go +167 -0
| @@ -3,6 +3,7 @@ package snapshot | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -27,6 +28,80 @@ func newWindowStateProvider( | |||
| 27 | 28 | 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 | 105 | func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) { | |
| 31 | 106 | repo := repomocks.NewMockSessionStateRepository(t) | |
| 32 | 107 | calls := 0 | |
| @@ -251,3 +326,95 @@ func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) { | |||
| 251 | 326 | require.NoError(t, err) | |
| 252 | 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 | + | } | |
| @@ -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 provider | 28 | 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 := 0 | 107 | 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 { | |||
| 139 | 139 | // Engine | |
| 140 | 140 | 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 | 153 | // Web content (managed by content.Coordinator) | |
| 143 | 154 | faviconAdapter *adapter.FaviconAdapter | |
| 144 | 155 | ||
| @@ -341,6 +352,34 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 341 | 352 | defer a.gtkApp.Unref() | |
| 342 | 353 | 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 | 383 | // Connect activate signal | |
| 345 | 384 | activateCb := func(_ gio.Application) { | |
| 346 | 385 | a.onActivate(ctx) | |
| @@ -372,6 +411,12 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 372 | 411 | ||
| 373 | 412 | if err := a.openInitialBrowserWindowShell(ctx, a.initialWindowURL()); err != nil { | |
| 374 | 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 | 420 | return | |
| 376 | 421 | } | |
| 377 | 422 | ||
| @@ -388,6 +433,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 388 | 433 | a.wireSessionManagerShortcut() | |
| 389 | 434 | a.initSnapshotService(ctx) | |
| 390 | 435 | a.initUpdateCoordinator(ctx) | |
| 436 | + | a.subscribeResidencyQuiescence() | |
| 391 | 437 | a.createInitialTab(ctx) | |
| 392 | 438 | a.finalizeActivation(ctx) | |
| 393 | 439 | } | |
| @@ -3065,6 +3111,19 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 3065 | 3111 | log := logging.FromContext(ctx) | |
| 3066 | 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 | 3127 | // Save final session state before shutdown | |
| 3069 | 3128 | if a.snapshotService != nil { | |
| 3070 | 3129 | if err := a.snapshotService.Stop(ctx); err != nil { | |
| @@ -3240,10 +3299,8 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||
| 3240 | 3299 | bw.mainWindow.Destroy() | |
| 3241 | 3300 | } | |
| 3242 | 3301 | } | |
| 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. | |
| 3247 | 3304 | }) | |
| 3248 | 3305 | // Wire popup tab WebView attachment | |
| 3249 | 3306 | a.tabCoord.SetOnAttachPopupToTab(a.attachPopupToTab) | |
| @@ -3779,11 +3836,26 @@ func (a *App) Quit() { | |||
| 3779 | 3836 | return | |
| 3780 | 3837 | } | |
| 3781 | 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 | 3846 | if a.gtkApp != nil { | |
| 3783 | 3847 | a.gtkApp.Quit() | |
| 3784 | 3848 | } | |
| 3785 | 3849 | } | |
| 3786 | 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 | 3859 | if glibCtx != nil && glibCtx.IsOwner() { | |
| 3788 | 3860 | quit() | |
| 3789 | 3861 | return | |
| @@ -139,6 +139,17 @@ type App struct { | |||||
| 139 | // Engine | 139 | // Engine | ||
| 140 | engine port.Engine | 140 | 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.FaviconAdapter | 154 | 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.runOnMainThread | 353 | 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 signal | 383 | // 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 | return | 420 | 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 shutdown | 3127 | // 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 attachment | 3305 | // 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 | return | 3836 | 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 | return | 3861 | return | ||
M internal/ui/browser_window.go +2 -0
| @@ -343,6 +343,7 @@ func (a *App) registerBrowserWindow(bw *browserWindow) { | |||
| 343 | 343 | return | |
| 344 | 344 | } | |
| 345 | 345 | a.browserWindowOrder = append(a.browserWindowOrder, bw.id) | |
| 346 | + | a.residencyNoteWindowsChanged() | |
| 346 | 347 | } | |
| 347 | 348 | ||
| 348 | 349 | func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) { | |
| @@ -392,6 +393,7 @@ func (a *App) removeBrowserWindow(id string) { | |||
| 392 | 393 | if wasMainWindow { | |
| 393 | 394 | a.clearMainBrowserWindowAfterRemoval(fallback) | |
| 394 | 395 | } | |
| 396 | + | a.residencyAfterWindowRemoved() | |
| 395 | 397 | } | |
| 396 | 398 | ||
| 397 | 399 | func (a *App) releaseNativePopupsForBrowserWindow(windowID string) { | |
| @@ -343,6 +343,7 @@ func (a *App) registerBrowserWindow(bw *browserWindow) { | |||||
| 343 | return | 343 | 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: | |||
| 8 | 8 | relay-window a real long-lived owner browser holds the relay; samples | |
| 9 | 9 | launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that | |
| 10 | 10 | 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 | 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 | 285 | 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 | 289 | dirs = { | |
| 286 | 290 | "config": os.path.join(scenario_dir, "owner-config"), | |
| 287 | 291 | "data": os.path.join(scenario_dir, "owner-data"), | |
| @@ -291,6 +295,12 @@ def start_relay_owner(binary, cef_dir, scenario_dir): | |||
| 291 | 295 | "root_cache": os.path.join(scenario_dir, "shared-profile"), | |
| 292 | 296 | } | |
| 293 | 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 | 304 | try: | |
| 295 | 305 | proc = subprocess.Popen( | |
| 296 | 306 | [binary, "browse", "about:blank"], | |
| @@ -299,18 +309,19 @@ def start_relay_owner(binary, cef_dir, scenario_dir): | |||
| 299 | 309 | env=env, | |
| 300 | 310 | ) | |
| 301 | 311 | except OSError: | |
| 302 | - | return None, None | |
| 312 | + | return None, None, None | |
| 303 | 313 | time.sleep(0.5) | |
| 304 | 314 | 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: | |
| 307 | 318 | proc.terminate() | |
| 308 | 319 | try: | |
| 309 | 320 | proc.wait(timeout=5) | |
| 310 | 321 | except subprocess.TimeoutExpired: | |
| 311 | 322 | proc.kill() | |
| 312 | - | return None, None | |
| 313 | - | return proc, dirs | |
| 323 | + | return None, None, None | |
| 324 | + | return proc, dirs, socket_path | |
| 314 | 325 | ||
| 315 | 326 | ||
| 316 | 327 | def stop_proc(proc): | |
| @@ -324,8 +335,98 @@ def stop_proc(proc): | |||
| 324 | 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 | 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 | 430 | run_token = secrets.token_hex(8) | |
| 330 | 431 | nav_token = secrets.token_hex(8) | |
| 331 | 432 | cutoff = post_nav_cutoff_seconds() | |
| @@ -345,7 +446,7 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_ | |||
| 345 | 446 | url = "http://127.0.0.1:%d/redirect?run=%s&nav=%s" % (port, run_token, nav_token) | |
| 346 | 447 | else: | |
| 347 | 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 | 450 | dirs = dict(owner_dirs) | |
| 350 | 451 | extra = {"DUMBER_BROWSER_FRESH_WINDOW": "1"} | |
| 351 | 452 | else: | |
| @@ -360,6 +461,24 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_ | |||
| 360 | 461 | extra = {} | |
| 361 | 462 | env = scenario_env(cef_dir, dirs, extra) | |
| 362 | 463 | 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 | 482 | spawn_ts = time.monotonic() | |
| 364 | 483 | proc = subprocess.Popen( | |
| 365 | 484 | [binary, "browse", url], | |
| @@ -429,11 +548,11 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_ | |||
| 429 | 548 | observation_ms = int((decision_ts - spawn_ts) * 1000) | |
| 430 | 549 | expected_document_requests = 2 if fixture == "redirect" else 1 | |
| 431 | 550 | complete = document_count == expected_document_requests and len(fcp) > 0 | |
| 432 | - | if scenario == "relay-window": | |
| 551 | + | if scenario in ("relay-window", "reopen-window"): | |
| 433 | 552 | relayed = bool(owner_alive_at_start and self_exited) | |
| 434 | 553 | else: | |
| 435 | 554 | relayed = False | |
| 436 | - | return { | |
| 555 | + | result = { | |
| 437 | 556 | "run": run_index, | |
| 438 | 557 | "scenario": scenario, | |
| 439 | 558 | "fixture": fixture, | |
| @@ -447,8 +566,25 @@ def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_ | |||
| 447 | 566 | "startup_milestones": len(milestones), | |
| 448 | 567 | "complete": complete, | |
| 449 | 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 | 588 | finally: | |
| 453 | 589 | server.shutdown() | |
| 454 | 590 | server.server_close() | |
| @@ -463,7 +599,7 @@ def main(): | |||
| 463 | 599 | parser.add_argument( | |
| 464 | 600 | "--scenario", | |
| 465 | 601 | required=True, | |
| 466 | - | choices=("process-warm", "profile-fresh", "relay-window"), | |
| 602 | + | choices=("process-warm", "profile-fresh", "relay-window", "reopen-window"), | |
| 467 | 603 | ) | |
| 468 | 604 | parser.add_argument( | |
| 469 | 605 | "--fixture", | |
| @@ -501,6 +637,7 @@ def main(): | |||
| 501 | 637 | os.makedirs(args.output) | |
| 502 | 638 | owner = None | |
| 503 | 639 | owner_dirs = None | |
| 640 | + | owner_socket = None | |
| 504 | 641 | shared_root_cache = None | |
| 505 | 642 | warmup_discarded = False | |
| 506 | 643 | if args.scenario == "process-warm": | |
| @@ -509,7 +646,12 @@ def main(): | |||
| 509 | 646 | if args.scenario == "relay-window": | |
| 510 | 647 | scenario_dir = os.path.join(args.output, "relay-scenario") | |
| 511 | 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 | 656 | results = [] | |
| 515 | 657 | try: | |
| @@ -528,6 +670,7 @@ def main(): | |||
| 528 | 670 | result = run_sample( | |
| 529 | 671 | args.binary, args.cef_dir, args.scenario, args.fixture, index, | |
| 530 | 672 | owner=owner, owner_dirs=owner_dirs, shared_root_cache=shared_root_cache, | |
| 673 | + | owner_socket=owner_socket, | |
| 531 | 674 | ) | |
| 532 | 675 | results.append(result) | |
| 533 | 676 | 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; samples | 8 | relay-window a real long-lived owner browser holds the relay; samples | ||
| 9 | launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that | 9 | launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that | ||
| 10 | same isolated profile | 10 | 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 None | 285 | 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, None | 312 | + | 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, None | 315 | + | 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, None | 323 | + | return None, None, None |
| 313 | - | return proc, dirs | 324 | + | 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 None | 463 | 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 1 | 549 | expected_document_requests = 2 if fixture == "redirect" else 1 | ||
| 431 | complete = document_count == expected_document_requests and len(fcp) > 0 | 550 | 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 = False | 554 | 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 = None | 638 | owner = None | ||
| 503 | owner_dirs = None | 639 | owner_dirs = None | ||
| 640 | + | owner_socket = None | |||
| 504 | shared_root_cache = None | 641 | shared_root_cache = None | ||
| 505 | warmup_discarded = False | 642 | 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): | |||
| 211 | 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 | 287 | if __name__ == "__main__": | |
| 215 | 288 | 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() | ||