Skip to content

Concurrency hardening for the async SDK - #60

Merged
Datata1 merged 7 commits into
mainfrom
concurrency-hardening
Aug 7, 2026
Merged

Concurrency hardening for the async SDK#60
Datata1 merged 7 commits into
mainfrom
concurrency-hardening

Conversation

@Datata1

@Datata1 Datata1 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

An audit of src/codesphere/_async found six concurrency defects. Each was
reproduced with an executable probe before being fixed, and each has a
regression test that fails against the previous code.

The async client is documented as the choice for code that "makes many
concurrent API calls", but the repo had essentially no concurrency coverage —
one asyncio.gather in the entire suite.

Verified fixed

Re-run of the original audit probes against this branch:

Defect Before Now
Concurrent async with scopes B: RuntimeError (client closed) B: ok
Close during retry backoff builtins.RuntimeError ClientStateError
Sync open() cross-thread TOCTOU constructed=2, orphaned=1 constructed=1, orphaned=0
Flags invalidate() vs in-flight fetch cached: True (fetches=1) cached: False (fetches=2)
Retried DELETE 404 caller sees -> NotFoundError caller sees -> ok
Concurrent model updates local reports 'A' (DIVERGED) local read -> StaleModelError

What changed

  • Lifecycleopen()/close() are reference counted behind _compat.Lock,
    so nested and concurrent scopes no longer tear the transport down for each
    other, and two threads racing open() in the sync twin can no longer orphan a
    connection pool. request() re-resolves the client per retry attempt; httpx's
    bare RuntimeError maps to the new ClientStateError(CodesphereError, RuntimeError).
  • Flags cache — a generation counter makes invalidate() win against an
    in-flight fetch while staying synchronous. _legacy_platform and the snapshot
    now come from one lock acquisition.
  • Retries — a 404 on a retried DELETE counts as success. Guarded on
    attempt > 0, scoped to DELETE. Per the platform's lack of idempotency keys,
    retries otherwise stay as-is and RetryConfig documents that a retried write
    can execute twice.
  • Models (breaking) — writes no longer copy values back. Workspace.update()
    and the three Domain write methods mark the instance stale; reads and
    to_dict()/to_json()/to_yaml() raise StaleModelError until refresh().
    Identity fields stay readable.
  • External actorswait_for_stage() pins its run by started_at and
    raises ConflictError if someone else restarts the stage. Both polling loops
    measure timeout against a monotonic deadline. LogStream is guarded as
    single-use.
  • Docs — new concurrency guide. Alongside the guarantees it states plainly
    what the SDK cannot do: with no ETags, If-Match, or idempotency keys,
    read-modify-write has an unclosable lost-update window.

Breaking change

Reading a field after update() now raises StaleModelError instead of
returning a value that may contradict the platform. CHANGELOG.md covers the
migration; needs a minor version bump before release.

Also removes codesphere.utils.update_model_fields (no remaining callers) and
fixes scripts/gen_sync.py, which aborted before post-processing on every run
that actually transformed a file.

Verification

505 tests pass; ruff, ty, make sync-check, and a strict docs build are all
clean.

🤖 Generated with Claude Code

Datata1 added 7 commits August 6, 2026 16:15
Adds deterministic concurrency tests for the async client and its
generated sync twin. Interleaving is driven by Event/Barrier handshakes
rather than wall-clock sleeps, so the ordering under test is fixed.

Tests assert the invariant (a failure surfaces as CodesphereError), not
the exception class, so they encode what callers depend on and survive
the fixes that follow.

Eleven of these fail against the current client, each for a distinct
defect: unrefcounted close(), a captured client across retry backoff,
cross-thread TOCTOU in the sync open(), a lost flags invalidation, a
retried DELETE whose 404 masks success, and in-place model mutation
that diverges from server write ordering.
Sharing one SDK across tasks was unsafe: open() was idempotent but
close() was not, so the first scope to exit tore the transport down for
everyone else. open()/close() are now reference counted behind
_compat.Lock, which also closes the check-then-act window that let two
threads in the sync twin each build a transport and orphan one.

The teardown awaits __aexit__ outside the lock so a slow drain cannot
block a concurrent open() of the next transport.

request() now re-resolves the client on every retry attempt instead of
capturing it once, so a close during backoff is caught by the SDK rather
than surfacing as httpx's bare RuntimeError. That error is remapped to
the new ClientStateError(CodesphereError, RuntimeError) only when the
client is actually gone; unrelated httpx internals still propagate. The
dual base keeps existing `except RuntimeError` handlers working.

