@@ -62,7 +62,7 @@ use std::time::Duration;
6262use animus_environment_protocol:: { EnvironmentHandle , EnvironmentSpec , ExecResponse , ExecStream , HarnessCommand } ;
6363use animus_plugin_protocol:: error_codes:: METHOD_NOT_SUPPORTED ;
6464use animus_session_backend:: session:: { SessionEvent , SessionRequest , SessionRun } ;
65- use anyhow:: { anyhow, Result } ;
65+ use anyhow:: { anyhow, Context , Result } ;
6666use orchestrator_config:: workflow_config:: resolve_environment;
6767use orchestrator_core:: EnvironmentClient ;
6868use orchestrator_plugin_host:: HostError ;
@@ -201,6 +201,16 @@ pub struct PreparedEnvironment {
201201 id : String ,
202202 backend_label : String ,
203203 torn_down : AtomicBool ,
204+ /// A DEDICATED tokio runtime that owns the resident host's stdio I/O driver
205+ /// for the whole environment lifetime. The lease's driver is spawned (via
206+ /// `tokio::spawn`) onto whatever runtime is current when the host is first
207+ /// leased; if that were a per-call throwaway runtime it would be dropped the
208+ /// instant `prepare` returned, killing the driver and orphaning the node so
209+ /// the next `exec` fails with "plugin connection lost". Holding this runtime
210+ /// for the struct's lifetime keeps the driver alive across prepare → exec →
211+ /// teardown. `None` only for test-injected backends (no real host). Shut down
212+ /// via `shutdown_background()` in `Drop` (safe from any context).
213+ runtime : Option < tokio:: runtime:: Runtime > ,
204214}
205215
206216impl PreparedEnvironment {
@@ -241,19 +251,45 @@ impl PreparedEnvironment {
241251 }
242252 let handle =
243253 backend. prepare ( spec) . map_err ( |err| anyhow ! ( "environment prepare failed for {backend_label}: {err:#}" ) ) ?;
244- Ok ( Self { backend, handle, id : environment. id . clone ( ) , backend_label, torn_down : AtomicBool :: new ( false ) } )
254+ Ok ( Self {
255+ backend,
256+ handle,
257+ id : environment. id . clone ( ) ,
258+ backend_label,
259+ torn_down : AtomicBool :: new ( false ) ,
260+ runtime : None ,
261+ } )
245262 }
246263
247- /// Prepare the node on a dedicated OS thread so the blocking
248- /// [`EnvironmentClient`] runs OFF the async runtime (its internal
249- /// `run_blocking` builds its own runtime when no handle is current), then
250- /// bridge the result back without stalling the caller's runtime worker.
264+ /// Prepare the node on a DEDICATED, long-lived runtime and hand that runtime
265+ /// back inside the [`PreparedEnvironment`] so the resident host's stdio I/O
266+ /// driver — spawned during lease acquisition — outlives `prepare` and stays
267+ /// reachable for every later `exec`/`teardown` RPC (which drive the host's
268+ /// channels from their own throwaway runtimes; cross-runtime channel comms
269+ /// are fine as long as the driver's runtime is alive).
270+ ///
271+ /// The runtime is built and `block_on` is entered from a bare OS thread (a
272+ /// runtime cannot be created from within another runtime's worker), then the
273+ /// idle runtime — whose own worker threads keep the driver parked and live —
274+ /// is moved back to the async caller and stored on the struct.
251275 pub ( crate ) async fn prepare_off_runtime ( project_root : & Path , environment : & ResolvedEnvironment ) -> Result < Self > {
252276 let project_root = project_root. to_path_buf ( ) ;
253277 let environment = environment. clone ( ) ;
254278 let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
255279 std:: thread:: spawn ( move || {
256- let _ = tx. send ( Self :: prepare ( & project_root, & environment) ) ;
280+ let result = ( || {
281+ let runtime = tokio:: runtime:: Builder :: new_multi_thread ( )
282+ . worker_threads ( 2 )
283+ . enable_all ( )
284+ . build ( )
285+ . context ( "building dedicated runtime for the environment host" ) ?;
286+ // Lease + prepare INSIDE this runtime so the host's I/O driver
287+ // binds here (not to a per-call throwaway that would die on return).
288+ let mut prepared = runtime. block_on ( async { Self :: prepare ( & project_root, & environment) } ) ?;
289+ prepared. runtime = Some ( runtime) ;
290+ Ok :: < _ , anyhow:: Error > ( prepared)
291+ } ) ( ) ;
292+ let _ = tx. send ( result) ;
257293 } ) ;
258294 rx. await . map_err ( |_| anyhow ! ( "environment prepare thread terminated unexpectedly" ) ) ?
259295 }
@@ -303,6 +339,21 @@ impl PreparedEnvironment {
303339 }
304340}
305341
342+ impl Drop for PreparedEnvironment {
343+ /// Shut the dedicated host runtime down WITHOUT blocking. `teardown()` (the
344+ /// RPC that deletes the node) has already run via the end-of-run call or the
345+ /// [`PreparedEnvironmentGuard`](crate::workflow_execute) backstop, and it
346+ /// needs this runtime's I/O driver alive — so tearing the node down is NOT
347+ /// done here. `shutdown_background` is used instead of an implicit drop
348+ /// because dropping a runtime inline panics when the surrounding thread is
349+ /// itself inside an async runtime (this `Drop` can fire on any thread).
350+ fn drop ( & mut self ) {
351+ if let Some ( runtime) = self . runtime . take ( ) {
352+ runtime. shutdown_background ( ) ;
353+ }
354+ }
355+ }
356+
306357/// Merge a routing rule's opaque `spec` overrides into the compiled
307358/// [`EnvironmentSpec`]: the wire-typed keys (`image`, `resources`, `env`) land
308359/// on their typed fields; everything else is carried opaquely on `metadata`. The
0 commit comments