Bounding concurrency in a tree of tasks without deadlock

A semaphore around tasks that wait on their children deadlocks or stops bounding. Slot lending keeps both bounds, with proofs, edge cases and repros.

Engineering25 min read
Bounding concurrency in a tree of tasks without deadlock. A tree under a cap of 2: the root holds a pool slot and lends it to dir 1, which lends it to a file; dir 2 runs on the other pool slot and lends it to its file; the other tasks wait without having started. A slot is a disc on the run that holds it, hollow once lent to a child, and a loan edge is periwinkle.
Enlarge cover

Bounding concurrency in a tree of tasks without deadlock

Zoom in to see details.

Bounding concurrency in a tree of tasks without deadlock. A tree under a cap of 2: the root holds a pool slot and lends it to dir 1, which lends it to a file; dir 2 runs on the other pool slot and lends it to its file; the other tasks wait without having started. A slot is a disc on the run that holds it, hollow once lent to a child, and a loan edge is periwinkle.

An indexer built on CocoIndex was killed for running out of memory. In CocoIndex a component is the unit of work, and a parent starts one by mounting it. The indexer had two concurrency limits: the application’s own gate admitted 256 files at a time, and the engine capped how many components ran at once. Yet when it died, about 15,000 file components were in flight, and each one held a file’s contents (#2486).

A tree walk mounts one file component per file. About 15,000 file components pile up in flight, each holding a file's contents and no engine slot, because each released its slot at its first child mount; nothing counts them. Downstream, the application gate admits 256 at a time into the slow chunk stage; the gate and the engine cap count only that part. Below, the process memory bar is full at its limit, and the process is killed for running out of memory. Source: CocoIndex PR #2486.
Two limits, and 15,000 files between themEnlarge

Two limits, and 15,000 files between them

Zoom in to see details.

A tree walk mounts one file component per file. About 15,000 file components pile up in flight, each holding a file's contents and no engine slot, because each released its slot at its first child mount; nothing counts them. Downstream, the application gate admits 256 at a time into the slow chunk stage; the gate and the engine cap count only that part. Below, the process memory bar is full at its limit, and the process is killed for running out of memory. Source: CocoIndex PR #2486.

Neither limit counted the files that held the memory. The same trap waits in any workload where a task waits for tasks it started: recursive directory walks, crawlers, nested asyncio.gather calls, Go errgroups with a limit, and the pipelines that build context for AI agents.

In this post:

  • The 30-line deadlock. Why async with sem: around a nested asyncio.gather hangs, and why a bigger cap only moves the threshold.
  • Releasing at the first child. Giving the slot back at the first child removes the deadlock and the bound with it. On the real engine, release-at-first-mount (CocoIndex 1.0.25) let a 2,000-file benchmark of the same shape reach over 1,100 file components in flight under a cap of 16; lending (1.1.1) holds it at 16.
  • Slot lending. A 68-line asyncio model you can copy, with the proofs: runs holding an unlent slot stay ≤ cap, runs in flight stay ≤ cap × levels, and a cap of 1 still finishes.
  • Five designs compared. Hold, release, one semaphore per level, helping and lending, in one table.
  • The edge cases. A second lock taken in the wrong order, and a parent that ends while lending.
  • Sizing the cap. Why a concurrency cap is not a memory cap, and how to size it per level.
  • A checklist for any recursive or fan-out workload.

When several updates share the pool, which waiter gets a freed slot is a separate problem. Why a FIFO semaphore starves small jobs covers it.

The figure below runs the same tree under a cap of 2 three ways, with the asyncio models in this post. Pick a strategy; Play replays it.

Strategy
t = 2slots in use 2 of 2in flight 7file contents in memory6 of 6done at t = 15, with all 6 of 6 files in memory at once; nothing bounds this

Every snippet below ran on the machine and versions listed at the end; the outputs are copied from those runs. For the rule alone, skip to how lending works or the checklist.

What breaks for AI agents and the context they read

Agents act on context that a pipeline builds and keeps fresh, and that pipeline is a tree of tasks: a codebase fans out to files, files to chunks, chunks to embedding calls; a workspace fans out to channels, threads and messages. The agent reads the index, so a failure in the indexer never reaches it as an error. Agents fan out the same way when a task starts tool calls or sub-agents and waits for their results. Claude Code, for example, lets sub-agents spawn their own, and caps the tree with a depth limit (3 layers by default) and a limit of 20 running sub-agents by default, at which a new spawn fails instead of waiting (docs). Refusing work at the cap avoids deadlock, at the cost of the caller handling the failure. Two mistakes each fail in a way the agent cannot see: holding the slot, and releasing it at the first child.

Two failure modes of an agent's context pipeline. Hold the slot: deadlock; the indexer hangs with no error, and the agent reads whatever the index held when it stopped. Release at the first child: out of memory; the indexer is killed mid-run, context stays stale, and a rerun dies the same way. Below, in green: slot lending gives no deadlock at any depth, and the work in flight stays bounded.
What breaks when an agent's context pipeline gets this wrongEnlarge

What breaks when an agent's context pipeline gets this wrong

Zoom in to see details.

Two failure modes of an agent's context pipeline. Hold the slot: deadlock; the indexer hangs with no error, and the agent reads whatever the index held when it stopped. Release at the first child: out of memory; the indexer is killed mid-run, context stays stale, and a rerun dies the same way. Below, in green: slot lending gives no deadlock at any depth, and the work in flight stays bounded.

A pipeline that keeps agent context fresh runs continuously over sources that grow, so it has to stay bounded and finish on every input.

A plain semaphore either deadlocks or stops bounding

Wrapping each task in a semaphore is the standard answer: the top answers to the Stack Overflow question “How to limit concurrency with Python asyncio?” (about 121,000 views as of November 2026) use asyncio.Semaphore, which is right for a flat list of tasks. In a tree, the question is what a parent does with its slot once its children need slots too, and a semaphore gives you two answers.

Hold the slot for the whole run: deadlock

A semaphore around a task that waits for its children deadlocks as soon as waiting parents hold every slot: the children they wait for can never start. This 30-line program hangs:

python
import asyncio

sem = asyncio.Semaphore(2)
holding = 0       # tasks inside `async with sem`
leaves_done = 0

async def node(depth: int) -> None:
    global holding, leaves_done
    async with sem:                      # hold a slot for the whole run
        holding += 1
        try:
            if depth == 0:
                await asyncio.sleep(0.01)    # a leaf does the real work
                leaves_done += 1
            else:
                await asyncio.gather(node(depth - 1), node(depth - 1))
        finally:
            holding -= 1

async def main() -> None:
    root = asyncio.create_task(node(3))  # 8 leaves, 15 tasks
    done, _ = await asyncio.wait({root}, timeout=2)
    if done:
        print("finished")
    else:
        print(f"stuck after 2 s: {holding} tasks hold both slots, "
              f"{leaves_done} of 8 leaves done")
        root.cancel()

asyncio.run(main())
text
stuck after 2 s: 2 tasks hold both slots, 0 of 8 leaves done

The root takes slot 1 and starts two children. The first child takes slot 2 and starts its own two children, which find no slot. The second child of the root is also waiting. Both slots now belong to tasks that are waiting for tasks that can’t start, and nothing will ever release them. This is the hold-and-wait condition for deadlock, with the semaphore as the only resource. A larger cap only moves the threshold: any tree with enough waiting parents to fill the cap stops the same way.

Python’s own ThreadPoolExecutor docs warn about the same shape with threads, where each slot is a worker thread: “Deadlocks can occur when the callable associated with a Future waits on the results of another Future.”

Release the slot at the first child: no bound

One fix, which CocoIndex used through 1.0.25, is to give the slot back before starting the children, and it does remove the deadlock. It also removes the bound: a task that has given its slot back is still in flight, so nothing limits how many tasks are in flight, and each one still holds whatever it loaded. A task is in flight from the moment it starts until it finishes. It is executing while it holds a slot it has not given away.

This repro is shaped like an indexer. A walk starts 2,000 file tasks. Each file takes a slot, reads 100 KB, gives the slot back, and waits for 2 chunk tasks that do the slow step (an embedding call in real life) under the same cap of 4:

python
import asyncio

CAP = 4
sem = asyncio.Semaphore(CAP)
in_flight = peak = 0  # file tasks that hold their contents

async def chunk(text: bytes) -> None:
    async with sem:
        await asyncio.sleep(0.01)        # the slow downstream step

async def file() -> None:
    global in_flight, peak
    await sem.acquire()
    in_flight += 1
    peak = max(peak, in_flight)
    text = bytes(100_000)                # read the file
    sem.release()                        # give the slot up before waiting
    await asyncio.gather(chunk(text), chunk(text))
    in_flight -= 1

async def main() -> None:
    await asyncio.gather(*(file() for _ in range(2000)))
    print(f"cap {CAP}: peak {peak} files in flight, "
          f"{peak * 100_000 / 1e6:.0f} MB of contents held")

asyncio.run(main())
text
cap 4: peak 2000 files in flight, 200 MB of contents held

Every file was in flight at once. Each file held a slot only for its read and gave it back without waiting, so all 2,000 reads ran before any chunk task started. At most 4 tasks held a slot at any moment, so the cap held for what it counts.

Three frames of a 3-file tree under a cap of 2; a slot is a disc on the run that holds it. At t = 0.5 the walk has given its slot back before starting its files; files 1 and 2 read on the two slots; file 3 waits for one. At t = 1.5 files 1 and 2 have given their slots back and keep their contents with none; file 3 reads on one slot and a chunk has the other. At t = 2.5 all 3 files are in memory and none holds a slot; two chunks hold the slots and four wait for one. Done at t = 8 with all 3 of 3 files in memory at once; nothing bounds that count.
Release at the first child: no slot counts the files in memoryEnlarge

Release at the first child: no slot counts the files in memory

Zoom in to see details.

Three frames of a 3-file tree under a cap of 2; a slot is a disc on the run that holds it. At t = 0.5 the walk has given its slot back before starting its files; files 1 and 2 read on the two slots; file 3 waits for one. At t = 1.5 files 1 and 2 have given their slots back and keep their contents with none; file 3 reads on one slot and a chunk has the other. At t = 2.5 all 3 files are in memory and none holds a slot; two chunks hold the slots and four wait for one. Done at t = 8 with all 3 of 3 files in memory at once; nothing bounds that count.

What happened to the indexer

This was CocoIndex’s rule from the day the component limit was added (#1678) through 1.0.25: a component gave its slot back on its first child mount, so that a parent waiting for its children could not deadlock the tree. The release was a few lines on the first child mount, commented “deadlock prevention” (component.rs before #2486). In the strip above, it is the moment a file loses its disc and turns dashed red, still in flight with its contents.

The indexer’s files had the shape that CocoIndex’s tests pin for an application gate: mount a quick first child, then wait on the gate (test_concurrency_control.py:474-478). The first mount released the file’s slot, so a file waiting at the gate held no engine slot, and the walk was free to start the next file. The walk read files far faster than the gated stage drained them, and every file it started kept its contents while it waited. Neither the cap nor the gate counted those waiting files, the roughly 15,000 in the opening figure.

We reproduced this on the released engine. engine_bench.py is the same shape on a smaller scale: a walk mounts 2,000 file components; each reads 100 KB, mounts a quick first child, then waits on an asyncio.Semaphore(4) gate around 2 chunk components that sleep 20 ms. Each run used max_inflight_components=16 and a fresh state database:

CocoIndexPeak file components in flightPeak memory
1.0.25 (release at first mount), run 11,131168 MB
1.0.25, run 21,125167 MB
1.1.1 (slot lending), run 11633 MB
1.1.1, run 21633 MB

On 1.0.25 the peak depends on how far the walk gets ahead of the gate, so it varies between runs; only the 2,000 files in the walk bound it. On 1.1.1 it equals the cap. Peak memory is the process’s peak physical footprint (macOS phys_footprint_peak; ru_maxrss undercounts on this machine). The difference, 134 to 135 MB, is mostly the extra file contents: 1,109 to 1,115 extra files at 100 KB is 111 to 112 MB. Wall time varied from 114 s to 201 s between runs of one version on a machine running other jobs, so these runs say nothing about speed.

Five designs, and only one keeps both bounds

The five designs differ in what a parent does with its slot once it has started children.

Four panels show the same 3-file tree under a cap of 2: a walk, three files, two chunks per file. A slot is a disc on the run that holds it, hollow once lent to a child, and a loan edge is periwinkle; in the per-level panel the digit in the disc, 1 to 3, is the level's semaphore. Hold the slot: the walk and file 1 hold both slots for their whole run; file 1's chunks and files 2 and 3 wait for a slot that never frees, so nothing can start and the tree deadlocks. Release at the first child: the walk and all three files have given their slots back and sit in memory with none; two chunks hold the slots and four wait, 6 in flight, and nothing bounds that count. One semaphore per level: each level has its own semaphore of 2, so the walk, two files and two chunks hold slots at once, 5 in use of a possible 6, and the number of levels must be known in advance. Lend to one child: the walk lends its slot to file 1, which lends it to its first chunk; file 2 holds the other pool slot and lends it to its first chunk; 2 slots in use, 5 in flight of a bound of 6; file 3 and the second chunks wait without having started.
What a parent does with its slot once it has childrenEnlarge

What a parent does with its slot once it has children

Zoom in to see details.

Four panels show the same 3-file tree under a cap of 2: a walk, three files, two chunks per file. A slot is a disc on the run that holds it, hollow once lent to a child, and a loan edge is periwinkle; in the per-level panel the digit in the disc, 1 to 3, is the level's semaphore. Hold the slot: the walk and file 1 hold both slots for their whole run; file 1's chunks and files 2 and 3 wait for a slot that never frees, so nothing can start and the tree deadlocks. Release at the first child: the walk and all three files have given their slots back and sit in memory with none; two chunks hold the slots and four wait, 6 in flight, and nothing bounds that count. One semaphore per level: each level has its own semaphore of 2, so the walk, two files and two chunks hold slots at once, 5 in use of a possible 6, and the number of levels must be known in advance. Lend to one child: the walk lends its slot to file 1, which lends it to its first chunk; file 2 holds the other pool slot and lends it to its first chunk; 2 slots in use, 5 in flight of a bound of 6; file 3 and the second chunks wait without having started.

Hold the slot. Both counts stay at most the cap until the tree deadlocks, which happens whenever as many parents as the cap can be waiting at once, as in a chain longer than the cap; a wide, shallow tree survives.

Release at the first child. No deadlock and a cap on executing tasks, but no bound on tasks in flight; CocoIndex through 1.0.25.

One semaphore per level. Give each level its own semaphore. A task at level d holds a slot of level d and its children use level d+1, so a parent never waits for a slot of its own kind and the wait graph has no cycle. Both counts are bounded, but you must know the number of levels ahead of time, which directory trees and crawls don’t tell you. The total becomes the per-level cap times the number of levels, or you split one budget and leave a level’s share idle when that level has no work. Trio’s documentation describes this deadlock for a fixed-size thread pool and suggests “separate limits for different types of jobs”, which is this design.

Don’t block (helping). A worker that waits for a subtask runs other work instead of parking, as in Java’s ForkJoinPool and Rayon’s join (prior art). The slot is a thread, and a joining thread keeps working, so the pool cannot fill with idle waiters. This covers only waits the pool knows about (a join on one of its own tasks) and only the thread count, not how many tasks have started and hold state. Go’s errgroup.TryGo allows a simpler form: when it reports the group full, the caller runs the child inline.

Lend to one child. A parent keeps its slot, and its first child that asks for a slot runs on it. Executing tasks stay at most the cap, in-flight tasks stay at most the cap times the number of levels, which need not be known, and the pool is shared by all levels. CocoIndex has done this since 1.0.26.

compare.py runs four of them on one workload, the indexer shape above with 2,000 files and a cap of 4 (the fifth, helping, has no direct equivalent in a single asyncio loop):

text
     hold: stuck (30 s timeout), peak files in flight    3 (  0.3 MB), peak chunks running 0
  release:             12.26 s, peak files in flight 2000 (200.0 MB), peak chunks running 4
per_level:             13.13 s, peak files in flight    4 (  0.4 MB), peak chunks running 4
     lend:             13.86 s, peak files in flight    4 (  0.4 MB), peak chunks running 4

Per-level works here because this tree has a fixed 3 levels and only the bottom level does slow work. It took 3 semaphores of 4 to do it, so 12 tasks were allowed to hold a slot.

DesignDeadlock-freeExecuting tasksTasks in flightLevels known aheadOne pool for all levels
Hold the slotno≤ cap≤ capnoyes
Release at the first childyes≤ capunboundednoyes
One semaphore per levelyes≤ cap × levels≤ cap × levelsyesno
Don’t block (helping)for joins on pool tasksthreads ≈ parallelism, plus spare threadsnot bounded by the poolnoyes
Lend to one childyes≤ cap≤ cap × levelsnoyes

Lending is the only row with a hard cap on executing tasks, a bound on tasks in flight and no need to know the levels.

How lending works: the rule and a model you can copy

A parent keeps its slot and lends it to its children one at a time. The first child that asks for a slot runs on the parent’s. Further children take free slots from the pool, or wait while the pool is empty. When the child on the lent slot finishes, the slot passes to the parent’s next waiting child.

Three frames of a 3-file tree under a cap of 2; a slot is a disc on the run that holds it, hollow once lent to a child, and a loan edge is periwinkle. At t = 0.5 the walk keeps its slot and lends it to file 1; file 2 takes the other slot; file 3 waits for whichever frees first. At t = 1.5 files 1 and 2 each lend their slot to their first chunk, so each slot is held at the end of a chain of loans, walk to file 1 to its first chunk, and file 2 to its first chunk; the second chunks wait. At t = 3 each first chunk is done and its loan passes to the second chunk, the parent's next waiting child; file 3 still waits, since no slot is free. Done at t = 8 with at most 2 of 3 files in flight; both bounds hold.
Lend to one child: each slot is held at the end of a chain of loansEnlarge

Lend to one child: each slot is held at the end of a chain of loans

Zoom in to see details.

Three frames of a 3-file tree under a cap of 2; a slot is a disc on the run that holds it, hollow once lent to a child, and a loan edge is periwinkle. At t = 0.5 the walk keeps its slot and lends it to file 1; file 2 takes the other slot; file 3 waits for whichever frees first. At t = 1.5 files 1 and 2 each lend their slot to their first chunk, so each slot is held at the end of a chain of loans, walk to file 1 to its first chunk, and file 2 to its first chunk; the second chunks wait. At t = 3 each first chunk is done and its loan passes to the second chunk, the parent's next waiting child; file 3 still waits, since no slot is free. Done at t = 8 with at most 2 of 3 files in flight; both bounds hold.

A child that runs on its parent’s slot holds a loan, and the parent is its lender; the engine’s code calls a slot a token. Here is the rule as a small asyncio model, written for this post. It follows the engine’s lending rules but leaves out what the Rust engine also does: cancellation, fairness, the build permit below, and admitting a child before its task spawns, so that the parent’s mount call itself waits.

python
"""A teaching model of slot lending in asyncio. Not CocoIndex's engine code."""
import asyncio
from collections import deque


def hand_off(waiters: deque, kind: str) -> bool:
    """Grant `kind` to the first waiter that is still waiting."""
    while waiters:
        fut = waiters.popleft()
        if not fut.done():
            fut.set_result(kind)
            return True
    return False


class Pool:
    def __init__(self, size: int) -> None:
        self.free = size
        self.waiters: deque = deque()

    def release(self) -> None:
        if not hand_off(self.waiters, "pool"):
            self.free += 1


class Run:
    """One task in the tree. Holds one token from admit() to finish()."""

    def __init__(self, pool: Pool, parent: "Run | None" = None) -> None:
        self.pool, self.parent = pool, parent
        self.token = None          # "pool" or "loan" once admitted
        self.lent = False          # a child runs on this run's token
        self.borrowers: deque = deque()
        self.finished = False

    async def admit(self) -> None:
        # Call from inside the parent's body: the parent is already admitted.
        p = self.parent
        if p and not p.lent and not p.finished:   # the parent's token first
            p.lent, self.token = True, "loan"
        elif self.pool.free:
            self.pool.free -= 1
            self.token = "pool"
        else:                      # wait for whichever frees first
            fut = asyncio.get_running_loop().create_future()
            self.pool.waiters.append(fut)
            if p and not p.finished:
                p.borrowers.append(fut)
            self.token = await fut

    def finish(self) -> None:
        self.finished = True
        self.borrowers.clear()     # they still wait in the pool's queue
        if not self.lent:          # else the loan gives the token back
            self._give_back()

    def _give_back(self) -> None:
        if self.token == "loan":
            self.parent._loan_returned()
        else:
            self.pool.release()

    def _loan_returned(self) -> None:
        self.lent = False
        if self.finished:          # ended while lending: release it now
            self._give_back()
        elif hand_off(self.borrowers, "loan"):
            self.lent = True

A waiting child registers one future in two queues, the pool’s and its parent’s, and takes whichever slot is granted first. hand_off skips futures that are already done, so the stale entry in the other queue is ignored when its turn comes. When both are free at once the engine takes the parent’s, so that pool slots stay with runs that have no parent to borrow from; its waits are also cancel-safe (a run dropped while queued leaves the queue, and a slot granted to a waiter that has gone goes to the next one), which the model leaves out. The model relies on asyncio running one callback at a time; a threaded version needs a lock around each step. Using it takes two calls around each task’s body:

python
async def node(pool, cap, parent, depth, fan_out):
    global leaves_running
    run = Run(pool, parent)
    await run.admit()                  # a mount waits here
    live.add(run)
    check(pool, cap)
    try:
        if depth == 0:
            leaves_running += 1
            peak["leaves"] = max(peak["leaves"], leaves_running)
            await asyncio.sleep(0.001)
            leaves_running -= 1
        else:
            await asyncio.gather(*(node(pool, cap, run, depth - 1, fan_out)
                                   for _ in range(fan_out)))
    finally:
        live.discard(run)
        run.finish()
        check(pool, cap)

check counts the runs that hold a slot not lent out, asserts that this never exceeds the slots taken from the pool, and records the peaks; at the end of each tree, tree.py asserts that every slot came back. In its output depth counts edges, so the tree has depth + 1 levels. Run on deep trees and wide trees:

text
cap  2, depth  3, fan-out    2,    15 tasks: peak executing 2, peak in flight 7 (bound 8), leaves at once 2, 0.01 s
cap  1, depth 20, fan-out    1,    21 tasks: peak executing 1, peak in flight 21 (bound 21), leaves at once 1, 0.00 s
cap  1, depth 10, fan-out    2,  2047 tasks: peak executing 1, peak in flight 11 (bound 11), leaves at once 1, 2.12 s
cap  2, depth 10, fan-out    2,  2047 tasks: peak executing 2, peak in flight 21 (bound 22), leaves at once 2, 2.20 s
cap  1, depth  1, fan-out 1000,  1001 tasks: peak executing 1, peak in flight 2 (bound 2), leaves at once 1, 3.81 s
cap 32, depth  1, fan-out 3000,  3001 tasks: peak executing 32, peak in flight 33 (bound 64), leaves at once 32, 0.25 s
cap  4, depth  3, fan-out   10,  1111 tasks: peak executing 4, peak in flight 13 (bound 16), leaves at once 4, 0.52 s

The first line is the tree that deadlocked in the first repro. A 21-level chain and a binary tree of 2,047 tasks both complete on one slot, and the chain reaches its bound of 21 exactly. A root with 3,000 children and a cap of 32 runs 32 children at once, 1 on the root’s lent slot and 31 on pool slots. CocoIndex pins the same three kinds of tree against the real engine in test_concurrency_control.py: a fan-out of 3,000 children whose peak equals the pool size, a binary tree 6 levels deep on a pool of 2, and a chain 21 levels deep on a pool of 1.

Why lending cannot deadlock, and the two bounds it keeps

Lending keeps one invariant: a blocked parent’s slot is always held by a descendant, so some run below it can proceed. From it follow deadlock freedom and two bounds: runs holding an unlent slot ≤ cap, and runs in flight ≤ cap × levels.

No deadlock. Suppose every admitted run is blocked, and pick an admitted run T as deep in the tree as any. T waits for its children, either for one to finish or for one to be admitted. A child that is admitted is deeper than T, which contradicts the choice of T. A child waiting for admission waits only if T’s slot is out on loan, because a child takes its parent’s free slot at once; the borrower is then an admitted child of T, deeper than T again. So the deepest admitted run can always proceed, and the tree completes with a cap as small as 1. The argument assumes runs only wait on their own children. A run that also waits on something outside the tree, like the indexer’s gate, can still block forever if that thing never opens; lending ensures the pool is never the reason.

Executing ≤ cap. Every slot starts in the pool, and there are cap of them. A slot moves only by lending, from a parent to one child at a time, so each slot in use is held by a chain: the run that took it from the pool, the child it lent it to, that child’s child, and so on. Exactly one run on each chain holds the slot without having lent it out, so runs holding an unlent slot are at most the slots in use, which is at most the cap. The engine’s module doc states the same bound (admission.rs:14-19). What the holder is doing with the slot, its own body, a call or a wait on a child, makes no difference to the pool.

In flight ≤ cap × levels. Every admitted run holds a slot, its own or a loan, so every run in flight sits on exactly one chain. A chain only goes down the tree, one level per loan, so it has at most one run per level. Cap chains of at most one run per level give the bound. The 21-level chain above reaches it. A parent whose slot is not on loan keeps it, and no other run can take it, because only a parent lends.

Two places a real implementation goes wrong

A real implementation must handle two cases the model simplifies: a second lock, and a parent that ends while lending. Most of the work in #2486 went there. Each is a rule for any implementation; the engine code and tests behind them are listed at the end of the section.

A second lock: never wait for a slot while holding the lock

CocoIndex serializes the runs of one component with a per-component lock, the build permit. A second run of a running component happens when two parents mount a shared component, or when two App.update() calls overlap. If the second run takes the permit and then waits for a slot, while the first run holds a slot and waits for the permit, each waits for the other.

Two runs, A and B, of the same child component, with a pool of 1 whose slot is lent to a sibling. Before the fix, once the sibling ends: run A holds the slot and waits for the build permit, while run B holds the build permit and waits for the slot, a cycle. After the fix, in six steps: B starts while A is in flight and takes no slot; B takes the permit and finds it needs a slot; B drops the permit, then waits for a slot; the sibling ends and A gets the slot; A takes the permit, runs and stores its memo; B gets a slot and the permit and reuses the memo. Order fixed: slot before permit.
Never wait for a slot while holding the build permitEnlarge

Never wait for a slot while holding the build permit

Zoom in to see details.

Two runs, A and B, of the same child component, with a pool of 1 whose slot is lent to a sibling. Before the fix, once the sibling ends: run A holds the slot and waits for the build permit, while run B holds the build permit and waits for the slot, a cycle. After the fix, in six steps: B starts while A is in flight and takes no slot; B takes the permit and finds it needs a slot; B drops the permit, then waits for a slot; the sibling ends and A gets the slot; A takes the permit, runs and stores its memo; B gets a slot and the permit and reuses the memo. Order fixed: slot before permit.

Fix one order, slot before permit, and never wait for the slot while holding the permit. In the engine, a run that starts while another run of the same component is in flight takes no slot before it spawns. It queues on the permit and asks for a slot only if it turns out to execute instead of reusing the result the first run stores (its memo). A run that holds the permit and still needs a slot drops the permit, waits for the slot, then takes the permit again and re-checks the memo. Without that second rule, the engine’s test for this cycle fails by timeout.

A parent that ends while lending: keep the slot with the loan

Structured code never lets a parent finish before its children, which is what makes a loan return before its lender ends. CocoIndex’s live components step outside that: a live component keeps updating after the run that mounted it has returned. If the parent released its slot when it ended, the pool could hand out a slot that the child still uses; if it dropped the slot, the slot would leak. So the slot stays parked with the loan until the child ends, and only then goes back to the pool. Children still waiting to borrow fall back to the pool.

Three steps with a pool of 1; a slot is a disc on the run that holds it, hollow once lent, and a loan edge is periwinkle. In CocoIndex the parent is the run that mounts a live component, which keeps running. Step 1: the parent takes the only slot and lends it to its child, which runs on it; pool 0 free. Step 2: the parent ends while the child runs; the slot stays parked with the loan, the pool still has 0 free, and a new run asks for a slot and waits. Step 3: the child ends; the slot goes back to the pool and straight to the waiting run, which now holds it; when it ends, 1 is free. Releasing the slot when the parent ends would let the pool hand it out twice; dropping it would leak it.
A parent that ends while lending: the slot stays with the loanEnlarge

A parent that ends while lending: the slot stays with the loan

Zoom in to see details.

Three steps with a pool of 1; a slot is a disc on the run that holds it, hollow once lent, and a loan edge is periwinkle. In CocoIndex the parent is the run that mounts a live component, which keeps running. Step 1: the parent takes the only slot and lends it to its child, which runs on it; pool 0 free. Step 2: the parent ends while the child runs; the slot stays parked with the loan, the pool still has 0 free, and a new run asks for a slot and waits. Step 3: the child ends; the slot goes back to the pool and straight to the waiting run, which now holds it; when it ends, 1 is free. Releasing the slot when the parent ends would let the pool hand it out twice; dropping it would leak it.

The teaching model does the same in finish() and _loan_returned(); parked.py runs the steps in the figure with a pool of 1:

text
after parent ends: 0 free
other run waiting: True
other run admitted: pool
at the end: 1 free

Every kind of run takes slots from the same pool, deletes and live-component cycles included, so cleanup cannot bring back the unbounded case.

Where this lives in the engine, at commit b5e83d9

Sizing the cap for memory

max_inflight_components caps how many components run at once; it does not cap memory. Lending bounds runs in flight to cap × levels, and each of those runs keeps whatever it loaded. In the engine benchmark, raising the cap from 16 to 256 on 1.1.1 raised the peak to 255 file components in flight and the peak memory from 33 MB to 68 MB.

To size the cap for memory, count per chain. Each chain holds at most one run per level, so the peak state is about cap × (the state one run holds at each level, summed over levels). With files that hold 1 MB each and chunks that hold little, the default cap of 1,024 (app.py:48-49) allows about 1 GB of file contents at once. Things to check:

  • Uneven items. If a few items are much larger than the rest, size the cap from the largest state per level, or split a large item into child components so each holds less.
  • Work inside a component. It is not under the cap. Lending starts when a child asks for a slot, so a component that awaits an ordinary function call, batched or not, keeps its slot, and coco.map() runs its calls as tasks in the same component; the flow-control post measured 40 concurrent chunk calls under a cap of 4. Bound those calls where they happen.
  • Application gates. Since 1.0.26, a gate placed after the first child mount stays bounded, because the waiting file keeps its slot and lends it to its first child (test_parent_blocked_on_an_asyncio_primitive_keeps_lending). The gate limits the stage behind it, and the cap limits the files waiting at it.

In CocoIndex, set the cap with max_inflight_components in coco.AppConfig or the COCOINDEX_MAX_INFLIGHT_COMPONENTS environment variable. The concurrency control docs and the flow-control post cover configuration, with an interactive trace of lending at different caps.

How Python, Trio, Go, Java and Rust handle nested limits

Each runtime below handles part of it.

  • Python. The ThreadPoolExecutor docs show the parent-waits-on-child deadlock with max_workers=1: “This will never complete because there is only one worker thread and it is executing this function.” asyncio.Semaphore does not record who holds a slot.
  • Trio. CapacityLimiter records which task borrowed each slot and raises RuntimeError if a task that already holds one asks again. That catches a task waiting on itself, not a parent waiting on a different child task.
  • Go. errgroup’s Go “blocks until the new goroutine can be added without the number of goroutines in the group exceeding the configured limit”. A binary tree of depth 3 under SetLimit(2), where each goroutine calls g.Go for its children and waits for them, stops with both admitted goroutines blocked in g.Go; with TryGo and an inline fallback, the same tree finishes (both in go-errgroup and go-trygo). Bryan Mills, proposing the non-blocking variant that became TryGo, wrote that it “is mostly useful for “concurrency-saturating” operations like tree or graph traversals”. The inline fallback is a simple form of lending: the child runs on the parent’s slot, but only in the parent’s goroutine and only if the group is full when the child starts.
  • Java. ForkJoinPool “attempts to maintain enough active (or available) threads by dynamically adding, suspending, or resuming internal worker threads, even if some tasks are stalled waiting to join others”. A joining worker runs or helps with other tasks first and adds a spare thread only as a last resort (ForkJoinPool.java, “Joining Tasks”), so the thread count can exceed the parallelism level; the docs promise nothing for “blocked I/O or other unmanaged synchronization”.
  • Rust. Rayon’s join “will look for other work while waiting for the thief to fully execute closure B”, and warns that blocking one closure on another, “for example, using a channel”, can deadlock.
  • Structured concurrency. Nathaniel J. Smith’s essay defines the property lending relies on: a nursery “doesn’t exit until all the tasks inside it have exited”. When parents outlive their children, a loan always returns before its lender ends, and a chain never has more runs than the tree has levels. Python’s asyncio.TaskGroup gives the same guarantee. Live components are where CocoIndex steps outside it, which is why the parked slot exists.

Checklist

When you cap a tree of tasks that start tasks of their own:

  1. A plain semaphore held for a task’s whole run deadlocks once parents whose children need slots fill the cap. Don’t hold one that way.
  2. Releasing the slot at the first child needs something else to bound the tasks in flight. Count tasks that have started, not only tasks that hold a slot.
  3. Lend a parent’s slot to one child at a time. Runs holding an unlent slot stay ≤ cap and runs in flight ≤ cap × levels, at any depth and with a cap as small as 1.
  4. Let a waiting child take whichever frees first, the parent’s slot or a pool slot. Prefer the parent’s, and leave the other queue.
  5. Fix the order of the slot and any second lock, and never wait for the slot while holding the lock.
  6. If a parent can finish before its children, keep its slot until its last loan returns.
  7. Put every kind of run on the same pool, cleanup and background work included.
  8. Size the cap for memory as cap × the state held per level, and bound concurrency inside a task separately.

How CocoIndex applies these rules

CocoIndex is an open-source incremental engine for the context AI agents read: codebases, documents, meeting notes, PR reviews and Slack threads. You write the pipeline in Python; when a source changes, only the affected components re-run, and the targets the agent queries stay in sync. In live mode a pipeline like that runs for as long as the agent needs fresh context, so the engine carries these rules:

  • One cap for the whole tree, max_inflight_components (default 1,024), with slot lending, so a hierarchy of any depth finishes (when tasks wait only on their own children) and waiting work stays bounded.
  • Fair sharing between live updates, so a small refresh of one source does not wait behind another source’s full re-index (how it works).

The engine code behind this post is in rust/core/src/engine/admission.rs, and its tests are in test_concurrency_control.py. CocoIndex is Apache 2.0 licensed. To try it, start with the examples and the concurrency control docs; if a workload of yours breaks one of these bounds, open an issue with the shape of the tree.

Reproduce it

Everything in this section was written for this post; none of it is engine code. The engine is Rust, and the links in the text point into it at commit b5e83d9. The snippets quoted above are complete programs; below are the rest: the harnesses for the asyncio teaching model, the benchmark that drives the released CocoIndex wheels from PyPI, the two Go programs behind the errgroup claims, the recorder behind the interactive figure and the strips, and every output. The runs used an Apple M5 Pro (18 cores, 48 GB) on macOS 27.0.1, Python 3.12.13, Go 1.27.3 with golang.org/x/sync v0.24.0, and CocoIndex 1.0.25 and 1.1.1 from PyPI, with a fresh state database for each run of engine_bench.py.

tree.py: the teaching model on deep and wide trees, with the bound checks
python
"""Run the lending model on a deep tree and a wide tree; check the bounds."""
import asyncio
import sys
import time

from lending import Pool, Run

live: set = set()                      # admitted, not finished
peak = {"in_flight": 0, "executing": 0, "leaves": 0}
leaves_running = 0


def check(pool: Pool, cap: int) -> None:
    executing = sum(1 for r in live if not r.lent)   # holds a token not lent out
    # A token granted to a waiter that has not resumed yet is in use but not
    # in `live`, so the count can be lower than the tokens taken, never higher.
    assert executing <= cap - pool.free, (executing, cap, pool.free)
    peak["in_flight"] = max(peak["in_flight"], len(live))
    peak["executing"] = max(peak["executing"], executing)


async def node(pool, cap, parent, depth, fan_out):
    global leaves_running
    run = Run(pool, parent)
    await run.admit()                  # a mount waits here
    live.add(run)
    check(pool, cap)
    try:
        if depth == 0:
            leaves_running += 1
            peak["leaves"] = max(peak["leaves"], leaves_running)
            await asyncio.sleep(0.001)
            leaves_running -= 1
        else:
            await asyncio.gather(*(node(pool, cap, run, depth - 1, fan_out)
                                   for _ in range(fan_out)))
    finally:
        live.discard(run)
        run.finish()
        check(pool, cap)


async def main(cap, depth, fan_out):
    pool = Pool(cap)
    for k in peak:
        peak[k] = 0
    t = time.perf_counter()
    await asyncio.wait_for(node(pool, cap, None, depth, fan_out), timeout=60)
    nodes = sum(fan_out ** d for d in range(depth + 1))
    print(f"cap {cap:>2}, depth {depth:>2}, fan-out {fan_out:>4}, {nodes:>5} tasks: "
          f"peak executing {peak['executing']}, peak in flight {peak['in_flight']} "
          f"(bound {cap * (depth + 1)}), leaves at once {peak['leaves']}, "
          f"{time.perf_counter() - t:.2f} s")
    assert peak["executing"] <= cap and peak["in_flight"] <= cap * (depth + 1)
    assert pool.free == cap and not live      # every token came back


if __name__ == "__main__":
    for cap, depth, fan_out in [(2, 3, 2), (1, 20, 1), (1, 10, 2), (2, 10, 2), (1, 1, 1000),
                                (32, 1, 3000), (4, 3, 10)]:
        asyncio.run(main(cap, depth, fan_out))
parked.py: the teaching model: a parent that ends while lending
python
"""A parent that ends while its token is lent (CocoIndex: live components)."""
import asyncio

from lending import Pool, Run


async def main():
    pool = Pool(1)
    parent = Run(pool)
    await parent.admit()                       # takes the only pool token
    child = Run(pool, parent)
    await child.admit()                        # runs on the parent's token
    parent.finish()                            # parent ends; child still runs
    print("after parent ends:", pool.free, "free")   # 0: the loan still holds it
    assert pool.free == 0
    other = Run(pool)
    waiting = asyncio.create_task(other.admit())
    await asyncio.sleep(0)
    print("other run waiting:", not waiting.done())
    child.finish()                             # the loan returns the parent's token
    await waiting
    print("other run admitted:", other.token)
    other.finish()
    print("at the end:", pool.free, "free")
    assert pool.free == 1


asyncio.run(main())
compare.py: four strategies on one workload, in asyncio
python
"""One workload, four strategies: a walk mounts FILES file tasks; each file
reads its contents, then mounts CHUNKS chunk tasks that do the slow step."""
import asyncio
import time

from lending import Pool, Run

CAP, FILES, CHUNKS, SIZE = 4, 2000, 2, 100_000
stats = {}


def enter(kind):
    stats[kind] = stats.get(kind, 0) + 1
    stats["peak_" + kind] = max(stats.get("peak_" + kind, 0), stats[kind])


def leave(kind):
    stats[kind] -= 1


# 1. Hold the slot while waiting for children.
async def hold():
    sem = asyncio.Semaphore(CAP)

    async def chunk(text):
        async with sem:
            enter("exec"); await asyncio.sleep(0.01); leave("exec")

    async def file():
        async with sem:
            enter("files"); text = bytes(SIZE)
            await asyncio.gather(*(chunk(text) for _ in range(CHUNKS)))
            leave("files")

    async with sem:
        await asyncio.gather(*(file() for _ in range(FILES)))


# 2. Release the slot before waiting (CocoIndex before 1.0.26: at the first child mount).
async def release():
    sem = asyncio.Semaphore(CAP)

    async def chunk(text):
        async with sem:
            enter("exec"); await asyncio.sleep(0.01); leave("exec")

    async def file():
        await sem.acquire()
        enter("files"); text = bytes(SIZE)
        sem.release()
        await asyncio.gather(*(chunk(text) for _ in range(CHUNKS)))
        leave("files")

    await sem.acquire()
    sem.release()
    await asyncio.gather(*(file() for _ in range(FILES)))


# 3. One semaphore per level of the tree.
async def per_level():
    level = [asyncio.Semaphore(CAP) for _ in range(3)]

    async def chunk(text):
        async with level[2]:
            enter("exec"); await asyncio.sleep(0.01); leave("exec")

    async def file():
        async with level[1]:
            enter("files"); text = bytes(SIZE)
            await asyncio.gather(*(chunk(text) for _ in range(CHUNKS)))
            leave("files")

    async with level[0]:
        await asyncio.gather(*(file() for _ in range(FILES)))


# 4. Lend the parent's slot to one child at a time (lending.py).
async def lend():
    pool = Pool(CAP)

    async def task(parent, body):
        run = Run(pool, parent)
        await run.admit()
        try:
            await body(run)
        finally:
            run.finish()

    async def chunk(run, text):
        enter("exec"); await asyncio.sleep(0.01); leave("exec")

    async def file(run):
        enter("files"); text = bytes(SIZE)
        await asyncio.gather(*(task(run, lambda r: chunk(r, text)) for _ in range(CHUNKS)))
        leave("files")

    async def walk(run):
        await asyncio.gather(*(task(run, file) for _ in range(FILES)))

    await task(None, walk)


async def main():
    for strategy in (hold, release, per_level, lend):
        stats.clear()
        t = time.perf_counter()
        try:
            await asyncio.wait_for(strategy(), timeout=30)
            result = f"{time.perf_counter() - t:5.2f} s"
        except TimeoutError:
            result = "stuck (30 s timeout)"
        files = stats.get("peak_files", 0)
        print(f"{strategy.__name__:>9}: {result:>19}, peak files in flight {files:>4} "
              f"({files * SIZE / 1e6:5.1f} MB), peak chunks running {stats.get('peak_exec', 0)}")


asyncio.run(main())
engine_bench.py: the incident’s shape on the released CocoIndex engine
python
"""The incident's shape on the real engine. A walk mounts FILES file
components. Each file reads 100 KB, mounts a quick first child, then waits on
an application gate (an asyncio.Semaphore of GATE) around the slow step: its
CHUNKS chunk components. Prints the peak number of file components in flight.

usage: python engine_bench.py <max_inflight_components> <db_dir>
"""
import asyncio
import os
import pathlib
import re
import resource
import subprocess
import sys
import threading
import time

import cocoindex as coco

FILES, CHUNKS, SIZE, CHUNK_SECONDS, GATE = 2000, 2, 100_000, 0.02, 4
gate: asyncio.Semaphore
lock = threading.Lock()
count = {"files": 0, "peak_files": 0, "chunks": 0, "peak_chunks": 0}


def enter(kind):
    with lock:
        count[kind] += 1
        count["peak_" + kind] = max(count["peak_" + kind], count[kind])


def leave(kind):
    with lock:
        count[kind] -= 1


@coco.fn
async def chunk(i: int, j: int) -> None:
    enter("chunks")
    await asyncio.sleep(CHUNK_SECONDS)   # the slow downstream step
    leave("chunks")


@coco.fn
async def metadata(i: int) -> None:
    pass


@coco.fn
async def file(i: int) -> None:
    enter("files")
    text = os.urandom(SIZE)              # the file's contents (random, so memory counts them)
    await coco.use_mount(coco.component_subpath("meta"), metadata, i)
    async with gate:                     # the application's own limit
        await asyncio.gather(*(
            coco.use_mount(coco.component_subpath(f"c{j}"), chunk, i, j)
            for j in range(CHUNKS)
        ))
    del text
    leave("files")


async def walk() -> None:
    global gate
    gate = asyncio.Semaphore(GATE)       # created on the engine's loop
    for i in range(FILES):
        await coco.mount(coco.component_subpath(str(i)), file, i)


def peak_memory_mb() -> float:
    """Peak physical footprint. On macOS ru_maxrss undercounts (memory
    compression), so read phys_footprint_peak from `footprint`."""
    if sys.platform == "darwin":
        out = subprocess.run(["footprint", str(os.getpid())], capture_output=True, text=True).stdout
        m = re.search(r"phys_footprint_peak: ([\d.]+) (KB|MB|GB)", out)
        if m:
            return float(m.group(1)) * {"KB": 1e-3, "MB": 1, "GB": 1e3}[m.group(2)]
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1e3  # KB on Linux


if __name__ == "__main__":
    cap, db = int(sys.argv[1]), pathlib.Path(sys.argv[2])
    env = coco.Environment(coco.Settings.from_env(db_path=db))
    app = coco.App(coco.AppConfig(name="bench", environment=env,
                                  max_inflight_components=cap), walk)
    t = time.perf_counter()
    app.update_blocking()
    rss_mb = peak_memory_mb()
    print(f"cocoindex {coco.__version__ if hasattr(coco, '__version__') else '?'}"
          f" cap {cap}: peak {count['peak_files']} file components in flight, "
          f"peak {count['peak_chunks']} chunks running, "
          f"{time.perf_counter() - t:.1f} s, peak memory {rss_mb:.0f} MB")
main.go: the errgroup tree from the prior-art section
go
package main

import (
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

// walk visits a binary tree of the given depth; every node waits for its children.
func walk(g *errgroup.Group, depth int) error {
	if depth == 0 {
		time.Sleep(time.Millisecond)
		return nil
	}
	done := make(chan error, 2)
	for i := 0; i < 2; i++ {
		g.Go(func() error { err := walk(g, depth-1); done <- err; return err })
	}
	<-done
	return <-done
}

func main() {
	var g errgroup.Group
	g.SetLimit(2)
	finished := make(chan error, 1)
	go func() { finished <- walk(&g, 3) }()
	select {
	case <-finished:
		fmt.Println("finished")
	case <-time.After(2 * time.Second):
		fmt.Println("stuck after 2 s: both goroutines the limit admits are blocked in g.Go")
	}
}
main.go: the same tree with TryGo and an inline fallback
go
package main

import (
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

// walk visits a binary tree of the given depth; every node waits for its children.
func walk(g *errgroup.Group, depth int) error {
	if depth == 0 {
		time.Sleep(time.Millisecond)
		return nil
	}
	done := make(chan error, 2)
	for i := 0; i < 2; i++ {
		f := func() error { err := walk(g, depth-1); done <- err; return err }
		if !g.TryGo(f) {
			f() // no slot free: run the child inline
		}
	}
	<-done
	return <-done
}

func main() {
	var g errgroup.Group
	g.SetLimit(2)
	finished := make(chan error, 1)
	go func() { finished <- walk(&g, 3) }()
	select {
	case <-finished:
		fmt.Println("finished")
	case <-time.After(2 * time.Second):
		fmt.Println("stuck after 2 s: both goroutines the limit admits are blocked in g.Go")
	}
}
race.py: the asyncio runs behind the interactive figure and the strips
python
"""Record the interactive race in fig/race-data.json.

The same tree (a walk mounts 6 files; each file reads for 1 tick, then waits on
2 chunks of 2 ticks) runs under a cap of 2 with three strategies: hold the slot
(asyncio.Semaphore), release it at the first child (asyncio.Semaphore), and lend it
(../_code/lending.py). A virtual clock makes every run deterministic: sleeps
advance time instantly. A sampler lets every hand-off due at an instant settle,
then records each node's state, which nodes hold a pool token or a loan, every
half tick. The static strips in the post (_diagrams-src/dark/gen.py) call
record() on a 3-file tree. Needs Python 3.12 or later: asyncio.Semaphore wakes
its waiters in a different order on older versions, which changes the frames.

usage: uv run --python 3.12 python race.py   (from any directory)
"""
import asyncio
import json
import pathlib
import selectors
import sys

if sys.version_info < (3, 12):
    sys.exit("race.py needs Python 3.12 or later (asyncio.Semaphore wake-up order)")

HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "_code"))
from lending import Pool, Run  # noqa: E402

CAP, FILES, CHUNKS, READ, CHUNK = 2, 6, 2, 1.0, 2.0
STEP, LIMIT = 0.5, 24.0
EPS = 1e-6                               # sample just after a tick, so events due at the tick count


class _InstantSelector(selectors.SelectSelector):
    def __init__(self, loop_ref):
        super().__init__()
        self.loop_ref = loop_ref

    def select(self, timeout=None):
        if timeout:
            self.loop_ref[0].now += timeout   # jump to the next timer
        return super().select(0)


class VirtualLoop(asyncio.SelectorEventLoop):
    def __init__(self):
        ref = [None]
        super().__init__(_InstantSelector(ref))
        ref[0] = self
        self.now = 0.0

    def time(self):
        return self.now


def names_of(files, chunks=CHUNKS):
    return ["walk"] + [f"f{i}" for i in range(1, files + 1)] + [
        f"f{i}c{j}" for i in range(1, files + 1) for j in range(1, chunks + 1)]


def record(strategy, files=FILES, cap=CAP):
    """Run one strategy and return its frames.

    Each frame: t, s (name -> none, wait, run, hold, lent, mem, done), k (name ->
    the pool token, "s1" or "s2", that the node holds or has lent out; a borrower
    shows its lender's token) and l (names running on a loan; lend only)."""
    names = names_of(files)
    # Long enough for any cap to finish (one task at a time); hold never finishes.
    limit = 6.0 if strategy == "hold" else max(LIMIT, files * (READ + CHUNKS * CHUNK) + 2)
    state = {n: "none" for n in names}
    frames, finished = [], {"t": None}
    owners = {}                          # name -> token id, for nodes holding a pool token

    def tokens(pool_holders, chain_root):
        """Assign stable ids to pool tokens: a new holder gets the lowest free id."""
        for n in list(owners):
            if n not in pool_holders:
                del owners[n]
        for n in pool_holders:
            if n not in owners:
                owners[n] = next(i for i in range(1, cap + 1) if i not in owners.values())
        return {n: f"s{owners[chain_root(n)]}" for n in names if chain_root(n) in owners}

    async def sampler(snapshot, fingerprint):
        loop = asyncio.get_running_loop()
        while True:
            # Let every hand-off due at this instant settle: yield until nothing changes.
            prev = None
            for _ in range(200):
                await asyncio.sleep(0)
                cur = fingerprint()
                if cur == prev:
                    break
                prev = cur
            frames.append({"t": round(loop.time(), 2), **snapshot()})
            if finished["t"] is not None or loop.time() >= limit:
                return
            await asyncio.sleep(STEP)

    async def sampler_after_tick(snapshot, fingerprint):
        await asyncio.sleep(EPS)
        await sampler(snapshot, fingerprint)

    sem = None                           # created on the virtual loop, in main()

    if strategy in ("hold", "release"):
        def fingerprint():
            return (tuple(state.values()), sem._value)

        def snapshot():
            holders = [n for n in names if state[n] in ("run", "hold")]
            return {"s": dict(state), "k": tokens(holders, lambda n: n)}

        async def chunk(name):
            state[name] = "wait"
            async with sem:
                state[name] = "run"
                await asyncio.sleep(CHUNK)
            state[name] = "done"

        async def file(i):
            name = f"f{i}"
            state[name] = "wait"
            await sem.acquire()
            state[name] = "run"
            await asyncio.sleep(READ)                  # read the file
            if strategy == "release":
                sem.release()
                state[name] = "mem"                    # waits without a slot
            else:
                state[name] = "hold"
            await asyncio.gather(*(chunk(f"{name}c{j}") for j in range(1, CHUNKS + 1)))
            if strategy == "hold":
                sem.release()
            state[name] = "done"

        async def walk():
            state["walk"] = "wait"
            await sem.acquire()
            if strategy == "release":
                sem.release()
                state["walk"] = "mem"
            else:
                state["walk"] = "hold"
            await asyncio.gather(*(file(i) for i in range(1, files + 1)))
            if strategy == "hold":
                sem.release()
            state["walk"] = "done"
    else:
        pool = Pool(cap)
        runs = {}

        def holding(n):
            r = runs.get(n)
            return r is not None and r.token is not None and not r.finished

        def chain_root(n):               # the run that took this node's token from the pool
            r = runs.get(n)
            while r is not None and r.token == "loan":
                r = r.parent
            return r.name if r is not None else n

        def fingerprint():
            return (tuple(state.values()), pool.free, len(pool.waiters),
                    tuple((n, r.token, r.lent, r.finished) for n, r in runs.items()))

        def snapshot():
            for n, r in runs.items():
                if state[n] in ("hold", "lent", "run") and holding(n):
                    if r.lent:
                        state[n] = "lent"
                    elif state[n] == "lent":
                        state[n] = "hold"
            pool_holders = [n for n in names if holding(n) and runs[n].token == "pool"]
            k = tokens(pool_holders, lambda n: chain_root(n) if holding(n) else n)
            loans = [n for n in names if holding(n) and runs[n].token == "loan"]
            return {"s": dict(state), "k": k, "l": loans}

        async def task(name, parent, body):
            r = Run(pool, parent)
            r.name = name
            runs[name] = r
            state[name] = "wait"
            await r.admit()
            try:
                await body(r)
            finally:
                r.finish()
                state[name] = "done"

        async def chunk_body(r, name):
            state[name] = "run"
            await asyncio.sleep(CHUNK)

        async def file_body(r, name):
            state[name] = "run"
            await asyncio.sleep(READ)
            state[name] = "hold"
            await asyncio.gather(*(task(f"{name}c{j}", r, lambda rr, n=f"{name}c{j}": chunk_body(rr, n))
                                   for j in range(1, CHUNKS + 1)))

        async def walk_body(r):
            state["walk"] = "hold"
            await asyncio.gather(*(task(f"f{i}", r, lambda rr, n=f"f{i}": file_body(rr, n))
                                   for i in range(1, files + 1)))

        async def walk():
            await task("walk", None, walk_body)

    async def main():
        nonlocal sem
        if strategy in ("hold", "release"):
            sem = asyncio.Semaphore(cap)
        s = asyncio.create_task(sampler_after_tick(snapshot, fingerprint))
        w = asyncio.create_task(walk())
        done, _ = await asyncio.wait({w}, timeout=limit)
        if done:
            w.result()                   # a crash raises here instead of reading as "done"
            finished["t"] = asyncio.get_running_loop().time()
        await s
        w.cancel()
        await asyncio.gather(w, return_exceptions=True)
        return finished["t"]

    loop = VirtualLoop()
    try:
        end = loop.run_until_complete(main())
    finally:
        loop.close()
    files_peak = max(sum(1 for i in range(1, files + 1) if f["s"][f"f{i}"] in ("run", "hold", "lent", "mem"))
                     for f in frames)
    return {"frames": frames, "end": end, "stuck": end is None, "files_peak": files_peak}


if __name__ == "__main__":
    out = {"cap": CAP, "files": FILES, "chunks": CHUNKS, "names": names_of(FILES),
           "runs": {k: record(k) for k in ("hold", "release", "lend")}}
    (HERE.parent / "fig" / "race-data.json").write_text(json.dumps(out, separators=(",", ":")))
    for k, r in out["runs"].items():
        print(f"{k:>7}: {'stuck' if r['stuck'] else 'done at t=' + str(r['end'])}, "
              f"{len(r['frames'])} frames, peak files in flight {r['files_peak']}")
outputs.txt: every output quoted in this post
text
# Machine: Apple M5 Pro, 18 cores, 48 GB; macOS 27.0.1; Python 3.12.13 (uv); Go 1.27.3; golang.org/x/sync v0.24.0; 2026-11-05

$ python deadlock.py
stuck after 2 s: 2 tasks hold both slots, 0 of 8 leaves done

$ python release_on_wait.py
cap 4: peak 2000 files in flight, 200 MB of contents held

$ python tree.py
cap  2, depth  3, fan-out    2,    15 tasks: peak executing 2, peak in flight 7 (bound 8), leaves at once 2, 0.01 s
cap  1, depth 20, fan-out    1,    21 tasks: peak executing 1, peak in flight 21 (bound 21), leaves at once 1, 0.00 s
cap  1, depth 10, fan-out    2,  2047 tasks: peak executing 1, peak in flight 11 (bound 11), leaves at once 1, 2.12 s
cap  2, depth 10, fan-out    2,  2047 tasks: peak executing 2, peak in flight 21 (bound 22), leaves at once 2, 2.20 s
cap  1, depth  1, fan-out 1000,  1001 tasks: peak executing 1, peak in flight 2 (bound 2), leaves at once 1, 3.81 s
cap 32, depth  1, fan-out 3000,  3001 tasks: peak executing 32, peak in flight 33 (bound 64), leaves at once 32, 0.25 s
cap  4, depth  3, fan-out   10,  1111 tasks: peak executing 4, peak in flight 13 (bound 16), leaves at once 4, 0.52 s

$ python parked.py
after parent ends: 0 free
other run waiting: True
other run admitted: pool
at the end: 1 free

$ go run ./go-errgroup
stuck after 2 s: both goroutines the limit admits are blocked in g.Go
$ go run ./go-trygo
finished

$ engine_bench.py (cocoindex from PyPI, one fresh LMDB dir per run; memory = macOS phys_footprint_peak)
cocoindex 1.0.25 cap 16: peak 1131 file components in flight, peak 8 chunks running, 153.5 s, peak memory 168 MB
cocoindex 1.1.1 cap 16: peak 16 file components in flight, peak 8 chunks running, 200.8 s, peak memory 33 MB
cocoindex 1.0.25 cap 16: peak 1125 file components in flight, peak 8 chunks running, 130.8 s, peak memory 167 MB
cocoindex 1.1.1 cap 16: peak 16 file components in flight, peak 8 chunks running, 114.2 s, peak memory 33 MB
cocoindex 1.1.1 cap 256: peak 255 file components in flight, peak 8 chunks running, 142.2 s, peak memory 68 MB

$ python compare.py
     hold: stuck (30 s timeout), peak files in flight    3 (  0.3 MB), peak chunks running 0
  release:             12.26 s, peak files in flight 2000 (200.0 MB), peak chunks running 4
per_level:             13.13 s, peak files in flight    4 (  0.4 MB), peak chunks running 4
     lend:             13.86 s, peak files in flight    4 (  0.4 MB), peak chunks running 4

Slot lending shipped in CocoIndex 1.0.26 (#2486) and fair sharing in 1.1.0 (#2624); the 1.0.26–1.1.0 changelog has the release notes.

Frequently asked questions

Why does an asyncio.Semaphore deadlock with nested asyncio.gather?

A task that holds a slot while it awaits asyncio.gather() on its children keeps the slot for the whole wait. Once enough waiting parents hold every slot, the children they wait for can never start, and nothing releases a slot. A binary tree 4 levels deep under asyncio.Semaphore(2) stops with both slots held and 0 of 8 leaves done.

See Hold the slot for the whole run: deadlock.

How do I limit the concurrency of recursive async tasks without deadlock?

Let a parent that waits for its children keep its slot and lend it to one child at a time. The first child that asks runs on the parent's slot, further children take free slots from the pool or wait, and when the lent child finishes the slot passes to the parent's next waiting child. A blocked parent then always has one child that can run, so a tree of any depth completes even with a cap of 1.

See How lending works: the rule and a model you can copy.

Why does releasing the semaphore before awaiting children run out of memory?

Releasing the slot removes the deadlock and the bound together. A task that has given its slot back is still in flight, so nothing limits how many tasks have started, and each keeps what it loaded. In the post's repro, 2,000 file tasks under a cap of 4 were all in flight at once, holding 200 MB of contents. CocoIndex before 1.0.26 released a component's slot at its first child mount, and one indexer was killed for memory with about 15,000 file components in flight.

See Release the slot at the first child: no bound.

What does slot lending bound?

Runs that hold a slot they have not lent out never exceed the cap, because each pool slot is held by one chain of loans and only the last run on the chain holds it unlent. Runs in flight, waiting parents included, never exceed the cap times the number of levels in the tree, because a chain has at most one run per level.

See Why lending cannot deadlock, and the two bounds it keeps.

Does Go's errgroup.SetLimit deadlock with nested Go calls?

It can. Go blocks until the group is below its limit, so if every admitted goroutine calls g.Go for its children and waits for them, all of them block in Go. A binary tree under SetLimit(2) stops that way; with TryGo and an inline fallback when the group is full, the same tree finishes.

See How Python, Trio, Go, Java and Rust handle nested limits.

Does max_inflight_components bound memory in CocoIndex?

No. It caps how many components run at once. With slot lending, components in flight stay within the cap times the number of levels, and each keeps what it loaded, so size the cap from the state one run holds at each level. Concurrency inside a component, such as coco.map() calls, is not under the cap.

See Sizing the cap for memory.

Build fresh context for your agents.

CocoIndex keeps derived data in sync with its sources. Only what changed re-runs.