Skip to content

Serialize config.toml writes with a cross-process lock (+ concurrency tests)#217

Merged
alexkroman merged 3 commits into
mainfrom
claude/concurrency-tests-dnlf2d
Jun 17, 2026
Merged

Serialize config.toml writes with a cross-process lock (+ concurrency tests)#217
alexkroman merged 3 commits into
mainfrom
claude/concurrency-tests-dnlf2d

Conversation

@alexkroman

@alexkroman alexkroman commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Concurrency tests

Adds tests/test_concurrency.py for the two genuinely contended runtime paths that lacked direct coverage:

  • core.config — config.toml's read-modify-write under thread contention: a 24-writer + reader stress test pins that _dump's temp-file + atomic os.replace never lets a concurrent reader observe a truncated file.
  • streaming.StreamSession.on_turn — runs on the SDK reader thread, and --system-audio drives two of them. A deterministic test proves the render + save + meta critical section holds _callback_lock (a second thread can't acquire it mid-save), plus a two-source stress test that every finalized turn lands in the saved transcript exactly once and intact.

Config write lock (the fix the tests motivated)

config.toml's mutating helpers do a read-modify-write (_load → mutate → _dump). The atomic os.replace already keeps a reader from seeing a torn file, but two assembly processes writing concurrently would still lose an update (both read the same config; the second dump clobbers the first). This wraps every read-modify-write in a cross-process filelock so concurrent writers no longer clobber each other; readers stay lock-free.

  • filelock graduates from a dev-only transitive dependency to a declared runtime dependency.
  • core/locking.py — generic cached cross-process FileLock helper (one instance per path, so nested acquisitions stay reentrant).
  • core/config_lock.py — config.toml's lock_path/write_lock/locked/update helpers, kept out of config.py to stay under the file-length gate (_load/_dump injected so it avoids config's private API).
  • set_* / clear_session / get_device_id / persist_login now route through the lock.

The lost-update concurrency test is correspondingly a no-loss test (16 concurrent writers each adding a distinct profile all survive), with unit tests pinning the lock path, per-dir rebuild, and that the lock is held across the dump.

Full scripts/check.sh is green (100% patch coverage, mutation gate, deptry, CodeQL). Rebased/merged onto latest main (which extracted keyring_store); the lock and that refactor compose cleanly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FueRkrQHWSfpPf1KNaZskX

claude added 2 commits June 17, 2026 04:38
Add tests/test_concurrency.py for the two genuinely contended runtime paths
that lacked direct coverage:

- core.config: a 24-writer + reader stress test pins that _dump's temp-file +
  atomic os.replace never lets a concurrent reader observe a truncated file,
  and a deterministic two-"process" interleave documents the flip side it does
  not solve — lost updates, since there is no cross-process write lock.
- streaming.StreamSession.on_turn: a deterministic test proves the render +
  save + meta critical section runs under _callback_lock (a second reader
  thread cannot acquire the lock mid-save), plus a two-source stress test that
  every finalized turn lands in the saved transcript exactly once and intact.

Test-only change; no source modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FueRkrQHWSfpPf1KNaZskX
config.toml's mutating helpers do a read-modify-write (_load -> mutate ->
_dump). The atomic os.replace in _dump already keeps a *reader* from seeing a
torn file, but two `assembly` processes writing concurrently would still lose
an update (both read the same config; the second dump clobbers the first).

Wrap every read-modify-write in a cross-process filelock so concurrent writers
no longer clobber each other; readers stay lock-free. filelock graduates from a
dev-only transitive dependency to a declared runtime dependency.

- add core/locking.py: generic cached cross-process FileLock helper (one
  instance per path, so nested acquisitions stay reentrant).
- add core/config_lock.py: config.toml's lock_path/write_lock/locked/update
  helpers (kept out of config.py to stay under the file-length gate; _load and
  _dump are injected so the module avoids config's private API).
- route set_*/clear_session/get_device_id/persist_login through the lock.

Update tests/test_concurrency.py: the lost-update test becomes a no-loss test
(16 concurrent writers each adding a distinct profile all survive), plus unit
tests pinning the lock path, per-dir rebuild, and that the lock is held across
the dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FueRkrQHWSfpPf1KNaZskX
@alexkroman alexkroman enabled auto-merge June 17, 2026 05:07
…sts-dnlf2d

# Conflicts:
#	aai_cli/AGENTS.md
#	aai_cli/core/config.py
@alexkroman alexkroman changed the title test: cover config write atomicity and streaming turn-lock concurrency Serialize config.toml writes with a cross-process lock (+ concurrency tests) Jun 17, 2026
@alexkroman alexkroman added this pull request to the merge queue Jun 17, 2026
Merged via the queue into main with commit 37e3a01 Jun 17, 2026
16 of 19 checks passed
@alexkroman alexkroman deleted the claude/concurrency-tests-dnlf2d branch June 17, 2026 05:42
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.

2 participants