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 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 22:10:48 -05:00
parent 2a7fda9506
commit cc0e2c4bf4
4 changed files with 132 additions and 0 deletions

View File

@@ -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`).

View File

@@ -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()

View File

@@ -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()

View File

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