232 lines
7.0 KiB
Markdown
232 lines
7.0 KiB
Markdown
|
|
---
|
|||
|
|
name: conc-anal
|
|||
|
|
description: >
|
|||
|
|
Concurrency analysis for tractor's trio-based
|
|||
|
|
async primitives. Trace task scheduling across
|
|||
|
|
checkpoint boundaries, identify race windows in
|
|||
|
|
shared mutable state, and verify synchronization
|
|||
|
|
correctness. Invoke on code segments the user
|
|||
|
|
points at, OR proactively when reviewing/writing
|
|||
|
|
concurrent cache, lock, or multi-task acm code.
|
|||
|
|
argument-hint: "[file:line-range or function name]"
|
|||
|
|
allowed-tools:
|
|||
|
|
- Read
|
|||
|
|
- Grep
|
|||
|
|
- Glob
|
|||
|
|
- Task
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
Perform a structured concurrency analysis on the
|
|||
|
|
target code. This skill should be invoked:
|
|||
|
|
|
|||
|
|
- **On demand**: user points at a code segment
|
|||
|
|
(file:lines, function name, or pastes a snippet)
|
|||
|
|
- **Proactively**: when writing or reviewing code
|
|||
|
|
that touches shared mutable state across trio
|
|||
|
|
tasks — especially `_Cache`, locks, events, or
|
|||
|
|
multi-task `@acm` lifecycle management
|
|||
|
|
|
|||
|
|
## 0. Identify the target
|
|||
|
|
|
|||
|
|
If the user provides a file:line-range or function
|
|||
|
|
name, read that code. If not explicitly provided,
|
|||
|
|
identify the relevant concurrent code from context
|
|||
|
|
(e.g. the current diff, a failing test, or the
|
|||
|
|
function under discussion).
|
|||
|
|
|
|||
|
|
## 1. Inventory shared mutable state
|
|||
|
|
|
|||
|
|
List every piece of state that is accessed by
|
|||
|
|
multiple tasks. For each, note:
|
|||
|
|
|
|||
|
|
- **What**: the variable/dict/attr (e.g.
|
|||
|
|
`_Cache.values`, `_Cache.resources`,
|
|||
|
|
`_Cache.users`)
|
|||
|
|
- **Scope**: class-level, module-level, or
|
|||
|
|
closure-captured
|
|||
|
|
- **Writers**: which tasks/code-paths mutate it
|
|||
|
|
- **Readers**: which tasks/code-paths read it
|
|||
|
|
- **Guarded by**: which lock/event/ordering
|
|||
|
|
protects it (or "UNGUARDED" if none)
|
|||
|
|
|
|||
|
|
Format as a table:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
| State | Writers | Readers | Guard |
|
|||
|
|
|---------------------|-----------------|-----------------|----------------|
|
|||
|
|
| _Cache.values | run_ctx, moc¹ | moc | ctx_key lock |
|
|||
|
|
| _Cache.resources | run_ctx, moc | moc, run_ctx | UNGUARDED |
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
¹ `moc` = `maybe_open_context`
|
|||
|
|
|
|||
|
|
## 2. Map checkpoint boundaries
|
|||
|
|
|
|||
|
|
For each code path through the target, mark every
|
|||
|
|
**checkpoint** — any `await` expression where trio
|
|||
|
|
can switch to another task. Use line numbers:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
L325: await lock.acquire() ← CHECKPOINT
|
|||
|
|
L395: await service_tn.start(...) ← CHECKPOINT
|
|||
|
|
L411: lock.release() ← (not a checkpoint, but changes lock state)
|
|||
|
|
L414: yield (False, yielded) ← SUSPEND (caller runs)
|
|||
|
|
L485: no_more_users.set() ← (wakes run_ctx, no switch yet)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Key trio scheduling rules to apply:**
|
|||
|
|
- `Event.set()` makes waiters *ready* but does NOT
|
|||
|
|
switch immediately
|
|||
|
|
- `lock.release()` is not a checkpoint
|
|||
|
|
- `await sleep(0)` IS a checkpoint
|
|||
|
|
- Code in `finally` blocks CAN have checkpoints
|
|||
|
|
(unlike asyncio)
|
|||
|
|
- `await` inside `except` blocks can be
|
|||
|
|
`trio.Cancelled`-masked
|
|||
|
|
|
|||
|
|
## 3. Trace concurrent task schedules
|
|||
|
|
|
|||
|
|
Write out the **interleaved execution trace** for
|
|||
|
|
the problematic scenario. Number each step and tag
|
|||
|
|
which task executes it:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[Task A] 1. acquires lock
|
|||
|
|
[Task A] 2. cache miss → allocates resources
|
|||
|
|
[Task A] 3. releases lock
|
|||
|
|
[Task A] 4. yields to caller
|
|||
|
|
[Task A] 5. caller exits → finally runs
|
|||
|
|
[Task A] 6. users-- → 0, sets no_more_users
|
|||
|
|
[Task A] 7. pops lock from _Cache.locks
|
|||
|
|
[run_ctx] 8. wakes from no_more_users.wait()
|
|||
|
|
[run_ctx] 9. values.pop(ctx_key)
|
|||
|
|
[run_ctx] 10. acm __aexit__ → CHECKPOINT
|
|||
|
|
[Task B] 11. creates NEW lock (old one popped)
|
|||
|
|
[Task B] 12. acquires immediately
|
|||
|
|
[Task B] 13. values[ctx_key] → KeyError
|
|||
|
|
[Task B] 14. resources[ctx_key] → STILL EXISTS
|
|||
|
|
[Task B] 15. 💥 RuntimeError
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Identify the **race window**: the range of steps
|
|||
|
|
where state is inconsistent. In the example above,
|
|||
|
|
steps 9–10 are the window (values gone, resources
|
|||
|
|
still alive).
|
|||
|
|
|
|||
|
|
## 4. Classify the bug
|
|||
|
|
|
|||
|
|
Categorize what kind of concurrency issue this is:
|
|||
|
|
|
|||
|
|
- **TOCTOU** (time-of-check-to-time-of-use): state
|
|||
|
|
changes between a check and the action based on it
|
|||
|
|
- **Stale reference**: a task holds a reference to
|
|||
|
|
state that another task has invalidated
|
|||
|
|
- **Lifetime mismatch**: a synchronization primitive
|
|||
|
|
(lock, event) has a shorter lifetime than the
|
|||
|
|
state it's supposed to protect
|
|||
|
|
- **Missing guard**: shared state is accessed
|
|||
|
|
without any synchronization
|
|||
|
|
- **Atomicity gap**: two operations that should be
|
|||
|
|
atomic have a checkpoint between them
|
|||
|
|
|
|||
|
|
## 5. Propose fixes
|
|||
|
|
|
|||
|
|
For each proposed fix, provide:
|
|||
|
|
|
|||
|
|
- **Sketch**: pseudocode or diff showing the change
|
|||
|
|
- **How it closes the window**: which step(s) from
|
|||
|
|
the trace it eliminates or reorders
|
|||
|
|
- **Tradeoffs**: complexity, perf, new edge cases,
|
|||
|
|
impact on other code paths
|
|||
|
|
- **Risk**: what could go wrong (deadlocks, new
|
|||
|
|
races, cancellation issues)
|
|||
|
|
|
|||
|
|
Rate each fix: `[simple|moderate|complex]` impl
|
|||
|
|
effort.
|
|||
|
|
|
|||
|
|
## 6. Output format
|
|||
|
|
|
|||
|
|
Structure the full analysis as:
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
## Concurrency analysis: `<target>`
|
|||
|
|
|
|||
|
|
### Shared state
|
|||
|
|
<table from step 1>
|
|||
|
|
|
|||
|
|
### Checkpoints
|
|||
|
|
<list from step 2>
|
|||
|
|
|
|||
|
|
### Race trace
|
|||
|
|
<interleaved trace from step 3>
|
|||
|
|
|
|||
|
|
### Classification
|
|||
|
|
<bug type from step 4>
|
|||
|
|
|
|||
|
|
### Fixes
|
|||
|
|
<proposals from step 5>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Tractor-specific patterns to watch
|
|||
|
|
|
|||
|
|
These are known problem areas in tractor's
|
|||
|
|
concurrency model. Flag them when encountered:
|
|||
|
|
|
|||
|
|
### `_Cache` lock vs `run_ctx` lifetime
|
|||
|
|
|
|||
|
|
The `_Cache.locks` entry is managed by
|
|||
|
|
`maybe_open_context` callers, but `run_ctx` runs
|
|||
|
|
in `service_tn` — a different task tree. Lock
|
|||
|
|
pop/release in the caller's `finally` does NOT
|
|||
|
|
wait for `run_ctx` to finish tearing down. Any
|
|||
|
|
state that `run_ctx` cleans up in its `finally`
|
|||
|
|
(e.g. `resources.pop()`) is vulnerable to
|
|||
|
|
re-entry races after the lock is popped.
|
|||
|
|
|
|||
|
|
### `values.pop()` → acm `__aexit__` → `resources.pop()` gap
|
|||
|
|
|
|||
|
|
In `_Cache.run_ctx`, the inner `finally` pops
|
|||
|
|
`values`, then the acm's `__aexit__` runs (which
|
|||
|
|
has checkpoints), then the outer `finally` pops
|
|||
|
|
`resources`. This creates a window where `values`
|
|||
|
|
is gone but `resources` still exists — a classic
|
|||
|
|
atomicity gap.
|
|||
|
|
|
|||
|
|
### Global vs per-key counters
|
|||
|
|
|
|||
|
|
`_Cache.users` as a single `int` (pre-fix) meant
|
|||
|
|
that users of different `ctx_key`s inflated each
|
|||
|
|
other's counts, preventing teardown when one key's
|
|||
|
|
users hit zero. Always verify that per-key state
|
|||
|
|
(`users`, `locks`) is actually keyed on `ctx_key`
|
|||
|
|
and not on `fid` or some broader key.
|
|||
|
|
|
|||
|
|
### `Event.set()` wakes but doesn't switch
|
|||
|
|
|
|||
|
|
`trio.Event.set()` makes waiting tasks *ready* but
|
|||
|
|
the current task continues executing until its next
|
|||
|
|
checkpoint. Code between `.set()` and the next
|
|||
|
|
`await` runs atomically from the scheduler's
|
|||
|
|
perspective. Use this to your advantage (or watch
|
|||
|
|
for bugs where code assumes the woken task runs
|
|||
|
|
immediately).
|
|||
|
|
|
|||
|
|
### `except` block checkpoint masking
|
|||
|
|
|
|||
|
|
`await` expressions inside `except` handlers can
|
|||
|
|
be masked by `trio.Cancelled`. If a `finally`
|
|||
|
|
block runs from an `except` and contains
|
|||
|
|
`lock.release()`, the release happens — but any
|
|||
|
|
`await` after it in the same `except` may be
|
|||
|
|
swallowed. This is why `maybe_open_context`'s
|
|||
|
|
cache-miss path does `lock.release()` in a
|
|||
|
|
`finally` inside the `except KeyError`.
|
|||
|
|
|
|||
|
|
### Cancellation in `finally`
|
|||
|
|
|
|||
|
|
Unlike asyncio, trio allows checkpoints in
|
|||
|
|
`finally` blocks. This means `finally` cleanup
|
|||
|
|
that does `await` can itself be cancelled (e.g.
|
|||
|
|
by nursery shutdown). Watch for cleanup code that
|
|||
|
|
assumes it will run to completion.
|