From d4e00bad692d4f3f8b884318ec5752fd02bcdffe Mon Sep 17 00:00:00 2001 From: Hails <26784904+Hailey-Ross@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:13:59 -0600 Subject: [PATCH] Setup survives a dead console (issue #20) setup.py crashed when its console went away (closed window, broken stdout) while it was still working. Writes to a dead console are now ignored so setup finishes; a test; doc 19. --- docs/hails-additions/19-setup-dead-console.md | 67 ++++++++++ tools/setup.py | 30 +++++ tools/test_setup_dead_console.py | 114 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 docs/hails-additions/19-setup-dead-console.md create mode 100644 tools/test_setup_dead_console.py diff --git a/docs/hails-additions/19-setup-dead-console.md b/docs/hails-additions/19-setup-dead-console.md new file mode 100644 index 00000000..9b509efe --- /dev/null +++ b/docs/hails-additions/19-setup-dead-console.md @@ -0,0 +1,67 @@ +# Setup survives a dead console (#20) + +Branch: `fix/setup-dead-console` (from `main` 4488651). Status: **done, uncommitted**. Setup (Python) only; no asset +rebuild, no game behaviour change. + +## Problem + +Upstream issue #20 (reported by @NotTylor): setup from `default.xex` stopped in the customiser library stage with + +``` +File "tools\asset_pipeline\customisation_library.py", line 152, in prepare + print('Prepared',part['slot'],len(model_data),'models',len(mat_data),'materials',flush=True) +OSError: [Errno 22] Invalid argument +``` + +The catalog stage before it had finished normally. The conversion itself was healthy; only a progress print failed. + +## Root cause + +`skate3setup.exe` inherits the game's stdout/stderr (`crates/skate-game/src/setup.rs` starts it with +`Command::new(setup)…status()` and no `Stdio` override), and the game inherits the supervisor's pipe. When that pipe or +console is gone (supervisor ended, launched from a shortcut or launcher without a console, etc.), every write to it +raises. On Windows a write to a pipe whose reader has closed raises exactly `OSError: [Errno 22] Invalid argument` +(reproduced locally with a closed `subprocess.PIPE`). The setup worker's prints are diagnostics only, but the exception +propagates through `install()` and aborts the whole conversion. Why the handle was dead for the reporter is still +unknown; the hardening holds either way. + +## Change + +`tools/setup.py`: `main()` first wraps `sys.stdout` and `sys.stderr` in `TolerantStream`, which forwards everything +to the real stream until a write or flush raises `OSError` (Errno 22, broken pipe, …) or `ValueError` (closed file). +From then on that stream's output is dropped silently and the setup carries on. It covers every mode of the packaged +worker: the setup window's conversion thread (the #20 path), `--task` conversion subprocesses (their stdout is a pipe +that `install.py` copies into the stage log, unaffected) and `--character-import`. A missing stream (`None`, windowed +exe) is left as it is. Files are not touched: `data\setup-error.log`, the stage logs and `setup-report.json` are +written exactly as before. + +No print site in a fingerprinted file was edited. `tools/setup.py` is not part of any asset group fingerprint +(`tools/asset_pipeline/versions.py`); `customisation_library.py`, where the failing print lives, is, so it stays +untouched. `fingerprints()` gives identical hashes for all five groups before and after the change, so existing +installs don't refresh any assets. + +## Files + +- `tools/setup.py`: `TolerantStream`, `tolerate_dead_console()`, called at the top of `main()`. +- `tools/test_setup_dead_console.py`: new tests. + +## Verification + +- `py -3.13 -m unittest tools.test_setup_dead_console -v`: 5 pass. + - `TolerantStream` swallows `OSError(EINVAL)`, `BrokenPipeError` and `ValueError` on write/flush and stops retrying + after the first failure; a working stream is forwarded unchanged; the guard wraps each stream once. + - End to end: a child process runs `setup.main()` in `--task` mode after the parent has closed both its stdout and + stderr pipes; the task prints 2000 progress lines to each, then writes its output file. Exit code 0, file written. + Control: the same task without the guard dies on its first print (non-zero exit, no output file), as in #20. +- All setup / asset-pipeline Python suites (`py -3.13 -m unittest` over `tools/test_*.py` and + `tools/asset_pipeline/test_*.py`): 114 pass, 1 skipped (pre-existing skip). The three modules that import from + `tools/` directly run the CI way (`discover -s tools`, as in `.github/workflows/release.yml`): release 2, update 22, + HUD font 1, prepared HUD 4, all pass. +- Group fingerprints before/after: identical. + +## Open questions + +- Why the reporter's console handle was dead. Starting `support\skate3setup.exe` while the game had already exited + would do it; a log from the reporter would tell. +- Errors stay visible: the setup window still shows the failure dialog and `data\setup-error.log`, which never + depended on the console. diff --git a/tools/setup.py b/tools/setup.py index f111719e..4ba189e2 100644 --- a/tools/setup.py +++ b/tools/setup.py @@ -12,7 +12,37 @@ def saved_source(marker): if selected.is_file() or selected.is_dir():return selected return None +class TolerantStream: + """Console stream whose write/flush failures are ignored. + + Progress output is diagnostics only. The setup exe inherits the game's + console pipe, and when that pipe or console is gone a print raises + OSError (Errno 22 / broken pipe) and aborted the whole conversion (#20). + After the first failure output is dropped; files and logs are unaffected.""" + def __init__(self,stream): + self._stream=stream;self._dead=False + def write(self,text): + if not self._dead: + try:return self._stream.write(text) + except (OSError,ValueError):self._dead=True + return len(text) + def writelines(self,lines): + for line in lines:self.write(line) + def flush(self): + if not self._dead: + try:self._stream.flush() + except (OSError,ValueError):self._dead=True + def __getattr__(self,name): + return getattr(self._stream,name) + +def tolerate_dead_console(): + for name in ('stdout','stderr'): + stream=getattr(sys,name) + # A windowed exe has no console stream at all; print() already skips None. + if stream is not None and not isinstance(stream,TolerantStream):setattr(sys,name,TolerantStream(stream)) + def main(): + tolerate_dead_console() if len(sys.argv)>1 and sys.argv[1]=='--character-import': # Keep the importer inside the already versioned setup payload: even # protocol-1 updaters deliver it atomically with the game executable. diff --git a/tools/test_setup_dead_console.py b/tools/test_setup_dead_console.py new file mode 100644 index 00000000..ccebe2fa --- /dev/null +++ b/tools/test_setup_dead_console.py @@ -0,0 +1,114 @@ +"""Setup keeps converting when its console is gone (issue #20: OSError Errno 22 on print).""" +import errno +import io +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tools import setup as setup_ui + +REPO = Path(__file__).resolve().parents[1] + +TASK = '''\ +import sys +from pathlib import Path +out = Path(sys.argv[1]) +for i in range(2000): + print(f'progress {i}', flush=True) + print(f'warning {i}', file=sys.stderr, flush=True) +(out/'done.txt').write_text('finished', encoding='utf-8') +''' + +# Starts the conversion only after the parent has closed both pipe ends, so every +# progress print hits a dead console. +CHILD = '''\ +import sys, time +from pathlib import Path +sys.path.insert(0, {repo!r}) +from tools import setup +root = Path({root!r}) +go = root/'go' +while not go.exists(): + time.sleep(0.01) +if {guarded!r}: + setup.ROOT = root + sys.argv = ['skate3setup', '--task', 'tools/progress_task.py', str(root)] + raise SystemExit(setup.main()) +import runpy +sys.argv = [str(root/'tools/progress_task.py'), str(root)] +runpy.run_path(sys.argv[0], run_name='__main__') +''' + + +class FailingStream: + def __init__(self, error): + self.error = error + self.writes = 0 + + def write(self, text): + self.writes += 1 + raise self.error + + def flush(self): + raise self.error + + +class DeadConsole(unittest.TestCase): + def test_print_to_failing_stream_is_ignored(self): + for error in (OSError(errno.EINVAL, 'Invalid argument'), BrokenPipeError(errno.EPIPE, 'Broken pipe'), + ValueError('I/O operation on closed file.')): + failing = FailingStream(error) + stream = setup_ui.TolerantStream(failing) + print('Preparing library', file=stream, flush=True) + print('next stage', file=stream, flush=True) + stream.writelines(['a\n', 'b\n']) + stream.flush() + # The first failure marks the console dead; later output is dropped without retrying. + self.assertEqual(failing.writes, 1) + + def test_working_stream_is_forwarded(self): + target = io.StringIO() + stream = setup_ui.TolerantStream(target) + print('progress 1', file=stream, flush=True) + self.assertEqual(target.getvalue(), 'progress 1\n') + self.assertEqual(stream.getvalue(), 'progress 1\n') # other attributes pass through + + def test_guard_wraps_both_streams_once(self): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + setup_ui.tolerate_dead_console() + setup_ui.tolerate_dead_console() + self.assertIsInstance(sys.stdout, setup_ui.TolerantStream) + self.assertIs(sys.stdout._stream, out) + self.assertIs(sys.stderr._stream, err) + print('hello') + self.assertEqual(out.getvalue(), 'hello\n') + + def run_child(self, guarded): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp).resolve() + (root/'tools').mkdir() + (root/'tools/progress_task.py').write_text(TASK, encoding='utf-8') + code = CHILD.format(repo=str(REPO), root=str(root), guarded=guarded) + child = subprocess.Popen([sys.executable, '-c', code], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, cwd=root) + child.stdout.close() + child.stderr.close() + (root/'go').touch() + code = child.wait(timeout=120) + return code, (root/'done.txt').is_file() + + def test_conversion_task_finishes_through_a_closed_pipe(self): + self.assertEqual(self.run_child(guarded=True), (0, True)) + + def test_closed_pipe_aborts_an_unguarded_task(self): + # Control: the same task without the guard dies on its first progress print, as in #20. + code, finished = self.run_child(guarded=False) + self.assertNotEqual(code, 0) + self.assertFalse(finished) + + +if __name__ == '__main__': + unittest.main()