fix: make SEA binary cache writes concurrency-safe - #358
Conversation
There was a problem hiding this comment.
1 issue found across 2 files
Confidence score: 4/5
- In
src/stagehand/_custom/sea_binary.py, the cache-write flow writes data and immediately renames the temp file without a flush/fsync, so a crash or power loss can leavedstwith incomplete bytes despite an apparently successful replace. That creates a durability/regression risk for cached binaries after restart—flush and fsync the temp file (and ideally the parent directory) beforetmp.replace(dst).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/stagehand/_custom/sea_binary.py">
<violation number="1" location="src/stagehand/_custom/sea_binary.py:75">
P2: The new cache-write path renames the temp file into place without flushing or fsyncing the data first (`file.write(data)` then `tmp.replace(dst)`). On a crash between the rename and the data reaching disk, `dst` can exist with empty/partial contents, and because both the `if dst.exists(): return dst` fast path and the OSError fallback treat any existing dst as complete, that corrupted binary is never repopulated and breaks every subsequent run. Flush (`file.flush()`) and, ideally, `os.fsync(file.fileno())` the temp file before the atomic replace (and fsync the directory after on POSIX) so the published cache entry is durable.</violation>
</file>
Architecture diagram
sequenceDiagram
participant DA as Download API
participant CB as _copy_to_cache()
participant TMP as tempfile.NamedTemporaryFile
participant FS as Cache Directory
participant PROC as Other Process
Note over DA,PROC: SEA Binary Cache Write Flow
DA->>CB: copy SEA binary to cache
alt Destination already exists
CB->>FS: Check cache_root/filename
FS-->>CB: Existing cached file
CB-->>DA: Return existing path
else Cache miss
CB->>FS: Read source binary
FS-->>CB: Binary data
CB->>TMP: Create unique temp file (prefix, .tmp)
TMP->>TMP: Write binary data
TMP-->>CB: Temp path
CB->>TMP: Close and cleanup handle
alt Atomic rename succeeds
CB->>FS: tmp.replace(dst)
FS-->>CB: Success
else OSError - another process won
CB->>FS: Check if dst exists
alt Destination exists (complete)
FS-->>CB: Existing complete binary
else Destination missing
CB->>CB: Re-raise original error
end
end
CB->>FS: Ensure executable bit set
CB->>TMP: Delete temp file (missing_ok=True)
CB-->>DA: Return cached path
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| tmp.write_bytes(data) | ||
| tmp.replace(dst) | ||
| with tempfile.NamedTemporaryFile(dir=cache_root, prefix=f".{filename}.", suffix=".tmp", delete=False) as file: | ||
| file.write(data) |
There was a problem hiding this comment.
P2: The new cache-write path renames the temp file into place without flushing or fsyncing the data first (file.write(data) then tmp.replace(dst)). On a crash between the rename and the data reaching disk, dst can exist with empty/partial contents, and because both the if dst.exists(): return dst fast path and the OSError fallback treat any existing dst as complete, that corrupted binary is never repopulated and breaks every subsequent run. Flush (file.flush()) and, ideally, os.fsync(file.fileno()) the temp file before the atomic replace (and fsync the directory after on POSIX) so the published cache entry is durable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/stagehand/_custom/sea_binary.py, line 75:
<comment>The new cache-write path renames the temp file into place without flushing or fsyncing the data first (`file.write(data)` then `tmp.replace(dst)`). On a crash between the rename and the data reaching disk, `dst` can exist with empty/partial contents, and because both the `if dst.exists(): return dst` fast path and the OSError fallback treat any existing dst as complete, that corrupted binary is never repopulated and breaks every subsequent run. Flush (`file.flush()`) and, ideally, `os.fsync(file.fileno())` the temp file before the atomic replace (and fsync the directory after on POSIX) so the published cache entry is durable.</comment>
<file context>
@@ -71,9 +71,21 @@ def _copy_to_cache(*, src: Path, filename: str, version: str) -> Path:
- tmp.write_bytes(data)
- tmp.replace(dst)
+ with tempfile.NamedTemporaryFile(dir=cache_root, prefix=f".{filename}.", suffix=".tmp", delete=False) as file:
+ file.write(data)
+ tmp = Path(file.name)
+
</file context>
Summary
Testing
uv run --frozen pytest tests/test_sea_binary.py -q(6 passed)uv run --frozen ruff check .uv run --frozen pyright -p .uv run --frozen mypy --platform linux .Fixes #351
Summary by cubic
Make SEA binary cache writes concurrency-safe. Previously concurrent writers shared a temp path and could race, leading to partial or orphaned files; now each writer uses a unique temp file, publishes atomically, and cleans up. Fixes #351.
tempfile.NamedTemporaryFile.Written for commit c4b682c. Summary will update on new commits.