Skip to content

Fix race condition in stateful pages marker with atomic writes - #7142

Open
masenf wants to merge 11 commits into
mainfrom
claude/upbeat-brown-jglset
Open

masenf wants to merge 11 commits into
mainfrom
claude/upbeat-brown-jglset

Conversation

@masenf

@masenf masenf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Description

Fixes a startup race condition in backend-only mode with multiple workers where a worker could read a truncated .web/backend/stateful_pages.json file and crash with JSONDecodeError.

Root cause: The marker file was written directly, so concurrent writers could produce partial/corrupted JSON that readers would encounter.

Solution: Write the marker atomically by:

  1. Writing to a temporary file in the same directory
  2. Using Path.replace() to atomically swap it into place
  3. Ensuring readers only ever see a complete, valid marker (or no marker at all)

Additionally, refactored the marker reading logic to handle the case where the marker doesn't exist yet (another worker may be writing it), which correctly falls through to evaluating all pages rather than assuming "no marker" means "no stateful pages".

Changes

reflex/app.py:

  • Modified _write_stateful_pages_marker() to use atomic writes via tempfile.mkstemp() and Path.replace()
  • Always writes the marker (even for stateless apps with an empty list), ensuring backend workers have a definitive signal

reflex/compiler/compiler.py:

  • Extracted marker reading into _read_stateful_pages_marker() helper that returns None if the marker doesn't exist yet
  • Simplified compile_app() logic: only skip full page evaluation if the marker exists and is readable; missing marker falls through to normal compilation

Tests:

  • Added test_write_stateful_pages_marker_never_truncates_final_path(): verifies the marker is never opened for writing (only swapped into place)
  • Added test_write_stateful_pages_marker_is_always_written(): verifies stateless apps write an empty marker
  • Added test_write_stateful_pages_marker_concurrent_readers_see_valid_json(): stress test with 4 concurrent writers and 4 concurrent readers, ensuring no JSONDecodeError or partial reads
  • Updated test_compile_registers_plugin_routes_on_backend_early_return() to correctly expect all pages evaluated when marker is missing

Test Plan

All new unit tests pass and cover the atomic write behavior and concurrent access patterns. Existing tests updated to reflect the corrected behavior when the marker is absent.

https://claude.ai/code/session_01GSjqov3yBj4cBasJqzyrrQ

Review in cubic

In prod backend-only mode with several Granian workers, every worker
takes the "evaluate all pages" path when .web is absent and writes
.web/backend/stateful_pages.json with mode "w", truncating it. A worker
starting slightly later saw the backend dir, read an empty or partial
marker, and died with JSONDecodeError.

Write the marker to a temporary file in the same directory and swap it
into place with Path.replace so readers only ever see a complete file.
Read the marker with a single read_text call and treat FileNotFoundError
as "no marker yet", falling through to evaluating all pages, which also
closes the window between one worker creating the backend dir and
swapping its marker in. The marker is now always written, including for
stateless apps, so that fall-through does not slow their startup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSjqov3yBj4cBasJqzyrrQ
@masenf
masenf requested a review from a team as a code owner September 14, 2026 20:50
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSjqov3yBj4cBasJqzyrrQ
@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previously reported marker correctness and regression-test concerns are resolved in the current code.

Summary

This PR makes the stateful-page marker safe for concurrent backend-worker startup.

  • Writes complete marker data to a same-directory temporary file before atomically replacing the final path.
  • Preserves cross-account readability and retries transient Windows sharing violations.
  • Treats missing, truncated, or temporarily unavailable markers as a request for full page evaluation.
  • Avoids modifying the marker during dry-run compilation.
  • Adds regression coverage for corruption recovery, permissions, cleanup, concurrency, and dry-run behavior.

Reviews (7) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

Comment thread reflex/app.py
Comment thread reflex/compiler/compiler.py
Comment thread reflex/app.py Outdated
@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 53 untouched benchmarks
⏩ 9 skipped benchmarks1


Comparing claude/upbeat-brown-jglset (e7664b6) with main (aaa44a0)

Open in CodSpeed

Footnotes

  1. 9 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review completed against the latest diff

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread reflex/compiler/compiler.py Outdated
Comment thread tests/units/test_app.py
Comment thread reflex/app.py Outdated
Comment thread reflex/app.py
Comment thread reflex/app.py
Comment thread tests/units/test_app.py Outdated

@FarhanAliRaza FarhanAliRaza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Validated end to end in a real example app (stateful + stateless + ComponentState pages): truncated marker crashes main with JSONDecodeError and recovers cleanly on this branch; two concurrent markerless backend workers start without errors and leave a valid marker; browser state behavior unregressed; reflex export succeeds and preserves the marker. Inline comments are non-blocking notes only.


Generated by Claude Code

Comment thread reflex/app.py
Comment thread reflex/app.py
Comment thread reflex/compiler/compiler.py
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.

3 participants