mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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:
@@ -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`).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user