From a6eb3ef7385c9c30a77bb86c7d6871c502dc61c8 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sun, 13 Sep 2026 12:41:27 -0400 Subject: [PATCH] refactor: remove duplicate sandbox and process plumbing Keep the minimal sandbox command's existing app/data identity while sharing its parser, seeding, execution and cleanup with the standard sandbox. Remove the unused SDK adapter copy and protocol shadow from the environment facade, plus unconsumed checkout-updater dependencies and their dead helper. Validation: 72 focused Python tests across sandbox and adapter/backend paths, 5 checkout-updater tests, Electron typecheck and ESLint passed. Ruff, touched-file Windows checks and the TS ratchet pass. Sandbox tests also passed against the original scripts before consolidation. --- apps/desktop/electron/main.ts | 4 - .../electron/updater/checkout-legacy.test.ts | 3 +- .../updater/checkout-ownership.test.ts | 2 - .../electron/updater/checkout-source.test.ts | 2 - apps/desktop/electron/updater/checkout.ts | 2 - scripts/dev-minimal-sandbox.sh | 199 +----------------- scripts/dev-sandbox.sh | 37 ++-- tests/scripts/test_dev_sandbox.py | 64 ++++++ tools/environments/base.py | 94 +-------- 9 files changed, 92 insertions(+), 315 deletions(-) create mode 100644 tests/scripts/test_dev_sandbox.py diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 19d1b5c4d2..7efb8925ac 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -2948,8 +2948,6 @@ function runGit(args, options: any = {}): Promise<{ code: number; stdout: string }) } -const firstLine = text => (text || '').split('\n').find(Boolean) || '' - function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) @@ -3116,7 +3114,6 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy { isMac: IS_MAC, defaultUpdateBranch: DEFAULT_UPDATE_BRANCH, updateHandoffDwellMs: UPDATE_HANDOFF_DWELL_MS, - directoryExists, readSourceUpdate: (updateRoot: string, opts: { force?: boolean }): Promise => readSourceUpdate({ python: findPythonForRoot(updateRoot), git: resolveGitBinary(), @@ -3127,7 +3124,6 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy { }), resolveUpdateRoot, resolveUpdaterBinary, - firstLine, emitUpdateProgress, rememberLog, diff --git a/apps/desktop/electron/updater/checkout-legacy.test.ts b/apps/desktop/electron/updater/checkout-legacy.test.ts index 1236682ad9..c485cb0ba8 100644 --- a/apps/desktop/electron/updater/checkout-legacy.test.ts +++ b/apps/desktop/electron/updater/checkout-legacy.test.ts @@ -23,9 +23,8 @@ it('offers manual recovery only for a missing source probe, never for a broken p readSourceUpdate: probe, hermesHome: home, isWindows: process.platform === 'win32', isMac: process.platform === 'darwin', defaultUpdateBranch: 'main', updateHandoffDwellMs: 0, - directoryExists: fs.existsSync, resolveUpdateRoot: (): string => root, resolveUpdaterBinary: vi.fn((): string => 'frozen-updater'), - firstLine: (text: string): string => text.split('\n')[0], emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), + emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), startHermes: vi.fn(async (): Promise => {}), stopBackendsForUpdate: vi.fn(async (): Promise => {}), repairMacUpdaterHelper: vi.fn(), preflightStateDb: vi.fn(), runningAppBundle: (): null => null, diff --git a/apps/desktop/electron/updater/checkout-ownership.test.ts b/apps/desktop/electron/updater/checkout-ownership.test.ts index dbe124253c..97fe62c9b3 100644 --- a/apps/desktop/electron/updater/checkout-ownership.test.ts +++ b/apps/desktop/electron/updater/checkout-ownership.test.ts @@ -17,10 +17,8 @@ it.each(['not-a-git-checkout', 'update-root-steward-owned-git-tree', 'fetch-fail isMac: process.platform === 'darwin', defaultUpdateBranch: 'main', updateHandoffDwellMs: 0, - directoryExists: (): boolean => true, resolveUpdateRoot: (): string => 'repo', resolveUpdaterBinary: vi.fn((): null => null), - firstLine: (text: string): string => text.split('\n')[0], emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), startHermes: vi.fn(async (): Promise => {}), diff --git a/apps/desktop/electron/updater/checkout-source.test.ts b/apps/desktop/electron/updater/checkout-source.test.ts index 109a7e36e2..df108b3763 100644 --- a/apps/desktop/electron/updater/checkout-source.test.ts +++ b/apps/desktop/electron/updater/checkout-source.test.ts @@ -141,9 +141,7 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ readSourceUpdate: (install: string, opts: { force?: boolean }): Promise => readSourceUpdate({ python, git: 'git', updateRoot: install, hermesHome: home, force: opts.force }), - directoryExists: fs.existsSync, resolveUpdaterBinary: (): null => null, - firstLine: (text: string): string => text.split('\n')[0], emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), diff --git a/apps/desktop/electron/updater/checkout.ts b/apps/desktop/electron/updater/checkout.ts index 6f23c4c86d..f03750e231 100644 --- a/apps/desktop/electron/updater/checkout.ts +++ b/apps/desktop/electron/updater/checkout.ts @@ -33,9 +33,7 @@ export interface CheckoutStrategyDeps { isMac: boolean defaultUpdateBranch: string updateHandoffDwellMs: number - directoryExists: (filePath: string) => boolean resolveUpdaterBinary: () => string | null - firstLine: (text: string) => string emitUpdateProgress: (payload: { stage: string; message: string; percent: number | null }) => void rememberLog: (chunk: unknown) => void diff --git a/scripts/dev-minimal-sandbox.sh b/scripts/dev-minimal-sandbox.sh index 1808674309..9dd836493f 100755 --- a/scripts/dev-minimal-sandbox.sh +++ b/scripts/dev-minimal-sandbox.sh @@ -1,198 +1,3 @@ #!/usr/bin/env bash -# Run a Hermes instance in an isolated sandbox — separate HERMES_HOME, -# separate Electron userData, and a distinct Desktop app name so it doesn't compete -# with your main desktop instance's single-instance lock. -# -# By default the sandbox is throwaway: a temp dir is created and removed on -# exit. Use --persistent to keep the sandbox across restarts (stored under -# .hermes-minimal-sandbox/ in the worktree git root). -# -# Usage: -# scripts/dev-minimal-sandbox.sh python -m hermes_cli.main -# scripts/dev-minimal-sandbox.sh hermes desktop -# scripts/dev-minimal-sandbox.sh electron . -# scripts/dev-minimal-sandbox.sh -- npm run dev # from apps/desktop/ -# scripts/dev-minimal-sandbox.sh --persistent hermes desktop -# scripts/dev-minimal-sandbox.sh --persistent -- npm run dev -# -# Seed the sandbox HERMES_HOME from an existing directory (e.g. your main -# ~/.hermes) so config, sessions, skills, etc. are pre-populated: -# scripts/dev-minimal-sandbox.sh --from ~/.hermes hermes desktop -# -# Override the app name (default: HermesSandbox): -# HERMES_DEV_SANDBOX_NAME=Staging scripts/dev-minimal-sandbox.sh hermes desktop -# -# Override the persistent sandbox dir name (default: .hermes-sandbox): -# HERMES_DEV_SANDBOX_DIR=.staging-sandbox scripts/dev-minimal-sandbox.sh --persistent hermes desktop - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -print_help() { - cat <<'EOF' -Usage: dev-minimal-sandbox.sh [--persistent] [--from DIR] [--] - -Run a Hermes instance in an isolated sandbox. - -Options: - --persistent Keep the sandbox dir across restarts (under the worktree - git root, in .hermes-sandbox/). Without this flag the - sandbox is a temp dir that is removed on exit. - --from DIR Copy DIR into the sandbox HERMES_HOME as the starting - point (config, sessions, skills, etc.). - Ignored if the sandbox HERMES_HOME already has content - (e.g. reusing a --persistent sandbox) to avoid clobbering. - --delete Delete the existing persistent sandbox in .hermes-sandbox. - -h, --help Show this help message. - -Environment: - HERMES_DEV_SANDBOX_NAME Override the app name (default: HermesSandbox) - HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: .hermes-sandbox) - -Examples: - dev-minimal-sandbox.sh hermes desktop - dev-minimal-sandbox.sh --persistent hermes desktop - dev-minimal-sandbox.sh --from ~/.hermes hermes desktop - dev-minimal-sandbox.sh -- npm run dev -EOF -} - -PERSISTENT=false -DELETE=false -SEED_DIR="" - -while [ "$#" -gt 0 ]; do - case "$1" in - --persistent) - PERSISTENT=true - shift - ;; - --from) - if [ "$#" -lt 2 ] || [[ "$2" == -* ]]; then - echo "error: --from requires a directory argument" >&2 - exit 1 - fi - SEED_DIR="$2" - shift 2 - ;; - --from=*) - SEED_DIR="${1#--from=}" - if [ -z "$SEED_DIR" ]; then - echo "error: --from requires a directory argument" >&2 - exit 1 - fi - shift - ;; - --delete) - DELETE=true - shift - ;; - -h|--help) - print_help - exit 0 - ;; - --) - shift - break - ;; - *) - break - ;; - esac -done - -if [ -n "$SEED_DIR" ]; then - if [ ! -d "$SEED_DIR" ]; then - echo "error: --from dir '$SEED_DIR' does not exist" >&2 - exit 1 - fi - # Resolve to absolute path so it's valid after we cd later. - SEED_DIR="$(cd "$SEED_DIR" && pwd)" -fi - -if [ "$#" -eq 0 ]; then - print_help >&2 - exit 1 -fi - - -SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-minimal-sandbox}" -GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")" -GIT_ROOT="$(cd "$GIT_ROOT" && pwd)" -PERSISTENT_SANDBOX_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME" - -if [ "$DELETE" = true ]; then - if [ -d "$PERSISTENT_SANDBOX_ROOT" ]; then - read -r -p "[sandbox] delete $PERSISTENT_SANDBOX_ROOT? [y/N] " REPLY - case "$REPLY" in - [yY]|[yY][eE][sS]) - echo "[sandbox] deleting $PERSISTENT_SANDBOX_ROOT" >&2 - rm -rf -- "$PERSISTENT_SANDBOX_ROOT" - ;; - *) - echo "[sandbox] aborted" >&2 - exit 1 - ;; - esac - else - echo "[sandbox] nothing to delete at $PERSISTENT_SANDBOX_ROOT" >&2 - fi - exit 0 -fi - -# Derive a per-worktree app name so multiple checkouts don't collide. -# Each worktree has its own toplevel path even though they share one repo, -# so we hash that path into a short, stable suffix. -WORKTREE_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")" -WORKTREE_ROOT="$(cd "$WORKTREE_ROOT" && pwd)" -WORKTREE_HASH="$(printf '%s' "$WORKTREE_ROOT" | cksum | cut -d' ' -f1)" -WORKTREE_NAME="$(basename "$WORKTREE_ROOT")" -DEFAULT_SANDBOX_NAME="HermesMinimalSandbox-${WORKTREE_NAME}-${WORKTREE_HASH}" - -SANDBOX_NAME="${HERMES_DEV_SANDBOX_NAME:-$DEFAULT_SANDBOX_NAME}" - -if [ "$PERSISTENT" = true ]; then - SANDBOX_ROOT="$PERSISTENT_SANDBOX_ROOT" -else - SANDBOX_ROOT="$(mktemp -d -t hermes-minimal-sandbox.XXXXXX)" -fi - -export HERMES_HOME="$SANDBOX_ROOT/hermes-home" -export HERMES_DESKTOP_USER_DATA_DIR="$SANDBOX_ROOT/user-data" -export HERMES_DESKTOP_APP_NAME="$SANDBOX_NAME" - -mkdir -p "$HERMES_HOME" "$HERMES_DESKTOP_USER_DATA_DIR" - -if [ -n "$SEED_DIR" ]; then - # Only seed when the sandbox HERMES_HOME is empty — avoids clobbering an - # existing persistent sandbox on re-run. - if [ -z "$(ls -A "$HERMES_HOME" 2>/dev/null)" ]; then - echo "[sandbox] seeding HERMES_HOME from $SEED_DIR" >&2 - cp -a "$SEED_DIR/." "$HERMES_HOME/" - else - echo "[sandbox] --from ignored: $HERMES_HOME already has content" >&2 - fi -fi - -echo "[sandbox] HERMES_HOME=$HERMES_HOME" >&2 -echo "[sandbox] userData=$HERMES_DESKTOP_USER_DATA_DIR" >&2 -echo "[sandbox] appName=$HERMES_DESKTOP_APP_NAME" >&2 -if [ "$PERSISTENT" = true ]; then - echo "[sandbox] persistent: $SANDBOX_ROOT" >&2 -else - echo "[sandbox] ephemeral (will be cleaned up on exit)" >&2 -fi - -if [ "$PERSISTENT" = false ]; then - cleanup() { - chmod -R u+w "$SANDBOX_ROOT" - rm -rf -- "$SANDBOX_ROOT" - } - trap cleanup EXIT - trap 'cleanup; exit 130' INT TERM -fi - -"$@" -rc=$? -exit $rc \ No newline at end of file +# Preserve this entrypoint's separate identity without duplicating sandbox lifecycle. +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/dev-sandbox.sh" diff --git a/scripts/dev-sandbox.sh b/scripts/dev-sandbox.sh index 368f4874c1..2562c86cc6 100755 --- a/scripts/dev-sandbox.sh +++ b/scripts/dev-sandbox.sh @@ -28,33 +28,44 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRYPOINT="$(basename "$0")" +# The minimal entrypoint is an alias, but its existing data and app identity stay separate. +if [ "$ENTRYPOINT" = dev-minimal-sandbox.sh ]; then + DEFAULT_DIR=.hermes-minimal-sandbox + APP_PREFIX=HermesMinimalSandbox + TEMP_PREFIX=hermes-minimal-sandbox +else + DEFAULT_DIR=.hermes-sandbox + APP_PREFIX=HermesSandbox + TEMP_PREFIX=hermes-sandbox +fi print_help() { - cat <<'EOF' -Usage: dev-sandbox.sh [--persistent] [--from DIR] [--] + cat < Run a Hermes instance in an isolated sandbox. Options: --persistent Keep the sandbox dir across restarts (under the worktree - git root, in .hermes-sandbox/). Without this flag the + git root, in $DEFAULT_DIR/). Without this flag the sandbox is a temp dir that is removed on exit. --from DIR Copy DIR into the sandbox HERMES_HOME as the starting point (config, sessions, skills, etc.). Ignored if the sandbox HERMES_HOME already has content (e.g. reusing a --persistent sandbox) to avoid clobbering. - --delete Delete the existing persistent sandbox in .hermes-sandbox. + --delete Delete the existing persistent sandbox in $DEFAULT_DIR. -h, --help Show this help message. Environment: - HERMES_DEV_SANDBOX_NAME Override the app name (default: HermesSandbox) - HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: .hermes-sandbox) + HERMES_DEV_SANDBOX_NAME Override the app name (default prefix: $APP_PREFIX) + HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: $DEFAULT_DIR) Examples: - dev-sandbox.sh hermes desktop - dev-sandbox.sh --persistent hermes desktop - dev-sandbox.sh --from ~/.hermes hermes desktop - dev-sandbox.sh -- npm run dev + $ENTRYPOINT hermes desktop + $ENTRYPOINT --persistent hermes desktop + $ENTRYPOINT --from ~/.hermes hermes desktop + $ENTRYPOINT -- npm run dev EOF } @@ -117,7 +128,7 @@ if [ "$#" -eq 0 ]; then fi -SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}" +SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-$DEFAULT_DIR}" GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")" GIT_ROOT="$(cd "$GIT_ROOT" && pwd)" PERSISTENT_SANDBOX_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME" @@ -148,14 +159,14 @@ WORKTREE_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/ WORKTREE_ROOT="$(cd "$WORKTREE_ROOT" && pwd)" WORKTREE_HASH="$(printf '%s' "$WORKTREE_ROOT" | cksum | cut -d' ' -f1)" WORKTREE_NAME="$(basename "$WORKTREE_ROOT")" -DEFAULT_SANDBOX_NAME="HermesSandbox-${WORKTREE_NAME}-${WORKTREE_HASH}" +DEFAULT_SANDBOX_NAME="${APP_PREFIX}-${WORKTREE_NAME}-${WORKTREE_HASH}" SANDBOX_NAME="${HERMES_DEV_SANDBOX_NAME:-$DEFAULT_SANDBOX_NAME}" if [ "$PERSISTENT" = true ]; then SANDBOX_ROOT="$PERSISTENT_SANDBOX_ROOT" else - SANDBOX_ROOT="$(mktemp -d -t hermes-sandbox.XXXXXX)" + SANDBOX_ROOT="$(mktemp -d -t "${TEMP_PREFIX}.XXXXXX")" fi export HERMES_HOME="$SANDBOX_ROOT/hermes-home" diff --git a/tests/scripts/test_dev_sandbox.py b/tests/scripts/test_dev_sandbox.py new file mode 100644 index 0000000000..1e1dbfdfea --- /dev/null +++ b/tests/scripts/test_dev_sandbox.py @@ -0,0 +1,64 @@ +"""Both development entrypoints keep their data identities and cleanup contract.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("entry,prefix", [("dev-sandbox.sh", "HermesSandbox-"), ("dev-minimal-sandbox.sh", "HermesMinimalSandbox-")]) +def test_ephemeral_sandbox_preserves_command_and_removes_state(tmp_path, entry, prefix): + root = Path(__file__).resolve().parents[2] + checkout = tmp_path / "checkout with spaces" + checkout.mkdir() + subprocess.run(["git", "init", "-q", str(checkout)], check=True, stdin=subprocess.DEVNULL, timeout=10) + output = tmp_path / "observed.json" + probe = tmp_path / "probe.py" + probe.write_text( + "import json, os, sys\nfrom pathlib import Path\n" + "keys = ['HERMES_HOME', 'HERMES_DESKTOP_USER_DATA_DIR', 'HERMES_DESKTOP_APP_NAME']\n" + "Path(sys.argv[1]).write_text(json.dumps({'env': {k: os.environ[k] for k in keys}, 'args': sys.argv[2:]}), encoding='utf-8')\n" + "sys.exit(7)\n", encoding="utf-8", + ) + env = {key: value for key, value in os.environ.items() if not key.startswith("HERMES_DEV_SANDBOX_")} + result = subprocess.run(["bash", str(root / "scripts" / entry), "--", sys.executable, + str(probe), str(output), "argument with spaces"], cwd=checkout, + env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=20) + assert result.returncode == 7, result.stderr + observed = json.loads(output.read_text(encoding="utf-8-sig")) + home = Path(observed["env"]["HERMES_HOME"]) + assert observed["args"] == ["argument with spaces"] + assert observed["env"]["HERMES_DESKTOP_APP_NAME"].startswith(prefix) + assert Path(observed["env"]["HERMES_DESKTOP_USER_DATA_DIR"]).parent == home.parent + assert not home.parent.exists() + + +@pytest.mark.platforms("posix") +def test_persistent_identities_seed_once_and_honor_overrides(tmp_path): + root = Path(__file__).resolve().parents[2] + checkout = tmp_path / "checkout" + checkout.mkdir() + subprocess.run(["git", "init", "-q", str(checkout)], check=True, stdin=subprocess.DEVNULL, timeout=10) + seed = tmp_path / "seed" + seed.mkdir() + (seed / "marker").write_text("first", encoding="utf-8") + env = {key: value for key, value in os.environ.items() if not key.startswith("HERMES_DEV_SANDBOX_")} + for entry, directory in [("dev-sandbox.sh", ".hermes-sandbox"), ("dev-minimal-sandbox.sh", ".hermes-minimal-sandbox")]: + command = ["bash", str(root / "scripts" / entry), "--persistent", "--from", str(seed), "--", "true"] + subprocess.run(command, cwd=checkout, env=env, check=True, stdin=subprocess.DEVNULL, capture_output=True, timeout=20) + marker = checkout / directory / "hermes-home" / "marker" + assert marker.read_text(encoding="utf-8-sig") == "first" + marker.write_text("retained", encoding="utf-8") + subprocess.run(command, cwd=checkout, env=env, check=True, stdin=subprocess.DEVNULL, capture_output=True, timeout=20) + assert marker.read_text(encoding="utf-8-sig") == "retained" + env.update(HERMES_DEV_SANDBOX_DIR=".custom", HERMES_DEV_SANDBOX_NAME="CustomSandbox") + result = subprocess.run(["bash", str(root / "scripts/dev-minimal-sandbox.sh"), "--persistent", "--", "env"], + cwd=checkout, env=env, check=True, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=20) + assert f"HERMES_HOME={checkout / '.custom/hermes-home'}" in result.stdout + assert "HERMES_DESKTOP_APP_NAME=CustomSandbox" in result.stdout + assert (checkout / ".custom/user-data").is_dir() diff --git a/tools/environments/base.py b/tools/environments/base.py index 97785bd25d..b12e106d10 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -17,7 +17,7 @@ import time import uuid from abc import ABC, abstractmethod from pathlib import Path -from typing import Callable, IO, Iterable, Protocol +from typing import Callable, Iterable from hermes_constants import get_hermes_home from tools.interrupt import consume_yield, is_interrupted, is_thread_interrupted @@ -142,98 +142,6 @@ def _file_mtime_key(host_path: str) -> tuple[float, int] | None: return None -# --------------------------------------------------------------------------- -# ProcessHandle protocol -# --------------------------------------------------------------------------- - - -class ProcessHandle(Protocol): - """Duck type that every backend's _run_bash() must return. - - subprocess.Popen satisfies this natively. SDK backends (Modal, Daytona) - return _ThreadedProcessHandle which adapts their blocking calls. - """ - - def poll(self) -> int | None: ... - def kill(self) -> None: ... - def wait(self, timeout: float | None = None) -> int: ... - - @property - def stdout(self) -> IO[str] | None: ... - - @property - def returncode(self) -> int | None: ... - - -class _ThreadedProcessHandle: - """Adapter for SDK backends (Modal, Daytona) that have no real subprocess. - - Wraps a blocking ``exec_fn() -> (output_str, exit_code)`` in a background - thread and exposes a ProcessHandle-compatible interface. An optional - ``cancel_fn`` is invoked on ``kill()`` for backend-specific cancellation - (e.g. Modal sandbox.terminate, Daytona sandbox.stop). - """ - - def __init__( - self, - exec_fn: Callable[[], tuple[str, int]], - cancel_fn: Callable[[], None] | None = None, - ): - self._cancel_fn = cancel_fn - self._done = threading.Event() - self._returncode: int | None = None - self._error: Exception | None = None - - # Pipe for stdout — drain thread in _wait_for_process reads the read end. - read_fd, write_fd = os.pipe() - self._stdout = os.fdopen(read_fd, "r", encoding="utf-8", errors="replace") # windows-footgun: ok (pipe is BOM-free) - self._write_fd = write_fd - - def _worker(): - try: - output, exit_code = exec_fn() - self._returncode = exit_code - # Write output into the pipe so drain thread picks it up. - try: - os.write(self._write_fd, output.encode("utf-8", errors="replace")) - except OSError: - pass - except Exception as exc: - self._error = exc - self._returncode = 1 - finally: - try: - os.close(self._write_fd) - except OSError: - pass - self._done.set() - - t = threading.Thread(target=_worker, daemon=True) - t.start() - - @property - def stdout(self): - return self._stdout - - @property - def returncode(self) -> int | None: - return self._returncode - - def poll(self) -> int | None: - return self._returncode if self._done.is_set() else None - - def kill(self): - if self._cancel_fn: - try: - self._cancel_fn() - except Exception: - pass - - def wait(self, timeout: float | None = None) -> int: - self._done.wait(timeout=timeout) - return self._returncode - - # --------------------------------------------------------------------------- # BaseEnvironment # ---------------------------------------------------------------------------