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