Skip to content

Commit 657658b

Browse files
martex-devclaude
andcommitted
M30a: a slow host could move the provenance digest
`test · windows-latest` failed on M30 with two calls to detect() inside one process disagreeing. Not a flake in the test: `docker --version` can take longer than the ten-second timeout on a loaded runner, the TimeoutExpired is caught, and the probe then reports exactly what it reports for a host with no docker at all -- so the isolation tier fell from docker to subprocess between one call and the next, and the digest of what the host could enforce moved with it. The probe now answers once per process. Failing to probe still reads as absent, which under-claims the host's isolation, and that is the safe direction to be wrong in here -- so the fix is to stop the answer changing rather than to make a timeout mean something new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3062e8e commit 657658b

2 files changed

Lines changed: 37 additions & 0 deletions

File tree

‎src/nullius/environment.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import sys
2222
from dataclasses import asdict, dataclass
2323
from enum import StrEnum
24+
from functools import cache
2425
from importlib.util import find_spec
2526

2627

@@ -92,7 +93,24 @@ def warnings(self) -> list[str]:
9293
return out
9394

9495

96+
@cache
9597
def _tool_version(executable: str, *args: str) -> str | None:
98+
"""Ask an external tool what version it is, once per process.
99+
100+
Cached because it must be: the digest this feeds is a *run's* provenance,
101+
and two calls inside one run have to agree about the host. They did not.
102+
A loaded machine can take longer than the timeout to answer `docker
103+
--version`, the timeout is caught, and the probe reports the same thing it
104+
reports for a host with no docker at all -- so the isolation tier fell from
105+
docker to subprocess between one call and the next and the digest moved
106+
with it. Windows CI found it; the failure is a property of load, not of
107+
platform.
108+
109+
Failing to probe still reads as absent, which under-claims what the host
110+
can enforce. That is the safe direction to be wrong in for this project,
111+
and it is the reason the fix is to stop the answer changing rather than to
112+
make a timeout mean something new.
113+
"""
96114
path = shutil.which(executable)
97115
if path is None:
98116
return None

‎tests/test_environment.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import dataclasses
6+
import subprocess
67

78
import pytest
89
from typer.testing import CliRunner
@@ -31,6 +32,24 @@ def test_digest_is_stable_and_sensitive() -> None:
3132
assert weaker.digest() != caps.digest(), "the tier must change the provenance digest"
3233

3334

35+
@pytest.mark.invariant
36+
def test_a_slow_host_cannot_move_the_provenance_digest(monkeypatch: pytest.MonkeyPatch) -> None:
37+
"""The digest is a run's record of what its host could enforce, so two calls
38+
inside one run have to agree. They did not: `docker --version` can take
39+
longer than the timeout on a loaded machine, the timeout is caught, and the
40+
probe then reports what it reports for a host with no docker at all. The
41+
isolation tier fell from docker to subprocess between one call and the next
42+
and the digest moved with it.
43+
"""
44+
before = detect().digest()
45+
46+
def refuse(*_args: object, **_kwargs: object) -> object:
47+
raise subprocess.TimeoutExpired(cmd="docker", timeout=10)
48+
49+
monkeypatch.setattr(subprocess, "run", refuse)
50+
assert detect().digest() == before
51+
52+
3453
@pytest.mark.parametrize(
3554
("tier", "expected_fragment"),
3655
[

0 commit comments

Comments
 (0)