diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2716a04..79a1fb6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,10 @@ jobs: # /usr/bin/python3 and resolves back to it. Containers use their own image. linux-containment: runs-on: ubuntu-24.04 + # A normal run takes about 4 minutes. A container engine that wedges + # (seen with gVisor on hosted runners) must fail the job, not hold it + # for GitHub's 6-hour default. + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Install containment dependencies @@ -42,8 +46,19 @@ jobs: # only). Parsers never get a network, and Docker's --network none # alone is refused by runsc on these runners ("cannot run with # network enabled in root network namespace"). + # + # gVisor's default platform, systrap, hangs on some hosts: the + # sandbox starts, but the container's first process never runs, and + # `docker rm -f` cannot remove it. Which hosted runner a job lands on + # decides it, so probe it and fall back to ptrace (slower, same + # isolation boundary) where it hangs. run: | - sudo runsc install -- --network=none + PLATFORM=systrap + if ! sudo timeout -s KILL 60 runsc --network=none --platform=systrap do /bin/true; then + echo "::warning::gVisor's systrap platform hangs on this runner; registering runsc with --platform=ptrace" + PLATFORM=ptrace + fi + sudo runsc install -- --network=none --platform="$PLATFORM" sudo systemctl restart docker docker info --format '{{json .Runtimes}}' | grep -q runsc - name: Fetch the pinned parser image (trusted acquisition step) @@ -57,7 +72,11 @@ jobs: # step even though its output is piped through tee. shell: bash run: | - .venv/bin/python -m pytest tests -q -rs | tee pytest.log + # --timeout (pytest-timeout): a test stuck longer than this fails + # with its name and every thread's stack, and the run goes on, so a + # hang in the container engine names the test that hit it. + # --durations lists the slowest tests either way. + .venv/bin/python -m pytest tests -q -rs --timeout=120 --durations=15 | tee pytest.log # Windows-only tests may skip here, and the GPU variant of the OCI # backend skips because hosted runners have no GPU; nothing else may. if grep "SKIPPED" pytest.log | grep -v "Windows feature" | grep -qv "no usable GPU"; then diff --git a/docs/containment.md b/docs/containment.md index da25ac4..50db911 100644 --- a/docs/containment.md +++ b/docs/containment.md @@ -102,6 +102,12 @@ as the entrypoint of the image, so it must resolve inside the image (e.g. with that reason — it is never replaced by another runtime. Register gVisor with `runsc install -- --network=none` so it builds no network stack at all; CI does this, and some Docker hosts refuse runsc with `--network none` alone. + On some hosts gVisor's default platform, `systrap`, hangs: the sandbox + starts but the container's first process never runs, and `docker rm -f` + cannot remove it (Stele then reports `ContainmentCleanupError`). Check with + `runsc --network=none --platform=systrap do /bin/true`. Where it hangs, + register `runsc install -- --network=none --platform=ptrace`: slower, the + same isolation boundary. CI probes this on every run. - **GPU.** `OciBackend(gpu=True)` passes NVIDIA GPUs through with CDI (`--device nvidia.com/gpu=all`) or Docker's `--gpus all`, and is unavailable ("no usable GPU") when the engine exposes none. gVisor supports GPUs only via diff --git a/pyproject.toml b/pyproject.toml index 29f5857..12137b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [] # Wasm/WASI sandbox backend (stele.containment.wasm). The API used (fuel, # epoch interruption, read-only preopens via fs_mutable) needs wasmtime 49+. wasm = ["wasmtime>=49"] -dev = ["pytest>=8", "wasmtime>=49"] +dev = ["pytest>=8", "pytest-timeout>=2.3", "wasmtime>=49"] [tool.hatch.build.targets.wheel] packages = ["stele"] diff --git a/uv.lock b/uv.lock index 0e3c400..0a50c4d 100644 --- a/uv.lock +++ b/uv.lock @@ -63,6 +63,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "stele" version = "0.1.0" @@ -71,6 +83,7 @@ source = { editable = "." } [package.optional-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-timeout" }, { name = "wasmtime" }, ] wasm = [ @@ -80,6 +93,7 @@ wasm = [ [package.metadata] requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3" }, { name = "wasmtime", marker = "extra == 'dev'", specifier = ">=49" }, { name = "wasmtime", marker = "extra == 'wasm'", specifier = ">=49" }, ]