Reframe Wool's context model around stdlib contextvars — Closes #223, #229, #232#224
Open
conradbzura wants to merge 7 commits into
Open
Reframe Wool's context model around stdlib contextvars — Closes #223, #229, #232#224conradbzura wants to merge 7 commits into
conradbzura wants to merge 7 commits into
Conversation
7dfb259 to
d329da9
Compare
This was referenced May 18, 2026
d134d3b to
6f9023f
Compare
6e4d925 to
572202c
Compare
b73f32a to
3f0f631
Compare
conradbzura
commented
May 19, 2026
conradbzura
commented
May 19, 2026
conradbzura
commented
May 19, 2026
conradbzura
commented
May 19, 2026
conradbzura
commented
May 19, 2026
conradbzura
commented
May 19, 2026
conradbzura
commented
May 19, 2026
142def5 to
91c6a91
Compare
This was referenced May 20, 2026
conradbzura
added a commit
to conradbzura/wool
that referenced
this pull request
May 20, 2026
Phase A keystone of PR wool-labs#224 remediations. Restructures the core Context API around stdlib dict.update semantics and consolidates the owner-stamping + task-factory-install boundaries. The previous Context.merge(self, other=None) was self-wins (incoming data wins on overlap), defaulted other to current_context(), and auto-armed via ensure_task_factory_installed when other was None. The direction was opaque at the call site and three side effects (mint chain id, install factory, build merged context) were bundled inside one method. The new Context.update(self, source) follows dict.update semantics — self is the receiver/base, source is the override. The body is pure data: no current_context() lookup, no auto-arm, no task-factory install. Callers explicitly compose the canonical pattern: (current_context() or Context()).update(incoming).mount() Supporting field-default changes make Context() the empty/fresh form: - chain_id has default_factory=uuid4; Context() mints a fresh id. - _owning_thread defaults to 0; _owning_task defaults to None. Construction is pure data; Context.mount becomes the sole owner-stamping site. Consolidating boundaries: - ensure_task_factory_installed renamed to _ensure_task_factory_installed and called from inside Context.mount. Every code path that arms a chain (var.set's first-arm, decoded.mount, the canonical update chain) now transits through one place. The function remains idempotent per-loop via _loops_with_factory (WeakSet). - The eager call at var.py:352 (ContextVar.set's unarmed branch) is removed — mount now handles it. The _apply_values helper is inlined into Context.mount; the drain parameter is dropped along with the type:ignore + isinstance dance. Context.__post_init__ now defensively coerces data / external_used / reset_vars / stub_pins to their immutable types (frozenset, MappingProxyType). Callers passing plain dicts/sets no longer smuggle mutable containers past the frozen-dataclass facade. Context.to_protobuf becomes a true inverse of from_protobuf: it reads from _manifest when not None, from backings otherwise. A decoded-but-not-mounted Context can now be re-emitted without mounting it — useful for forwarding proxies and pre-mount inspection hooks. The chain-Context residency precondition is narrowed to live contexts only. Context.update asserts self._manifest is None — only live or fresh contexts can be the receiver of an update; a decoded receiver would silently drop its pending manifest. ContextVar.reset reorders to build the evolved Context first, then perform the fallible backing.reset and token._used flip immediately before mount, so an unexpected raise in the dataclass evolve no longer half-applies the reset. Context.fork now routes through mount via context.fork().mount() in both _forked_scope and to_thread._run — the new chain is owner-less at construction and mount stamps the right thread/task. The Context class docstring grows a Lifecycle paragraph naming the three states (fresh, decoded, live) and an asymmetric-keying note explaining data (instance-keyed) vs reset_vars (key-tuple-keyed). The vars property documents the single-mount manifest aliasing contract. BREAKING CHANGE: Context.merge is renamed to Context.update with inverted argument semantics. Callers that used incoming.merge(other) must rewrite to other.update(incoming). Default-None merging is gone; callers must explicitly pass (current_context() or Context()) as the receiver.
This was referenced May 24, 2026
ee71b44 to
25fad45
Compare
3c59537 to
1a25b86
Compare
bac0269 to
af2fb8e
Compare
This was
linked to
issues
Jun 26, 2026
Add uvloop as a dev dependency so the parity suite runs under both the default asyncio loop and uvloop, register the stdlib_parity pytest marker, and measure coverage from spawned worker processes so worker- side dispatch code counts toward the gate.
Reshape the protocol wire contract for the chain model: rename the per-dispatch wire message to ChainManifest carrying value-only ContextVar entries, adjust the package re-exports, and rename the protocol exception module to its plural form.
Replace the two-system context model with a single Wool-owned stdlib contextvars.ContextVar holding an immutable Chain. Chain state is an index over per-variable backing vars; ChainManifest carries decoded wire state and mounts it onto a live or fresh chain. Add the task factory (copy-on-fork, displacement detection), the chain-ownership guard, the stdlib-aligned to_thread, the RuntimeContext overlay, and a shared variable registry. Root the exception hierarchy at WoolError and WoolWarning, with ChainContention, ContextVarCollision, TaskFactoryDisplaced, SerializationError, ChainSerializationError, and SerializationWarning underneath. Remove the home-grown Token and the base, stub, and token modules.
Wrap every worker-wire request and response in a Frame subclass that carries its own ChainManifest, and mount it into the active chain at receive time as a single canonical step. Frame.chain_manifest is a union of the decoded manifest, the decode error, or absent; a strict- mode decode failure is deferred to mount, which raises it or chains it onto an exception payload's __context__. The dispatch driver caches one contextvars.Context per chain id so async-generator frames reuse it across yields.
Read dispatch-time fields off RuntimeContext in the routine wrapper and Task, switch discovery to the protobuf wire namespace, and update the loadbalancer error handling to the ChainSerializationError shape. Reshape the public wool API: remove Context, current_context, copy_context, create_task, and ContextAlreadyBound; alias Token to contextvars.Token; and add to_thread and install_task_factory.
Decompose the context tests into per-class mirror modules (chain, manifest, runtime, var, token, factory, guard, exceptions), add the stdlib_parity suite pinning Wool against the analogous contextvars and asyncio surfaces under both loops, add Frame round-trip coverage, and rewrite the worker and integration tests for the per-frame architecture. The unit suite holds the 98% coverage gate.
Rewrite the package, context, and worker READMEs to match the shipped surface: the stdlib-aligned public API, the ChainManifest.mount wire-apply pattern, and the Frame-based dispatch vocabulary.
76a52e6 to
b12b13c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Re-found Wool's context model directly on Python's stdlib
contextvars. There is one context system, not two: Wool chain state rides in a single Wool-owned stdlibcontextvars.ContextVar(wool.__chain__) as an immutableChaindataclass, so it propagates with stdlib visibility across every conformant event loop (including uvloop) and across every cooperative asyncio scheduling edge —call_soon/call_later/call_at,add_reader/add_writer/add_signal_handler,Future.add_done_callback. Child task creation propagates as a fork — the child inherits the parent's variable values on a freshly minted chain id, not the parent's chain itself. OS-thread offload is the one deliberate exception: an armed chain entered from a second OS thread fails loud rather than propagating silently. The chain-contention guard arms only when awool.ContextVarhas been set; a process that touches neitherwool.ContextVarnor the dispatch path pays nothing.Reshape the public API surface. Remove
wool.Context,wool.current_context,wool.copy_context,wool.create_task, andwool.ContextAlreadyBound. Aliaswool.Tokento stdlibcontextvars.Token(wool.Token is contextvars.Token); user code reuses the stdlib implementation directly and the previous home-grown Token class is deleted. Addwool.to_thread,wool.install_task_factory, and an exception hierarchy rooted at two umbrellas —wool.WoolErrorandwool.WoolWarning— withwool.ChainContention,wool.ContextVarCollision,wool.TaskFactoryDisplaced,wool.SerializationError,wool.ChainSerializationError, andwool.SerializationWarningunderneath.wool.ContextVarandwool.RuntimeContextcarry over.Introduce a Frame/ChainManifest wire-frame abstraction in
wool.runtime.worker.frame: every request and response on the worker wire wraps in aFramesubclass (TaskRequestFrame,SendRequestFrame,ThrowRequestFrame,NextRequestFrame,ResultResponseFrame,ExceptionResponseFrame,AckResponseFrame,NackResponseFrame) that carries its own decoded sender-side state alongside the payload. Receive sites mount that state into the active chain as a single canonical step, replacing the ad-hoc wire-context plumbing previously scattered acrossworker/session.py,worker/connection.py, andworker/service.py.This is a breaking change to the public API; backward compatibility is not preserved.
Closes #223, #229, #232
The diagram below traces one
wool.ContextVarvalue across a cross-process routine dispatch — arm, encode, apply, mutate, and apply back:sequenceDiagram participant Caller participant Connection participant DispatchSession participant Routine rect rgb(0, 0, 0, 0) Note over Caller,Routine: Arm & dispatch Caller->>Caller: wool.ContextVar.set(v) — mint chain id, arm the Chain Caller->>Connection: dispatch routine Connection->>DispatchSession: TaskRequestFrame — pure dispatch metadata, no chain manifest Connection->>Connection: NextRequestFrame.for_send — auto-capture the armed chain into a ChainManifest Connection->>DispatchSession: NextRequestFrame end rect rgb(0, 0, 0, 0) Note over Caller,Routine: Worker apply & run DispatchSession->>DispatchSession: Frame.from_protobuf — decode, then mount the ChainManifest into the worker's cached context DispatchSession->>Routine: drive (await / asend) Routine->>Routine: wool.ContextVar.get() reads the caller's v Routine->>Routine: wool.ContextVar.set(v') mutates on the chain end rect rgb(0, 0, 0, 0) Note over Caller,Routine: Apply back Routine-->>DispatchSession: result DispatchSession->>DispatchSession: ResultResponseFrame.for_send — auto-capture the worker chain into a ChainManifest DispatchSession-->>Connection: ResultResponseFrame Connection->>Connection: Frame.from_protobuf — caller adopts worker mutations Connection-->>Caller: result — wool.ContextVar now reads v' end Note over Caller,Routine: The worker caches one contextvars.Context per chain id — async-generator routines repeat apply/run and apply-back once per __anext__ / asend via NextRequestFrame / SendRequestFrame / ResultResponseFrame, reusing that cached context across yieldsProposed changes
Chain-backed storage
Wool chain state — the bound-
wool.ContextVarindex, the logical-chain UUID, the owner-thread / owning-task ownership stamps, the reset-variable signals, and the stub-pin set — rides in a single immutableChaindataclass (runtime/context/chain.py, frozen, identity-equality) held in one Wool-owned stdlibcontextvars.ContextVar(wool.__chain__). TheChainis an index, not a value store: eachwool.ContextVar's value lives in its own backingcontextvars.ContextVar, and the chain'svarsfield records which variables are bound. Because the values live in the stdlibcontextvars.Contextrather than in the chain, callback write-isolation rests on nativecontextvarscopy-on-write: a callback runs in acopy_context()copy, so its backing-variable writes (and the new chain it installs) stay in that copy.Chain.mountis the single owner-stamping and arming boundary: it re-stamps the owning thread / task from the calling scope, rewinds reset signals, setswool.__chain__, and self-installs Wool's task factory on the running loop (idempotent per loop via aWeakSet). Wire ingress goes throughChainManifest.mount(*, stamp_owner, install_factory, merge_with=None): it drains the decoded values into their backing variables, then either seeds a fresh chain from the manifest alone (merge_with=None) or unions the manifest onto a live receiver chain that keeps its chain id (merge_withset), and delegates the arming toChain.mount. Incoming wire state wins on overlap; reset-only keys drop out so the reset propagates.Reorganise the context module around the new model. The live
Chainlives inchain.py; the stdlib-compatibleContextVar(backed by a per-varcontextvars.ContextVar) invar.py; the chain-ownership guard inguard.py; a shared, cycle-freeContextVarregistry inregistry.py;install_task_factoryand the task-factory composition path infactory.py; the stdlib-alignedto_threadinthreading.py; and theRuntimeContextoverlay for dispatch-time fields inruntime.py. Deferred wire-side state is decoupled from the live chain:ChainManifest(the decoded-but-unmounted snapshot) lives inmanifest.py, and the context error hierarchy is centralised inruntime/context/exceptions.py.Reshape the public API and root the exception hierarchy
Remove
wool.Context,wool.current_context,wool.copy_context,wool.create_task, andwool.ContextAlreadyBound. Addwool.to_threadandwool.install_task_factory.wool.ContextVarandwool.RuntimeContextcarry over unchanged.Root the exception surface at two umbrellas:
wool.WoolError(Exception)andwool.WoolWarning(Warning), both insrc/wool/exceptions.py. Under them:wool.SerializationError,wool.ChainSerializationError(aSerializationError),wool.SerializationWarning,wool.ChainContention,wool.ContextVarCollision, andwool.TaskFactoryDisplaced, all defined inruntime/context/exceptions.py. The earlierContextSerializationError/ContextDecodeError/ContextDecodeWarningnames are removed outright — no back-compat aliases.Alias
wool.Tokento stdlibcontextvars.Token—wool.Token is contextvars.Token, with no Wool subclass or shim. A token is therefore a plain stdlib token: it carries no Wool-internal state and does not transport across the wire (vanillapickle/cloudpickle/copyraiseTypeError), so value-state propagation rides the chain manifest rather than the token. The dispatch-serialisation machinery lives onwool.ContextVar(guarded against vanilla pickling), not on the token.Armed-gating, copy-on-fork, and the chain-contention guard
Enforcement is armed-gated. While unset,
wool.__chain__defaults toNone; the surrounding context is unarmed and behaves as a plaincontextvars.Context— no chain UUID and no guard. A context is armed by either of two events: the firstwool.ContextVar.set()on it, or aChainManifest.mount(...)applying incoming wire state.The chain-contention guard enforces one invariant — a single chain is never run by two runners at once — across both trigger paths:
contextvars.Contextis explicitly reused: handing a context that is already driving a live task to a secondcreate_taskraisesChainContention. Once the first task completes, that context is free to drive another.ChainContention— so a plainasyncio.to_threadfrom an armed context fails loud, andwool.to_thread(a detached fork onto a fresh chain) is the supported alternative.ChainContentioncarries structured diagnostic fields (chain_id,kind: Literal["thread", "task"], plus the offending thread id or task) and interpolates them into the error message so a stack trace surfaces who collided with whom.wool.TaskFactoryDisplacedis raised when a third-party factory installed after Wool's silently drops copy-on-fork for subsequently-created tasks; displacement is detected at three converging points — the factory'sweakref.finalize, the per-task done-callback backstop, and the set-time factory inspection.Cooperatively-scheduled work is deliberately not arbitrated.
call_soon/call_later/call_at,add_reader/add_writer/add_signal_handler, andFuture.add_done_callbackcallbacks inherit the scheduling scope's context viacopy_context()and run on its chain UUID unchanged. They execute synchronously on the owning thread and never overlap with their scheduler, so sharing the chain is safe.install_task_factorycomposes with an existing factory via a__wool_inner__chain it stamps on its wrapper. The composition-chain walk detects Wool buried under a later third-party install, sowool → third-party → woolis recognised and skipped (memoised per layer). A module-levelthreading.Lockguards both the install read-modify-write and the per-task registry against the (narrow) cross-thread install race.Frame/ChainManifest wire-frame abstraction
Introduce a
Framehierarchy inwool.runtime.worker.framethat wraps every request and response on the worker wire:RequestFrame:TaskRequestFrame,NextRequestFrame,SendRequestFrame,ThrowRequestFrameResponseFrame:ResultResponseFrame,ExceptionResponseFrame,AckResponseFrame,NackResponseFrameMid-stream frames mix in a
_ChainManifestFramebase that captures or accepts a manifest; boundary frames (TaskRequestFrame,AckResponseFrame,NackResponseFrame) carry none.Frame.chain_manifestis a union —ChainManifest | ChainSerializationError | None— read as "the decoded manifest, the reason it couldn't be parsed, or absent."Frame.from_protobufdecodes the optional wirecontextfield throughChainManifest.from_protobufand, on a strict-mode decode failure, captures theChainSerializationErroras the union's error arm rather than raising at decode time. The failure is deferred toFrame.mount, which raises it for a value frame or, when the frame's payload is itself aBaseException, walks the payload's__context__chain and attaches the decode error at the tail — so a malformed manifest never preempts the routine's primary signal. Each frame owns its own (de)serialisation, giving senders and receivers a single canonical place to encode and decode chain state.ChainManifest.from_protobufis the pure decoder: it resolves variable identities and deserialises values without ever touching acontextvars.Context, emittingSerializationWarningper bad entry and aggregating them into oneChainSerializationErrorunder strict mode.Reorganise the protocol package so the codegen has a clean home. Extract the
sys.pathhack, thewire_pb2/wire_pb2_grpcimport block (wrapped asProtobufImportError), and theAddServicerToServerProtocolplusadd_to_serverregistry into_wire.py; the package__init__re-exports from it. The wire message isChainManifest(value-onlyContextVarentries), riding on everyRequest/Responseas the optionalcontextfield.Per-frame context architecture on the worker
Route every request and response on the worker wire through the new
RequestFrameandResponseFrametypes. Stop threading wire-context application through ad-hoc helpers insession.py,service.py, andconnection.py; let each frame carry its own manifest and mount it into the active chain at receive time as a single canonical step. Build outbound traffic inconnection.pyfromRequestFramesubclasses and consumeResponseFramesubclasses on_read_next; emit theservice.pyterminal responses (result, exception, ack, nack) throughResponseFrame; reframesession.py's request and response paths around the Frame hierarchy. The chain rides only on the mid-stream payload frames (Next/Send/Throw/Result/Exception), so the first drive frame — not the dispatch metadata — propagates the armed chain.The dispatch driver caches the worker-side step context per wool chain id in a
WeakValueDictionary[UUID, contextvars.Context]. Frames sharing a chain id reuse onecontextvars.Context, so a routine'ssetbefore an async-generator yield and itsresetafter land in the same context — stdlib async-generator parity, since asyncio drives every resumption of one generator in a single context. Frames carrying distinct chain ids resolve to distinct contexts and stay isolated. An unarmed dispatch — one whose caller propagates no chain — runs its frames in a single working context left unarmed, so a stateless routine's plainasyncio.to_threadoffload copies a bare context and never trips the chain-contention guard; if the routine's ownsetarms that working context, the driver indexes it under the freshly minted chain id, so the back-propagated next frame (which now carries that id) reuses it.Routine, discovery, loadbalancer
Update the
@routinedecorator and the underlyingTaskwrapper to read dispatch-time fields offRuntimeContextrather than the removedContextaliases, and surfaceRuntimeContexton theTaskitself so call sites can inspect what was actually dispatched. Switchdiscovery/local.pyand the matching discovery test to the wire-namespace import (from wool import protocol) so they refer to the protobufWorkerMetadatawithout colliding with the wool dataclass of the same name. Update the loadbalancer exception-handling docstrings to reference the currentChainSerializationErrorshape rather than the removed decode-alias names.Build, documentation, and test coverage
Add
uvloopas a dev dependency and register thestdlib_paritypytest marker so the new parity suite runs under both the default asyncio loop and uvloop. Measure coverage from spawned worker processes (concurrency = multiprocessing,thread, sigterm flush) so worker-side dispatch code is visible to the gate. Rewrite the context subsystem README, the worker README, and the package README to match the shipped surface — the stdlib-aligned public API, theChainManifest.mountwire-apply pattern, and the Frame-based dispatch vocabulary.Test coverage spans three families: unit coverage of the new context module in
tests/runtime/context/(test_chain.py,test_manifest.py,test_runtime.py,test_var.py,test_token.py,test_factory.py,test_guard.py,test_exceptions.py); astdlib_paritysuite intests/stdlib_parity/(test_context_parity.py,test_task_creation.py,test_executor_offload.py,test_loop_callbacks.py) pinning Wool's behaviour against the analogous stdlibcontextvars/asynciosurfaces under both loops; and Frame round-trip coverage intests/runtime/worker/test_frame.py, rewritten worker tests acrosstests/runtime/worker/, plus end-to-end coverage intests/integration/test_context_var_propagation.py. The unit suite holds the 98% coverage gate: reachable defensive branches are tested directly, and the genuinely-unreachable guards are marked# pragma: no cover.Test cases
TestContextVarContextVarwith and without a constructor defaultgetis called on an unarmed then armed contextLookupErrorContextVar.get, armed-gatingTestContextVarsetthenresetare calledsetmints the chain and returns a single-useTokenContextVar.set/resetTestContextVarTokenfrom a different chain or var, or an already-used tokenresetis called with itcontextvars.Token.resetraisesValueErrorContextVar.resetrejection via stdlib delegationTestContextVar(namespace, name)keyContextVarCollisionTestTokenwool.Tokenafter a first and a nestedsetold_valueis read, then the token is pickled, cloudpickled, and copiedold_valuereportsMISSINGthen the prior value; vanillapickle,cloudpickle, andcopyare rejected withTypeErrorTestTokenwool.Token(alias ofcontextvars.Token)repris inspectedcontextvars.ContextVarTestChainChaindataclass with chain id, vars, reset signals, stub pinsmountfield-replacement are exercisedmountreturns an armed copyTestChainto_protobufemits the wire manifestSerializationWarning(aggregated under strict mode)TestChainManifestChainManifestwith values, resets, and an undeclared varChainManifest.from_protobufdecodes itChainSerializationErroron strict-mode failureTestChainMountChainManifest.mount(merge_with=...)is appliedTestRuntimeContextRuntimeContextentered with adispatch_timeoutoverride__enter__/__exit__and a protobuf round-trip are exercisedRuntimeContextlifecycle and codectest_factory.pytest_factory.pyinstall_task_factoryis re-invoked / called outside a loop / collected__wool_inner__walk and skips; raises a Wool-flavouredRuntimeErroroutside a loop; treats an idle-loop finalizer as teardown, not displacementtest_factory.pywool.ContextVarset or task completion runsTaskFactoryDisplacedso the caller knows copy-on-fork is no longer in effectTestChainContentionwool.ContextVarget/set/resetrunsChainContentionwithkind="thread"interpolating the chain id, owner thread, and offender; no false positive when the owner task is pending-off-loop or doneTestChainContentionGuardBoundarycontextvars.Contextalready driving a live taskChainContentionwithkind="task"; a callback on the owning thread does notTestChainContention(exceptions)ChainContentioninstance with structured fieldsWoolError/WoolWarningTestWoolToThreadParitywool.to_threadruns a function touching awool.ContextVarwool.to_threadTestAsyncioToThreadParityasyncio.to_threadtouches awool.ContextVarChainContentionTestRunInExecutorParitywool.ContextVarset in a scopeloop.run_in_executorruns on a thread and a process poolTestTaskCreationValuePropagationParitywool.ContextVarset in a scopeasyncio.create_task/loop.create_task/TaskGroupcreate child tasksTestCallbackValuePropagationParitywool.ContextVarset in a scopecall_soon/call_later/call_at/Future.add_done_callbackTestAsyncGeneratorValuePropagationyieldin an armed contextasend/athrow/acloseTestFrameToProtobufRequestFrameorResponseFramesubclassfor_sendis called with payload and serializerFramewith payload encoded into the matching protobuf field and the armed chain captured into aChainManifestTestFrameFromProtobufFrame.from_protobufis invokedChainSerializationErrorarm ofchain_manifest, deferred rather than raisedTestChainsDecodeErrorOntoPayloadChainSerializationErrorFrame.mountruns__context__tail rather than preempting itTestContextVarPropagation(integration)wool.ContextVar