Skip to content

fix(launcher): ask an owned game to quit before restarting it - #253

Open
Furroxide wants to merge 2 commits into
devfrom
fix/launcher-graceful-restart
Open

Furroxide wants to merge 2 commits into
devfrom
fix/launcher-graceful-restart

Conversation

@Furroxide

@Furroxide Furroxide commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Outcome

When the launcher restarted a game it owned, it stopped the process with TerminateProcess. No clean exit was
recorded, so the restarted game opened in safe mode with every mod off, and a session launch from safe mode is refused.
#234 made a normal quit record the clean exit; this makes the launcher's restart use one. It is what #239's join needs
before a join that closes the player's own game can start its session.

How it asks: TopiaForge's own channel. The launcher writes launcher-quit-<request key>.json into the install's
manager staging. The manager in the game looks for its own request twice a second from the plugin's Update, takes it
once, and calls Application.Quit. From there it is exactly the QUIT button's path (#234, #237): the game's own
quit steps run with every mod and patch in place, the quit is held while mods unload, and the clean exit is recorded.

  • Why not the game's own quit-request file. Robotopia quits when a quit-request file appears in its
    persistent-data folder (the string is in GameCode.dll). That folder belongs to every copy of the game on the
    machine, so a request there would reach a game the launcher did not start, which the ownership rules forbid. It is
    also an undocumented game behaviour that no surface check can prove, so it would be a GameCompat binding to keep.
  • The cost of the choice: it needs the manager running, which every launch the launcher owns has. A game whose
    manager does not answer (it failed to load, or the game hangs) is stopped once the answer window passes.

