From 612cba9bd32fe0049560e515b4f9194664f307b5 Mon Sep 17 00:00:00 2001 From: 491034170 <142008960+491034170@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:57:40 +0800 Subject: [PATCH 1/2] fix(ci): make pre-push runner portable on Windows --- .github/scripts/preflight_runner.py | 60 ++++++++++++++++++++---- .github/scripts/test_pr_preflight.py | 70 ++++++++++++++++++++++++++++ scripts/pre-push-singleflight | 2 +- 3 files changed, 122 insertions(+), 10 deletions(-) diff --git a/.github/scripts/preflight_runner.py b/.github/scripts/preflight_runner.py index f1247c9155a..d04d1b25239 100755 --- a/.github/scripts/preflight_runner.py +++ b/.github/scripts/preflight_runner.py @@ -16,6 +16,24 @@ POLL_SECONDS = 0.2 STATUS_INTERVAL_SECONDS = 5.0 +FORWARD_SIGNAL_NAMES = ("SIGINT", "SIGTERM", "SIGHUP") + + +def supported_forward_signals() -> tuple[int, ...]: + return tuple(signum for name in FORWARD_SIGNAL_NAMES if isinstance((signum := getattr(signal, name, None)), int)) + + +def forward_signal_to_child(child: subprocess.Popen[str], signum: int) -> None: + if child.poll() is not None: + return + try: + kill_process_group = getattr(os, "killpg", None) + if callable(kill_process_group): + kill_process_group(child.pid, signum) + else: + child.send_signal(signum) + except ProcessLookupError: + pass def atomic_json(path: Path, value: dict) -> None: @@ -32,9 +50,38 @@ def read_json(path: Path) -> dict: return {} +def windows_process_exists(pid: int) -> bool: + import ctypes + from ctypes import wintypes + + process_query_limited_information = 0x1000 + error_access_denied = 5 + still_active = 259 + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)) + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = (wintypes.HANDLE,) + kernel32.CloseHandle.restype = wintypes.BOOL + + handle = kernel32.OpenProcess(process_query_limited_information, False, pid) + if not handle: + return ctypes.get_last_error() == error_access_denied + try: + exit_code = wintypes.DWORD() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return True + return exit_code.value == still_active + finally: + kernel32.CloseHandle(handle) + + def process_exists(pid: int) -> bool: if pid <= 0: return False + if os.name == "nt": + return windows_process_exists(pid) try: os.kill(pid, 0) return True @@ -173,15 +220,10 @@ def write_status() -> None: ) def forward_signal(signum: int, _frame: object) -> None: - if child is not None and child.poll() is None: - try: - os.killpg(child.pid, signum) - except ProcessLookupError: - pass - - previous_handlers = { - signum: signal.signal(signum, forward_signal) for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) - } + if child is not None: + forward_signal_to_child(child, signum) + + previous_handlers = {signum: signal.signal(signum, forward_signal) for signum in supported_forward_signals()} exit_code = 1 try: print(f"Pre-push single-flight log: {log_path}") diff --git a/.github/scripts/test_pr_preflight.py b/.github/scripts/test_pr_preflight.py index ae439a3cfcd..b7c7dec0f98 100755 --- a/.github/scripts/test_pr_preflight.py +++ b/.github/scripts/test_pr_preflight.py @@ -6,19 +6,23 @@ import io import json import os +import signal import subprocess import sys import tempfile import time import unittest +from unittest import mock from pathlib import Path +import preflight_runner from pr_metadata import load_from_api from pr_preflight import select_checks SCRIPT_DIR = Path(__file__).resolve().parent RUNNER = SCRIPT_DIR / "preflight_runner.py" REPO_ROOT = SCRIPT_DIR.parents[1] +PRE_PUSH_SINGLEFLIGHT = REPO_ROOT / "scripts" / "pre-push-singleflight" class FakeResponse(io.BytesIO): @@ -59,6 +63,10 @@ def opener(request: object, timeout: int) -> FakeResponse: class SelectionTests(unittest.TestCase): + def test_pre_push_wrapper_reuses_current_bash_interpreter(self) -> None: + wrapper = PRE_PUSH_SINGLEFLIGHT.read_text(encoding="utf-8") + self.assertIn(' -- "$BASH" scripts/pre-push "$@"', wrapper) + def test_make_preflight_resolves_pr_metadata_before_running_checks(self) -> None: result = subprocess.run( ["make", "-n", "preflight"], @@ -196,6 +204,68 @@ def test_pr_body_file_env_is_honored(self) -> None: self.assertIn(str(body.resolve()), result.stdout) +class SignalCompatibilityTests(unittest.TestCase): + @unittest.skipUnless(os.name == "nt", "Windows-specific process probe") + def test_windows_process_probe_finds_current_process(self) -> None: + self.assertTrue(preflight_runner.windows_process_exists(os.getpid())) + + @unittest.skipUnless(os.name == "nt", "Windows-specific process probe") + def test_windows_process_probe_rejects_exited_process(self) -> None: + child = subprocess.Popen([sys.executable, "-c", "pass"]) + child.wait(timeout=5) + + self.assertFalse(preflight_runner.windows_process_exists(child.pid)) + + def test_process_exists_uses_windows_probe_without_os_kill(self) -> None: + with ( + mock.patch.object(preflight_runner.os, "name", "nt"), + mock.patch.object(preflight_runner, "windows_process_exists", return_value=True) as windows_probe, + mock.patch.object(preflight_runner.os, "kill") as kill, + ): + self.assertTrue(preflight_runner.process_exists(4321)) + + windows_probe.assert_called_once_with(4321) + kill.assert_not_called() + + def test_supported_forward_signals_skip_unavailable_members(self) -> None: + with mock.patch.object(preflight_runner.signal, "SIGHUP", None, create=True): + self.assertEqual( + preflight_runner.supported_forward_signals(), + (signal.SIGINT, signal.SIGTERM), + ) + + def test_forward_signal_uses_child_api_without_killpg(self) -> None: + child = mock.Mock(pid=4321) + child.poll.return_value = None + + with mock.patch.object(preflight_runner.os, "killpg", None, create=True): + preflight_runner.forward_signal_to_child(child, signal.SIGTERM) + + child.send_signal.assert_called_once_with(signal.SIGTERM) + + @unittest.skipUnless(os.name == "nt", "Windows-specific signal forwarding") + def test_forward_signal_terminates_windows_child(self) -> None: + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + preflight_runner.forward_signal_to_child(child, signal.SIGTERM) + child.wait(timeout=5) + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=5) + + def test_forward_signal_keeps_posix_process_group_behavior(self) -> None: + child = mock.Mock(pid=4321) + child.poll.return_value = None + killpg = mock.Mock() + + with mock.patch.object(preflight_runner.os, "killpg", killpg, create=True): + preflight_runner.forward_signal_to_child(child, signal.SIGTERM) + + killpg.assert_called_once_with(4321, signal.SIGTERM) + child.send_signal.assert_not_called() + + class SingleFlightTests(unittest.TestCase): def run_runner( self, diff --git a/scripts/pre-push-singleflight b/scripts/pre-push-singleflight index a3a6a4f8f74..29a7d2d9430 100755 --- a/scripts/pre-push-singleflight +++ b/scripts/pre-push-singleflight @@ -4,4 +4,4 @@ set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" -exec python3 .github/scripts/preflight_runner.py --name pre-push -- scripts/pre-push "$@" +exec python3 .github/scripts/preflight_runner.py --name pre-push -- "$BASH" scripts/pre-push "$@" From ec6b6f9fd5d6bb5afbb7f0f9a55a3c0ea6ce3107 Mon Sep 17 00:00:00 2001 From: 491034170 <142008960+491034170@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:01:22 +0800 Subject: [PATCH 2/2] fix(ci): resolve Windows backend Python for pre-push --- .github/scripts/test_pr_preflight.py | 10 ++++++++++ scripts/pre-push | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/scripts/test_pr_preflight.py b/.github/scripts/test_pr_preflight.py index b7c7dec0f98..56abd67e25d 100755 --- a/.github/scripts/test_pr_preflight.py +++ b/.github/scripts/test_pr_preflight.py @@ -23,6 +23,7 @@ RUNNER = SCRIPT_DIR / "preflight_runner.py" REPO_ROOT = SCRIPT_DIR.parents[1] PRE_PUSH_SINGLEFLIGHT = REPO_ROOT / "scripts" / "pre-push-singleflight" +PRE_PUSH = REPO_ROOT / "scripts" / "pre-push" class FakeResponse(io.BytesIO): @@ -67,6 +68,15 @@ def test_pre_push_wrapper_reuses_current_bash_interpreter(self) -> None: wrapper = PRE_PUSH_SINGLEFLIGHT.read_text(encoding="utf-8") self.assertIn(' -- "$BASH" scripts/pre-push "$@"', wrapper) + def test_pre_push_accepts_and_propagates_windows_backend_python(self) -> None: + pre_push = PRE_PUSH.read_text(encoding="utf-8") + setup_prefix = pre_push[: pre_push.index("require_backend_python()")] + + self.assertIn('BACKEND_PYTHON="${BACKEND_PYTHON:-}"', setup_prefix) + self.assertIn('"$PWD/backend/.venv/bin/python"', setup_prefix) + self.assertIn('"$PWD/backend/.venv/Scripts/python.exe"', setup_prefix) + self.assertIn('PYRIGHT_PYTHON="$BACKEND_PYTHON" bash scripts/typecheck.sh', pre_push) + def test_make_preflight_resolves_pr_metadata_before_running_checks(self) -> None: result = subprocess.run( ["make", "-n", "preflight"], diff --git a/scripts/pre-push b/scripts/pre-push index 8f652600efa..9c450e62afe 100755 --- a/scripts/pre-push +++ b/scripts/pre-push @@ -86,14 +86,22 @@ SELECTED_BACKEND_TESTS_REASON=$(mktemp "${TMPDIR:-/tmp}/omi-pre-push-backend-tes FAST_BACKEND_TESTS=$(mktemp "${TMPDIR:-/tmp}/omi-pre-push-fast-backend-tests.XXXXXX") trap 'rm -f "$CHANGED_FILES_LIST" "$SELECTED_BACKEND_TESTS" "$SELECTED_BACKEND_TESTS_REASON" "$FAST_BACKEND_TESTS"' EXIT printf '%s\n' "${CHANGED_FILES[@]}" > "$CHANGED_FILES_LIST" -BACKEND_PYTHON="$PWD/backend/.venv/bin/python" +BACKEND_PYTHON="${BACKEND_PYTHON:-}" +if [[ -z "$BACKEND_PYTHON" ]]; then + for backend_python_candidate in "$PWD/backend/.venv/bin/python" "$PWD/backend/.venv/Scripts/python.exe"; do + if [[ -x "$backend_python_candidate" ]]; then + BACKEND_PYTHON="$backend_python_candidate" + break + fi + done +fi require_backend_python() { - if [[ -x "$BACKEND_PYTHON" ]]; then + if [[ -n "$BACKEND_PYTHON" && -x "$BACKEND_PYTHON" ]]; then return 0 fi - echo "FAIL: a selected backend check requires backend/.venv/bin/python, but it was not found." >&2 - echo " Create it with: cd backend && bash scripts/sync-python-deps.sh" >&2 + echo "FAIL: a selected backend check requires a usable backend Python." >&2 + echo " Set BACKEND_PYTHON or run: cd backend && bash scripts/sync-python-deps.sh" >&2 return 1 } @@ -275,7 +283,7 @@ check_backend_typecheck_if_needed() { require_backend_python ( cd backend - bash scripts/typecheck.sh + PYRIGHT_PYTHON="$BACKEND_PYTHON" bash scripts/typecheck.sh ) }