WIP: fix(runtime): handle windowless Windows stop requests #102

Draft
mooki wants to merge 1 commit from mooki/Longshot:fix/windows-graceful-stop into main
First-time contributor

Why

A windowless Windows game has no game window to receive a polite close request, so a tester had to force it and the co-op relay saw a silent disappearance instead of a leave. This WIP adds a bounded normal-close attempt for delivered window and console events, without claiming that quitting finishes a save.

What changed

  • host/windows_stop.c: a hidden top-level window receives WM_CLOSE and confirmed WM_ENDSESSION; console handlers accept Ctrl+C, Ctrl+Break and OS-ending events. They request work once, not perform renderer, save, network or log work in the console handler. Repeated events do not extend the deadline.
  • A separate worker calls rx_close_asked() and hos_window_closed_exit(). The message thread limits event-triggered close to four seconds; if the process remains alive, it terminates its own process with exit code 124. That is an incomplete, forced stop, not a successful graceful shutdown.
  • host/display.c: installs this route only for HOS_HEADLESS, after renderer startup. Creating/freeing the renderer's temporary preparation console resets console handlers; installing earlier would lose the handler. Control quit remains the startup route.
  • host/control.c and host/hos_relaunch.c: share the normal-close tail and record stop: normal close work returned; save drain unsupported before the log flush and exit. It means the callbacks returned, not that guest saves or every cache write succeeded.
  • tests/test_windows_stop.py drives the production callbacks against game-free Win32 stand-ins, including a stuck worker, message flooding, cancellation and startup errors. The existing Windows startup fixture also checks installation and its failure path.
  • docs/development.md and docs/control.md: distinguish control quit, console events, window messages, Linux signals and force; document the startup limit, deadline and unsupported save draining.

The hidden window is never shown or activated and is not the renderer's surface. A message-only window was rejected: it cannot be enumerated and does not receive broadcasts. A console handler alone would not address the reported taskkill route or Windows' user32/logoff caveat. Actual plain-taskkill delivery is still untested.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
HOS_HEADLESS Existing offscreen mode gains the Windows stop handlers after renderer startup Off; handlers active only when headless Off by default: ordinary play needs its game window Intended for started windowless Windows games Saving, forced kills, startup before renderer initialization; console events without a console All Windows game execution
HOS_CONTROL Existing opt-in bridge quit now shares the normal-close tail Unset Off by default: enable deliberately for owned test sessions Existing Linux/Windows control workflows; game-free bridge checked here Acknowledging completed saves or forcing a hung close Windows game execution and integrated co-op

No new setting is added. The four-second deadline applies only to the new Windows-event route; control quit retains its existing behavior.

Measurements

main: f41db065cf (pinned base, 2026-11-12)
this PR: move/fix/windows-graceful-stop @ 830b2c5908
Base whole suite: 509 passed, 0 failed, 19 skipped (shared base result).
Head whole suite: 510 passed, 0 failed, 19 skipped.

Hardware: Arch Linux 7.2.15-1-omarchy, native GCC 16.2.1 and Clang 23.1.3; Windows target uses the llvm-mingw 20261104 UCRT headers/libraries. No GPU was used.
Scene: authored game-free fixtures, no game or save.
Method: one failing startup-fixture check against main's unchanged display source, one passing branch check, and the new stop driver with both native compilers. These are behavior checks, not interleaved game measurements or timing claims. The whole suite below belongs to the exact head above; main's supplied passing suite was not repeated.
How to reproduce: python -I -B tests/test_windows_stop.py --cc gcc, then --cc clang; python -I -B tests/test_windows_display_startup.py. Run the latter with --source naming main's host/display.c to observe the missing stop route. python -I -B tests/run_all.py runs the complete game-free suite.

main @ f41db065c this PR @ 830b2c590
Startup fixture requires headless stop installation Fails: route not installed Passes
New event/worker fixture No stop implementation on main Passes with GCC and Clang
Whole game-free suite 509 passed, 0 failed, 19 skipped 510 passed, 0 failed, 19 skipped
main display source:
FAIL: headless installs its stop route once

