Skip to content

Commit 9c4c18c

Browse files
authored
fix(runner): keep environment host alive across prepare->exec->teardown (REQUIREMENT-048) — v0.4.31 (#19)
The resident-host lease's stdio I/O driver is spawned onto whatever runtime is current when the host is first leased. prepare_off_runtime leased it on a bare thread, where EnvironmentClient::run_blocking builds a per-call throwaway current-thread runtime that is dropped the instant prepare returns — killing the driver and orphaning the node, so the next exec failed with 'plugin connection lost' and no node survived. PreparedEnvironment now owns a DEDICATED multi-thread runtime for its whole lifetime and leases the host on it, so the driver stays reachable for every later exec/teardown RPC. The runtime is shut down via shutdown_background() in Drop (safe from any context).
1 parent 37b2144 commit 9c4c18c

2 files changed

Lines changed: 59 additions & 8 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "animus-workflow-runner-default"
3-
version = "0.4.30"
3+
version = "0.4.31"
44
edition = "2021"
55
description = "Reference workflow_runner plugin for Animus v0.5 (stdio JSON-RPC + direct-execute CLI; replaces in-tree workflow-runner-v2)"
66
license = "Elastic-2.0"

src/phase_environment.rs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ use std::time::Duration;
6262
use animus_environment_protocol::{EnvironmentHandle, EnvironmentSpec, ExecResponse, ExecStream, HarnessCommand};
6363
use animus_plugin_protocol::error_codes::METHOD_NOT_SUPPORTED;
6464
use animus_session_backend::session::{SessionEvent, SessionRequest, SessionRun};
65-
use anyhow::{anyhow, Result};
65+
use anyhow::{anyhow, Context, Result};
6666
use orchestrator_config::workflow_config::resolve_environment;
6767
use orchestrator_core::EnvironmentClient;
6868
use 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

206216
impl 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

Comments
 (0)