From 0c0feb6c1b566240380af0356207132eac5e7632 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Thu, 27 Aug 2026 09:47:30 -0700 Subject: [PATCH 1/4] ci: Expand cross-platform test coverage Split lint, type checking, and distribution builds from the test matrix, then run tests on Linux, macOS, and Windows across supported Python versions. Add one CI Overall result for branch protection. Invoke Poe tasks directly and replace shell-only harness behavior with portable Python so Windows exercises the same commands. Preserve GGT failure details, exclude uvloop on Windows, and use GGT 1.5.6. Stabilize the changelog escape-key test under saturated Windows runners by waiting for the UI transition it asserts. --- .github/workflows/ci.yml | 97 ++++++- pyproject.toml | 4 +- scripts/poe/README.md | 4 +- scripts/poe/poe.toml | 16 +- scripts/poe/tasks/poe | 35 ++- scripts/poe/tasks/tool | 469 ++++++++++++------------------- scripts/poe/workspace_poe.py | 81 ++++-- scripts/release.py | 9 +- tests/unit/test_clogapp.py | 15 +- tests/unit/test_poe_tool.py | 125 ++++---- tests/unit/test_workspace_poe.py | 66 +++++ uv.lock | 10 +- 12 files changed, 495 insertions(+), 436 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a7ba093..5b4bca48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,25 +9,66 @@ on: permissions: {} jobs: - lint-test-and-build: - name: test (py${{ matrix.python-version }}) + lint-and-typecheck: + name: Lint and typecheck runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - name: Sync dependencies + run: uv sync --all-packages --locked --python 3.12 + + - name: Lint + run: uv run poe lint + + - name: Typecheck + run: uv run poe typecheck + + test: + name: Test (${{ matrix.os.name }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os.runner }} environment: ci permissions: contents: read + defaults: + run: + shell: bash strategy: fail-fast: false matrix: + os: + - name: Linux + runner: ubuntu-latest + redis-image: redis:7-alpine + redis-url: redis://localhost:6379/9 + - name: macOS + runner: macos-latest + redis-image: "" + redis-url: "" + - name: Windows + runner: windows-latest + redis-image: "" + redis-url: "" python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] services: # The vercel-apscheduler driver tests exercise their Lua scripts against - # a real Redis; without this service they skip silently. + # a real Redis on Linux; without this service they skip silently. redis: - image: redis:7-alpine + image: ${{ matrix.os.redis-image }} # zizmor: ignore[unpinned-images] ports: - 6379:6379 env: - APSCHEDULER_TEST_REDIS_URL: redis://localhost:6379/9 + APSCHEDULER_TEST_REDIS_URL: ${{ matrix.os.redis-url }} BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} @@ -82,14 +123,8 @@ jobs: - name: Sync dependencies run: uv sync --all-packages --locked --python ${{ matrix.python-version }} - - name: Lint - run: ./scripts/lint.sh - - - name: Typecheck - run: ./scripts/typecheck.sh - - name: Test - run: ./scripts/test.sh + run: uv run poe test env: VERCEL_DEVALUE_JS: ${{ runner.temp }}/devalue VERCEL_WORKFLOW_CLI: ${{ runner.temp }}/workflow-cli/node_modules/workflow @@ -98,5 +133,41 @@ jobs: if: matrix.python-version == '3.12' run: uv run poe test-examples + build: + name: Build packages + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - name: Sync dependencies + run: uv sync --all-packages --locked --python 3.12 + - name: Build package - run: ./scripts/build.sh + run: uv run poe dist + + overall: + name: CI Overall + if: always() + needs: + - build + - lint-and-typecheck + - test + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Check CI results + if: >- + needs.build.result != 'success' || + needs.lint-and-typecheck.result != 'success' || + needs.test.result != 'success' + run: exit 1 diff --git a/pyproject.toml b/pyproject.toml index d4d6c8c0..900756a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dev = [ "hypothesis>=6.0.0,<7", "respx>=0.21.0,<1", "cryptography>=48.0.1", - "uvloop<1", + "uvloop<1; sys_platform != 'win32'", "mypy>=1.20.2,<2", "django-stubs>=6.0.6,<7", "build<2", @@ -31,7 +31,7 @@ dev = [ "tomli>=2.0.0,<3; python_version < '3.11'", "vendoring>=1,<2", "lograil~=0.7.0", - "ggt>=1.5.5; python_version >= '3.11'", + "ggt>=1.5.6; python_version >= '3.11'", ] [tool.uv] diff --git a/scripts/poe/README.md b/scripts/poe/README.md index 63ea2ef2..ae6995ea 100644 --- a/scripts/poe/README.md +++ b/scripts/poe/README.md @@ -238,8 +238,8 @@ When changing this system, verify all shell code with system's default bash (helps catching new bash-isms on macOS). ```sh -shellcheck -x scripts/build.sh scripts/poe/tasks/poe scripts/poe/tasks/tool -/bin/bash -n scripts/build.sh scripts/poe/tasks/poe scripts/poe/tasks/tool +shellcheck -x scripts/build.sh +/bin/bash -n scripts/build.sh python3 -m py_compile scripts/poe/workspace_poe.py scripts/poe/workspace_poe_resolve.py scripts/workspace-task.sh scripts/qa.sh scripts/workspace-root-task.sh ``` diff --git a/scripts/poe/poe.toml b/scripts/poe/poe.toml index 193b752c..d4f1457b 100644 --- a/scripts/poe/poe.toml +++ b/scripts/poe/poe.toml @@ -1,12 +1,12 @@ [tool.poe.env] -POE = { default = "${POE_CONF_DIR}/tasks/poe" } -PYTEST = { default = "${POE_CONF_DIR}/tasks/pytest" } -RUFF_CHECK = { default = "${POE_CONF_DIR}/tasks/ruff-check" } -RUFF_CHECK_FIX = { default = "${POE_CONF_DIR}/tasks/ruff-check-fix" } -RUFF_FORMAT = { default = "${POE_CONF_DIR}/tasks/ruff-format" } -RUFF_FORMAT_FIX = { default = "${POE_CONF_DIR}/tasks/ruff-format-fix" } -TY = { default = "${POE_CONF_DIR}/tasks/ty" } -MYPY = { default = "${POE_CONF_DIR}/tasks/mypy" } +POE = { default = "python ${POE_CONF_DIR}/tasks/poe" } +PYTEST = { default = "python ${POE_CONF_DIR}/tasks/tool pytest" } +RUFF_CHECK = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-check" } +RUFF_CHECK_FIX = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-check-fix" } +RUFF_FORMAT = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-format" } +RUFF_FORMAT_FIX = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-format-fix" } +TY = { default = "python ${POE_CONF_DIR}/tasks/tool ty" } +MYPY = { default = "python ${POE_CONF_DIR}/tasks/tool mypy" } [tool.poe.tasks.fix] cmd = "$POE_CONF_DIR/workspace_poe.py tool-group fix" diff --git a/scripts/poe/tasks/poe b/scripts/poe/tasks/poe index f3a5f4ae..dd441fff 100755 --- a/scripts/poe/tasks/poe +++ b/scripts/poe/tasks/poe @@ -1,13 +1,28 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env python +from __future__ import annotations -if (($# == 0)); then - echo "usage: $0 [args ...]" >&2 - exit 2 -fi +import subprocess +import sys +from collections.abc import Sequence -task="$1" -shift -poe --dry-run "$task" "$@" 2>&1 | sed 's/^Poe => //' -exec poe -q "$task" "$@" +def main(argv: Sequence[str] | None = None) -> int: + args = list(argv if argv is not None else sys.argv[1:]) + if not args: + raise SystemExit(f"usage: {sys.argv[0]} [args ...]") + + preview = subprocess.run( + ("poe", "--dry-run", *args), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + sys.stdout.write(preview.stdout.replace("Poe => ", "", 1)) + if preview.returncode: + return preview.returncode + return subprocess.call(("poe", "-q", *args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/poe/tasks/tool b/scripts/poe/tasks/tool index c95dd708..08acf95a 100755 --- a/scripts/poe/tasks/tool +++ b/scripts/poe/tasks/tool @@ -1,310 +1,185 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env python +from __future__ import annotations -task_name="$(basename "$0")" -script_dir="$(cd "$(dirname "$0")" && pwd -P)" -workspace_root="$(cd "$script_dir/../../.." && pwd -P)" +import os +import shlex +import shutil +import subprocess +import sys +from collections.abc import Iterable, Sequence +from pathlib import Path -workspace_poe_parallel_enabled() { - case "${WORKSPACE_POE_PARALLEL:-}" in - 0|false|no) - return 1 - ;; - *) - return 0 - ;; - esac -} +FALSE_VALUES = {"0", "false", "no"} +TRUE_VALUES = {"1", "true", "yes"} +WORKSPACE_ROOT = Path(__file__).resolve().parents[3] -workspace_poe_pytest_forced() { - case "${FORCE_PYTEST:-}" in - 1|true|yes) - return 0 - ;; - *) - return 1 - ;; - esac -} -workspace_poe_mypy_cache_package() { - if [[ -n "${WORKSPACE_POE_PACKAGE:-}" ]]; then - printf '%s\n' "$WORKSPACE_POE_PACKAGE" - elif [[ "$(pwd -P)" == "$workspace_root" ]]; then - printf 'root\n' - else - basename "$(pwd -P)" - fi -} +def enabled(name: str, *, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.lower() not in FALSE_VALUES -workspace_poe_drop_duplicate_extra_args() { - local start - local index - local matched - if ((${#extra_args[@]} == 0 || ${#scope_args[@]} < ${#extra_args[@]})); then - return - fi +def split_env(name: str) -> list[str]: + value = os.environ.get(name, "") + return shlex.split(value) if value else [] - for ((start = 0; start <= ${#scope_args[@]} - ${#extra_args[@]}; start++)); do - matched=1 - for ((index = 0; index < ${#extra_args[@]}; index++)); do - if [[ "${scope_args[start + index]}" != "${extra_args[index]}" ]]; then - matched=0 - break - fi - done - if ((matched)); then - extra_args=() - return - fi - done -} -case "$task_name" in - pytest) - if [[ "${WORKSPACE_POE_TEST_RUNNER:-}" == ggt ]]; then - command=(ggt) - test_runner=ggt - elif [[ "${WORKSPACE_POE_TEST_RUNNER:-}" == pytest ]]; then - command=(pytest) - test_runner=pytest - elif ! workspace_poe_pytest_forced && command -v ggt >/dev/null 2>&1; then - command=(ggt) - test_runner=ggt - else - command=(pytest) - test_runner=pytest - fi - if [[ "$test_runner" == ggt ]] && - [[ "${WORKSPACE_POE_LOGRAIL_PROGRESS:-}" == 1 ]]; then - command+=(--output-format json) - fi - ;; - ruff-check) - command=(ruff check) - ;; - ruff-check-fix) - command=(ruff check --fix) - ;; - ruff-format) - command=(ruff format --check) - ;; - ruff-format-fix) - command=(ruff format) - ;; - ty) - command=(ty check) - ;; - mypy) - command=(mypy) - ;; - *) - echo "unsupported poe tool wrapper: $task_name" >&2 - exit 2 - ;; -esac +def has_option( + args: Iterable[str], + *, + flags: set[str], + prefixes: tuple[str, ...], +) -> bool: + return any(arg in flags or arg.startswith(prefixes) for arg in args) -extra_args=() -if [[ -n "${POE_EXTRA_ARGS:-}" ]]; then - eval "extra_args=(${POE_EXTRA_ARGS})" -fi -if [[ "${extra_args[0]:-}" == -- ]]; then - extra_args=("${extra_args[@]:1}") -fi -scope_args=("$@") -if [[ "$task_name" == ruff-* && -z "${RUFF_CACHE_DIR:-}" ]]; then - export RUFF_CACHE_DIR="$workspace_root/.ruff_cache" -fi -if [[ "$task_name" == mypy ]]; then - cache_dir="" - has_config_file=0 - has_cache_dir=0 - option_value=0 - if ((${#scope_args[@]})); then - for arg in "${scope_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - --config-file) - has_config_file=1 - option_value=1 - ;; - --config-file=*) - has_config_file=1 - ;; - --cache-dir) - has_cache_dir=1 - option_value=1 - ;; - --cache-dir=*) - has_cache_dir=1 - ;; - esac - done - fi - if ((${#extra_args[@]})); then - for arg in "${extra_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - --config-file) - has_config_file=1 - option_value=1 - ;; - --config-file=*) - has_config_file=1 - ;; - --cache-dir) - has_cache_dir=1 - option_value=1 - ;; - --cache-dir=*) - has_cache_dir=1 - ;; - esac - done - fi - if ((has_config_file == 0)); then - if ((${#scope_args[@]})); then - scope_args=(--config-file "$workspace_root/pyproject.toml" "${scope_args[@]}") - else - scope_args=(--config-file "$workspace_root/pyproject.toml") - fi - fi - if ((has_cache_dir == 0)); then - cache_dir="$workspace_root/.mypy_cache/$(workspace_poe_mypy_cache_package)" - if ((${#scope_args[@]})); then - scope_args=(--cache-dir "$cache_dir" "${scope_args[@]}") - else - scope_args=(--cache-dir "$cache_dir") - fi - fi -fi -if ((${#scope_args[@]} == 0)) && [[ -n "${WORKSPACE_POE_SCOPE_ARGS:-}" ]]; then - eval "scope_args=(${WORKSPACE_POE_SCOPE_ARGS})" -fi -if [[ "$task_name" == mypy ]]; then - has_target=0 - option_value=0 - if ((${#scope_args[@]})); then - for arg in "${scope_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - --cache-dir|--config-file|--python-version) - option_value=1 - ;; - --cache-dir=*|--config-file=*|--python-version=*) - ;; - --*) - ;; - *) - has_target=1 - ;; - esac - done - fi - if ((has_target == 0)); then - if [[ -n "${WORKSPACE_POE_SCOPE_ARGS:-}" ]]; then - eval "scope_args+=( ${WORKSPACE_POE_SCOPE_ARGS} )" - else - scope_args+=(.) - fi - fi -fi -if [[ "$task_name" == pytest ]]; then - has_workers=0 - has_verbosity=0 - option_value=0 - if ((${#scope_args[@]})); then - for arg in "${scope_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - -n|--numprocesses|-j|--jobs) - has_workers=1 - option_value=1 - ;; - -n*|--numprocesses=*|-j*|--jobs=*) - has_workers=1 - ;; - -q|--quiet|-v|--verbose) - has_verbosity=1 - ;; - -q*|-v*) - has_verbosity=1 - ;; - esac - done - fi - if ((${#extra_args[@]})); then - for arg in "${extra_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - -n|--numprocesses|-j|--jobs) - has_workers=1 - option_value=1 - ;; - -n*|--numprocesses=*|-j*|--jobs=*) - has_workers=1 - ;; - -q|--quiet|-v|--verbose) - has_verbosity=1 - ;; - -q*|-v*) - has_verbosity=1 - ;; - esac - done - fi - if ((has_verbosity == 0)) && - [[ "${WORKSPACE_POE_LOGRAIL_PROGRESS:-}" == 1 ]] && - [[ "$test_runner" == pytest ]]; then - if ((${#scope_args[@]})); then - scope_args=(-v "${scope_args[@]}") - else - scope_args=(-v) - fi - fi - if ((has_workers == 0)); then - if [[ "$test_runner" == pytest ]] && workspace_poe_parallel_enabled; then - scope_args=(-n auto "${scope_args[@]}") - elif [[ "$test_runner" == ggt ]] && ! workspace_poe_parallel_enabled; then - scope_args=(-j 1 "${scope_args[@]}") - fi - fi -fi -if ((${#scope_args[@]} == 0)); then - if [[ "$task_name" == ruff-* && "$(pwd -P)" == "$workspace_root" ]]; then - scope_args=(tests examples) - else - scope_args=(.) - fi -fi +def has_mypy_target(args: Sequence[str]) -> bool: + takes_value = False + for arg in args: + if takes_value: + takes_value = False + continue + if arg in {"--cache-dir", "--config-file", "--python-version"}: + takes_value = True + elif arg.startswith(("--cache-dir=", "--config-file=", "--python-version=")): + continue + elif not arg.startswith("--"): + return True + return False -workspace_poe_drop_duplicate_extra_args -run_args=("${command[@]}") -if ((${#scope_args[@]})); then - run_args+=("${scope_args[@]}") -fi -if ((${#extra_args[@]})); then - run_args+=("${extra_args[@]}") -fi +def drop_duplicate_args(scope_args: list[str], extra_args: list[str]) -> list[str]: + size = len(extra_args) + if not size or len(scope_args) < size: + return extra_args + if any( + scope_args[index : index + size] == extra_args + for index in range(len(scope_args) - size + 1) + ): + return [] + return extra_args -printf '%q ' "${run_args[@]}" -printf '\n' -exec "${run_args[@]}" + +def pytest_command() -> tuple[list[str], str]: + selected = os.environ.get("WORKSPACE_POE_TEST_RUNNER") + force_pytest = os.environ.get("FORCE_PYTEST", "").lower() in TRUE_VALUES + if selected == "ggt" or (selected != "pytest" and not force_pytest and shutil.which("ggt")): + command = ["ggt"] + runner = "ggt" + else: + command = ["pytest"] + runner = "pytest" + if runner == "ggt" and os.environ.get("WORKSPACE_POE_LOGRAIL_PROGRESS") == "1": + command.extend(("--output-format", "json")) + return command, runner + + +def build_command(task_name: str, argv: Sequence[str]) -> list[str]: + test_runner: str | None = None + if task_name == "pytest": + command, test_runner = pytest_command() + else: + commands = { + "ruff-check": ["ruff", "check"], + "ruff-check-fix": ["ruff", "check", "--fix"], + "ruff-format": ["ruff", "format", "--check"], + "ruff-format-fix": ["ruff", "format"], + "ty": ["ty", "check"], + "mypy": ["mypy"], + } + try: + command = commands[task_name] + except KeyError: + raise SystemExit(f"unsupported poe tool wrapper: {task_name}") from None + + extra_args = split_env("POE_EXTRA_ARGS") + if extra_args[:1] == ["--"]: + extra_args.pop(0) + scope_args = list(argv) + + if task_name.startswith("ruff-"): + os.environ.setdefault("RUFF_CACHE_DIR", str(WORKSPACE_ROOT / ".ruff_cache")) + + if task_name == "mypy": + all_args = [*scope_args, *extra_args] + if not has_option( + all_args, + flags={"--config-file"}, + prefixes=("--config-file=",), + ): + scope_args[:0] = [ + "--config-file", + str(WORKSPACE_ROOT / "pyproject.toml"), + ] + if not has_option( + all_args, + flags={"--cache-dir"}, + prefixes=("--cache-dir=",), + ): + package = os.environ.get("WORKSPACE_POE_PACKAGE") + if not package: + package = "root" if Path.cwd().resolve() == WORKSPACE_ROOT else Path.cwd().name + scope_args[:0] = [ + "--cache-dir", + str(WORKSPACE_ROOT / ".mypy_cache" / package), + ] + + if not scope_args: + scope_args = split_env("WORKSPACE_POE_SCOPE_ARGS") + + if task_name == "mypy" and not has_mypy_target(scope_args): + scope_args.extend(split_env("WORKSPACE_POE_SCOPE_ARGS") or ["."]) + + if task_name == "pytest": + all_args = [*scope_args, *extra_args] + has_workers = has_option( + all_args, + flags={"-n", "--numprocesses", "-j", "--jobs"}, + prefixes=("-n", "--numprocesses=", "-j", "--jobs="), + ) + has_verbosity = has_option( + all_args, + flags={"-q", "--quiet", "-v", "--verbose"}, + prefixes=("-q", "-v"), + ) + parallel = enabled("WORKSPACE_POE_PARALLEL", default=True) + if ( + not has_verbosity + and os.environ.get("WORKSPACE_POE_LOGRAIL_PROGRESS") == "1" + and test_runner == "pytest" + ): + scope_args.insert(0, "-v") + if not has_workers: + if test_runner == "pytest" and parallel: + scope_args[:0] = ["-n", "auto"] + elif test_runner == "ggt" and not parallel: + scope_args[:0] = ["-j", "1"] + + if not scope_args: + if task_name.startswith("ruff-") and Path.cwd().resolve() == WORKSPACE_ROOT: + scope_args = ["tests", "examples"] + else: + scope_args = ["."] + + extra_args = drop_duplicate_args(scope_args, extra_args) + return [*command, *scope_args, *extra_args] + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(argv if argv is not None else sys.argv[1:]) + invoked_name = Path(sys.argv[0]).name + if invoked_name == "tool": + if not args: + raise SystemExit("missing poe tool name") + task_name = args.pop(0) + else: + task_name = invoked_name + command = build_command(task_name, args) + print(shlex.join(command), flush=True) + return subprocess.call(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/poe/workspace_poe.py b/scripts/poe/workspace_poe.py index 731e27ac..af5e6f3e 100755 --- a/scripts/poe/workspace_poe.py +++ b/scripts/poe/workspace_poe.py @@ -8,7 +8,7 @@ import subprocess import sys import tempfile -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path from typing import Any @@ -18,6 +18,7 @@ PARALLEL_FALSE = {"0", "false", "no"} FAILURE_TAIL_LINES = 20 QUIET_FAILURE_DETAIL = "workspace_poe.failure.detail" +TEST_FAILURE_DETAILS = "workspace_poe.test.failure_details" ROOT_TASKS = { "fix": "fix-root", "lint": "lint-root", @@ -402,28 +403,28 @@ def run_root_task(task: str, argv: Sequence[str]) -> int: else: args.append("tests/unit") args.extend(extra_args) - return run_command( - env_command( - "pytest", - shlex.join((os.environ["PYTEST"], *args)), - category="test", - parser="pytest", - ) - ) + return run_command(root_pytest_command(args, category="test")) if task == "test-examples-root": extra_args = list(argv) or poe_extra_args() args = ["tests/test_examples.py", *extra_args] - return run_command( - env_command( - "pytest", - shlex.join((os.environ["PYTEST"], *args)), - category="test-examples", - parser="pytest", - ) - ) + return run_command(root_pytest_command(args, category="test-examples")) raise SystemExit(f"unsupported root task: {task}") +def root_pytest_command(args: Sequence[str], *, category: str) -> CommandSpec: + subject = os.environ.get("WORKSPACE_POE_PACKAGE") or Path.cwd().name + return CommandSpec( + label=lograil_name("pytest", subject), + argv=(sys.executable, str(SCRIPT_DIR / "tasks" / "tool"), "pytest", *args), + cwd=Path.cwd(), + env=os.environ.copy(), + display_label="pytest", + category=category, + subject=subject, + parser="pytest", + ) + + def env_command( label: str, command: str, @@ -470,7 +471,7 @@ def run_group( for command in commands: remaps = list(DEFAULT_REMAPS) if command.category in PYTEST_TASKS and command.parser == "generic": - remaps.append(_preserve_test_scope_identity) + remaps.append(_ggt_entry_remap()) if command.suppress_output: remaps.append(_quiet_entry) specs.append( @@ -518,7 +519,13 @@ def print_failure_summary(processes: Sequence[Any]) -> None: def failure_tail_lines(entries: Sequence[dict[str, Any]]) -> list[str]: lines: list[str] = [] + retained: list[str] = [] for entry in entries: + details = entry.get(TEST_FAILURE_DETAILS) + if isinstance(details, (list, tuple)): + for detail in details: + if isinstance(detail, str) and detail and detail not in retained: + retained.append(detail) message = entry.get("message") or entry.get(QUIET_FAILURE_DETAIL) if not isinstance(message, str): continue @@ -528,7 +535,10 @@ def failure_tail_lines(entries: Sequence[dict[str, Any]]) -> list[str]: if is_low_signal_failure_tail_line(message): continue lines.append(message) - return lines[-FAILURE_TAIL_LINES:] + retained = retained[-FAILURE_TAIL_LINES:] + lines = [line for line in lines if line not in retained] + available = FAILURE_TAIL_LINES - len(retained) + return [*retained, *(lines[-available:] if available else [])] def is_low_signal_failure_tail_line(message: str) -> bool: @@ -539,6 +549,8 @@ def is_low_signal_failure_tail_line(message: str) -> bool: return True if stripped.startswith("tests/") and "::" in stripped and "PASSED" in stripped: return True + if stripped.startswith("PASSED "): + return True return False @@ -552,11 +564,32 @@ def _quiet_entry(entry: dict[str, Any]) -> dict[str, Any]: return entry -def _preserve_test_scope_identity(entry: dict[str, Any]) -> dict[str, Any]: - """Keep the package label while accepting native ggt progress detail.""" - entry.pop("lograil.progress.process", None) - entry.pop("lograil.progress.subject", None) - return entry +def _ggt_entry_remap() -> Callable[[dict[str, Any]], dict[str, Any]]: + """Keep package identity and retain failures past lograil's bounded tail.""" + failures: list[str] = [] + + def remap(entry: dict[str, Any]) -> dict[str, Any]: + entry.pop("lograil.progress.process", None) + entry.pop("lograil.progress.subject", None) + if entry.get("levelname") in {"ERROR", "CRITICAL"}: + message = entry.get("message") + detail = entry.get("ggt.detail") + detail_messages = ( + tuple( + detail.get(field) + for field in ("error_message", "server_traceback", "stdout", "stderr") + ) + if isinstance(detail, dict) + else () + ) + for failure in (message, *detail_messages): + if isinstance(failure, str) and failure and failure not in failures: + failures.append(failure) + if failures: + entry[TEST_FAILURE_DETAILS] = tuple(failures[-FAILURE_TAIL_LINES:]) + return entry + + return remap def run_sequential( diff --git a/scripts/release.py b/scripts/release.py index 43aad53e..475a1d46 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -431,7 +431,14 @@ def _release_pr_numbers(releases: list[Release]) -> dict[Path, int]: def _fragment_pr_number(path: Path) -> int | None: try: log = subprocess.check_output( - ["git", "log", "--full-history", "--format=%s", "--", str(path.relative_to(ROOT))], + [ + "git", + "log", + "--full-history", + "--format=%s", + "--", + path.relative_to(ROOT).as_posix(), + ], cwd=ROOT, text=True, ) diff --git a/tests/unit/test_clogapp.py b/tests/unit/test_clogapp.py index b37d4c64..a354d8cb 100644 --- a/tests/unit/test_clogapp.py +++ b/tests/unit/test_clogapp.py @@ -370,7 +370,10 @@ async def test_clogedit_escape_returns_to_previous_step() -> None: assert pilot.app.return_value == [] -async def test_clogedit_single_escape_on_package_step_arms_exit() -> None: +async def test_clogedit_single_escape_on_package_step_arms_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 30) selection = clogedit.ChangelogSelection( { "pkg": clogedit.PackageNewsState( @@ -397,7 +400,10 @@ async def test_clogedit_single_escape_on_package_step_arms_exit() -> None: assert pilot.app.return_value == [] -async def test_clogedit_double_escape_on_package_step_exits() -> None: +async def test_clogedit_double_escape_on_package_step_exits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 30) selection = clogedit.ChangelogSelection( { "pkg": clogedit.PackageNewsState( @@ -415,7 +421,8 @@ async def test_clogedit_double_escape_on_package_step_exits() -> None: ) async with app.run_test() as pilot: - await pilot.press("escape", "escape") + await pilot.press("escape") + await pilot.press("escape") assert pilot.app.return_value == [] @@ -443,7 +450,7 @@ async def test_clogedit_escape_exit_timeout_resets(monkeypatch: pytest.MonkeyPat await pilot.pause(0.03) assert app.escape_exit_timer is None assert app.status == clogedit.PACKAGE_STATUS - monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 0.5) + monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 30) await pilot.press("escape") assert app.escape_exit_timer is not None await pilot.press("ctrl+d") diff --git a/tests/unit/test_poe_tool.py b/tests/unit/test_poe_tool.py index 8a8253c7..78b36b76 100644 --- a/tests/unit/test_poe_tool.py +++ b/tests/unit/test_poe_tool.py @@ -1,105 +1,90 @@ from __future__ import annotations -import os -import subprocess +import importlib.machinery +import importlib.util +import sys from pathlib import Path -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from collections.abc import Sequence +TOOL = Path(__file__).resolve().parents[2] / "scripts" / "poe" / "tasks" / "tool" +LOADER = importlib.machinery.SourceFileLoader("poe_tool", str(TOOL)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC is not None +poe_tool = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = poe_tool +LOADER.exec_module(poe_tool) -TOOL = Path(__file__).resolve().parents[2] / "scripts" / "poe" / "tasks" / "pytest" - - -def _runner(path: Path, name: str) -> None: - executable = path / name - executable.write_text('#!/bin/sh\nprintf \'%s\\n\' "$0" "$@"\n') - executable.chmod(0o755) - - -def _run( - tmp_path: Path, - args: Sequence[str], +def _command( + monkeypatch, + args: tuple[str, ...], *, + ggt_available: bool = True, force_pytest: bool = False, lograil_progress: bool = False, parallel: bool = True, ) -> list[str]: - env = os.environ.copy() - env["PATH"] = os.pathsep.join((str(tmp_path), "/usr/bin", "/bin")) - env.pop("POE_EXTRA_ARGS", None) - env.pop("WORKSPACE_POE_TEST_RUNNER", None) + monkeypatch.delenv("POE_EXTRA_ARGS", raising=False) + monkeypatch.delenv("WORKSPACE_POE_TEST_RUNNER", raising=False) + monkeypatch.delenv("WORKSPACE_POE_SCOPE_ARGS", raising=False) + monkeypatch.setattr( + poe_tool.shutil, + "which", + lambda command: f"/bin/{command}" if command == "ggt" and ggt_available else None, + ) if lograil_progress: - env["WORKSPACE_POE_LOGRAIL_PROGRESS"] = "1" + monkeypatch.setenv("WORKSPACE_POE_LOGRAIL_PROGRESS", "1") else: - env.pop("WORKSPACE_POE_LOGRAIL_PROGRESS", None) - env.pop("WORKSPACE_POE_SCOPE_ARGS", None) + monkeypatch.delenv("WORKSPACE_POE_LOGRAIL_PROGRESS", raising=False) if force_pytest: - env["FORCE_PYTEST"] = "1" + monkeypatch.setenv("FORCE_PYTEST", "1") else: - env.pop("FORCE_PYTEST", None) + monkeypatch.delenv("FORCE_PYTEST", raising=False) if parallel: - env.pop("WORKSPACE_POE_PARALLEL", None) + monkeypatch.delenv("WORKSPACE_POE_PARALLEL", raising=False) else: - env["WORKSPACE_POE_PARALLEL"] = "0" - result = subprocess.run( - (TOOL, *args), - check=True, - capture_output=True, - cwd=tmp_path, - env=env, - text=True, - ) - return result.stdout.splitlines() + monkeypatch.setenv("WORKSPACE_POE_PARALLEL", "0") + return poe_tool.build_command("pytest", args) -def test_pytest_wrapper_prefers_ggt(tmp_path: Path) -> None: - _runner(tmp_path, "ggt") - _runner(tmp_path, "pytest") - - output = _run(tmp_path, ("tests",)) - - assert output[-2:] == [str(tmp_path / "ggt"), "tests"] +def test_pytest_wrapper_prefers_ggt(monkeypatch) -> None: + assert _command(monkeypatch, ("tests",)) == ["ggt", "tests"] def test_pytest_wrapper_runs_ggt_sequentially_when_parallel_is_disabled( - tmp_path: Path, + monkeypatch, ) -> None: - _runner(tmp_path, "ggt") - - output = _run(tmp_path, ("tests",), parallel=False) - - assert output[-4:] == [str(tmp_path / "ggt"), "-j", "1", "tests"] + assert _command(monkeypatch, ("tests",), parallel=False) == [ + "ggt", + "-j", + "1", + "tests", + ] def test_pytest_wrapper_uses_structured_ggt_output_for_lograil( - tmp_path: Path, + monkeypatch, ) -> None: - _runner(tmp_path, "ggt") - - output = _run(tmp_path, ("tests",), lograil_progress=True) - - assert output[-4:] == [ - str(tmp_path / "ggt"), + assert _command(monkeypatch, ("tests",), lograil_progress=True) == [ + "ggt", "--output-format", "json", "tests", ] -def test_pytest_wrapper_falls_back_to_parallel_pytest(tmp_path: Path) -> None: - _runner(tmp_path, "pytest") - - output = _run(tmp_path, ("tests",)) - - assert output[-4:] == [str(tmp_path / "pytest"), "-n", "auto", "tests"] - - -def test_pytest_wrapper_can_force_pytest(tmp_path: Path) -> None: - _runner(tmp_path, "ggt") - _runner(tmp_path, "pytest") +def test_pytest_wrapper_falls_back_to_parallel_pytest(monkeypatch) -> None: + assert _command(monkeypatch, ("tests",), ggt_available=False) == [ + "pytest", + "-n", + "auto", + "tests", + ] - output = _run(tmp_path, ("tests",), force_pytest=True) - assert output[-4:] == [str(tmp_path / "pytest"), "-n", "auto", "tests"] +def test_pytest_wrapper_can_force_pytest(monkeypatch) -> None: + assert _command(monkeypatch, ("tests",), force_pytest=True) == [ + "pytest", + "-n", + "auto", + "tests", + ] diff --git a/tests/unit/test_workspace_poe.py b/tests/unit/test_workspace_poe.py index 62eec506..9e15e97b 100644 --- a/tests/unit/test_workspace_poe.py +++ b/tests/unit/test_workspace_poe.py @@ -105,6 +105,7 @@ def test_scope_command_lograil_name_includes_task_and_package() -> None: def test_example_scope_command_maps_root_to_internal_task(monkeypatch) -> None: + monkeypatch.delenv("FORCE_PYTEST", raising=False) monkeypatch.setattr( workspace_poe.shutil, "which", @@ -131,6 +132,7 @@ def test_example_scope_command_maps_root_to_internal_task(monkeypatch) -> None: def test_example_scope_command_preserves_package_passthrough(monkeypatch) -> None: + monkeypatch.delenv("FORCE_PYTEST", raising=False) monkeypatch.setattr( workspace_poe.shutil, "which", @@ -152,6 +154,28 @@ def test_example_scope_command_preserves_package_passthrough(monkeypatch) -> Non assert command.parser == "generic" +def test_root_test_builds_native_pytest_wrapper_argv(monkeypatch) -> None: + recorded = [] + monkeypatch.setenv("PYTEST", "python scripts/poe/tasks/tool pytest") + monkeypatch.delenv("POE_EXTRA_ARGS", raising=False) + monkeypatch.delenv("WORKSPACE_POE_SCOPE_ARGS", raising=False) + + def run_command(command): + recorded.append(command) + return 0 + + monkeypatch.setattr(workspace_poe, "run_command", run_command) + + assert workspace_poe.run_root_task("test-root", ()) == 0 + + assert recorded[0].argv[:4] == ( + sys.executable, + str(workspace_poe.SCRIPT_DIR / "tasks" / "tool"), + "pytest", + "tests/unit", + ) + + def test_test_scope_uses_pytest_parser_for_pytest_fallback(monkeypatch) -> None: monkeypatch.delenv("FORCE_PYTEST", raising=False) monkeypatch.setattr(workspace_poe.shutil, "which", lambda *args, **kwargs: None) @@ -242,6 +266,48 @@ def fake_run_process_group(specs): assert mapped["lograil.progress.description"] == "tests/test_api.py::test_get" +def test_ggt_remap_retains_failure_after_later_passes() -> None: + remap = workspace_poe._ggt_entry_remap() + entries = [ + remap( + { + "levelname": "ERROR", + "message": "ERROR test_verify.test_refresh: RuntimeError: refresh failed", + } + ) + ] + entries.extend( + remap({"levelname": "INFO", "message": f"PASSED test_verify.test_{index}"}) + for index in range(60) + ) + + lines = workspace_poe.failure_tail_lines(entries[-50:]) + + assert lines == ["ERROR test_verify.test_refresh: RuntimeError: refresh failed"] + assert workspace_poe.failure_tail_lines(entries) == lines + + +def test_ggt_remap_retains_teardown_traceback_without_a_message() -> None: + remap = workspace_poe._ggt_entry_remap() + entries = [ + remap( + { + "levelname": "ERROR", + "ggt.detail": { + "kind": "error", + "error_message": "fixture teardown failed\nRuntimeError: cleanup failed", + }, + } + ), + remap({"levelname": "INFO", "message": "FAILURE: 85 tests, 1 errors"}), + ] + + assert workspace_poe.failure_tail_lines(entries) == [ + "fixture teardown failed\nRuntimeError: cleanup failed", + "FAILURE: 85 tests, 1 errors", + ] + + def test_run_sequential_can_stop_after_first_failure(monkeypatch) -> None: commands = [ workspace_poe.CommandSpec("first", ("first",), Path.cwd(), {}), diff --git a/uv.lock b/uv.lock index 3e0dc781..0ec45c3f 100644 --- a/uv.lock +++ b/uv.lock @@ -40,7 +40,7 @@ dev = [ { name = "cryptography", specifier = ">=48.0.1" }, { name = "django-stubs", specifier = ">=6.0.6,<7" }, { name = "fastapi", specifier = ">=0.115.0,<1" }, - { name = "ggt", marker = "python_full_version >= '3.11'", specifier = ">=1.5.5" }, + { name = "ggt", marker = "python_full_version >= '3.11'", specifier = ">=1.5.6" }, { name = "hatchling", specifier = ">=1.27.0,<2" }, { name = "hypothesis", specifier = ">=6.0.0,<7" }, { name = "lograil", specifier = "~=0.7.0" }, @@ -60,7 +60,7 @@ dev = [ { name = "twine", specifier = "<7" }, { name = "ty", specifier = "==0.0.55" }, { name = "uvicorn", specifier = ">=0.30.0,<1" }, - { name = "uvloop", specifier = "<1" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "<1" }, { name = "vendoring", specifier = ">=1,<2" }, { name = "zizmor", specifier = ">=1.24.1,<2" }, ] @@ -729,14 +729,14 @@ wheels = [ [[package]] name = "ggt" -version = "1.5.5" +version = "1.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/ca/4fe1070242edd6f1981ca53ebe6c0175f32eaa72e046462c0cc84ce1cfb4/ggt-1.5.5.tar.gz", hash = "sha256:20a671444ee1b26aadb623ab951ac1ea4f17023b333dff7cbb4feed75227a903", size = 199433, upload-time = "2026-08-26T16:46:34.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/40/f4943f0de73a99c98595f23b0127ae56acb75d076671af171946d474d5be/ggt-1.5.6.tar.gz", hash = "sha256:7a43ed482b1b1741bbdeb798e8379b0d0cda189889ed354220363df0531bb475", size = 199752, upload-time = "2026-08-26T19:22:40.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/2f/15d2f9bea4df60080ff19bf7ae7c16d4e82c5cbe8338f46d829a260e5632/ggt-1.5.5-py3-none-any.whl", hash = "sha256:0b0d9beec4290f382ac210c2479bb6782c66082d40ff23d17582e9e1f57f7ddc", size = 124216, upload-time = "2026-08-26T16:46:33.353Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/12027320cf0a6f7017f87b6a4c02c3532bf51b03d8e281f7f64aa20009e5/ggt-1.5.6-py3-none-any.whl", hash = "sha256:a39a01ac509438573cbdff3a13528d059183d7b485e9ccd25e04f2a979690ff4", size = 124283, upload-time = "2026-08-26T19:22:38.997Z" }, ] [[package]] From 0c889664aaf1d5cccee174606c02b7eabf13beb0 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Thu, 27 Aug 2026 09:48:58 -0700 Subject: [PATCH 2/4] sandbox: Make process tests portable Treat the remote sandbox as Linux regardless of the SDK host. Define the supported process-time fields and signals from that Linux contract so Windows clients do not hide valid sandbox capabilities. Add ProcessSignal while retaining Signals for compatibility, and mark direct Signals use as deprecated. Synchronize the resume race test by waiting until its second acquisition is actually blocked. --- .../windows-process-kill.bugfix.md | 1 + .../tests/test_sandbox_process.py | 16 +++++++- .../tests/test_sandbox_public_flow.py | 15 ++++++- src/vercel-sandbox/vercel/sandbox/__init__.py | 2 + .../vercel/sandbox/_internal/async_runtime.py | 14 +++++-- .../vercel/sandbox/_internal/models.py | 40 +++++++++++++++++++ .../sandbox/_internal/runtime_common.py | 24 ++++++----- .../vercel/sandbox/_internal/sync_runtime.py | 14 +++++-- src/vercel-sandbox/vercel/sandbox/sync.py | 2 + 9 files changed, 105 insertions(+), 23 deletions(-) create mode 100644 changes/vercel-sandbox/windows-process-kill.bugfix.md diff --git a/changes/vercel-sandbox/windows-process-kill.bugfix.md b/changes/vercel-sandbox/windows-process-kill.bugfix.md new file mode 100644 index 00000000..dc7ba8a3 --- /dev/null +++ b/changes/vercel-sandbox/windows-process-kill.bugfix.md @@ -0,0 +1 @@ +Expose Linux process signals consistently on every SDK host platform. diff --git a/src/vercel-sandbox/tests/test_sandbox_process.py b/src/vercel-sandbox/tests/test_sandbox_process.py index 9613aac5..f9ef5eac 100644 --- a/src/vercel-sandbox/tests/test_sandbox_process.py +++ b/src/vercel-sandbox/tests/test_sandbox_process.py @@ -166,6 +166,7 @@ def test_public_process_exports() -> None: for name in ( "CompletedProcess", "Process", + "ProcessSignal", "ProcessStatus", "SandboxCredentials", "SandboxCredentialsFactory", @@ -174,6 +175,7 @@ def test_public_process_exports() -> None: assert name in sandbox.__all__ for name in ( "CompletedProcess", + "ProcessSignal", "ProcessStatus", "SandboxCredentials", "SyncProcess", @@ -233,12 +235,22 @@ def signal_handler(request: httpx.Request) -> httpx.Response: await process.terminate() await process.kill() await process.send_signal(signal.SIGINT) + await process.send_signal(sandbox.ProcessSignal.SIGUSR1) + await process.send_signal("USR1") + with pytest.raises(ValueError, match="Unknown signal"): + await process.send_signal(32) assert get_process.calls[0].request.url.params["wait"] == "false" assert get_process.calls[1].request.url.params["wait"] == "true" assert logs.call_count == 1 assert all(call.request.headers["connection"] == "close" for call in logs.calls) - assert signals == [signal.SIGTERM, signal.SIGKILL, signal.SIGINT] + assert signals == [ + sandbox.ProcessSignal.SIGTERM, + sandbox.ProcessSignal.SIGKILL, + sandbox.ProcessSignal.SIGINT, + sandbox.ProcessSignal.SIGUSR1, + sandbox.ProcessSignal.SIGUSR1, + ] @respx.mock @@ -273,7 +285,7 @@ def signal_handler(request: httpx.Request) -> httpx.Response: process.terminate() process.kill() - assert signals == [signal.SIGTERM, signal.SIGKILL] + assert signals == [sandbox.ProcessSignal.SIGTERM, sandbox.ProcessSignal.SIGKILL] @respx.mock diff --git a/src/vercel-sandbox/tests/test_sandbox_public_flow.py b/src/vercel-sandbox/tests/test_sandbox_public_flow.py index 4d62b3eb..87c4e9eb 100644 --- a/src/vercel-sandbox/tests/test_sandbox_public_flow.py +++ b/src/vercel-sandbox/tests/test_sandbox_public_flow.py @@ -5,7 +5,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import timedelta from itertools import islice -from threading import Event +from threading import Condition, Event from typing import Any import anyio @@ -3516,9 +3516,18 @@ async def managed() -> None: @respx.mock -def test_sync_session_acquisition_shares_implicit_resume(mock_env_clear: None) -> None: +def test_sync_session_acquisition_shares_implicit_resume( + mock_env_clear: None, +) -> None: resume_started = Event() + acquire_joined = Event() release_resume = Event() + + class ObservedCondition(Condition): + def wait(self, timeout: float | None = None) -> bool: + acquire_joined.set() + return super().wait(timeout) + respx.get("https://sandbox.test/v2/sandboxes/preview", params={"resume": "false"}).mock( return_value=httpx.Response(200, json=_sandbox_response(session_id="sbx_old")) ) @@ -3543,10 +3552,12 @@ def resume_handler(_request: httpx.Request) -> httpx.Response: with session(service_options=_session_options()): box = sandbox_sync.get_sandbox(name="preview") + box._recovery_condition = ObservedCondition() with ThreadPoolExecutor(max_workers=2) as executor: implicit = executor.submit(box.query_processes) assert resume_started.wait(timeout=5) explicit = executor.submit(box.session) + assert acquire_joined.wait(timeout=5) release_resume.set() assert implicit.result(timeout=5) == [] acquired = explicit.result(timeout=5) diff --git a/src/vercel-sandbox/vercel/sandbox/__init__.py b/src/vercel-sandbox/vercel/sandbox/__init__.py index 52b1f325..14a99447 100644 --- a/src/vercel-sandbox/vercel/sandbox/__init__.py +++ b/src/vercel-sandbox/vercel/sandbox/__init__.py @@ -60,6 +60,7 @@ NetworkPolicyRule, NetworkPolicySubnets, NetworkPolicyTransform, + ProcessSignal, ProcessStatus, SandboxQuery, SandboxQueryByCreatedAt, @@ -499,6 +500,7 @@ async def get_snapshot(*, snapshot_id: str) -> Snapshot: "SandboxApiError", "SandboxCleanupError", "ProcessStatus", + "ProcessSignal", "Process", "CompletedProcess", "SandboxCredentials", diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py index 6adcaae9..33ca60d4 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py @@ -45,6 +45,7 @@ FailoverRegionsInput, NetworkPolicy, ProcessLog, + ProcessSignal, SandboxQuery, SandboxResources, SandboxSource, @@ -182,12 +183,17 @@ async def communicate(self, input: None = None) -> tuple[str | None, str | None] await self.wait() return stdout, stderr - async def send_signal(self, signal: int | str | signal_module.Signals) -> None: + async def send_signal(self, signal: int | str | signal_module.Signals | ProcessSignal) -> None: """Send a signal to the running process. Args: - signal: Numeric signal, ``Signals`` member, or name such as + signal: Numeric signal, ``ProcessSignal`` member, or name such as ``"TERM"`` or ``"SIGTERM"``. + + Note: + Passing ``signal.Signals`` directly is deprecated. Use + ``ProcessSignal`` so signal availability does not depend on the + SDK host platform. """ payload = await self._service.send_process_signal( session_id=self._session_id, @@ -198,11 +204,11 @@ async def send_signal(self, signal: int | str | signal_module.Signals) -> None: async def terminate(self) -> None: """Request graceful process termination with ``SIGTERM``.""" - await self.send_signal(signal_module.SIGTERM) + await self.send_signal(ProcessSignal.SIGTERM) async def kill(self) -> None: """Terminate the process immediately with ``SIGKILL``.""" - await self.send_signal(signal_module.SIGKILL) + await self.send_signal(ProcessSignal.SIGKILL) class Snapshot(SnapshotHandleBase): diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/models.py b/src/vercel-sandbox/vercel/sandbox/_internal/models.py index f7cd9b0a..1a31c4a8 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/models.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/models.py @@ -3,6 +3,7 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass from datetime import timedelta +from enum import IntEnum from subprocess import CalledProcessError from types import MappingProxyType from typing import Any, Literal, TypeAlias, cast @@ -579,6 +580,45 @@ class ProcessStatus(StrEnum): EXITED = "exited" +class ProcessSignal(IntEnum): + """Signals supported by remote Linux sandbox processes.""" + + SIGHUP = 1 + SIGINT = 2 + SIGQUIT = 3 + SIGILL = 4 + SIGTRAP = 5 + SIGABRT = 6 + SIGIOT = 6 + SIGBUS = 7 + SIGFPE = 8 + SIGKILL = 9 + SIGUSR1 = 10 + SIGSEGV = 11 + SIGUSR2 = 12 + SIGPIPE = 13 + SIGALRM = 14 + SIGTERM = 15 + SIGSTKFLT = 16 + SIGCHLD = 17 + SIGCLD = 17 + SIGCONT = 18 + SIGSTOP = 19 + SIGTSTP = 20 + SIGTTIN = 21 + SIGTTOU = 22 + SIGURG = 23 + SIGXCPU = 24 + SIGXFSZ = 25 + SIGVTALRM = 26 + SIGPROF = 27 + SIGWINCH = 28 + SIGIO = 29 + SIGPOLL = 29 + SIGPWR = 30 + SIGSYS = 31 + + @dataclass(frozen=True, slots=True) class CompletedProcess: """The captured result of one completed remote process.""" diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py index 6c5fc3df..b55079a6 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py @@ -16,6 +16,7 @@ from vercel.sandbox._internal.models import ( JSONObject, NetworkPolicy, + ProcessSignal, ProcessStatus, SandboxStatus, _WriteFile, @@ -173,19 +174,20 @@ def _validate_file_mode(mode: object) -> int | None: return mode -def _signal_number(value: int | str | signal_module.Signals | None) -> int: +def _signal_number(value: int | str | signal_module.Signals | ProcessSignal | None) -> int: if value is None: - return int(signal_module.Signals.SIGTERM) - if isinstance(value, signal_module.Signals): - return int(value) - if isinstance(value, int): - return value - normalized = value.upper() - if not normalized.startswith("SIG"): - normalized = f"SIG{normalized}" + return int(ProcessSignal.SIGTERM) + if isinstance(value, str): + normalized = value.upper() + if not normalized.startswith("SIG"): + normalized = f"SIG{normalized}" + try: + return int(ProcessSignal[normalized]) + except KeyError as exc: + raise ValueError(f"Unknown signal: {value!r}") from exc try: - return int(signal_module.Signals[normalized]) - except KeyError as exc: + return int(ProcessSignal(value)) + except (TypeError, ValueError) as exc: raise ValueError(f"Unknown signal: {value!r}") from exc diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py index e4653e4f..0e3602c0 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py @@ -39,6 +39,7 @@ FailoverRegionsInput, NetworkPolicy, ProcessLog, + ProcessSignal, SandboxQuery, SandboxResources, SandboxSource, @@ -184,12 +185,17 @@ def communicate(self, input: None = None) -> tuple[str | None, str | None]: self.wait() return stdout, stderr - def send_signal(self, signal: int | str | signal_module.Signals) -> None: + def send_signal(self, signal: int | str | signal_module.Signals | ProcessSignal) -> None: """Send a signal to the running process. Args: - signal: Numeric signal, ``Signals`` member, or name such as + signal: Numeric signal, ``ProcessSignal`` member, or name such as ``"TERM"`` or ``"SIGTERM"``. + + Note: + Passing ``signal.Signals`` directly is deprecated. Use + ``ProcessSignal`` so signal availability does not depend on the + SDK host platform. """ payload = iter_coroutine( self._service.send_process_signal( @@ -202,11 +208,11 @@ def send_signal(self, signal: int | str | signal_module.Signals) -> None: def terminate(self) -> None: """Request graceful process termination with ``SIGTERM``.""" - self.send_signal(signal_module.SIGTERM) + self.send_signal(ProcessSignal.SIGTERM) def kill(self) -> None: """Terminate the process immediately with ``SIGKILL``.""" - self.send_signal(signal_module.SIGKILL) + self.send_signal(ProcessSignal.SIGKILL) class SyncSnapshot(SnapshotHandleBase): diff --git a/src/vercel-sandbox/vercel/sandbox/sync.py b/src/vercel-sandbox/vercel/sandbox/sync.py index 8a364f72..4d24f150 100644 --- a/src/vercel-sandbox/vercel/sandbox/sync.py +++ b/src/vercel-sandbox/vercel/sandbox/sync.py @@ -33,6 +33,7 @@ NetworkPolicyRule, NetworkPolicySubnets, NetworkPolicyTransform, + ProcessSignal, ProcessStatus, SandboxQuery, SandboxQueryByCreatedAt, @@ -487,6 +488,7 @@ def get_snapshot(*, snapshot_id: str) -> SyncSnapshot: "SandboxApiError", "SandboxCleanupError", "ProcessStatus", + "ProcessSignal", "CompletedProcess", "SandboxCredentials", "SandboxCredentialsError", From 8684514e7b30e844bb51449cee7a0b4ae108a97c Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Thu, 27 Aug 2026 09:50:04 -0700 Subject: [PATCH 3/4] workflow: Make sandbox host-independent Model the Linux workflow runtime explicitly when tests run on Windows, and decode Node CLI output as UTF-8 instead of using the host code page. Avoid GGT and zipimport recursion by returning already-loaded sandbox imports directly and performing third-party spec discovery in the host import context. Module execution and its restrictions remain sandboxed. --- .../linux-sandbox-platform.bugfix.md | 1 + .../tests/integration/test_devalue.py | 4 +- .../integration/test_workflow_cli_interop.py | 2 + .../tests/unit/test_py_sandbox.py | 70 ++++++++++++---- .../unit/test_workflow_manifest_command.py | 3 +- .../vercel/workflow/_internal/py_sandbox.py | 79 ++++++++++++++++--- 6 files changed, 133 insertions(+), 26 deletions(-) create mode 100644 changes/vercel-workflow/linux-sandbox-platform.bugfix.md diff --git a/changes/vercel-workflow/linux-sandbox-platform.bugfix.md b/changes/vercel-workflow/linux-sandbox-platform.bugfix.md new file mode 100644 index 00000000..820cfb9c --- /dev/null +++ b/changes/vercel-workflow/linux-sandbox-platform.bugfix.md @@ -0,0 +1 @@ +Make the workflow sandbox expose Linux paths, platform identity, and clock constants on every host. diff --git a/src/vercel-workflow/tests/integration/test_devalue.py b/src/vercel-workflow/tests/integration/test_devalue.py index b46d9f45..3493cfca 100644 --- a/src/vercel-workflow/tests/integration/test_devalue.py +++ b/src/vercel-workflow/tests/integration/test_devalue.py @@ -564,6 +564,7 @@ def interop(devalue_entry: Path) -> dict[str, _Outcome]: input=json.dumps(payload), capture_output=True, text=True, + encoding="utf-8", timeout=120, check=False, env={**os.environ, "DEVALUE_ENTRY": str(devalue_entry)}, @@ -775,7 +776,7 @@ def flush() -> None: ) pending.clear() - for line in test_file.read_text().split("\n"): + for line in test_file.read_text(encoding="utf-8").split("\n"): match = field.match(line) if match is None: continue @@ -862,6 +863,7 @@ def corpus_results( input=json.dumps(pairs), capture_output=True, text=True, + encoding="utf-8", timeout=120, check=False, env={**os.environ, "DEVALUE_ENTRY": str(devalue_entry)}, diff --git a/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py b/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py index e7a5185d..3e443fe6 100644 --- a/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py +++ b/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py @@ -96,6 +96,7 @@ def _npm_install_into(prefix: Path) -> bool: str(prefix), ], capture_output=True, + encoding="utf-8", text=True, timeout=600, check=False, @@ -191,6 +192,7 @@ def _inspect_raw(cli: Path, data_dir: Path, *args: str) -> str: result = subprocess.run( ["node", str(cli), "inspect", *args, "--backend", "local", "--json"], capture_output=True, + encoding="utf-8", text=True, timeout=120, cwd=data_dir, diff --git a/src/vercel-workflow/tests/unit/test_py_sandbox.py b/src/vercel-workflow/tests/unit/test_py_sandbox.py index 102fcdb4..d0257a71 100644 --- a/src/vercel-workflow/tests/unit/test_py_sandbox.py +++ b/src/vercel-workflow/tests/unit/test_py_sandbox.py @@ -12,12 +12,13 @@ from __future__ import annotations -import platform +import struct import sys from contextlib import contextmanager import pytest +from vercel.workflow._internal import py_sandbox from vercel.workflow._internal.py_sandbox import Sandbox, SandboxRestrictionError from vercel.workflow.sandbox import ( ALL_CLEANUPS, @@ -135,13 +136,17 @@ def test_os_path_allowed(self): ns = _run_in_sandbox("import os; result = os.path.join('a', 'b')") assert ns["result"] == "a/b" + def test_os_path_alias_allowed(self): + ns = _run_in_sandbox("from os.path import dirname; result = dirname('a/b')") + assert ns["result"] == "a" + def test_os_sep_allowed(self): ns = _run_in_sandbox("import os; result = os.sep") assert ns["result"] == "/" def test_os_name_allowed(self): ns = _run_in_sandbox("import os; result = os.name") - assert isinstance(ns["result"], str) + assert ns["result"] == "posix" def test_os_fspath_allowed(self): ns = _run_in_sandbox("import os; result = os.fspath('/tmp')") @@ -208,15 +213,9 @@ def test_os_environ_is_copy(self): class TestPlatformRestrictions: - def test_platform_system_answered_by_the_host(self): - """The sandbox's own `platform` cannot answer: it reaches `os.uname()`. - - Which matters beyond anyone calling it directly -- before 3.13 `uuid` - calls it at import on everything but Windows and macOS, so blocking it - means a workflow cannot import `uuid` at all on Linux. - """ + def test_platform_system_reports_the_sandbox_runtime(self): ns = _run_in_sandbox("import platform; result = platform.system()") - assert ns["result"] == platform.system() + assert ns["result"] == "Linux" def test_platform_host_identity_blocked(self): # `node` and `uname` report which *machine* this is, and two replays of @@ -277,7 +276,15 @@ def test_time_struct_time_allowed(self): _run_in_sandbox("import time; _ = time.struct_time") def test_time_constants_allowed(self): - _run_in_sandbox("import time; _ = time.CLOCK_MONOTONIC") + constants = { + "CLOCK_REALTIME": 0, + "CLOCK_MONOTONIC": 1, + "CLOCK_PROCESS_CPUTIME_ID": 2, + "CLOCK_THREAD_CPUTIME_ID": 3, + } + for name, expected in constants.items(): + ns = _run_in_sandbox(f"import time; result = time.{name}") + assert ns["result"] == expected @pytest.mark.asyncio async def test_concurrent_coroutine_not_affected_by_sandbox(self): @@ -825,9 +832,44 @@ def test_math_works(self): ns = _run_in_sandbox("import math; result = math.sqrt(16)") assert ns["result"] == 4.0 - def test_shutil_works(self): - _run_in_sandbox("import os; lurr = os.supports_dir_fd") - _run_in_sandbox("import shutil") + def test_zipimport_bootstrap_module_bypasses_finders(self, monkeypatch: pytest.MonkeyPatch): + sandbox = Sandbox() + assert sandbox.table["struct"] is struct + + def unexpected_import(*args, **kwargs): + pytest.fail(f"preloaded module reached importlib: {args!r} {kwargs!r}") + + monkeypatch.setattr(py_sandbox.importlib, "__import__", unexpected_import) + with sandbox.enter(): + assert __import__("struct") is struct + + def test_real_spec_finders_run_against_host_modules(self, monkeypatch: pytest.MonkeyPatch): + sandbox = Sandbox() + sandbox.table.pop("struct") + finder = next(f for f in sys.meta_path if isinstance(f, py_sandbox._SandboxFinder)) + seen: list[object] = [] + + class RecordingFinder: + @staticmethod + def find_spec(fullname, path, target): + import struct as finder_struct + + assert not py_sandbox.in_sandbox() + seen.append(finder_struct) + return None + + index = sys.meta_path.index(finder) + monkeypatch.setattr(sys, "meta_path", [*sys.meta_path[: index + 1], RecordingFinder()]) + with sandbox.enter(): + assert finder._find_real_spec("missing", None, None) is None + + assert seen == [struct] + assert "struct" not in sandbox.table + + def test_shutil_works_on_a_non_posix_host(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(py_sandbox, "_SANDBOX_MARKER_MODULES", {"posix"}) + ns = _run_in_sandbox("import posix, shutil; result = hasattr(posix, 'open')") + assert ns["result"] is False def test_pathlib_works(self): ns = _run_in_sandbox("import pathlib; result = isinstance(0, pathlib.Path)") diff --git a/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py b/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py index 0ce177b0..7f610a6f 100644 --- a/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py +++ b/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py @@ -19,6 +19,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -232,7 +233,7 @@ def test_the_command_line_works_as_a_command_line(tmp_path) -> None: text=True, timeout=120, env={ - "PATH": "/usr/bin:/bin", + **os.environ, "WORKFLOW_TARGET_WORLD": "local", "PYTHONPATH": str(tmp_path), }, diff --git a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py index 40d3d458..5bf6c0db 100644 --- a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py +++ b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py @@ -7,7 +7,9 @@ import importlib import logging import os +import posixpath import random +import struct import sys import threading import types @@ -283,8 +285,8 @@ def _current_task(loop: Any = None) -> Any: proxy.__dict__["current_task"] = _current_task -def _host_system() -> str: - return _host_import("platform").system() +def _sandbox_system() -> str: + return "Linux" _RESTRICTIONS: dict[str, _ModulePolicy] = { @@ -292,7 +294,7 @@ def _host_system() -> str: "datetime": _blocklist("datetime", datetime=_RestrictedDatetime, date=_RestrictedDate), "platform": _allowlist( "platform", - system=_host_system, + system=_sandbox_system, ), "os": _allowlist( "os", @@ -315,6 +317,16 @@ def _host_system() -> str: "getcwd", "_get_exports_list", "PathLike", + path=posixpath, + sep="/", + altsep=None, + extsep=".", + pathsep=":", + curdir=".", + pardir="..", + devnull="/dev/null", + linesep="\n", + name="posix", environ=os.environ.copy(), allow_if=str.isupper, drops=["fork", "register_at_fork"], @@ -327,6 +339,10 @@ def _host_system() -> str: "get_clock_info", "clock_getres", "struct_time", + CLOCK_REALTIME=0, + CLOCK_MONOTONIC=1, + CLOCK_PROCESS_CPUTIME_ID=2, + CLOCK_THREAD_CPUTIME_ID=3, allow_if=str.isupper, ), "socket": _allowlist( @@ -382,6 +398,13 @@ def _host_system() -> str: "readline", # terminal input } +# The sandbox advertises a Linux userspace even when the host is Windows. +# Some standard-library modules use ``os.name`` to conditionally import the +# Linux syscall module, but exposing the real module would bypass the sandbox. +# Supply only the module marker so those imports remain portable without +# making any syscalls available. +_SANDBOX_MARKER_MODULES: set[str] = {"posix"} if "posix" not in sys.builtin_module_names else set() + _PASSTHROUGHS: set[str] = { # Carefully selected stdlib modules that do not import any restricted modules "abc", @@ -421,6 +444,10 @@ def _host_system() -> str: "math", "numbers", "operator", + # Re-executing os.py against the sandbox's Linux marker module on Windows + # would require real POSIX syscall exports. Wrap the initialized host + # module with the restrictions above instead. + "os", "posixpath", "pprint", "quopri", @@ -602,6 +629,12 @@ def find_spec( # If we aren't actually in a sandbox, defer to the normal finders. if not _in_sandbox.get(False): return None + if fullname in _SANDBOX_MARKER_MODULES: + return spec_from_loader( + fullname, + _PreloadedLoader(types.ModuleType(fullname)), + origin="sandbox-marker", + ) if fullname in self._blocked: # Return a stub module instead of raising — other modules may # ``import subprocess`` at module level but never call it. @@ -650,12 +683,18 @@ def _find_real_spec( path: Sequence[str] | None, target: types.ModuleType | None, ) -> ModuleSpec | None: - if self in sys.meta_path: - for finder in sys.meta_path[sys.meta_path.index(self) + 1 :]: - if hasattr(finder, "find_spec"): - spec = finder.find_spec(fullname, path, target) - if spec is not None: - return spec + table_token = _sandbox_sys_modules.set(None) + sandbox_token = _in_sandbox.set(False) + try: + if self in sys.meta_path: + for finder in sys.meta_path[sys.meta_path.index(self) + 1 :]: + if hasattr(finder, "find_spec"): + spec = finder.find_spec(fullname, path, target) + if spec is not None: + return spec + finally: + _in_sandbox.reset(sandbox_token) + _sandbox_sys_modules.reset(table_token) return None @@ -811,6 +850,15 @@ def _sandbox_import( ) -> types.ModuleType: if not _in_sandbox.get(False): return real_import(name, globals, locals, fromlist or (), level) + table = _sandbox_sys_modules.get() + if level == 0 and table is not None and name in table: + module = table[name] + if fromlist and all(item == "*" or hasattr(module, item) for item in fromlist): + return module + if not fromlist: + top_level = table.get(name.partition(".")[0]) + if top_level is not None: + return top_level return importlib.__import__(name, globals, locals, fromlist or (), level) builtins.__import__ = _sandbox_import @@ -853,7 +901,18 @@ def _new_sandbox_table() -> dict[str, types.ModuleType]: """ _ensure_installed() - table: dict[str, types.ModuleType] = {"sys": sys} + table: dict[str, types.ModuleType] = { + "sys": sys, + # Python 3.13+'s frozen zipimport imports struct lazily while probing + # ZIP64 entries. Windows console-script executables are zip candidates, + # so resolving struct through the sandbox finder can recursively probe + # the same executable before zipimport has cached its directory. + "struct": struct, + # os.py normally installs this alias while it initializes. The + # sandbox wraps the already initialized host os module, so install the + # Linux path alias explicitly in every fresh module table. + "os.path": posixpath, + } # Snapshot atomically (list() over the view is a single C op) so a # concurrent import in another thread can't trip "dict changed size". for key, mod in list(_real_sys_modules.items()): From ab1fbec81f59a9815da32b5fdccaad60bfed76d6 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Thu, 27 Aug 2026 13:05:27 -0700 Subject: [PATCH 4/4] queue: Cancel stuck dev server tasks Calling force_exit does not wake a Windows server loop that is blocked in I/O. After a bounded graceful shutdown, cancel the actual AnyIO serve task so its thread and event loop unwind deterministically. --- .../windows-devserver-shutdown.bugfix.md | 1 + .../tests/unit/test_queue_asgi.py | 63 +++++++++++++++++++ .../tests/unit/test_queue_lease.py | 2 +- .../vercel/queue/_internal/devserver.py | 63 +++++++++++++++---- 4 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 changes/vercel-queue/windows-devserver-shutdown.bugfix.md diff --git a/changes/vercel-queue/windows-devserver-shutdown.bugfix.md b/changes/vercel-queue/windows-devserver-shutdown.bugfix.md new file mode 100644 index 00000000..82d10a04 --- /dev/null +++ b/changes/vercel-queue/windows-devserver-shutdown.bugfix.md @@ -0,0 +1 @@ +Force embedded development servers to exit when graceful shutdown stalls. diff --git a/src/vercel-queue/tests/unit/test_queue_asgi.py b/src/vercel-queue/tests/unit/test_queue_asgi.py index 06bc6c04..f9434e4d 100644 --- a/src/vercel-queue/tests/unit/test_queue_asgi.py +++ b/src/vercel-queue/tests/unit/test_queue_asgi.py @@ -4,9 +4,12 @@ import contextlib import logging +import threading from collections.abc import AsyncIterable, Iterator from dataclasses import dataclass +from types import SimpleNamespace +import anyio import httpx import pytest @@ -64,6 +67,66 @@ def _server(**kwargs: object) -> Iterator[_Server]: assert capsys.readouterr().out == '{"baseUrl": "http://127.0.0.1:54321"}\n' +def test_devserver_shutdown_cancels_a_stuck_server() -> None: + class _Server: + should_exit = False + force_exit = False + cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + class _Thread(threading.Thread): + def __init__(self, server: _Server) -> None: + super().__init__() + self._server = server + self.join_timeouts: list[float] = [] + + def join(self, timeout: float | None = None) -> None: + assert timeout is not None + self.join_timeouts.append(timeout) + + def is_alive(self) -> bool: + return not self._server.cancelled + + server = _Server() + thread = _Thread(server) + + queue_devserver_internal._stop_server(server, thread, "test server") + + assert server.should_exit is True + assert server.force_exit is True + assert server.cancelled is True + assert thread.join_timeouts == [5, 5] + + +def test_cancellable_devserver_stops_its_event_loop_task() -> None: + started = threading.Event() + + class _Config: + def get_loop_factory(self) -> None: + return None + + class _Server: + def __init__(self, config: object) -> None: + self.config = config + + async def serve(self, sockets: list[object] | None = None) -> None: + started.set() + await anyio.sleep_forever() + + uvicorn = SimpleNamespace(Server=_Server) + server = queue_devserver_internal._cancellable_server(uvicorn, _Config()) + thread = threading.Thread(target=server.run) + thread.start() + + assert started.wait(timeout=5) + server.cancel() + thread.join(timeout=5) + + assert not thread.is_alive() + + class _FakeClient: def __init__(self, *, exc: BaseException | None = None) -> None: self.exc = exc diff --git a/src/vercel-queue/tests/unit/test_queue_lease.py b/src/vercel-queue/tests/unit/test_queue_lease.py index 73a31961..516a8a4c 100644 --- a/src/vercel-queue/tests/unit/test_queue_lease.py +++ b/src/vercel-queue/tests/unit/test_queue_lease.py @@ -1850,7 +1850,7 @@ def _wait_for_sync_lease_deadline( condition: Callable[[], bool] | None = None, ) -> None: expected = server.state.now + timedelta(seconds=seconds) - deadline = time.monotonic() + 1 + deadline = time.monotonic() + 5 while server.state.by_id[message_id].lease_deadline_by_consumer[consumer] != expected or ( condition is not None and not condition() ): diff --git a/src/vercel-queue/vercel/queue/_internal/devserver.py b/src/vercel-queue/vercel/queue/_internal/devserver.py index 92da5f79..fcf00acb 100644 --- a/src/vercel-queue/vercel/queue/_internal/devserver.py +++ b/src/vercel-queue/vercel/queue/_internal/devserver.py @@ -15,6 +15,9 @@ from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass +import anyio +from anyio import from_thread + from ..embedded import create_embedded_queue_app from .asgi import QueueClientAsgiApp, asgi_app from .client import QueueClient @@ -55,8 +58,9 @@ def embedded_queue_dev_server( log_level="warning", lifespan="off", ws="none", + timeout_graceful_shutdown=5, ) - server = uvicorn.Server(config) + server = _cancellable_server(uvicorn, config) thread = threading.Thread(target=_profiled_server_run(server, profile), daemon=True) thread.start() _wait_for_server(server) @@ -69,11 +73,10 @@ def embedded_queue_dev_server( _thread=thread, ) finally: - server.should_exit = True - thread.join(timeout=5) - if thread.is_alive(): - raise RuntimeError("embedded queue dev server did not stop") - app.state.close() + try: + _stop_server(server, thread, "embedded queue dev server") + finally: + app.state.close() @contextlib.contextmanager @@ -110,8 +113,9 @@ def queue_client_asgi_dev_server( # noqa: PLR0913 log_level="warning", lifespan="on", ws="none", + timeout_graceful_shutdown=5, ) - server = uvicorn.Server(config) + server = _cancellable_server(uvicorn, config) thread = threading.Thread(target=_profiled_server_run(server, profile), daemon=True) thread.start() _wait_for_server(server, "queue client ASGI dev server") @@ -123,10 +127,7 @@ def queue_client_asgi_dev_server( # noqa: PLR0913 _thread=thread, ) finally: - server.should_exit = True - thread.join(timeout=5) - if thread.is_alive(): - raise RuntimeError("queue client ASGI dev server did not stop") + _stop_server(server, thread, "queue client ASGI dev server") def main(argv: list[str] | None = None) -> int: @@ -181,6 +182,46 @@ def _wait_for_server(server: Any, name: str = "embedded queue dev server") -> No raise RuntimeError(f"{name} did not start") +def _stop_server(server: Any, thread: threading.Thread, name: str) -> None: + server.should_exit = True + thread.join(timeout=5) + if thread.is_alive(): + server.force_exit = True + server.cancel() + thread.join(timeout=5) + if thread.is_alive(): + raise RuntimeError(f"{name} did not stop") + + +def _cancellable_server(uvicorn: Any, config: Any) -> Any: + class CancellableServer(uvicorn.Server): + _cancel_scope: anyio.CancelScope | None = None + _portal: from_thread.BlockingPortal | None = None + + def run(self, sockets: list[Any] | None = None) -> None: + backend_options: dict[str, Any] = {} + get_loop_factory = getattr(self.config, "get_loop_factory", None) + if get_loop_factory is not None: + backend_options["loop_factory"] = get_loop_factory() + else: + self.config.setup_event_loop() + anyio.run(self._run, sockets, backend_options=backend_options) + + async def _run(self, sockets: list[Any] | None) -> None: + async with from_thread.BlockingPortal() as self._portal: + with anyio.CancelScope() as self._cancel_scope: + await self.serve(sockets=sockets) + + def cancel(self) -> None: + if self._portal is not None and self._cancel_scope is not None: + try: + self._portal.start_task_soon(self._cancel_scope.cancel) + except RuntimeError: + pass + + return CancellableServer(config) + + def _server_port(server: Any) -> int: for asyncio_server in server.servers: sockets = asyncio_server.sockets or ()