this PR:
PASS: five console events request once; handlers do no close/log work; repeats preserve deadline
PASS: hidden-window close, cancelled/confirmed end-session and original deadline
PASS: hung close bounded without log locks, including a flooded message queue (exit 124)
PASS: startup owns both workers; event/thread failures reported
PASS: control quit uses the normal-close path and flushes its completion marker before exit
PASS: Windows display startup API failure and success controls

The baseline additionally rejects the missing setup-failure exit/log behavior. Its display source has the same Git blob as the pinned base. The new stop implementation is absent on main; no baseline Windows event-execution comparison is claimed.

Testing

Tested: On Linux, the named stop driver passes with GCC and Clang; tests/test_windows_display_startup.py, tests/test_menu_restart.py and tests/test_control.py pass. The complete exact-head suite reports 510 passed, 0 failed, 19 skipped. The complete host/windows_stop.c, host/display.c, host/hos_relaunch.c and host/control.c compile to Windows x86-64 objects with -Wall -Wextra -Werror. Both new fixtures compile and link for that target using tests/test_windows_stop.py --compile-only --cc COMPILER; the actual stop and relaunch objects also link with authored logging/renderer stand-ins. Generated runtime declarations came only from the public a64-kit source, not game data. Initial cross-compilation setup failures were unused link flags on compile-only commands and missing generated declarations; after correcting those inputs, the affected commands passed. No warning was suppressed.

Not tested: Nothing was executed on Windows: neither fixtures nor game. No game was built or run on Linux either. Actual taskkill, console delivery, hidden-window visibility/focus, logoff/shutdown, forced-timeout behavior, startup termination, saves, GPU/driver teardown, co-op leave receipt and hosted-relay/session shutdown remain untested. No performance, completed-save or guaranteed-delivery claim. This is not ready for merge.

Needs other testers:

  • Windows: start an owned headless game, use plain taskkill /PID without /F, and inspect the actual game exit and normal-close marker; expect delivered WM_CLOSE to take normal close without showing a window (request to open)
  • Windows: Ctrl+C and Ctrl+Break in the game's attached console, including a second event; expect one close request and no restarted deadline (request to open)
  • Windows: close the attached console; expect the close hooks to get time to return, or an explicitly incomplete exit rather than a completed-save claim (request to open)
  • Windows: confirmed and cancelled logoff/shutdown; expect cancellation not to quit and confirmed shutdown to attempt the close work within the OS deadline (request to open)
  • Windows with the co-op draft integrated: quit by the supported routes and inspect the relay's leave line plus hosted relay/session exit; expect leave rather than only silent-member expiry when the close work completes (request to open)
  • Windows: run the new fixtures and a deliberately stalled disposable close; expect bounded exit 124 or an OS-imposed earlier end, never a clean-stop verdict (request to open)

Screenshots

Nothing on screen. The added stop window must remain hidden; this has not been observed on Windows.

Risk and rollback

Four seconds can cut short a slow renderer/cache close; the existing preparation stop alone can wait ten seconds. Use the control command when that time is needed. The new watchdog deliberately avoids logger locks, so a blocked writer or forced OS end may leave no final timeout line. Require both the normal-close marker and an observed exit 0; even that is not save completion. A missing marker, exit 124/nonzero, or no exit is incomplete.

The hidden window and two waiting host threads exist only in headless Windows mode after renderer initialization. Console attachment, debugger handling and terminal input mode affect delivery. Windows can impose a shorter deadline or terminate without delivery. Reverting this commit removes the new route and restores the separate control-close tail; control quit remains available.

Reviewer notes

The promised investigation found no save-drain primitive in main. Guest Commit is a no-op and neither old close route waits for a complete guest save transaction. This change does not invent that guarantee.