Ownership stays exactly as strict (#216). A request is written only for a process _ownedLaunchProcesses holds and
_gameProcessLiveness has just verified, the same checks that guarded the stop. It names that process's own launch
request, which only that process knows. The game takes only a request naming its own launch request; a foreign or
malformed one is removed unread. A game started any other way has no request id, so nothing can ask it to quit.

The wait, and the fallback (OwnedGameQuitPolicy, defaults):

Step Bound Then
The game takes the request 10 s (the manager looks every 0.5 s; this leaves room for frames a level load holds) Not taken: the request is withdrawn and the game is stopped
The game exits 30 s from the request (the manager holds a quit for at most 10 s while mods unload) Not exited: the request is withdrawn and the game is stopped
The game had already exited Nothing is asked or stopped
The launcher closes while it waits An untaken request is withdrawn, nothing is stopped, and no replacement starts

An unknown liveness answer is never taken as a quit. The wait counts its own pauses, so tests never wait on the wall
clock.

It reports which happened. LaunchResult.replacedGame is quit, stopped or exited, and its message leads with
one sentence for the player:

  • "The running game quit when asked."
  • "The running game did not quit when asked, so it was stopped. This start opens in safe mode, with every mod off;
    restart it from the launcher to load them." (A restart of that safe-mode game is graceful, so it loads the mods.)
  • "The running game had already exited."

The CLI prints it before "Game process started", and the launcher keeps it on the status line for that launch, as a
warning after a stop. The launcher log records the timings and the reason for a stop.

For #239

_closeOwnedGameForInstall should replace if (alive == true) await _gameProcessStopper(process); with
if (alive == true) await _closeOwnedGame(install, process);, a private method of the same library. It returns the
ReplacedGameExit, or null when the repository closed while it waited.

  • The consent's warning can change. The game now quits as its QUIT button would. What a quit saves is the
    game's own doing, so "progress not saved there is lost" becomes, for example, "Joining quits the game, as its Quit
    button does". JoinConsentGiven(closeOwnedGameAcknowledged:) is still needed, since the game still closes.
  • One case to handle: stopped. The server profile's game then opens in safe mode, where the session is refused.
    The join should report that and offer to restart, which is graceful from safe mode.

Deviations from the brief

Not covered here

  • The feedback page. A graceful quit runs the game's own quit steps, so, as with QUIT, the game opens its
    shutdown feedback page unless the No Feedback URL mod is on. Troubleshooting.md says so. Suppressing it for
    launcher-requested quits would need a game binding in the manager.
  • Run 2's feedback page. A game in safe mode has every mod off, No Feedback URL included, so a graceful quit of
    it opens the game's feedback page, as its QUIT button does. The "restart it from the launcher to load them" advice
    after a stop therefore opens that page once, when No Feedback URL is in the profile.

Compatibility and security

  • Public API/schema/save/package impact: additive. A new launch-staging channel, launcher-quit-<request key>.json
    (schemaVersion 1: quitId, requestId), in both languages, with shared fixtures. LaunchResult gains
    replacedGame and replacing; LocalLauncherRepository gains the optional ownedGameQuit policy; ReplacedGameExit
    and OwnedGameQuitPolicy are new. No SDK surface changes; the manager's quit handling and journal format are
    unchanged.
  • Filesystem/network/archive/process or credential impact: the launcher writes, and withdraws, one small file in the
    owned install's manager staging; the game claims and deletes it. A restart now waits up to 30 s before it stops a game
    that does not quit. Nothing new is logged that names a path or an address.
  • Migration or rollback: revert the commits. A launcher without them stops the game as before; a game without them
    ignores the request, and the launcher stops it after 10 s.

Verification

On a Windows 11 x64 machine, rebased onto dev at 680d7a9, with the pinned Flutter 3.44.6 and its Dart 3.12.2:

  • C#. dotnet build TopiaForge.slnx -c Release -warnaserror: 0 warnings, 0 errors. ModManager, ModRuntime, Mods.Analyzers,
    Mods.Multiplayer.Generators and Mods.Multiplayer harnesses: all pass. New: LauncherQuitTests (11 checks:
    taken once; a foreign request removed unread and another process's request untouched; malformed requests removed; a
    request written during a take waits at its name; stale quit claims swept and live ones kept; the watcher looks once
    an interval, quits once, keeps looking past a foreign request and after a failed look, rejects bad arguments, and
    reads the never-stopped quit clock), one more PluginQuitCoordinatorTests check (the teardown limit reads that
    clock), and the quit fixtures in LaunchSessionFixtureTests (240 of 240). dotnet format --verify-no-changes: no finding in
    an edited file. tools/verify-csharp-release-surface.sh: validated 11 SDK packages and all 7 C# release harnesses.

  • Dart and Flutter.

    • packages/launcher_domain: 2,279 pass; the quit fixtures add 13 cases.
    • packages/launcher_data: 1,054, 9 skipped pass. New: "restarting a game this launcher started" (8: asks and stops nothing
      when the game quits; stops one that never takes the request once the answer window has passed; stops one that takes
      it but hangs once the exit window has passed; never asks a game it did not start; a launcher closed while it waits
      withdraws the request and stops nothing; a game that exits before it is asked; a request that cannot be written
      falls back to stopping; an unknown liveness answer is never a quit). The fake game takes only the request at its
      own launch request's channel name.
    • apps/topiaforge_cli: 517, 4 skipped pass; the workflow tests add 3 (quit, stopped with what to do, and a launch that
      replaced none).
    • apps/topiaforge_launcher_flutter: 146, 5 skipped; packages/launcher_ui 3 pass; a new widget test shows the
      safe-mode notice after a stop. These two ran on bdf5dd6, before the rebase; the Dart tree is unchanged since, and
      dev's feat(multiplayer): track the player intent mod code acts on #238 changed no Dart file.
    • dart format, dart analyze --fatal-infos and flutter analyze on each: clean.
  • Mutations, 18 of 18 caught, each restored:

    • Dart, 11: the request never written; the answer window ignored; an unanswered request left behind; a taken
      request never noticed; a closing repository that keeps waiting; a request naming another launch; an owned
      process's request id not recorded; a game that already exited asked anyway; an unknown answer counted as a quit;
      the restart stopping outright, as before; the result never saying how the game ended.
    • C#, 7: a request for another process acted on; the watcher quitting more than once; the watcher looking every
      frame; every failed look reported; stale quit claims never swept; the watcher's clock, and the quit coordinator's,
      each put back on the startup stopwatch.
  • In the game: Robotopia 0.0.2545, on a scratch copy of the game with its own BepInEx and windowed
    at 2560x1440, driven through this branch's own LocalLauncherRepository with its own data root, and with fix(launcher-data): keep PowerShell progress out of the running-game probe #251's
    probe fix applied, without which this machine refuses every launch:

    Run First game Restart Replacement
    1, before the clock fix normal, main menu reached request not taken in 10 s, so stopped (12.8 s in all) safe mode, as the result said
    2 safe mode, left by run 1's stop taken; quit within 1.4 s (3.2 s in all) normal: its mod loaded, main menu reached
    3 normal, its mod loaded taken; quit within 0.8 s (2.5 s in all) normal: its mod loaded; then ended by the same request, within 1 s

    The first game's manager.log in run 3: "The launcher that started this game asked it to quit.", then "Quit
    requested. Mods unload once the game has finished its own quit steps.", then No Feedback URL's "Suppressed shutdown
    feedback page launch". The replacement's has no safe-mode line. Run 1 found the frozen quit clock (the first
    commit). Run 2's safe-mode game had every mod off, so the game's own quit steps opened its feedback page, as
    QUIT would. No input was needed: the restart is the launcher's.

  • python -I .github/scripts/check_topiaforge_residue.py and check_readme_counts.py: pass. Every touched Dart file is
    under 500 lines.

