19 of the 29 remaining tokio edges in PR #10354 share one root cause, and it is not nineteen problems. Every one of them is the decline path: a thread that cannot get a turnloop loop of its own keeps the tokio implementation as a live fallback.
$ python3 scripts/tokio_inventory.py # groups whose blocker names the decline
B 8 the four database ext crates
A 4 perry-ext-http / perry-ext-net
D 2 http2 on a declining thread
F 2 fastify's 'upgrade' case
C 1 agent.rs
E 1 perry-ext-ws
G 1 stdlib fetch
Since turnloop P9 landed per-agent loops, the declining thread is no longer "a worker". The inventory's own wording: "a second thread acting for an agent another thread already owns (a host pump thread; Android's UI thread for perry-native)".
Why this is tractable, and mostly already built
crates/perry-runtime/src/event_pump/agent_loop.rs keeps a ROUTES registry, one Route per agent:
struct Route {
agent: AgentId,
owner: ThreadId, // only this thread may own the agent's loop
loop_id: u64,
in_turn: Arc<AtomicBool>, // readable by a producer under the registry lock
notifier: Option<Notifier>, // the loop's wake endpoint
}
A non-owner thread already finds another agent's route and wakes it — wake_parked_agents_slow() walks ROUTES and calls notifier.notify() on every loop currently inside a turn. So the cross-thread path exists; it carries a wake and not work.
turnloop ships the missing half and Perry does not use it: Loop::poster() -> Poster, with
pub fn post(&self, token: Token, payload: Payload) -> Result<(), PostError>
whose contract is documented as "Returns ownership on full/closed. On Ok the payload is owned by this loop. A wake error after enqueue returns payload: None: the post was accepted and must not be retried." Poster is Send.
Why posting is safe here, which is the crux
A pump thread acting for agent X and the thread that owns agent X's loop are both serving agent X's heap. So a completion delivered on the owner is delivered where the JS values live — which is the invariant the whole design rests on. This is not the "route completions back across agents" idea that was rightly rejected for a shared loop; it is two threads serving one agent, which is what a pump thread already is.
What this does and does not solve
Centrally, adding poster: Option<Poster> to Route alongside notifier (set at the same place, agent_loop.rs:397) and turning claim_route's LoopState::Declined into "may post, may not own" is a contained change.
Per binding, it is not. The declining code paths do not submit one operation — they construct whole tokio-backed objects (a perry-ext-net socket task, a database connection, a hyper accept loop). Each binding has to route its operations through the owner's loop rather than build a tokio object. That is why every lane so far has correctly declined to touch it: the central enabler is small, the conversions are not.
Suggested order
poster on Route, and a post_to_owning_agent(...) helper that returns a clear error when there is no route rather than silently declining.
- One binding converted end to end as the proof —
perry-ext-net's socket task is the smallest and unblocks group A, which the others sit behind.
- Then B (8 edges) as the largest single win.
Acceptance for step 1
A test where thread A owns agent X's loop and thread B, acting for the same agent, posts an operation that completes on A — with the payload arriving exactly once, and a clean error (not a silent decline) when no route exists. Plus the existing wake_parked_agents behaviour unchanged, since it walks the same registry.
19 of the 29 remaining tokio edges in PR #10354 share one root cause, and it is not nineteen problems. Every one of them is the decline path: a thread that cannot get a turnloop loop of its own keeps the tokio implementation as a live fallback.
Since turnloop P9 landed per-agent loops, the declining thread is no longer "a worker". The inventory's own wording: "a second thread acting for an agent another thread already owns (a host pump thread; Android's UI thread for
perry-native)".Why this is tractable, and mostly already built
crates/perry-runtime/src/event_pump/agent_loop.rskeeps aROUTESregistry, oneRouteper agent:A non-owner thread already finds another agent's route and wakes it —
wake_parked_agents_slow()walksROUTESand callsnotifier.notify()on every loop currently inside a turn. So the cross-thread path exists; it carries a wake and not work.turnloop ships the missing half and Perry does not use it:
Loop::poster() -> Poster, withwhose contract is documented as "Returns ownership on full/closed. On
Okthe payload is owned by this loop. A wake error after enqueue returnspayload: None: the post was accepted and must not be retried."PosterisSend.Why posting is safe here, which is the crux
A pump thread acting for agent X and the thread that owns agent X's loop are both serving agent X's heap. So a completion delivered on the owner is delivered where the JS values live — which is the invariant the whole design rests on. This is not the "route completions back across agents" idea that was rightly rejected for a shared loop; it is two threads serving one agent, which is what a pump thread already is.
What this does and does not solve
Centrally, adding
poster: Option<Poster>toRoutealongsidenotifier(set at the same place,agent_loop.rs:397) and turningclaim_route'sLoopState::Declinedinto "may post, may not own" is a contained change.Per binding, it is not. The declining code paths do not submit one operation — they construct whole tokio-backed objects (a
perry-ext-netsocket task, a database connection, a hyper accept loop). Each binding has to route its operations through the owner's loop rather than build a tokio object. That is why every lane so far has correctly declined to touch it: the central enabler is small, the conversions are not.Suggested order
posteronRoute, and apost_to_owning_agent(...)helper that returns a clear error when there is no route rather than silently declining.perry-ext-net's socket task is the smallest and unblocks group A, which the others sit behind.Acceptance for step 1
A test where thread A owns agent X's loop and thread B, acting for the same agent, posts an operation that completes on A — with the payload arriving exactly once, and a clean error (not a silent decline) when no route exists. Plus the existing
wake_parked_agentsbehaviour unchanged, since it walks the same registry.