Co-op is not on main. Pull request 68 inserts its bounded coop_shutdown() into hos_window_closed_exit. When integrating, retain that call once in the common tail before the normal-close marker, not a second copy in control quit. It attempts BYE/LEAVE and a worker/relay stop; it does not acknowledge network delivery or drain reliable events. That combination still needs the Windows tester above. Pull request 47 supplies owned-session exit observation and explicit force, and correctly reports save draining unsupported.

Windows API references: console handler return/timeout contract, user32/logoff and handler-reset rules, message-only window limits.

The original report and promise are https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6812494715 and https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6812576925 . This is source/fixture evidence, not a new Windows reproduction. The branch stays on its tested base while main has advanced; rebase and runtime evidence remain readiness requirements.

Issues

See #83

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main. Pinned tested base; main advanced during this investigation.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). Requests are to open while WIP.
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. No game measurements; WIP.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • A game-free test added for the fix, failing on main and passing here.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Changed Windows objects and game-free fixtures compiled; full game builds remain unrun.
  • Docs updated.

New on this forge; not opened on GitHub.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra [email protected]

## Why A windowless Windows game has no game window to receive a polite close request, so a tester had to force it and the co-op relay saw a silent disappearance instead of a leave. This WIP adds a bounded normal-close attempt for delivered window and console events, without claiming that quitting finishes a save. ## What changed - `host/windows_stop.c`: a hidden top-level window receives `WM_CLOSE` and confirmed `WM_ENDSESSION`; console handlers accept Ctrl+C, Ctrl+Break and OS-ending events. They request work once, not perform renderer, save, network or log work in the console handler. Repeated events do not extend the deadline. - A separate worker calls `rx_close_asked()` and `hos_window_closed_exit()`. The message thread limits event-triggered close to four seconds; if the process remains alive, it terminates its own process with exit code 124. That is an incomplete, forced stop, not a successful graceful shutdown. - `host/display.c`: installs this route only for `HOS_HEADLESS`, after renderer startup. Creating/freeing the renderer's temporary preparation console resets console handlers; installing earlier would lose the handler. Control quit remains the startup route. - `host/control.c` and `host/hos_relaunch.c`: share the normal-close tail and record `stop: normal close work returned; save drain unsupported` before the log flush and exit. It means the callbacks returned, not that guest saves or every cache write succeeded. - `tests/test_windows_stop.py` drives the production callbacks against game-free Win32 stand-ins, including a stuck worker, message flooding, cancellation and startup errors. The existing Windows startup fixture also checks installation and its failure path. - `docs/development.md` and `docs/control.md`: distinguish control quit, console events, window messages, Linux signals and force; document the startup limit, deadline and unsupported save draining. The hidden window is never shown or activated and is not the renderer's surface. A message-only window was rejected: it cannot be enumerated and does not receive broadcasts. A console handler alone would not address the reported `taskkill` route or Windows' user32/logoff caveat. Actual plain-taskkill delivery is still untested. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `HOS_HEADLESS` | Existing offscreen mode gains the Windows stop handlers after renderer startup | Off; handlers active only when headless | Off by default: ordinary play needs its game window | Intended for started windowless Windows games | Saving, forced kills, startup before renderer initialization; console events without a console | All Windows game execution | | `HOS_CONTROL` | Existing opt-in bridge quit now shares the normal-close tail | Unset | Off by default: enable deliberately for owned test sessions | Existing Linux/Windows control workflows; game-free bridge checked here | Acknowledging completed saves or forcing a hung close | Windows game execution and integrated co-op | No new setting is added. The four-second deadline applies only to the new Windows-event route; control quit retains its existing behavior. ## Measurements <!-- branch-state --> main: f41db065cf6667ec638a10aa6990099392d10dca (pinned base, 2026-11-12) this PR: move/fix/windows-graceful-stop @ 830b2c5908afa820b8a309b93c44e92a2f470a7d Base whole suite: 509 passed, 0 failed, 19 skipped (shared base result). Head whole suite: 510 passed, 0 failed, 19 skipped. <!-- /branch-state --> Hardware: Arch Linux 7.2.15-1-omarchy, native GCC 16.2.1 and Clang 23.1.3; Windows target uses the llvm-mingw 20261104 UCRT headers/libraries. No GPU was used. Scene: authored game-free fixtures, no game or save. Method: one failing startup-fixture check against main's unchanged display source, one passing branch check, and the new stop driver with both native compilers. These are behavior checks, not interleaved game measurements or timing claims. The whole suite below belongs to the exact head above; main's supplied passing suite was not repeated. How to reproduce: `python -I -B tests/test_windows_stop.py --cc gcc`, then `--cc clang`; `python -I -B tests/test_windows_display_startup.py`. Run the latter with `--source` naming main's `host/display.c` to observe the missing stop route. `python -I -B tests/run_all.py` runs the complete game-free suite. | | main @ f41db065c | this PR @ 830b2c590 | |---|---|---| | Startup fixture requires headless stop installation | Fails: route not installed | Passes | | New event/worker fixture | No stop implementation on main | Passes with GCC and Clang | | Whole game-free suite | 509 passed, 0 failed, 19 skipped | 510 passed, 0 failed, 19 skipped | ```text main display source: FAIL: headless installs its stop route once this PR: PASS: five console events request once; handlers do no close/log work; repeats preserve deadline PASS: hidden-window close, cancelled/confirmed end-session and original deadline PASS: hung close bounded without log locks, including a flooded message queue (exit 124) PASS: startup owns both workers; event/thread failures reported PASS: control quit uses the normal-close path and flushes its completion marker before exit PASS: Windows display startup API failure and success controls ``` The baseline additionally rejects the missing setup-failure exit/log behavior. Its display source has the same Git blob as the pinned base. The new stop implementation is absent on main; no baseline Windows event-execution comparison is claimed. ## Testing **Tested:** On Linux, the named stop driver passes with GCC and Clang; `tests/test_windows_display_startup.py`, `tests/test_menu_restart.py` and `tests/test_control.py` pass. The complete exact-head suite reports 510 passed, 0 failed, 19 skipped. The complete `host/windows_stop.c`, `host/display.c`, `host/hos_relaunch.c` and `host/control.c` compile to Windows x86-64 objects with `-Wall -Wextra -Werror`. Both new fixtures compile and link for that target using `tests/test_windows_stop.py --compile-only --cc COMPILER`; the actual stop and relaunch objects also link with authored logging/renderer stand-ins. Generated runtime declarations came only from the public a64-kit source, not game data. Initial cross-compilation setup failures were unused link flags on compile-only commands and missing generated declarations; after correcting those inputs, the affected commands passed. No warning was suppressed. **Not tested:** Nothing was executed on Windows: neither fixtures nor game. No game was built or run on Linux either. Actual `taskkill`, console delivery, hidden-window visibility/focus, logoff/shutdown, forced-timeout behavior, startup termination, saves, GPU/driver teardown, co-op leave receipt and hosted-relay/session shutdown remain untested. No performance, completed-save or guaranteed-delivery claim. This is not ready for merge. **Needs other testers:** - Windows: start an owned headless game, use plain `taskkill /PID` without `/F`, and inspect the actual game exit and normal-close marker; expect delivered WM_CLOSE to take normal close without showing a window (request to open) - Windows: Ctrl+C and Ctrl+Break in the game's attached console, including a second event; expect one close request and no restarted deadline (request to open) - Windows: close the attached console; expect the close hooks to get time to return, or an explicitly incomplete exit rather than a completed-save claim (request to open) - Windows: confirmed and cancelled logoff/shutdown; expect cancellation not to quit and confirmed shutdown to attempt the close work within the OS deadline (request to open) - Windows with the co-op draft integrated: quit by the supported routes and inspect the relay's leave line plus hosted relay/session exit; expect leave rather than only silent-member expiry when the close work completes (request to open) - Windows: run the new fixtures and a deliberately stalled disposable close; expect bounded exit 124 or an OS-imposed earlier end, never a clean-stop verdict (request to open) ## Screenshots Nothing on screen. The added stop window must remain hidden; this has not been observed on Windows. ## Risk and rollback Four seconds can cut short a slow renderer/cache close; the existing preparation stop alone can wait ten seconds. Use the control command when that time is needed. The new watchdog deliberately avoids logger locks, so a blocked writer or forced OS end may leave no final timeout line. Require both the normal-close marker and an observed exit 0; even that is not save completion. A missing marker, exit 124/nonzero, or no exit is incomplete. The hidden window and two waiting host threads exist only in headless Windows mode after renderer initialization. Console attachment, debugger handling and terminal input mode affect delivery. Windows can impose a shorter deadline or terminate without delivery. Reverting this commit removes the new route and restores the separate control-close tail; control quit remains available. ## Reviewer notes The promised investigation found no save-drain primitive in main. Guest Commit is a no-op and neither old close route waits for a complete guest save transaction. This change does not invent that guarantee. Co-op is not on main. [Pull request 68](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/68) inserts its bounded `coop_shutdown()` into `hos_window_closed_exit`. When integrating, retain that call once in the common tail before the normal-close marker, not a second copy in control quit. It attempts BYE/LEAVE and a worker/relay stop; it does not acknowledge network delivery or drain reliable events. That combination still needs the Windows tester above. [Pull request 47](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/47) supplies owned-session exit observation and explicit force, and correctly reports save draining unsupported. Windows API references: [console handler return/timeout contract](https://learn.microsoft.com/en-us/windows/console/handlerroutine), [user32/logoff and handler-reset rules](https://learn.microsoft.com/en-us/windows/console/setconsolectrlhandler), [message-only window limits](https://learn.microsoft.com/en-us/windows/win32/winmsg/window-features#message-only-windows). The original report and promise are https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6812494715 and https://github.com/NotRexed/Hookshot/pull/2274#issuecomment-6812576925 . This is source/fixture evidence, not a new Windows reproduction. The branch stays on its tested base while main has advanced; rebase and runtime evidence remain readiness requirements. ## Issues See #83 ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [ ] Rebased on current `main`. Pinned tested base; main advanced during this investigation. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). Requests are to open while WIP. - [ ] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. No game measurements; WIP. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Changed Windows objects and game-free fixtures compiled; full game builds remain unrun. - [x] Docs updated. --- New on this forge; not opened on GitHub. Assisted-by: GPT-6 Astra Co-Authored-By: GPT-6 Astra <[email protected]>
fix(runtime): handle windowless Windows stop requests
Some checks are pending
PR description / Description follows the template (pull_request_target) Waiting to run
Linux AppImage / appimage (pull_request) Blocked by required conditions
Windows builder / Longshot Builder zip (pull_request) Blocked by required conditions
Windows builder / Windows Vulkan back end (llvm-mingw) (pull_request) Blocked by required conditions
Windows builder / dav1d for Windows (llvm-mingw) (pull_request) Blocked by required conditions
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / Python source checks (windows-2022, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (windows-2022, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
830b2c5908
Use a hidden top-level window and console events to request normal close on a worker. Bound event-triggered teardown separately and document that no quit route is a save barrier. See #83.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Some checks are pending
PR description / Description follows the template (pull_request_target) Waiting to run
Linux AppImage / appimage (pull_request) Blocked by required conditions
Windows builder / Longshot Builder zip (pull_request) Blocked by required conditions
Windows builder / Windows Vulkan back end (llvm-mingw) (pull_request) Blocked by required conditions
Windows builder / dav1d for Windows (llvm-mingw) (pull_request) Blocked by required conditions
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / Python source checks (windows-2022, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (windows-2022, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
This pull request is marked as a work in progress.
Some workflows are waiting to be reviewed.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'fix/windows-graceful-stop:mooki-fix/windows-graceful-stop'
git switch 'mooki-fix/windows-graceful-stop'
Sign in to join this conversation.
No description provided.