Skip to content

fix(core): stop atomic writes from colliding on a constant PID suffix#5721

Open
AmirF194 wants to merge 1 commit into
odysseus-dev:devfrom
AmirF194:fix/5596-atomic-write-pid-collision
Open

fix(core): stop atomic writes from colliding on a constant PID suffix#5721
AmirF194 wants to merge 1 commit into
odysseus-dev:devfrom
AmirF194:fix/5596-atomic-write-pid-collision

Conversation

@AmirF194

Copy link
Copy Markdown

Summary

atomic_write_json/atomic_write_text in core/atomic_io.py build the temp filename as {path}.tmp.{os.getpid()}, meant to keep two concurrent writers from colliding on the same rename target. os.getpid() is constant for the life of a process, so it only distinguishes writers running in different processes. Odysseus runs as a single long-lived process per container, so os.getpid() never changes, and two concurrent writers to the same path (for example, two request handlers racing a settings save) always compute the identical temp path. Whichever finishes os.replace() first removes the shared temp file out from under the other, which then raises FileNotFoundError on its own os.replace() instead of landing its write. This is issue #5596's exact symptom (intermittent 500s, reverted settings).

The fix swaps the PID suffix for uuid4(), which is unique per call regardless of process or thread identity.

While tracing every writer of this pattern, routes/prefs_routes.py's _save() turned out to be an independent, hand-rolled copy of the same temp-file logic (not the shared core.atomic_io helper other routes already use, e.g. routes/auth_routes.py), with the identical PID-suffix bug. It's replaced with a call to atomic_write_json.

Target branch

  • This PR targets dev, not main.

Linked Issue

Fixes #5596

Type of Change

  • Bug fix (non-breaking — fixes a confirmed issue)

Checklist

  • I searched open issues and open PRs — this is not a duplicate.
  • This PR targets dev
  • My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
  • I actually ran the app (uvicorn app:app) and verified the change works end-to-end. Type-checks and unit tests are not enough.

How to Test

  1. python -m pytest tests/test_atomic_io.py tests/test_prefs_atomic_write.py -v, which includes a new regression test, test_atomic_write_json_concurrent_writers_do_not_collide, that spins up two threads writing the same path concurrently (a slow json.dump widens the race window deterministically). It fails with FileNotFoundError against dev HEAD and passes against this branch.
  2. Ran the real app (python -m uvicorn app:app, AUTH_ENABLED=false) in a clean Docker container and exercised PUT /api/prefs/{key} for a dozen keys, then GET /api/prefs: every key landed, no errors, no leftover .tmp.* files in data/.
  3. Full suite: python -m pytest -q gives the same 39 failed / 21 errors as unmodified dev HEAD, all in src/agent_loop.py-dependent tests, unrelated to this change (src/agent_loop.py uses dict[str, Any] without importing Any, which also currently blocks the app from booting via uvicorn app:app on dev HEAD; I patched that locally, outside this diff, only to run the end-to-end check in item 2). Everything under tests/test_atomic_io.py, tests/test_prefs_atomic_write.py, tests/test_preset_atomic_save.py, and tests/test_api_key_manager_atomic_save.py passes.

Not verified: production-scale concurrency (many simultaneous real users). The repro isolates the exact mechanism (shared, constant PID as the sole temp-file discriminator) rather than reproducing load-level frequency.

atomic_write_json/atomic_write_text build their temp filename as
"{path}.tmp.{os.getpid()}". os.getpid() is constant for the life of a
process, so it only ever distinguishes concurrent writers that live in
different OS processes. Odysseus runs as a single long-lived process
per container, so two concurrent writers to the same path (e.g. two
request handlers racing a settings save) always compute the identical
temp path. Whichever finishes os.replace() first removes the shared
tmp file out from under the other, which then raises FileNotFoundError
on its own os.replace() instead of landing its write.

Fix: derive the temp suffix from uuid4() instead of the PID, so every
call gets a distinct temp path regardless of process/thread identity.

routes/prefs_routes.py's _save() had an independent, hand-rolled copy
of the exact same PID-suffix logic (not the shared core.atomic_io
helper other routes already use, e.g. routes/auth_routes.py) with the
same bug. Replaced it with a call to atomic_write_json.

Fixes odysseus-dev#5596
@github-actions github-actions Bot added the ready for review Description complete — ready for maintainer review label Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Atomic writes collide in containers — the temp filename uses the PID, which is always 1

1 participant