Roll a version that cannot start back without waiting for a click.

start.pyw showed the import failure in a message box before it ended with
code 86, and the stub waits six seconds for that code: unless the player
closed the box in time, the broken version stayed the current one. When
app/previous.txt names another complete release, the failure now goes to
crash.log only and the process ends at once, so the stub rolls back and
the previous version says on its home screen which version did not start.
The box stays when there is nothing to go back to, when Steam started the
launcher without the stub, and when the stub's wait is already over.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-10 15:13:40 -05:00
parent 88495c296c
commit e69a84fe59
3 changed files with 148 additions and 20 deletions

View File

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

View File

@@ -1,27 +1,79 @@
"""What NX2navi.exe runs: python\\pythonw.exe app\\<version>\\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()

View File

@@ -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."""