From 22798dffe901eb88398a5bc7801baad59ae22c5f Mon Sep 17 00:00:00 2001 From: "fei.zheng" Date: Mon, 26 Oct 2026 12:04:16 -0600 Subject: [PATCH] Keep the Windows launcher alive when stdout is cp1252. GitHub Actions Windows runners encode the console as cp1252. Child output is decoded with errors=replace, so a relayed line can contain U+FFFD, and writing that character aborts the action with UnicodeEncodeError. Co-authored-by: Cursor --- bootstrap/launch.py | 25 +++++++++++++++ tests/test_skillscope.py | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/bootstrap/launch.py b/bootstrap/launch.py index d07db42..af4c7dd 100644 --- a/bootstrap/launch.py +++ b/bootstrap/launch.py @@ -39,6 +39,28 @@ VERSION_PATTERN = re.compile(r"""^__version__\s*=\s*['"]([^'"]+)['"]""", re.MULTILINE) +def _configure_stdio() -> None: + """Let a cp1252 Windows console print every character the child emits. + + Hosted Windows runners start Python with the console code page, usually + cp1252. Child output is decoded with ``errors="replace"``, so a relayed + line can contain U+FFFD. Writing that character through a cp1252 + ``sys.stdout`` raises ``UnicodeEncodeError`` and aborts the action. + UTF-8 encodes it. + + A missing stream, a redirect that has no ``reconfigure``, or an + ``OSError`` / ``ValueError`` from the stream must not abort startup. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + continue + + def _env(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() @@ -82,6 +104,9 @@ def packaged_version(checkout: Path) -> str: def main() -> int: + # Before any print or relayed child line. The crash is a cp1252 stdout + # refusing U+FFFD; every later write goes through these two streams. + _configure_stdio() repo = Path(_env("SKILLSCOPE_REPO", ".") or ".").expanduser().resolve() command = _env("SKILLSCOPE_COMMAND") if not command: diff --git a/tests/test_skillscope.py b/tests/test_skillscope.py index 5d4cf7b..485ef6d 100644 --- a/tests/test_skillscope.py +++ b/tests/test_skillscope.py @@ -1007,6 +1007,73 @@ def test_it_installs_from_the_action_checkout(self) -> None: with mock.patch.dict(os.environ, {"SKILLSCOPE_ACTION_PATH": str(root)}): self.assertEqual(self.module.action_root(), root) + def test_a_cp1252_console_can_relay_the_replacement_character(self) -> None: + # Child stdout is decoded with errors="replace", so U+FFFD reaches + # sys.stdout.write. A cp1252 console raises UnicodeEncodeError there + # unless startup reconfigured the stream to UTF-8. + buffer = io.BytesIO() + stdout = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict") + stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict") + child = io.StringIO("hot kernel \ufffd\n") + + class FakeProc: + def __init__(self) -> None: + self.stdout = child + + def wait(self) -> int: + return 0 + + repo_root = Path(__file__).resolve().parent.parent + try: + with tempfile.TemporaryDirectory() as tmp: + with ( + mock.patch.object(self.module.sys, "stdout", stdout), + mock.patch.object(self.module.sys, "stderr", stderr), + mock.patch.object(self.module.subprocess, "Popen", return_value=FakeProc()), + mock.patch.dict( + os.environ, + { + "SKILLSCOPE_COMMAND": "behavioral", + "SKILLSCOPE_ARGS": "", + "SKILLSCOPE_STDIN": "", + "SKILLSCOPE_REPO": tmp, + "SKILLSCOPE_ACTION_PATH": str(repo_root), + "GITHUB_OUTPUT": "", + "GITHUB_STEP_SUMMARY": "", + }, + ), + ): + code = self.module.main() + self.assertEqual(code, 0) + self.assertEqual(stdout.encoding, "utf-8") + self.assertEqual(stdout.errors, "replace") + self.assertEqual(stderr.encoding, "utf-8") + self.assertIn("\ufffd".encode("utf-8"), buffer.getvalue()) + finally: + stdout.close() + stderr.close() + + def test_stdio_setup_survives_a_stream_that_cannot_be_reconfigured(self) -> None: + class RaisesOSError: + def reconfigure(self, **_kwargs: object) -> None: + raise OSError("console code page is locked") + + closed = io.TextIOWrapper(io.BytesIO(), encoding="cp1252") + closed.close() + cases = ( + (None, None), + (io.StringIO(), io.StringIO()), + (RaisesOSError(), RaisesOSError()), + (closed, closed), + ) + for stdout, stderr in cases: + with self.subTest(stdout=type(stdout).__name__): + with ( + mock.patch.object(self.module.sys, "stdout", stdout), + mock.patch.object(self.module.sys, "stderr", stderr), + ): + self.module._configure_stdio() + class TestSelection(unittest.TestCase): """What CI runs for a change, and what it is right to skip."""