From 6f21b112da3f5d2328a06e98df50507e72044e1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:53:23 +0800 Subject: [PATCH] fix: wait for the Python console runtime on Windows --- ...indows-python-console-spawn-wait.i18n.yaml | 6 +++ ...09-06-windows-python-console-spawn-wait.md | 27 ++++++++++ ...06-windows-python-console-spawn-wait.zh.md | 27 ++++++++++ python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- .../src/deepseek_harness_runtime/__init__.py | 6 ++- python/sdk/tests/test_runtime_resolution.py | 54 ++++++++++++++++++- python/sdk/tests/test_smoke_model.py | 15 ++++++ scripts/smoke-python-runtime.py | 1 + 10 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.i18n.yaml new file mode 100644 index 0000000000..652b921960 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.md +2026-09-06-windows-python-console-spawn-wait.md: 92443bcf8a6e4e5609dc469efa4ebd1d82ab127f +2026-09-06-windows-python-console-spawn-wait.zh.md: dba2f324b29955580fc11e7cea7a0525a8bc8c86 diff --git a/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.md b/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.md new file mode 100644 index 0000000000..92443bcf8a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.md @@ -0,0 +1,27 @@ +# Agent Note: Wait for the Windows Python console runtime + +Status: implemented + +English | [中文](2026-09-06-windows-python-console-spawn-wait.zh.md) + +## Problem + +The installed Python `dsh.exe` console command intermittently exits with Windows access violation `0xc0000005` before initializing a profile. Its smoke assertion omitted the process status and reported only empty streams. A [native faulthandler probe](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34030851888) captures the fault in Python 3.10 `os._execvpe`, called by the runtime console entry, rather than in the bundled Node executable. Direct executable controls pass. + +## Decision + +The [Python console entry](../../../../python/sdk-runtime/src/deepseek_harness_runtime/__init__.py) uses `subprocess.run` on Windows, inherits standard streams and environment, waits for runtime completion, and exits with the runtime status. POSIX retains `os.execvpe` process replacement. Windows CRT exec is not POSIX process replacement; the explicit spawn-and-wait path avoids the observed native exec operation. + +The [installed-wheel smoke](../../../../scripts/smoke-python-runtime.py) reports decimal and unsigned 32-bit hexadecimal status alongside captured streams when profile installation fails. This preserves the distinction between ordinary command failure and native process exceptions. + +## Alternatives considered + +**Disable Node compile caching.** Not selected: cache environment changes correlated with early probes, but cold-cache controls also passed and Python faulthandler locates the actual fault at the native exec call. Cache configuration remains unchanged. + +**Retry or bypass the installed console command.** Rejected because either masks the shipped command failure instead of repairing its process launch. The keyless installed-wheel assertion remains required. + +## Consequences + +Windows keeps a Python parent until the runtime exits; it no longer depends on CRT overlay behavior. The standard synchronous subprocess implementation owns waiting and interruption cleanup. No custom process-tree manager or global host setting is added. + +[Runtime-resolution tests](../../../../python/sdk/tests/test_runtime_resolution.py) retain POSIX forwarding and cover Windows argument/environment forwarding, statuses 0/37/513, real child completion, Unicode streams and arguments with spaces. Native Windows owns the wide exit-status case because POSIX truncates process statuses to eight bits. The [native fixed-count comparison](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34031142773) passes all four patched launches with compile caching enabled; all four unpatched controls also pass in that batch, so it is not a same-batch reproduction. Full installed-wheel CI must validate the final artifact separately from local branch-level tests. diff --git a/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.zh.md b/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.zh.md new file mode 100644 index 0000000000..dba2f324b2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-06-windows-python-console-spawn-wait.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 等待 Windows Python 控制台运行时 + +Status: implemented + +[English](2026-09-06-windows-python-console-spawn-wait.md) | 中文 + +## 问题 + +Python 安装的 `dsh.exe` 控制台命令会在初始化 profile 前间歇性地以 Windows 访问冲突 `0xc0000005` 退出。其冒烟断言遗漏进程状态,只报告空标准流。[原生 faulthandler 探测](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34030851888) 将故障定位在运行时控制台入口调用的 Python 3.10 `os._execvpe`,而非打包的 Node 可执行文件。直接启动可执行文件的对照通过。 + +## 决策 + +[Python 控制台入口](../../../../python/sdk-runtime/src/deepseek_harness_runtime/__init__.py) 在 Windows 上使用 `subprocess.run`,继承标准流与环境,等待运行时结束,再以运行时状态退出。POSIX 保留 `os.execvpe` 进程替换。Windows CRT exec 并非 POSIX 进程替换;显式启动并等待的路径避开观测到的原生 exec 操作。 + +[安装后 wheel 冒烟测试](../../../../scripts/smoke-python-runtime.py) 在 profile 安装失败时,同时报告十进制、无符号 32 位十六进制状态与捕获的标准流。这保留普通命令失败和原生进程异常的区别。 + +## 已考虑的替代方案 + +**禁用 Node 编译缓存。** 未采用:早期探测中缓存环境变化与结果相关,但冷缓存对照也能通过,且 Python faulthandler 将实际故障定位在原生 exec 调用。缓存配置保持不变。 + +**重试或绕过已安装的控制台命令。** 拒绝,因为二者都会掩盖已发布命令的失败,而不是修复进程启动。keyless 安装后 wheel 断言仍为必需检查。 + +## 后果 + +Windows 保留 Python 父进程直到运行时退出,不再依赖 CRT overlay 行为。标准同步子进程实现负责等待和中断清理。不添加自定义进程树管理器或全局主机设置。 + +[运行时解析测试](../../../../python/sdk/tests/test_runtime_resolution.py) 保留 POSIX 转发验证,并覆盖 Windows 参数/环境转发、状态 0/37/513、真实子进程完成、Unicode 标准流和带空格的参数。宽退出状态由原生 Windows 验证,因为 POSIX 会将进程状态截断为八位。[原生固定次数对照](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34031142773) 中,启用编译缓存的四次修复后启动全部通过;该批次四次未修复对照也全部通过,因此它不是同批次复现。完整安装后 wheel CI 必须独立于本地分支级测试,验证最终产物。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 410f250dd9..62e489fa02 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 050ae85d9b0a38b82a3c66a84c3d8f34e137be6c -README.zh.md: 7066d7224752294c25b58cfe8fb6a94524a2013d +README.md: fb7478f305015e60dafd23861ab6fd91f10757f3 +README.zh.md: 7c663aba8d387fb7b1048afe28d50faee7350303 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 050ae85d9b..fb7478f305 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -19,7 +19,7 @@ Both carriers execute the same `dsh` grammar and shipped profiles, including the - `bundled_package_dir() -> Path` returns the installed module-data root and verifies its release metadata. - `bundled_runtime_path() -> Path` returns the current platform executable and verifies required sidecars. - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` returns the executable argv by default. Explicit `mode="node"` or `DSH_RUNTIME_MODE=node` selects the repo-only Node carrier. -- `main()` implements the installed `dsh` console command and rejects an absent or blank `DSH_HOME` before replacing the Python process. +- `main()` implements the installed `dsh` console command and rejects an absent or blank `DSH_HOME`. On Windows it waits for the bundled process with inherited standard streams and forwards its exit status; on POSIX it replaces the Python process. Unsupported platforms and missing executables or sidecars raise `FileNotFoundError` with the build and installation routes. Unknown runtime modes raise `ValueError`. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 7066d72247..7c663aba8d 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -19,7 +19,7 @@ Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模 - `bundled_package_dir() -> Path` 返回已安装模块数据根目录,并校验发布元数据。 - `bundled_runtime_path() -> Path` 返回当前平台可执行程序,并校验必需伴随文件。 - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` 默认返回可执行程序 argv。显式 `mode="node"` 或 `DSH_RUNTIME_MODE=node` 会选择仅限仓库使用的 Node 载体。 -- `main()` 实现已安装的 `dsh` 控制台命令,并在替换 Python 进程前拒绝缺失或空白的 `DSH_HOME`。 +- `main()` 实现已安装的 `dsh` 控制台命令,并拒绝缺失或空白的 `DSH_HOME`。在 Windows 上,它让打包进程继承标准流,等待其结束并转发退出状态;在 POSIX 上,它替换 Python 进程。 不支持的平台以及缺失的可执行程序或伴随文件会抛出 `FileNotFoundError`,并指出构建与安装路径。未知运行时模式会抛出 `ValueError`。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 1fc5377e10..5235ff4d00 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -24,6 +24,7 @@ from __future__ import annotations import os import platform import shutil +import subprocess import sys from pathlib import Path @@ -156,7 +157,7 @@ def _node_launch_args() -> tuple[str, str]: def main() -> None: - """Execute the bundled dsh CLI with an explicitly selected Harness home.""" + """Launch the CLI with explicit DSH_HOME; wait on Windows, replace the process on POSIX.""" if not os.environ.get("DSH_HOME", "").strip(): print( "dsh: the Python runtime command requires an explicit DSH_HOME; " @@ -165,6 +166,9 @@ def main() -> None: ) raise SystemExit(2) argv = (*resolve_bundled_launch_args(), *sys.argv[1:]) + if sys.platform == "win32": + # Windows CRT exec does not replace the process; wait and preserve the runtime status. + raise SystemExit(subprocess.run(argv, env=os.environ).returncode) os.execvpe(argv[0], argv, os.environ) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 3241fd3ce1..dd520df78b 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -2,7 +2,11 @@ from __future__ import annotations +import os +import subprocess +import sys from pathlib import Path +from types import SimpleNamespace import deepseek_harness_runtime as runtime import pytest @@ -129,7 +133,7 @@ def test_python_dsh_command_executes_the_bundled_cli( called: dict[str, object] = {} monkeypatch.setenv("DSH_HOME", "/explicit/home") monkeypatch.setattr(runtime, "resolve_bundled_launch_args", lambda: ("/runtime",)) - monkeypatch.setattr(runtime.sys, "argv", ["dsh", "plugin", "--profile", "sdk", "list"]) + monkeypatch.setattr(runtime, "sys", SimpleNamespace(platform="linux", argv=["dsh", "plugin", "--profile", "sdk", "list"])) def execvpe(file: str, args: tuple[str, ...], env: dict[str, str]) -> None: called.update(file=file, args=args, home=env.get("DSH_HOME")) @@ -143,3 +147,51 @@ def test_python_dsh_command_executes_the_bundled_cli( "args": ("/runtime", "plugin", "--profile", "sdk", "list"), "home": "/explicit/home", } + + +@pytest.mark.parametrize("returncode", [0, 37, 513]) +def test_windows_console_waits_and_forwards_runtime_status(monkeypatch: pytest.MonkeyPatch, returncode: int) -> None: + monkeypatch.setenv("DSH_HOME", "/explicit/home") + monkeypatch.setattr(runtime, "sys", SimpleNamespace(platform="win32", argv=["dsh", "plugin", "argument with spaces", "中文"])) + monkeypatch.setattr(runtime, "resolve_bundled_launch_args", lambda: ("runtime.exe",)) + called = [] + + def run(args: tuple[str, ...], **kwargs: object) -> subprocess.CompletedProcess[str]: + called.append((args, kwargs)) + return subprocess.CompletedProcess(args, returncode) + + def forbidden_exec(*args: object) -> None: + pytest.fail("Windows console must wait instead of entering CRT exec") + + monkeypatch.setattr(subprocess, "run", run) + monkeypatch.setattr(runtime.os, "execvpe", forbidden_exec) + with pytest.raises(SystemExit) as result: + main() + assert result.value.code == returncode + assert called == [(("runtime.exe", "plugin", "argument with spaces", "中文"), {"env": os.environ})] + + +@pytest.mark.parametrize("returncode", [0, 37, pytest.param(513, marks=pytest.mark.skipif(sys.platform != "win32", reason="POSIX truncates process exit codes to eight bits"))]) +def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path: Path, returncode: int) -> None: + child = tmp_path / "child with spaces.py" + sentinel = tmp_path / "finished" + child.write_text( + "import pathlib,sys\n" + "assert sys.argv[1] == 'argument with spaces'\n" + "assert sys.argv[2] == '中文'\n" + "print('stdout-中文', flush=True)\n" + "print('stderr-中文', file=sys.stderr, flush=True)\n" + f"pathlib.Path({str(sentinel)!r}).write_text('done')\n" + f"raise SystemExit({returncode})\n", encoding="utf-8", + ) + driver = ( + "import deepseek_harness_runtime as runtime; from types import SimpleNamespace; " + f"runtime.sys = SimpleNamespace(platform='win32', argv=['dsh', 'argument with spaces', '中文']); " + f"runtime.resolve_bundled_launch_args = lambda: ({sys.executable!r}, {str(child)!r}); runtime.main()" + ) + result = subprocess.run([sys.executable, "-c", driver], capture_output=True, text=True, encoding="utf-8", + env={**os.environ, "DSH_HOME": str(tmp_path), "PYTHONIOENCODING": "utf-8"}, timeout=15) + assert result.returncode == returncode, result.stderr + assert result.stdout == "stdout-中文\n" + assert result.stderr == "stderr-中文\n" + assert sentinel.read_text() == "done" diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py index 8dfafd12c8..55570501a9 100644 --- a/python/sdk/tests/test_smoke_model.py +++ b/python/sdk/tests/test_smoke_model.py @@ -1,6 +1,7 @@ from __future__ import annotations import runpy +import subprocess from pathlib import Path import pytest @@ -262,3 +263,17 @@ def test_snapshot_generation_filename_must_match_header(tmp_path: Path) -> None: with pytest.raises(AssertionError, match="filename declares Session format v1"): SMOKE["selected_snapshot_session_files"](tmp_path) + + +@pytest.mark.parametrize("returncode", [1, -1073741819, 3221225477]) +def test_profile_plugin_failure_reports_native_exit_status(monkeypatch: pytest.MonkeyPatch, returncode: int) -> None: + def failed_install(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", failed_install) + with pytest.raises(AssertionError) as error: + SMOKE["smoke_sdk_profile_plugin"]("http://127.0.0.1:1") + message = str(error.value) + assert f"returncode={returncode}" in message + assert f"0x{returncode & 0xffffffff:08x}" in message + assert "stdout='' stderr=''" in message diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index ad7e663bad..0a1331d718 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1227,6 +1227,7 @@ def smoke_sdk_profile_plugin(base_url: str) -> None: if installed.returncode != 0: raise AssertionError( f"Python-installed dsh could not add the external profile plugin: " + f"returncode={installed.returncode} (0x{installed.returncode & 0xffffffff:08x}) " f"stdout={installed.stdout!r} stderr={installed.stderr!r}" ) manifest = json.loads((dsh_home / "profiles" / "sdk" / "package.json").read_text())