91a47fb42c
ci / rust (push) Successful in 2m26s
Governed compute for unified-memory AI hardware — the machines where CPU and GPU share one pool and there is no separate VRAM allocation to bounce off. Over-commit that pool and the box thrashes and wedges, SSH and ping included, before the OOM killer gets a turn. Compute does not run inference. It supervises the servers that do: - Admission control. A model starts only if committed + requested + margin fits the budget. The refusal is the feature. - A 1 Hz watchdog on MemAvailable that stops the newest model before thrash. - Scenes: named sets of models activated as one transactional unit, with pre-flight validation and rollback to the previously active Scene on failure. Scenes reference model ids, never weight paths or commands, so a Scene obtained from elsewhere cannot introduce code. - Process ownership bound to (boot_id, pid, start_time_ticks, pgid == pid), so a reused PID can never be group-killed. - A protocol-transparent TCP gateway, so clients keep one address while model runtimes move behind it. - An MCP server, so agents drive the node as tools rather than as a CLI. One binary, six direct dependencies, no async runtime outside the MCP surface. Published from the internal monorepo with a fresh history. The private development tree keeps its own history; nothing here carries it.
610 lines
22 KiB
Rust
610 lines
22 KiB
Rust
//! MCP server: the Governor's surface, exposed to agents as tools.
|
|
//!
|
|
//! Agents already drive Compute by shelling out to the CLI and scraping the
|
|
//! column-aligned output. That works until a column moves. Speaking MCP means
|
|
//! the agent gets the same numbers the Governor reasons about, as data, with a
|
|
//! schema attached — and it means the *shape* of what an agent may do becomes
|
|
//! something this file decides rather than something `bash` decides.
|
|
//!
|
|
//! Two deliberate constraints shape everything below.
|
|
//!
|
|
//! **Read-first.** Every tool here except `scene_activate` is a pure read.
|
|
//! Activating a Scene stops every registered model that is not in it, which on
|
|
//! this box means taking down whatever is currently serving — a live voice
|
|
//! pipeline included. There is no undo an agent can reach for: if the target
|
|
//! Scene then fails to come up, recovery depends on rollback that may itself
|
|
//! fail. That asymmetry is why `scene adopt`, `scene resume`, and
|
|
//! `scene deactivate` are absent entirely; they are operator verbs whose
|
|
//! correctness depends on knowing what the box was doing five minutes ago.
|
|
//! `scene_activate` is exposed because planning a switch is genuinely the
|
|
//! useful thing an agent wants, and it defaults to planning only.
|
|
//!
|
|
//! **The safety boundary is the operator's, not the agent's.** A `dry_run`
|
|
//! argument defaulting to `true` documents intent but guards nothing: the agent
|
|
//! writes the arguments, so it can write `false`. The only boundary an agent
|
|
//! cannot cross is one set before it connects, so a real transition also
|
|
//! requires `lumbridge-compute mcp --allow-activate`, chosen by the human who
|
|
//! launched the server. Without that flag `dry_run: false` is refused, and the
|
|
//! refusal says so rather than silently planning instead.
|
|
|
|
use anyhow::{Context, Result};
|
|
use rmcp::handler::server::router::tool::ToolRouter;
|
|
use rmcp::handler::server::wrapper::Parameters;
|
|
use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
|
|
use rmcp::{tool, tool_handler, tool_router, ErrorData, Json, ServerHandler, ServiceExt};
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::os::fd::FromRawFd;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use crate::config::{find_scene, load_scenes, Registry};
|
|
use crate::proc::State;
|
|
use crate::{eval, governor, lifecycle, mem};
|
|
|
|
pub fn run(root: &Path, allow_activate: bool) -> Result<()> {
|
|
let transport_stdout = hand_over_stdout()?;
|
|
// The banner has to go to stderr for the same reason everything else does;
|
|
// it doubles as confirmation to the operator that the mutating tool is off.
|
|
eprintln!(
|
|
"Lumbridge Compute MCP server on stdio · root {} · scene activation {}",
|
|
root.display(),
|
|
if allow_activate {
|
|
"ENABLED (--allow-activate)"
|
|
} else {
|
|
"disabled; plan only"
|
|
}
|
|
);
|
|
|
|
let server = ComputeMcp::new(root.to_path_buf(), allow_activate);
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.context("building the MCP runtime")?;
|
|
runtime.block_on(async move {
|
|
let transport = (
|
|
tokio::io::stdin(),
|
|
tokio::fs::File::from_std(transport_stdout),
|
|
);
|
|
let service = server
|
|
.serve(transport)
|
|
.await
|
|
.context("negotiating the MCP stdio session")?;
|
|
service.waiting().await.context("serving MCP over stdio")?;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Hand the real stdout to the transport and point fd 1 at stderr.
|
|
///
|
|
/// On stdio transport fd 1 *is* the JSON-RPC framing, and this crate reports
|
|
/// progress with `println!` throughout — `lifecycle::activate` narrates every
|
|
/// stop and start. One such line interleaved into the framing desynchronises
|
|
/// the client mid-transition, which is the worst possible moment to lose it.
|
|
/// Rather than audit every print (and every future one), move the file
|
|
/// descriptor: library output lands on stderr, where operators already read
|
|
/// this server's logs, and the protocol gets a channel nothing else can write.
|
|
fn hand_over_stdout() -> Result<std::fs::File> {
|
|
let saved = unsafe { libc::dup(libc::STDOUT_FILENO) };
|
|
if saved < 0 {
|
|
return Err(std::io::Error::last_os_error())
|
|
.context("duplicating stdout for the MCP transport");
|
|
}
|
|
if unsafe { libc::dup2(libc::STDERR_FILENO, libc::STDOUT_FILENO) } < 0 {
|
|
let error = std::io::Error::last_os_error();
|
|
unsafe { libc::close(saved) };
|
|
return Err(error).context("redirecting stdout to stderr");
|
|
}
|
|
// SAFETY: `saved` is a fresh descriptor from `dup` that nothing else owns.
|
|
Ok(unsafe { std::fs::File::from_raw_fd(saved) })
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ComputeMcp {
|
|
root: Arc<PathBuf>,
|
|
allow_activate: bool,
|
|
tool_router: ToolRouter<ComputeMcp>,
|
|
}
|
|
|
|
#[tool_router]
|
|
impl ComputeMcp {
|
|
fn new(root: PathBuf, allow_activate: bool) -> Self {
|
|
Self {
|
|
root: Arc::new(root),
|
|
allow_activate,
|
|
tool_router: Self::tool_router(),
|
|
}
|
|
}
|
|
|
|
/// Governor status for this node: unified-memory totals, the hard budget
|
|
/// and its margins, how much is committed by models that are actually
|
|
/// serving, how much is still admittable, the persisted Scene state, and
|
|
/// the last transition or watchdog error. Read this before reasoning about
|
|
/// whether anything else will fit.
|
|
#[tool(
|
|
name = "governor_status",
|
|
annotations(
|
|
title = "Governor status",
|
|
read_only_hint = true,
|
|
open_world_hint = false
|
|
)
|
|
)]
|
|
async fn governor_status(&self) -> Result<Json<StatusReport>, ErrorData> {
|
|
let root = self.root.clone();
|
|
offload(move || collect_status(&root)).await.map(Json)
|
|
}
|
|
|
|
/// List every model in the node's registry with its declared worst-case
|
|
/// footprint, serving port, and whether it is currently serving. Footprints
|
|
/// are what admission control is decided against, not observed usage.
|
|
#[tool(
|
|
name = "model_list",
|
|
annotations(
|
|
title = "List registered models",
|
|
read_only_hint = true,
|
|
open_world_hint = false
|
|
)
|
|
)]
|
|
async fn model_list(&self) -> Result<Json<ModelList>, ErrorData> {
|
|
let root = self.root.clone();
|
|
offload(move || collect_models(&root)).await.map(Json)
|
|
}
|
|
|
|
/// List the Scenes this node can activate, with each one's total footprint
|
|
/// and member models. A Scene is a named set of models brought up as a
|
|
/// single unit.
|
|
#[tool(
|
|
name = "scene_list",
|
|
annotations(title = "List scenes", read_only_hint = true, open_world_hint = false)
|
|
)]
|
|
async fn scene_list(&self) -> Result<Json<SceneList>, ErrorData> {
|
|
let root = self.root.clone();
|
|
offload(move || collect_scenes(&root)).await.map(Json)
|
|
}
|
|
|
|
/// Show one Scene: its models, their footprints and live state, and the
|
|
/// Governor's admission verdict — whether the Scene fits its budget once
|
|
/// the safety margin is added, and by how much it fits or misses.
|
|
#[tool(
|
|
name = "scene_show",
|
|
annotations(
|
|
title = "Show a scene and its admission verdict",
|
|
read_only_hint = true,
|
|
open_world_hint = false
|
|
)
|
|
)]
|
|
async fn scene_show(
|
|
&self,
|
|
Parameters(params): Parameters<SceneNameParams>,
|
|
) -> Result<Json<SceneDetail>, ErrorData> {
|
|
let root = self.root.clone();
|
|
offload(move || collect_scene(&root, ¶ms.name))
|
|
.await
|
|
.map(Json)
|
|
}
|
|
|
|
/// List the evaluation suites available on this node, with their versions
|
|
/// and case counts. Running a suite is deliberately not exposed: it drives
|
|
/// real load against a serving model for an unbounded time.
|
|
#[tool(
|
|
name = "eval_list",
|
|
annotations(
|
|
title = "List eval suites",
|
|
read_only_hint = true,
|
|
open_world_hint = false
|
|
)
|
|
)]
|
|
async fn eval_list(&self) -> Result<Json<EvalList>, ErrorData> {
|
|
let root = self.root.clone();
|
|
offload(move || collect_evals(&root)).await.map(Json)
|
|
}
|
|
|
|
/// Plan — or, if the operator allowed it, perform — a Scene transition.
|
|
///
|
|
/// With `dry_run` true (the default) this runs every check that guards a
|
|
/// real transition and returns the models it would stop and start, changing
|
|
/// nothing. With `dry_run` false it actually switches the node: models
|
|
/// outside the Scene are stopped, which will interrupt anything they serve,
|
|
/// and the call blocks until every Scene model reports healthy or the
|
|
/// transition rolls back. A real transition additionally requires the
|
|
/// server to have been started with `--allow-activate`; without it,
|
|
/// `dry_run: false` is refused. Ask a human before setting it.
|
|
#[tool(
|
|
name = "scene_activate",
|
|
annotations(
|
|
title = "Activate a scene (plans by default)",
|
|
read_only_hint = false,
|
|
destructive_hint = true,
|
|
idempotent_hint = false,
|
|
open_world_hint = false
|
|
)
|
|
)]
|
|
async fn scene_activate(
|
|
&self,
|
|
Parameters(params): Parameters<SceneActivateParams>,
|
|
) -> Result<Json<ActivationReport>, ErrorData> {
|
|
if !params.dry_run && !self.allow_activate {
|
|
return Err(ErrorData::invalid_params(
|
|
"this MCP server is running plan-only: a real Scene transition needs an \
|
|
operator to restart it as `lumbridge-compute mcp --allow-activate`. \
|
|
Re-run with dry_run: true to see the plan.",
|
|
None,
|
|
));
|
|
}
|
|
let root = self.root.clone();
|
|
offload(move || activate_scene(&root, ¶ms.name, params.dry_run))
|
|
.await
|
|
.map(Json)
|
|
}
|
|
}
|
|
|
|
#[tool_handler(router = self.tool_router)]
|
|
impl ServerHandler for ComputeMcp {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
|
.with_server_info(Implementation::new(
|
|
"lumbridge-compute",
|
|
env!("CARGO_PKG_VERSION"),
|
|
))
|
|
.with_instructions(
|
|
"Lumbridge Compute runs many AI models on one unified-memory box. There is a \
|
|
single memory pool shared by CPU and GPU, so over-committing does not fail \
|
|
gracefully — the machine thrashes and wedges before the OOM killer acts. The \
|
|
Governor prevents that by admitting a model only if its declared footprint \
|
|
plus a safety margin still fits a hard budget.\n\n\
|
|
Start from `governor_status` for what is committed and what is admittable, \
|
|
and `scene_show` for whether a named Scene would be admitted. Everything is \
|
|
read-only except `scene_activate`, which plans by default. Activating a Scene \
|
|
stops every registered model outside it, so treat it as a production change \
|
|
and get a human's agreement before asking for a non-dry run.",
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- tool parameters -------------------------------------------------------
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct SceneNameParams {
|
|
/// Scene name, as reported by `scene_list`.
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct SceneActivateParams {
|
|
/// Scene name, as reported by `scene_list`.
|
|
name: String,
|
|
/// Validate and report the plan without changing anything. Defaults to
|
|
/// true; set it to false only with a human's explicit agreement.
|
|
#[serde(default = "yes")]
|
|
dry_run: bool,
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
// ---- tool results ----------------------------------------------------------
|
|
//
|
|
// Lists are wrapped in objects rather than returned bare: MCP structured
|
|
// content must be a JSON object, and a named field leaves room to report
|
|
// alongside the list later without breaking a client's schema.
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct StatusReport {
|
|
memory_total_gb: f64,
|
|
memory_available_gb: f64,
|
|
budget_gb: f64,
|
|
safety_margin_gb: f64,
|
|
watchdog_floor_gb: f64,
|
|
/// Sum of the footprints of every registered model currently serving.
|
|
committed_gb: f64,
|
|
/// What a new model could still claim without breaching the safety margin.
|
|
headroom_gb: f64,
|
|
/// Scene whose exact model health was last verified.
|
|
active_scene: Option<String>,
|
|
/// Scene the node should return to after a restart.
|
|
desired_scene: Option<String>,
|
|
/// Previous proven Scene, used if the desired one cannot resume.
|
|
last_known_good_scene: Option<String>,
|
|
last_error: Option<String>,
|
|
running: Vec<RunningModel>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct RunningModel {
|
|
id: String,
|
|
name: String,
|
|
footprint_gb: f64,
|
|
port: Option<u16>,
|
|
/// True when Compute owns this process identity and may signal it. False
|
|
/// means the model is serving but was started outside Compute, so a Scene
|
|
/// transition will refuse to touch it until it is adopted.
|
|
compute_owned: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct ModelList {
|
|
models: Vec<ModelEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct ModelEntry {
|
|
id: String,
|
|
name: String,
|
|
/// Declared worst-case unified memory once serving, not observed usage.
|
|
footprint_gb: f64,
|
|
port: Option<u16>,
|
|
running: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct SceneList {
|
|
scenes: Vec<SceneEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct SceneEntry {
|
|
name: String,
|
|
version: u32,
|
|
description: String,
|
|
/// Total footprint of the Scene's models that exist in the registry.
|
|
footprint_gb: f64,
|
|
models: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct SceneDetail {
|
|
name: String,
|
|
version: u32,
|
|
description: String,
|
|
budget_gb: f64,
|
|
models: Vec<SceneModel>,
|
|
/// Model ids the Scene references that the registry does not define.
|
|
missing_models: Vec<String>,
|
|
footprint_gb: f64,
|
|
/// Footprint plus the Governor's safety margin — the figure compared
|
|
/// against the budget.
|
|
required_gb: f64,
|
|
/// The Governor's verdict: would this Scene be admitted?
|
|
admits: bool,
|
|
verdict: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct SceneModel {
|
|
id: String,
|
|
/// Absent when the id is missing from the registry.
|
|
name: Option<String>,
|
|
footprint_gb: Option<f64>,
|
|
running: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct EvalList {
|
|
suites: Vec<EvalSuite>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct EvalSuite {
|
|
name: String,
|
|
version: u32,
|
|
cases: usize,
|
|
description: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct ActivationReport {
|
|
scene: String,
|
|
budget_gb: f64,
|
|
/// Serving models outside the Scene, which the transition stops first.
|
|
stop: Vec<String>,
|
|
/// Scene models not yet serving, in the order they would be admitted.
|
|
start: Vec<String>,
|
|
/// False when this was a plan and the node was left untouched.
|
|
applied: bool,
|
|
}
|
|
|
|
// ---- collectors ------------------------------------------------------------
|
|
//
|
|
// These compose the same primitives the CLI prints from — `governor`, `mem`,
|
|
// `config`, `proc::State` — rather than parsing the CLI's output. The numbers
|
|
// an agent sees are therefore the numbers admission control uses, by
|
|
// construction.
|
|
|
|
fn collect_status(root: &Path) -> Result<StatusReport> {
|
|
let registry = Registry::load(root)?;
|
|
let memory = mem::read()?;
|
|
let committed = governor::committed_gb(®istry);
|
|
let managed = State::load_checked(root)?;
|
|
let budget = governor::DEFAULT_BUDGET_GB;
|
|
|
|
let running = governor::running_ids(®istry)
|
|
.into_iter()
|
|
.map(|id| {
|
|
let model = ®istry.models[&id];
|
|
RunningModel {
|
|
name: model.name.clone(),
|
|
footprint_gb: model.footprint_gb,
|
|
port: model.serve.port,
|
|
compute_owned: managed
|
|
.procs
|
|
.get(&id)
|
|
.is_some_and(|process| process.owned_alive()),
|
|
id,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(StatusReport {
|
|
memory_total_gb: memory.total_gb,
|
|
memory_available_gb: memory.available_gb,
|
|
budget_gb: budget,
|
|
safety_margin_gb: governor::SAFETY_MARGIN_GB,
|
|
watchdog_floor_gb: governor::WATCHDOG_FLOOR_GB,
|
|
committed_gb: committed,
|
|
headroom_gb: (budget - committed - governor::SAFETY_MARGIN_GB).max(0.0),
|
|
active_scene: managed.active_scene,
|
|
desired_scene: managed.desired_scene,
|
|
last_known_good_scene: managed.last_known_good_scene,
|
|
last_error: managed.last_error,
|
|
running,
|
|
})
|
|
}
|
|
|
|
fn collect_models(root: &Path) -> Result<ModelList> {
|
|
let registry = Registry::load(root)?;
|
|
Ok(ModelList {
|
|
models: registry
|
|
.models
|
|
.iter()
|
|
.map(|(id, model)| ModelEntry {
|
|
id: id.clone(),
|
|
name: model.name.clone(),
|
|
footprint_gb: model.footprint_gb,
|
|
port: model.serve.port,
|
|
running: governor::is_running(model),
|
|
})
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
fn collect_scenes(root: &Path) -> Result<SceneList> {
|
|
let registry = Registry::load(root)?;
|
|
Ok(SceneList {
|
|
scenes: load_scenes(root)?
|
|
.into_iter()
|
|
.map(|scene| SceneEntry {
|
|
name: scene.metadata.name,
|
|
version: scene.metadata.version,
|
|
description: scene.metadata.description,
|
|
footprint_gb: scene
|
|
.models
|
|
.iter()
|
|
.filter_map(|id| registry.models.get(id))
|
|
.map(|model| model.footprint_gb)
|
|
.sum(),
|
|
models: scene.models,
|
|
})
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
fn collect_scene(root: &Path, name: &str) -> Result<SceneDetail> {
|
|
let registry = Registry::load(root)?;
|
|
let scene = find_scene(root, name)?;
|
|
let budget = scene.budget_gb.unwrap_or(governor::DEFAULT_BUDGET_GB);
|
|
|
|
let mut footprint = 0.0;
|
|
let mut missing = Vec::new();
|
|
let mut models = Vec::new();
|
|
for id in &scene.models {
|
|
match registry.models.get(id) {
|
|
Some(model) => {
|
|
footprint += model.footprint_gb;
|
|
models.push(SceneModel {
|
|
id: id.clone(),
|
|
name: Some(model.name.clone()),
|
|
footprint_gb: Some(model.footprint_gb),
|
|
running: governor::is_running(model),
|
|
});
|
|
}
|
|
None => {
|
|
missing.push(id.clone());
|
|
models.push(SceneModel {
|
|
id: id.clone(),
|
|
name: None,
|
|
footprint_gb: None,
|
|
running: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Same three-way verdict the CLI prints: a missing id is fatal regardless
|
|
// of arithmetic, because the Scene cannot be resolved at all.
|
|
let required = footprint + governor::SAFETY_MARGIN_GB;
|
|
let (admits, verdict) = if !missing.is_empty() {
|
|
(
|
|
false,
|
|
format!("{} model(s) missing from the registry", missing.len()),
|
|
)
|
|
} else if required <= budget {
|
|
(
|
|
true,
|
|
format!("fits with {:.1} GB to spare", budget - required),
|
|
)
|
|
} else {
|
|
(
|
|
false,
|
|
format!("exceeds the budget by {:.1} GB", required - budget),
|
|
)
|
|
};
|
|
|
|
Ok(SceneDetail {
|
|
name: scene.metadata.name,
|
|
version: scene.metadata.version,
|
|
description: scene.metadata.description,
|
|
budget_gb: budget,
|
|
models,
|
|
missing_models: missing,
|
|
footprint_gb: footprint,
|
|
required_gb: required,
|
|
admits,
|
|
verdict,
|
|
})
|
|
}
|
|
|
|
fn collect_evals(root: &Path) -> Result<EvalList> {
|
|
Ok(EvalList {
|
|
suites: eval::catalogue(root)?
|
|
.into_iter()
|
|
.map(|suite| EvalSuite {
|
|
name: suite.name,
|
|
version: suite.version,
|
|
cases: suite.cases,
|
|
description: suite.description,
|
|
})
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
fn activate_scene(root: &Path, name: &str, dry_run: bool) -> Result<ActivationReport> {
|
|
// Plan first either way. On a real transition this is the plan we report;
|
|
// `activate` re-derives and re-validates its own under the transition lock,
|
|
// so the report describes intent and the lock still owns the truth.
|
|
let plan = lifecycle::plan(root, name)?;
|
|
if !dry_run {
|
|
lifecycle::activate(root, name, false)?;
|
|
}
|
|
Ok(ActivationReport {
|
|
scene: plan.scene,
|
|
budget_gb: plan.budget_gb,
|
|
stop: plan.stop,
|
|
start: plan.start,
|
|
applied: !dry_run,
|
|
})
|
|
}
|
|
|
|
/// Run a synchronous Governor call off the transport's thread.
|
|
///
|
|
/// Every read here touches `/proc` and probes serving ports with blocking
|
|
/// socket timeouts, and a real transition can sit for minutes waiting on model
|
|
/// health. Doing that inline would stall the JSON-RPC reader for the duration,
|
|
/// so the synchronous core stays on the blocking pool where it belongs.
|
|
async fn offload<T, F>(work: F) -> Result<T, ErrorData>
|
|
where
|
|
F: FnOnce() -> Result<T> + Send + 'static,
|
|
T: Send + 'static,
|
|
{
|
|
match tokio::task::spawn_blocking(work).await {
|
|
Ok(Ok(value)) => Ok(value),
|
|
// `{:#}` keeps anyhow's context chain, which is where the actionable
|
|
// half of a Compute error lives ("adopt the active Scene first").
|
|
Ok(Err(error)) => Err(ErrorData::internal_error(format!("{error:#}"), None)),
|
|
Err(join) => Err(ErrorData::internal_error(
|
|
format!("Compute worker task failed: {join}"),
|
|
None,
|
|
)),
|
|
}
|
|
}
|