From cc0e2c4bf4fc6bc3ff6d104a09865e80aeda383f Mon Sep 17 00:00:00 2001 From: chrg6 Date: Thu, 12 Nov 2026 22:10:48 -0500 Subject: [PATCH] Say at the next start which job the launcher was stopped in. A job that writes into the game folder leaves data/job-running.json while it runs and removes it when it ends. A launcher closed or killed in the middle leaves it, and the next start logs it and tells the player which job did not finish and that Rebuild the Game or Begin Installation goes on from the steps that were done. Co-Authored-By: Claude Fable 5.2 --- docs/launcher.md | 7 +++++ launcher/jobs.py | 47 +++++++++++++++++++++++++++++++ launcher/ui/app.py | 14 +++++++++ tests/launcher_test.py | 64 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) diff --git a/docs/launcher.md b/docs/launcher.md index 7fcc5e22..ee2d3e3e 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -449,6 +449,13 @@ one's name. An install, a rebuild and the rebuild of an update claim `game`; an download claim `app`; the update check claims nothing and runs beside anything. Two launcher folders that share one game folder are not kept apart: the lock belongs to the launcher folder. +A job that claims `game` leaves `data/job-running.json` (what it is, the game folder, when it started) from its start +to its end. A job that ends removes it, also after Cancel or an error, which the job screen has told. A launcher that +was stopped in the middle (closed by the system, a power cut) leaves it: the next start logs it and says on the home or +first screen which job did not finish and what finishes it (`jobs.interrupted`), once. Nothing is rolled back: the +build keeps the old `ocarina.exe` until the new one is linked and records each finished step, so the game folder +stays playable and Rebuild the Game, or Begin Installation, goes on from the steps that were done. + ## Releases 1. Bump `VERSION`; build the shader translator (`tools/build_xlate.py`). diff --git a/launcher/jobs.py b/launcher/jobs.py index f386d8c2..74678075 100644 --- a/launcher/jobs.py +++ b/launcher/jobs.py @@ -3,6 +3,8 @@ staging of an update. Each job is a list of steps the window shows as rows; the timer (rev changes on every change) and never blocks on it. The same jobs run headless from launcher/cli.py. BackgroundUpdate decides when a staged update installs.""" import collections +import json +import os import threading import time import traceback @@ -18,6 +20,29 @@ PENDING, RUNNING, DONE, SKIPPED, FAILED = 'pending', 'running', 'done', 'skipped PLAIN_FAILURES = (feed.FeedError, build.BuildError, locks.Busy) +# data/job-running.json: the job that is writing into the game folder, from its start to its end (Job._main). A job +# that ends, also by Cancel or an error, removes it and has told its result on screen. One the launcher was stopped +# in (closed by the system, a power cut, a crash) leaves it, and the next start says so (interrupted). +MARKER = 'job-running.json' + + +def interrupted(ctx): + """The job the launcher was stopped in, as it wrote it ({'task', 'kind', 'game_dir', 'started'}), or None. The + marker is removed: it is told once. Call it while this process holds the launcher folder's lock and runs no + job, or the marker read is a running job's.""" + path = ctx.layout.data / MARKER + try: + with open(path, encoding='utf-8') as f: + left = json.load(f) + except (OSError, ValueError): + left = None + try: + path.unlink(missing_ok=True) + except OSError: + pass + return left if isinstance(left, dict) and left.get('task') else None + + def failure_line(e): """A failure in one line for the log: its kind, its message and what it came from.""" cause = e.__cause__ @@ -256,10 +281,27 @@ class Job: file.release() raise + def _mark(self): + """Leave MARKER for the time the job writes into the game folder; True when it was written.""" + if 'game' not in self.claims: + return False + try: + path = self.ctx.layout.data / MARKER + tmp = path.with_name(path.name + '.tmp') + with open(tmp, 'w', encoding='utf-8') as f: + json.dump({'task': self.doing(), 'kind': self.kind, 'game_dir': str(getattr(self, 'game_dir', '')), + 'started': time.time()}, f) + os.replace(tmp, path) + return True + except OSError: + return False + def _main(self): file = claimed = None + marked = False try: file, claimed = self._hold() + marked = self._mark() self.run() self.result = 'ok' except net.Cancelled: @@ -275,6 +317,11 @@ class Job: if s.state == RUNNING: s.state = FAILED if self.result == 'failed' else PENDING s.value = 'Stopped' if self.result == 'failed' else 'Cancelled' + if marked: + try: + (self.ctx.layout.data / MARKER).unlink(missing_ok=True) + except OSError: + pass locks.release_claim(claimed) if file: file.release() diff --git a/launcher/ui/app.py b/launcher/ui/app.py index c71bcb4f..293bc9ec 100644 --- a/launcher/ui/app.py +++ b/launcher/ui/app.py @@ -755,6 +755,11 @@ class App: self.settings.save() if self.ctx.zstd_missing: # told at once, not when Begin Installation stops at the build tools self.message = self.ctx.zstd_missing + left = None if a.selftest else jobs.interrupted(self.ctx) # before any job of this start leaves its own + if left: + self.ctx.log(f'the launcher was stopped during {left["task"]} (started ' + f'{time.strftime("%Y-%m-%d %H:%M", time.localtime(left.get("started") or 0))}, game folder ' + f'{left.get("game_dir") or "none"})') for old in self.layout.root.glob('python.old*'): # the Python an update replaced (launcher/native/nx2navi.c) shutil.rmtree(old, ignore_errors=True) if gd or self.settings.get('dump'): @@ -771,6 +776,12 @@ class App: # build from before the update and Rebuild the Game finishes it, not the first-run screen and its dump self.show('home') self.after_update_notice() + if left and not self.message: + # make_game.py keeps the old exe until the new one is linked and records each finished step, so the + # game folder is playable and a rebuild goes on from there; the player is told that it is due + self.message = (f'NX2navi was closed or stopped during {left["task"]}, which did not finish. Play ' + 'starts the last finished build; Rebuild the Game finishes what was left.') + self.render() if self.install_staged('a staged update waited for the launcher to start'): return if a.play: # Play from Steam with a mod's code to build in first (play_only) @@ -779,6 +790,9 @@ class App: if self.settings.get('auto_check'): self.check_now() else: + if left and not self.message: + self.message = (f'NX2navi was closed or stopped during {left["task"]}, which did not finish. Begin ' + 'Installation goes on from the steps that were done.') self.show('first_run') self.find_dumps() diff --git a/tests/launcher_test.py b/tests/launcher_test.py index 14db7f99..cda6263e 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -3809,6 +3809,70 @@ class LockTest(Tmp): self.assertEqual(again.result, 'ok', again.error) # the first job let go when it ended self.assertEqual((locks._files, locks._claims), ({}, {})) + def test_a_job_the_launcher_was_stopped_in_is_told_at_the_next_start(self): + """A job that writes into the game folder leaves data/job-running.json while it runs. Ended, also by an + error, it removes it; a launcher stopped in the middle leaves it, and the next start says which job did not + finish, once.""" + self.patch_tools() + ctx = self.make_ctx('ok') + self.addCleanup(ctx._file.close) + marker = ctx.layout.data / jobs.MARKER + during = [] + + class Watched(jobs.RebuildJob): + def run(self): + during.append(json.loads(marker.read_text(encoding='utf-8'))) + + self.assertEqual(Watched(ctx, self.tmp / 'game').run_inline().result, 'ok') + self.assertEqual((during[0]['task'], during[0]['kind'], during[0]['game_dir']), + ('the rebuild of the game', 'rebuild', str(self.tmp / 'game'))) + self.assertFalse(marker.exists()) + self.assertIsNone(jobs.interrupted(ctx)) + + class Failing(jobs.RebuildJob): + def run(self): + raise build.BuildError('the build stopped') + + self.assertEqual(Failing(ctx, self.tmp / 'game').run_inline().result, 'failed') + self.assertFalse(marker.exists()) # it ended and said so on screen + check = jobs.Job(ctx, []) + self.assertFalse(check._mark()) # a job that writes nothing there leaves none + self.assertTrue(jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game')._mark()) # stopped before its end + self.assertTrue(marker.exists()) + self.assertEqual(jobs.interrupted(ctx)['task'], 'the installation of the game') + self.assertFalse(marker.exists()) + self.assertIsNone(jobs.interrupted(ctx)) # told once + marker.write_text('{"task": ') # cut while it was written + self.assertIsNone(jobs.interrupted(ctx)) + self.assertFalse(marker.exists()) + + def test_the_window_says_which_job_did_not_finish(self): + game = made_up_game(self.tmp / 'game') + App, a = mock_app(self, game) + a.args.selftest = False + a.ctx.zstd_missing = '' + self.assertTrue(jobs.RebuildJob(a.ctx, game)._mark()) + App.start(a) + a.show.assert_called_once_with('home') + self.assertIn('stopped during the rebuild of the game', a.message) + self.assertIn('Rebuild the Game finishes', a.message) + a.ctx._file.flush() + self.assertIn('the launcher was stopped during the rebuild of the game', + (self.layout.logs / 'launcher.log').read_text(encoding='utf-8')) + App, a = mock_app(self, self.tmp / 'no game yet') # an install that was stopped: first run + a.args.selftest = False + a.ctx.zstd_missing = '' + self.assertTrue(jobs.InstallJob(a.ctx, self.tmp / 'g.nup', self.tmp / 'no game yet')._mark()) + App.start(a) + a.show.assert_called_once_with('first_run') + self.assertIn('stopped during the installation of the game', a.message) + self.assertIn('Begin Installation goes on', a.message) + App, a = mock_app(self, game) # the next start has nothing to tell + a.args.selftest = False + a.ctx.zstd_missing = '' + App.start(a) + self.assertEqual(a.message, '') + def test_a_failed_job_lets_go_of_the_folder(self): self.patch_tools() ctx = self.make_ctx('--fail')