Skip to content
This repository was archived by the owner on Sep 1, 2026. It is now read-only.

Commit c4b682c

Browse files
fix: make SEA binary cache writes concurrency-safe
1 parent 9fe6865 commit c4b682c

2 files changed

Lines changed: 48 additions & 4 deletions

File tree

‎src/stagehand/_custom/sea_binary.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import os
44
import sys
5-
import hashlib
65
import platform
6+
import tempfile
77
import importlib.resources as importlib_resources
88
from pathlib import Path
99
from contextlib import suppress
@@ -71,9 +71,21 @@ def _copy_to_cache(*, src: Path, filename: str, version: str) -> Path:
7171
return dst
7272

7373
data = src.read_bytes()
74-
tmp = cache_root / f".{filename}.{hashlib.sha256(data).hexdigest()}.tmp"
75-
tmp.write_bytes(data)
76-
tmp.replace(dst)
74+
with tempfile.NamedTemporaryFile(dir=cache_root, prefix=f".{filename}.", suffix=".tmp", delete=False) as file:
75+
file.write(data)
76+
tmp = Path(file.name)
77+
78+
try:
79+
try:
80+
tmp.replace(dst)
81+
except OSError:
82+
# Another process may have populated the cache first. Its atomic
83+
# replace guarantees that an existing destination is complete.
84+
if not dst.exists():
85+
raise
86+
finally:
87+
tmp.unlink(missing_ok=True)
88+
7789
_ensure_executable(dst)
7890
return dst
7991

‎tests/test_sea_binary.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import importlib.util
44
from pathlib import Path
5+
from concurrent.futures import ThreadPoolExecutor
56

67
import pytest
78

@@ -23,6 +24,37 @@ def _load_download_binary_module():
2324
download_binary = _load_download_binary_module()
2425

2526

27+
def test_copy_to_cache_reuses_existing_binary(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
28+
source = tmp_path / "source"
29+
source.write_bytes(b"new")
30+
cached = tmp_path / "cache" / "test" / "stagehand-test"
31+
cached.parent.mkdir(parents=True)
32+
cached.write_bytes(b"cached")
33+
monkeypatch.setattr(sea_binary, "_cache_dir", lambda: tmp_path / "cache")
34+
35+
result = sea_binary._copy_to_cache(src=source, filename="stagehand-test", version="test")
36+
37+
assert result == cached
38+
assert result.read_bytes() == b"cached"
39+
40+
41+
def test_copy_to_cache_is_safe_when_called_concurrently(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
42+
source = tmp_path / "source"
43+
source.write_bytes(b"binary" * 1_000_000)
44+
monkeypatch.setattr(sea_binary, "_cache_dir", lambda: tmp_path / "cache")
45+
46+
def copy(_index: int) -> Path:
47+
return sea_binary._copy_to_cache(src=source, filename="stagehand-test", version="test")
48+
49+
with ThreadPoolExecutor(max_workers=16) as executor:
50+
results = list(executor.map(copy, range(16)))
51+
52+
expected = tmp_path / "cache" / "test" / "stagehand-test"
53+
assert results == [expected] * 16
54+
assert expected.read_bytes() == source.read_bytes()
55+
assert list(expected.parent.glob("*.tmp")) == []
56+
57+
2658
def test_resolve_binary_path_defaults_cache_version_to_package_version(
2759
monkeypatch: pytest.MonkeyPatch,
2860
tmp_path: Path,

0 commit comments

Comments
 (0)