test_open_is_idempotent asserted the old (buggy) teardown semantics and
is replaced by tests for reuse, balanced counting, and close underflow.
invalidate() cleared the snapshot outside the lock, so a get() already
awaiting its request would overwrite the None with data fetched before
the invalidation. The invalidation evaporated and the next read served
the stale snapshot.

A fetch now stamps the generation it started under and installs its
result only if invalidate() has not bumped it meanwhile. invalidate()
stays synchronous: taking the lock there would block the caller for the
duration of an in-flight flags request.

require() also read _legacy_platform outside the lock after get()
returned, so a concurrent refresh could pair a snapshot with the legacy
flag of a different fetch. _fetch() now returns both, and the new
_get_with_legacy() hands them to callers from one lock acquisition.
When a DELETE reached the platform and only its response was lost (a
gateway 503), the retry saw 404 and the SDK raised NotFoundError for an
operation that had in fact succeeded. Tearing down a landscape reported
"no landscape deployed" precisely because the teardown worked.

A 404 is now treated as success when the request is a DELETE and at
least one retry has already run. The attempt > 0 guard matters: a 404 on
the first attempt is a genuine miss and still raises. The rule is scoped
to DELETE, so a retried PUT that 404s is unaffected.

Safe for every current DELETE operation: all five declare
response_model=NoneType, so the response body is never parsed.

The RetryConfig docstring now states the residual risk this does not
solve. PUT and DELETE are idempotent as methods but not always in
effect, and without idempotency keys the SDK cannot stop a retried
teardown from executing twice. Callers who cannot tolerate that are
pointed at max_retries=0.
BREAKING CHANGE: reading a field on a model after update() raises
StaleModelError until refresh() is called.

Writes copied values back into the local model, which is wrong by
construction under concurrency: the platform orders writes by arrival
while the client sees them by response. Two tasks updating the same
workspace could leave the model reporting "first" while the server held
"second", permanently and silently.

Workspace.update() wrote back the request payload; the Domain methods
wrote back the server's response, which is authoritative for the moment
it was produced but can still be overtaken by a concurrent write. Both
are now handled the same way: the instance is marked stale.

Staleness drops the field values from __dict__ so reads route through
__getattr__, which costs nothing until a model actually goes stale.
Identity fields survive, so a stale instance can still be logged and
re-fetched, and repr() marks it. model_dump/model_dump_json are guarded
too: pydantic serializes straight from __dict__, so without that a stale
model would quietly dump only its identity.

Workspace.refresh() and Domain.refresh() re-read the entity in place.
Domain writes still return the server's response; prefer it over self.

Removes codesphere.utils.update_model_fields, now unused.
wait_for_stage() could report success for a pipeline run the caller
never started: if someone else redeployed mid-wait, it simply observed
whatever run was current. The API exposes no run id, but started_at
changes on restart, so the run is pinned on first sighting and a
different value now raises ConflictError.

Both wait_for_stage() and wait_until_running() counted elapsed time by
summing poll_interval, ignoring how long the status requests took. A
slow endpoint could overrun the timeout by a wide margin (5.15s against
a 0.2s budget in the regression test). Both now measure against a
monotonic deadline and never sleep past it.

LogStream is backed by one SSE response body that can only be read once.
Re-entering it stranded the first stream context, and a second iterator
surfaced a raw httpx.StreamConsumed. Both now raise ClientStateError
explaining that streams are single-use.

Adds docs/guides/concurrency.md. Alongside the guarantees, it states
plainly what the SDK cannot do: with no ETags, If-Match, or idempotency
keys, read-modify-write cycles have an unclosable lost-update window and
retried writes can execute twice.
unasyncd exits non-zero whenever it transforms a file, the way a
formatter signals "changed". Since run() defaulted to fatal, the very
run that did the work aborted before restore_all_blocks() and before
ruff normalized the generated tree — so `make sync-gen` failed on every
real change and had to be run twice to produce a correct result.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🛡️ Bandit Security Scan Results

✅ No security issues found by Bandit.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Coverage

Test Execution Summary

Tests Skipped Failures Errors Time
505 0 💤 0 ❌ 0 🔥 8.916s ⏱️

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Integration Test Results

65 tests    0 ✅  14s ⏱️
 1 suites   0 💤
 1 files    26 ❌  39 🔥

For more details on these failures and errors, see this check.

Results for commit 424b98e.

@Datata1
Datata1 merged commit 7a54341 into main Aug 7, 2026
3 of 5 checks passed
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.

1 participant