symfonic.kernel.contracts.run_spawn¶
run_spawn ¶
The lineage a task keeps when it is spawned with a stripped context.
asyncio.create_task(coro, context=contextvars.Context()) inside a kernel
run starts coro with no lineage at all. Owner ruling (CORE-199): if that
coroutine begins a new top-level run, the run descends from the run that
spawned it, so it inherits the spawner's erase captures instead of observing
fresh ones that would launder what it was handed after a forget.
The kernel wraps the running loop's task factory when it issues a run. The
wrapper runs in the spawning context, so it records the spawner's lineage --
or, for a task spawned by a stripped task, that task's recorded lineage --
against the task its inner factory returns. It keys the task, not the
coroutine: a host factory can wrap the coroutine before building the task
(Sentry's patch_asyncio does), so task.get_coro() is not the
coroutine the kernel saw. An eager inner factory runs the task's first step
before it returns; for that step the lineage is the build in progress, which
only the stack running the coroutine the kernel was handed claims
(run_build). The kernel consults the
record only when it issues a run with no lineage in context.
contextvars.Context().run(asyncio.create_task, coro) calls the factory
itself under an empty context, so the spawner's lineage is not in context
there either. The kernel therefore also records each run it issues against the
task that issued it, until that issuance is reset; the factory falls back to
the innermost such run of the current task. A task that issued no live run --
or whose run has ended -- hands nothing on.
The same record covers a coroutine the issuing task drives itself under an
empty context, Context().run(coro.send, None): a run issued there finds
no lineage in context and descends from the task's innermost live run.
A task built without the kernel's factory -- the factory replaced, or
asyncio.Task constructed directly -- has no record; run_census finds
it and fails closed.
Work handed to the loop's default executor -- loop.run_in_executor(None,
...) and asyncio.to_thread -- runs on another thread, which must run its
own loop to issue a run, and run_in_executor copies no context. The kernel
installs a default executor that records the submitter's lineage, resolved as
for a task (context, issuing run, spawn record), and hands it to the function
for exactly its call: a run issued on that thread with no other lineage
descends from it. Submitted outside any run, it hands nothing on. A default
executor the host set before the loop's first run is wrapped, not replaced;
one set after it, or any explicitly passed executor, a raw thread or another
process, is not covered.
A loop callback runs in no task. The kernel hands it the lineage of the code
that scheduled it -- resolved as for a task -- for exactly its call
(run_callbacks), so a task it creates descends from the scheduler; a
callback scheduled outside any run, as a server's per-connection callbacks
are, hands nothing on.
enter_issuing ¶
Record lineage as a live run of the current task; return that task.
exit_issuing ¶
Forget the record :func:enter_issuing made, whatever task resets it.
Source code in src/symfonic/kernel/contracts/run_spawn.py
handed_lineage ¶
issuing_lineage ¶
The innermost live run the current task issued, if any.
spawned_lineage ¶
The lineage the running task was spawned under; fails closed if unseen.
track_spawns ¶
Wrap the running loop's task factory, once; outside a loop, nothing.
Raises ContractViolationError (:data:UNCARRIED), before changing the
loop, if the loop refuses the callback carrier (run_callbacks).