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')