Skip to content

fix: make SEA binary cache writes concurrency-safe - #358

Open
abhinavkr26104 wants to merge 1 commit into
browserbase:mainfrom
abhinavkr26104:fix/351-sea-cache-race
Open

fix: make SEA binary cache writes concurrency-safe#358
abhinavkr26104 wants to merge 1 commit into
browserbase:mainfrom
abhinavkr26104:fix/351-sea-cache-race

Conversation

@abhinavkr26104

@abhinavkr26104 abhinavkr26104 commented Aug 15, 2026

Copy link
Copy Markdown

Summary

  • write downloaded SEA binaries to unique temporary files in the cache directory
  • atomically publish completed downloads and tolerate another process winning the cache race
  • clean up temporary artifacts on both success and failure
  • add regression coverage for concurrent writers and existing-cache behavior

Testing

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.

  • Write downloaded SEA binaries to unique temp files in the cache directory via tempfile.NamedTemporaryFile.
  • Atomically replace the destination; if another process already populated the cache, keep that file.
  • Always remove temp files, on both success and failure.
  • Add regression tests for existing-cache reuse and concurrent writers.

Written for commit c4b682c. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 leave dst with 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) before tmp.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
Loading

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent local-mode starts race while populating the SEA binary cache

1 participant