Checklist

  • I read AGENTS.md and preserved CLEAN/Bloc/Unity/TopiaForge UI boundaries.
  • I added focused regression coverage for meaningful defects.
  • I ran the relevant build, analyze, test, format, package, and line-count gates.
  • I added provenance, license text, and modification notes for new third-party material. (None added.)
  • I did not include secrets, proprietary game assemblies, generated build directories, or unrelated changes.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Unity PR artifacts are ready.

Artifact Link
VPM package listing Download artifact

Workflow run: Unity Source Validation #669
Commit: dc76b72

Artifacts are temporary and expire according to the workflow retention policy.

A launcher-quit-<request key>.json request in manager staging asks the
game the launcher started to quit. It names that process's launch
request, which no other game knows. The manager looks for its own request
twice a second from the plugin's Update, takes it once, as a join is
taken, and calls Application.Quit, so the quit handler lets the game's
own quit steps run and holds the quit while mods unload: the clean exit
is recorded, and the next start is not safe mode. A foreign or malformed
request is removed unread, and stale claims of it are swept with the
join claims.

TopiaForge's own channel, not the quit-request file the game watches in
its persistent-data folder: that folder belongs to every copy of the game
on the machine, and the file is an undocumented game behaviour.

The request reads and writes the same in both languages, from new shared
fixtures in channels/cases.json.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
A restart stopped the game it owned with TerminateProcess, so no clean
exit was recorded and the replacement opened in safe mode with every mod
off. It now asks the game to quit through the launcher-quit request,
waits up to 10 s for the game to take it and 30 s in all for it to exit,
and stops it only then; a game that already exited is left alone. The
request is withdrawn whenever the game did not take it, including when
the launcher closes while it waits, and then nothing is stopped.

Ownership is unchanged: only a process this launcher started and has just
verified is ever asked, under the launch request it was started with.

LaunchResult says how the replaced game ended (quit, stopped or exited),
and the CLI and the launcher's status line say so, with what to do when
a stop leaves this start in safe mode.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
@Furroxide
Furroxide force-pushed the fix/launcher-graceful-restart branch from a8ee58a to dc76b72 Compare October 19, 2026 03:46

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant