diff --git a/docs/launcher.md b/docs/launcher.md index 0bcd0729..377fc245 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -254,7 +254,10 @@ The stub swaps `python.next` in while no Python runs, then starts the new versio one. When the new version fails to start (exit code 86 from `start.pyw`, or any error within six seconds), the stub sets -`current.txt` back to `previous.txt`'s version and starts it with `--rolled-back`. The game is never rebuilt while it +`current.txt` back to `previous.txt`'s version and starts it with `--rolled-back`. `start.pyw` writes the failure to +`data/logs/crash.log` and shows it in a message box only when there is no version to go back to, when Steam started +it, or when the six seconds are over (`quiet_exit`): a box holds the exit code back until it is closed, and the +broken version then stayed the current one. The game is never rebuilt while it runs. Keep Build Cache off deletes the lifted C and the objects (about 5 GB) after each build but keeps `build-cache/state.json`, so updates still rebuild without the dump (compiling everything). diff --git a/launcher/start.pyw b/launcher/start.pyw index 5339fda3..e666025e 100644 --- a/launcher/start.pyw +++ b/launcher/start.pyw @@ -1,27 +1,79 @@ """What NX2navi.exe runs: python\\pythonw.exe app\\\\launcher\\start.pyw [arguments]. The embeddable Python puts only its ._pth entries on sys.path, so the release folder (the parent of launcher/) goes -first here. A failure before the window is up is written to data/logs/crash.log, shown in a message box and ends the -process with exit code 86, which tells the stub to go back to the previous version (launcher/native/nx2navi.c). +first here. A failure before the window is up is written to data/logs/crash.log and ends the process with exit code +86, which tells the stub to go back to the previous version (launcher/native/nx2navi.c wWinMain). The stub waits +six seconds for that code (QUICK_MS), so the failure is shown in a message box only when the stub has no version to +go back to or no longer waits (quiet_exit): a box holds the exit code back until the player closes it, and a new +version that could not start then stayed the current one. """ import os import sys import traceback APP = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, APP) STARTUP_FAILED = 86 +# The stub's wait for this process's exit code is QUICK_MS, 6 s (launcher/native/nx2navi.c start_release). An exit +# this long after the process was made still reaches it, with a second to spare for the exit itself. +STUB_WAITS = 5.0 -def crash(text): - root = os.environ.get('NX2NAVI_LAUNCHER_ROOT') or os.path.dirname(os.path.dirname(APP)) +def launcher_root(): + return os.environ.get('NX2NAVI_LAUNCHER_ROOT') or os.path.dirname(os.path.dirname(APP)) + + +def rollback_target(root): + """The version the stub goes back to when this one ends with STARTUP_FAILED (launcher/native/nx2navi.c + wWinMain): the one app/previous.txt names, when it is another than this one and its folder holds a launcher. + '' when there is none.""" try: - logs = os.path.join(root, 'data', 'logs') + with open(os.path.join(root, 'app', 'previous.txt'), encoding='utf-8-sig') as f: + prev = (f.read().splitlines() or [''])[0].strip() + except (OSError, UnicodeDecodeError): + return '' + if not prev or prev == os.path.basename(APP): + return '' + return prev if os.path.isfile(os.path.join(root, 'app', prev, 'launcher', 'start.pyw')) else '' + + +def process_age(): + """Seconds since Windows made this process, which is when the stub began to wait for it. Infinite where that + cannot be read, so that the failure is then shown rather than left to a stub that may have stopped waiting.""" + try: + import ctypes + from ctypes import wintypes + k = ctypes.windll.kernel32 + k.GetCurrentProcess.restype = wintypes.HANDLE + k.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + made, ended, kernel, user, now = (wintypes.FILETIME() for _ in range(5)) + if not k.GetProcessTimes(k.GetCurrentProcess(), *(ctypes.byref(t) for t in (made, ended, kernel, user))): + return float('inf') + k.GetSystemTimeAsFileTime(ctypes.byref(now)) + ticks = [t.dwHighDateTime << 32 | t.dwLowDateTime for t in (made, now)] # 100 ns each + return max(0.0, (ticks[1] - ticks[0]) / 1e7) + except Exception: + return float('inf') + + +def quiet_exit(root, argv, age): + """True when a failed start ends without a message box: the stub goes back to the previous version on the + exit code (rollback_target), and that version's home screen says which version did not start. Not when Steam + started this version without the stub (--play, launcher/steam.py PLAY_TEXT), and not once the stub has stopped + waiting (age, process_age): nobody acts on the exit code then, and without the box nothing would be seen.""" + return bool(rollback_target(root)) and '--play' not in argv and age < STUB_WAITS + + +def crash(text, box=True): + """The failure into data/logs/crash.log and, with box, into a message box that waits for the player.""" + try: + logs = os.path.join(launcher_root(), 'data', 'logs') os.makedirs(logs, exist_ok=True) with open(os.path.join(logs, 'crash.log'), 'a', encoding='utf-8') as f: f.write(text + '\n') except OSError: pass + if not box: + return try: import ctypes ctypes.windll.user32.MessageBoxW(None, 'NX2navi could not start.\n\n' + text[-1500:], 'NX2navi', 0x10) @@ -29,17 +81,22 @@ def crash(text): print(text, file=sys.stderr) -try: - from launcher.ui.app import main -except Exception: - crash(traceback.format_exc()) - sys.exit(STARTUP_FAILED) +def run(): + sys.path.insert(0, APP) + try: + from launcher.ui.app import main + except Exception: + crash(traceback.format_exc(), box=not quiet_exit(launcher_root(), sys.argv[1:], process_age())) + sys.exit(STARTUP_FAILED) + try: + code = main() + except SystemExit: + raise + except Exception: + crash(traceback.format_exc()) + sys.exit(1) + sys.exit(code or 0) -try: - code = main() -except SystemExit: - raise -except Exception: - crash(traceback.format_exc()) - sys.exit(1) -sys.exit(code or 0) + +if __name__ == '__main__': + run() diff --git a/tests/launcher_test.py b/tests/launcher_test.py index 99d0ac36..0c2e1f72 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -3195,6 +3195,74 @@ class WhilePlayingTest(Tmp): a.root.deiconify.assert_called_once() +class StartScriptTest(Tmp): + """launcher/start.pyw when the version cannot be imported: with a version to go back to it ends with code 86 at + once and shows no message box, so the stub's six-second wait sees the code and rolls back.""" + + def release(self, ver, broken=False): + app = self.tmp / 'root' / 'app' / ver / 'launcher' + (app / 'ui').mkdir(parents=True) + shutil.copy(ROOT / 'launcher' / 'start.pyw', app / 'start.pyw') + (app / '__init__.py').write_text('') + (app / 'ui' / '__init__.py').write_text('') + (app / 'ui' / 'app.py').write_text("raise RuntimeError('this version is broken')\n" if broken else + 'def main():\n return 0\n') + return app / 'start.pyw' + + def load(self, script): + import importlib.machinery + import importlib.util + loader = importlib.machinery.SourceFileLoader('nx2navi_start_script', str(script)) + mod = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader)) + loader.exec_module(mod) # not as __main__: nothing starts + return mod + + def test_which_version_the_stub_goes_back_to(self): + root = self.tmp / 'root' + s = self.load(self.release('0.7.2', broken=True)) + self.assertEqual(s.rollback_target(str(root)), '') # no previous.txt + (root / 'app' / 'previous.txt').write_text('0.7.1\n') + self.assertEqual(s.rollback_target(str(root)), '') # it names a folder that is not there + self.release('0.7.1') + self.assertEqual(s.rollback_target(str(root)), '0.7.1') + (root / 'app' / 'previous.txt').write_bytes(b'\xef\xbb\xbf 0.7.1 \r\n') # as the stub reads the file + self.assertEqual(s.rollback_target(str(root)), '0.7.1') + self.assertTrue(s.quiet_exit(str(root), [], 1.0)) + self.assertTrue(s.quiet_exit(str(root), ['--continue-update', '0.7.1'], 1.0)) + self.assertFalse(s.quiet_exit(str(root), [], 5.5)) # the stub no longer waits: the box + self.assertFalse(s.quiet_exit(str(root), [], float('inf'))) + self.assertFalse(s.quiet_exit(str(root), ['--play'], 1.0)) # Steam started it: no stub to roll back + (root / 'app' / 'previous.txt').write_text('0.7.2\n') # this version itself: nothing to go back to + self.assertEqual(s.rollback_target(str(root)), '') + self.assertFalse(s.quiet_exit(str(root), [], 1.0)) + if os.name == 'nt': + self.assertLess(s.process_age(), 3600) + else: + self.assertEqual(s.process_age(), float('inf')) + + def test_a_broken_version_ends_with_86_and_no_box(self): + root = self.tmp / 'root' + script = self.release('0.7.2', broken=True) + self.release('0.7.1') + (root / 'app' / 'previous.txt').write_text('0.7.1\n') + env = dict(os.environ, NX2NAVI_LAUNCHER_ROOT=str(root)) + try: # a message box would wait for a click: the timeout is the test that there is none + r = subprocess.run([sys.executable, str(script)], env=env, capture_output=True, text=True, timeout=30) + except subprocess.TimeoutExpired: + self.fail('start.pyw did not end: it waits in a message box') + self.assertEqual(r.returncode, 86, r.stderr) + log = (root / 'data' / 'logs' / 'crash.log').read_text(encoding='utf-8') + self.assertIn('this version is broken', log) + + def test_a_version_that_starts_returns_its_code(self): + root = self.tmp / 'root' + script = self.release('0.7.2') + r = subprocess.run([sys.executable, str(script)], env=dict(os.environ, NX2NAVI_LAUNCHER_ROOT=str(root)), + capture_output=True, text=True, timeout=30) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertFalse((root / 'data' / 'logs' / 'crash.log').exists()) + + class UnfinishedUpdateTest(Tmp): """A game whose last update stopped in the compile (state.json without the compile step, the exe of the build before still there): the window opens on home and says how to finish, and an update goes on with the game."""