Add plugin startup resilience diagnostics (#759)

Add plugin startup resilience diagnostics

Validation
* Validation tier: Tier 3 - shared plugin startup/config/runtime behavior plus doctor diagnostic capture, refreshed onto current main for PR #759; no plugin protocol/schema or release metadata change.
* git fetch --no-tags origin main:refs/remotes/origin/main codex/plugin-startup-resilience-doctor:refs/remotes/origin/codex/plugin-startup-resilience-doctor: PASS, origin/main at 4f02a65c.
* git rebase origin/main: PASS, no conflicts.
* git diff --check origin/main...HEAD: PASS, no output.
* git diff --check: PASS, no output.
* git diff --cached --check: PASS, no output.
* cargo fmt --all -- --check: PASS.
* cargo test -p mesh-llm-config plugin_startup --lib: PASS, 2 passed.
* LLAMA_STAGE_BUILD_DIR=<stage-build-dir> cargo test -p mesh-llm-host-runtime plugin::tests --lib -- --test-threads=1: PASS, 25 passed.
* LLAMA_STAGE_BUILD_DIR=<stage-build-dir> cargo test -p mesh-llm-host-runtime split_doctor_captures_plugin_startup_surfaces --lib -- --test-threads=1: PASS, 1 passed.
* LLAMA_STAGE_BUILD_DIR=<stage-build-dir> cargo test -p mesh-llm-host-runtime runtime_data --lib -- --test-threads=1: PASS, 27 passed.
* LLAMA_STAGE_BUILD_DIR=<stage-build-dir> cargo check -p mesh-llm: PASS.
* LLAMA_STAGE_BUILD_DIR=<stage-build-dir> cargo clippy -p mesh-llm-config -p mesh-llm-host-runtime --all-targets -- -D warnings: PASS.
* Remote CI: PASS on refreshed head 06f9411e; PR Builds and PR Quality Checks completed successfully.
* Ledger: not applicable - not required for selected validation tier/change family.
* Version: not applicable - no release/version sync required for this non-release plugin runtime diagnostic change.
* Not run: just build - not required for selected validation tier; no UI assets or release bundle changed.
* Not run: live legacy CPU/intelSDE plugin startup smoke - no local legacy/emulated plugin host was available; targeted config, runtime, API, and doctor tests cover the changed branches.

Rollback
* git revert <merge-commit-sha>
This commit is contained in:
Ivan Golovach 2026-05-31 13:52:48 -07:00 committed by GitHub
parent 4f02a65cb5
commit d9d9096955
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 502 additions and 32 deletions

View file

@ -142,7 +142,11 @@ store.update(|config| {
config.enable_builtin_plugin("telemetry")?;
config.upsert_plugin("endpoint-plugin")?
.enabled(true)
.url("http://localhost:8000/v1");
.url("http://localhost:8000/v1")
.connect_timeout_secs(75)
.init_timeout_secs(90)
.optional(true)
.lazy_start(true);
config.upsert_external_plugin("custom-tool", "mesh-tool", ["--serve"])?;
Ok(())
})?;

View file

@ -181,6 +181,7 @@ impl ConfigEditor {
command: None,
args: Vec::new(),
url: None,
startup: Default::default(),
});
self.config.plugins.len() - 1
}
@ -382,6 +383,26 @@ impl PluginConfigEditor<'_> {
self.plugin.url = Some(url.into());
self
}
pub fn connect_timeout_secs(&mut self, seconds: u64) -> &mut Self {
self.plugin.startup.connect_timeout_secs = Some(seconds);
self
}
pub fn init_timeout_secs(&mut self, seconds: u64) -> &mut Self {
self.plugin.startup.init_timeout_secs = Some(seconds);
self
}
pub fn optional(&mut self, optional: bool) -> &mut Self {
self.plugin.startup.optional = optional;
self
}
pub fn lazy_start(&mut self, lazy_start: bool) -> &mut Self {
self.plugin.startup.lazy_start = lazy_start;
self
}
}
fn normalize_non_empty(value: &str, label: &str) -> Result<String> {

View file

@ -1,5 +1,6 @@
mod authoring;
mod model;
mod plugin_validation;
mod store;
mod validate;
@ -15,7 +16,7 @@ pub use validate::validate_config;
mod tests {
use super::{
ConfigStore, GpuAssignment, LocalServingNodeConfig, MeshConfig, ModelRuntimeKind,
parse_config_toml,
parse_config_toml, validate_config,
};
use std::fs;
use tempfile::TempDir;
@ -30,6 +31,58 @@ mod tests {
assert!(config.models.is_empty());
}
#[test]
fn plugin_startup_config_round_trips_from_toml() {
let config: MeshConfig = toml::from_str(
r#"
version = 1
[[plugin]]
name = "metrics"
command = "mesh-llm-plugin-metrics"
[plugin.startup]
connect_timeout_secs = 75
init_timeout_secs = 90
optional = true
lazy_start = true
"#,
)
.expect("plugin startup config should parse");
let startup = &config.plugins[0].startup;
assert_eq!(startup.connect_timeout_secs, Some(75));
assert_eq!(startup.init_timeout_secs, Some(90));
assert!(startup.optional);
assert!(startup.lazy_start);
validate_config(&config).expect("positive startup timeouts should validate");
}
#[test]
fn plugin_startup_config_rejects_zero_timeouts() {
let config: MeshConfig = toml::from_str(
r#"
version = 1
[[plugin]]
name = "metrics"
command = "mesh-llm-plugin-metrics"
[plugin.startup]
connect_timeout_secs = 0
"#,
)
.expect("plugin startup config should parse before validation");
let err = validate_config(&config).expect_err("zero connect timeout must be rejected");
assert!(
err.to_string()
.contains("plugin[0].startup.connect_timeout_secs must be at least 1"),
"unexpected validation error: {err}"
);
}
#[test]
fn config_store_add_model_preserves_existing_fields() {
let temp_dir = TempDir::new().unwrap();

View file

@ -978,4 +978,24 @@ pub struct PluginConfigEntry {
/// Optional URL passed to the plugin as `MESH_LLM_PLUGIN_URL`.
#[serde(default)]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "PluginStartupConfig::is_default")]
pub startup: PluginStartupConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct PluginStartupConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub init_timeout_secs: Option<u64>,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub lazy_start: bool,
}
impl PluginStartupConfig {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}

View file

@ -0,0 +1,20 @@
use anyhow::{Result, bail};
use crate::PluginConfigEntry;
pub(crate) fn validate_plugin_entries(entries: &[PluginConfigEntry]) -> Result<()> {
for (index, entry) in entries.iter().enumerate() {
validate_plugin_startup(entry, index)?;
}
Ok(())
}
fn validate_plugin_startup(entry: &PluginConfigEntry, index: usize) -> Result<()> {
if matches!(entry.startup.connect_timeout_secs, Some(0)) {
bail!("plugin[{index}].startup.connect_timeout_secs must be at least 1 when set");
}
if matches!(entry.startup.init_timeout_secs, Some(0)) {
bail!("plugin[{index}].startup.init_timeout_secs must be at least 1 when set");
}
Ok(())
}

View file

@ -1,4 +1,5 @@
use crate::model::{merge_hardware, merge_model_fit, merge_multimodal, merge_throughput};
use crate::plugin_validation::validate_plugin_entries;
use crate::*;
use anyhow::{Result, bail};
use semver::{BuildMetadata, Version};
@ -30,6 +31,7 @@ pub fn validate_config(config: &MeshConfig) -> Result<()> {
}
validate_mesh_requirements_config(&config.mesh_requirements)?;
validate_telemetry_config(&config.telemetry)?;
validate_plugin_entries(&config.plugins)?;
let defaults_hardware = config
.defaults
.as_ref()

View file

@ -15,7 +15,23 @@ const SKIPPY_DIAGNOSTIC_ENDPOINTS: &[(&str, &str, &str)] = &[
"/api/runtime/stages",
"runtime-stages.json",
),
(
"runtime_endpoints",
"/api/runtime/endpoints",
"runtime-endpoints.json",
),
("runtime_llama", "/api/runtime/llama", "runtime-llama.json"),
("plugins", "/api/plugins", "plugins.json"),
(
"plugin_endpoints",
"/api/plugins/endpoints",
"plugin-endpoints.json",
),
(
"plugin_providers",
"/api/plugins/providers",
"plugin-providers.json",
),
];
pub(crate) async fn dispatch_doctor_command(command: &DoctorCommand) -> Result<()> {
@ -309,8 +325,8 @@ fn split_readiness_lines(report: &Value) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
capture_skippy_native_log, select_runtime_instance, split_readiness_lines,
write_split_readiness_report,
SKIPPY_DIAGNOSTIC_ENDPOINTS, capture_skippy_native_log, select_runtime_instance,
split_readiness_lines, write_split_readiness_report,
};
use crate::runtime::instance::LocalInstanceSnapshot;
use serde_json::json;
@ -358,6 +374,18 @@ mod tests {
assert!(written.contains("\"verdict\": \"ready\""));
}
#[test]
fn split_doctor_captures_plugin_startup_surfaces() {
let paths = SKIPPY_DIAGNOSTIC_ENDPOINTS
.iter()
.map(|(_, path, _)| *path)
.collect::<Vec<_>>();
assert!(paths.contains(&"/api/runtime/endpoints"));
assert!(paths.contains(&"/api/plugins"));
assert!(paths.contains(&"/api/plugins/providers"));
}
#[test]
fn select_runtime_instance_prefers_matching_console_port() {
let first = instance_snapshot(100, Some(3131), "/tmp/mesh-100");

View file

@ -153,6 +153,7 @@ fn resolve_installed_cli_plugin(command: &str) -> Result<Option<plugin::External
args: Vec::new(),
url: None,
env: Default::default(),
startup: Default::default(),
}))
}

View file

@ -1,5 +1,7 @@
use super::installed::{append_installed_plugins, configured_external_plugin_spec};
use super::{BLOBSTORE_PLUGIN_ID, PluginSummary};
use super::installed::{
ConfiguredExternalPlugin, append_installed_plugins, configured_external_plugin_spec,
};
use super::{BLOBSTORE_PLUGIN_ID, PluginStartupOptions, PluginSummary};
use crate::{
MeshRequirementRejectReason, MeshRequirements, NodeVersionBounds, ProtocolGenerationBounds,
ReleaseAttestationRequirement,
@ -11,11 +13,11 @@ pub use mesh_llm_config::{
FlashAttentionType, GpuAssignment, GpuConfig, HardwareConfig, IntegerOrString,
LocalServingNodeConfig, MeshConfig, MeshRequirementsConfig, ModelConfigDefaults,
ModelConfigEditor, ModelConfigEntry, ModelDefaultsEditor, ModelFitConfig, ModelRuntimeKind,
MultimodalConfig, OwnerControlConfig, PluginConfigEditor, PluginConfigEntry, PrefixCacheConfig,
ReasoningBudget, ReasoningEnabled, RequestDefaultsConfig, ReservedObjectConfig, SkippyConfig,
SpeculativeConfig, StringOrStringList, TelemetryConfig, TelemetryMetricsConfig,
TensorSplitConfig, ThroughputConfig, config_path, config_to_toml, load_config,
parse_config_toml, validate_config,
MultimodalConfig, OwnerControlConfig, PluginConfigEditor, PluginConfigEntry,
PluginStartupConfig, PrefixCacheConfig, ReasoningBudget, ReasoningEnabled,
RequestDefaultsConfig, ReservedObjectConfig, SkippyConfig, SpeculativeConfig,
StringOrStringList, TelemetryConfig, TelemetryMetricsConfig, TensorSplitConfig,
ThroughputConfig, config_path, config_to_toml, load_config, parse_config_toml, validate_config,
};
use mesh_llm_plugin::MeshVisibility;
use std::collections::BTreeMap;
@ -206,6 +208,7 @@ pub struct ExternalPluginSpec {
pub url: Option<String>,
/// Extra environment passed only to the plugin process.
pub env: BTreeMap<String, String>,
pub startup: PluginStartupOptions,
}
#[derive(Clone, Copy, Debug)]
@ -224,7 +227,11 @@ pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Resul
}
let enabled = entry.enabled.unwrap_or(true);
if entry.name == BLOBSTORE_PLUGIN_ID {
if entry.command.is_some() || !entry.args.is_empty() || entry.url.is_some() {
if entry.command.is_some()
|| !entry.args.is_empty()
|| entry.url.is_some()
|| !entry.startup.is_default()
{
bail!(
"Plugin '{}' is served by mesh-llm itself; only `enabled` may be set",
BLOBSTORE_PLUGIN_ID
@ -236,7 +243,10 @@ pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Resul
if !enabled {
continue;
}
externals.push(configured_external_plugin_spec(entry)?);
match configured_external_plugin_spec(entry)? {
ConfiguredExternalPlugin::Active(spec) => externals.push(spec),
ConfiguredExternalPlugin::Inactive(summary) => inactive.push(summary),
}
}
append_installed_plugins(&mut externals, &mut inactive, &mut names);
@ -267,6 +277,7 @@ pub fn blobstore_plugin_spec() -> Result<ExternalPluginSpec> {
],
url: None,
env: BTreeMap::new(),
startup: PluginStartupOptions::default(),
})
}

View file

@ -1,13 +1,20 @@
use super::PluginSummary;
use super::config::{ExternalPluginSpec, PluginConfigEntry};
use super::startup::PluginStartupOptions;
use anyhow::{Context, Result, bail};
use mesh_llm_plugin_manager::{InstalledPluginMetadata, PluginStore, default_store_root};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub(crate) enum ConfiguredExternalPlugin {
Active(ExternalPluginSpec),
Inactive(PluginSummary),
}
pub(crate) fn configured_external_plugin_spec(
entry: &PluginConfigEntry,
) -> Result<ExternalPluginSpec> {
) -> Result<ConfiguredExternalPlugin> {
let startup = PluginStartupOptions::from_config(&entry.startup);
let command = entry
.command
.as_deref()
@ -17,16 +24,25 @@ pub(crate) fn configured_external_plugin_spec(
let command = match command {
Some(command) => command,
None => installed_plugin_command_for_name(&entry.name)?,
None => match installed_plugin_command_for_name(&entry.name) {
Ok(command) => command,
Err(error) if startup.optional => {
return Ok(ConfiguredExternalPlugin::Inactive(
optional_configured_plugin_summary(entry, &startup, error),
));
}
Err(error) => return Err(error),
},
};
Ok(ExternalPluginSpec {
Ok(ConfiguredExternalPlugin::Active(ExternalPluginSpec {
name: entry.name.clone(),
command,
args: entry.args.clone(),
url: entry.url.clone(),
env: BTreeMap::new(),
})
startup,
}))
}
pub(crate) fn append_installed_plugins(
@ -97,6 +113,29 @@ fn installed_plugin_spec(metadata: &InstalledPluginMetadata) -> ExternalPluginSp
args: Vec::new(),
url: None,
env: BTreeMap::new(),
startup: PluginStartupOptions::default(),
}
}
fn optional_configured_plugin_summary(
entry: &PluginConfigEntry,
startup: &PluginStartupOptions,
error: anyhow::Error,
) -> PluginSummary {
PluginSummary {
name: entry.name.clone(),
kind: "external".to_string(),
enabled: true,
status: "missing".to_string(),
pid: None,
version: None,
capabilities: Vec::new(),
command: entry.command.clone(),
args: entry.args.clone(),
tools: Vec::new(),
manifest: None,
startup: Some(startup.summary()),
error: Some(format!("optional plugin not loaded: {error}")),
}
}
@ -135,6 +174,7 @@ fn installed_store_error_summary(error: anyhow::Error) -> PluginSummary {
args: Vec::new(),
tools: Vec::new(),
manifest: None,
startup: None,
error: Some(error.to_string()),
}
}
@ -156,6 +196,7 @@ fn installed_plugin_summary(
args: Vec::new(),
tools: Vec::new(),
manifest: None,
startup: None,
error,
}
}

View file

@ -3,6 +3,7 @@ mod installed;
pub(crate) mod mcp;
mod runtime;
pub(crate) mod stapler;
mod startup;
mod support;
mod transport;
@ -43,8 +44,9 @@ pub use self::config::{
ConfigEditor, ConfigStore, GpuAssignment, GpuConfig, LocalServingNodeConfig, MeshConfig,
MeshRequirementsConfig, ModelConfigEditor, ModelConfigEntry, ModelDefaultsEditor,
ModelRuntimeKind, OwnerControlConfig, PluginConfigEditor, PluginConfigEntry, PluginHostMode,
ResolvedPlugins, TelemetryConfig, TelemetryMetricsConfig, bundled_cli_plugin_spec, config_path,
config_to_toml, load_config, parse_config_toml, resolve_plugins,
PluginStartupConfig, ResolvedPlugins, TelemetryConfig, TelemetryMetricsConfig,
bundled_cli_plugin_spec, config_path, config_to_toml, load_config, parse_config_toml,
resolve_plugins,
};
#[cfg(test)]
pub(crate) use self::config::{
@ -57,6 +59,7 @@ pub(crate) use self::config::{
mesh_requirements_validation_error,
};
use self::runtime::ExternalPlugin;
pub use self::startup::{PluginStartupOptions, PluginStartupSummary};
pub(crate) use self::support::parse_optional_json;
use self::support::{format_args_for_log, format_slice_for_log, format_tool_names_for_log};
#[cfg(all(test, unix))]
@ -72,7 +75,6 @@ use tokio::sync::oneshot;
pub const BLOBSTORE_PLUGIN_ID: &str = "blobstore";
pub(crate) const PROTOCOL_VERSION: u32 = mesh_llm_plugin::PROTOCOL_VERSION;
const CONNECT_TIMEOUT_SECS: u64 = 10;
const REQUEST_TIMEOUT_SECS: u64 = 30;
const HEALTH_CHECK_INTERVAL_SECS: u64 = 15;
const ENDPOINT_STARTUP_GRACE_SECS: u64 = 30;
@ -156,6 +158,8 @@ pub struct PluginSummary {
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<PluginManifestOverview>,
#[serde(skip_serializing_if = "Option::is_none")]
pub startup: Option<PluginStartupSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
@ -454,6 +458,7 @@ impl PluginManager {
args: spec.args.clone(),
tools: Vec::new(),
manifest: None,
startup: Some(spec.startup.summary()),
error: Some(error.to_string()),
}
}
@ -562,6 +567,7 @@ impl PluginManager {
args: Vec::new(),
tools: Vec::new(),
manifest: Some(plugin_manifest_overview(&manifest)),
startup: None,
error: None,
})
.collect::<Vec<_>>();
@ -650,6 +656,7 @@ impl PluginManager {
args: Vec::new(),
tools: Vec::new(),
manifest: Some(plugin_manifest_overview(&manifest)),
startup: None,
error: None,
};
self.publish_plugin_summary(&summary);
@ -1334,7 +1341,11 @@ impl PluginManager {
};
self.publish_plugin_summary(&summary);
let manifest = self.manifest(plugin_name).await.ok().flatten();
let manifest = if let Some(plugin) = self.inner.plugins.get(plugin_name) {
plugin.manifest_snapshot().await
} else {
self.manifest(plugin_name).await.ok().flatten()
};
let Some(manifest) = manifest else {
self.clear_plugin_endpoint_health(plugin_name).await;
self.publish_plugin_summary(&summary);
@ -1946,6 +1957,7 @@ mod tests {
command: Some("mesh-llm-plugin-demo".into()),
args: vec!["--stdio".into()],
url: None,
startup: Default::default(),
}],
defaults: None,
..MeshConfig::default()
@ -1959,6 +1971,86 @@ mod tests {
assert!(resolved.inactive.is_empty());
}
#[test]
fn external_plugin_startup_policy_is_resolved() {
let config = MeshConfig {
plugins: vec![PluginConfigEntry {
name: "metrics".into(),
enabled: Some(true),
command: Some("mesh-llm-plugin-metrics".into()),
args: Vec::new(),
url: None,
startup: PluginStartupConfig {
connect_timeout_secs: Some(75),
init_timeout_secs: Some(90),
optional: true,
lazy_start: true,
},
}],
defaults: None,
..MeshConfig::default()
};
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
let spec = resolved
.externals
.iter()
.find(|spec| spec.name == "metrics")
.expect("configured plugin should resolve");
assert_eq!(spec.startup.connect_timeout().as_secs(), 75);
assert_eq!(spec.startup.init_timeout().as_secs(), 90);
assert!(spec.startup.optional);
assert!(spec.startup.lazy_start);
}
#[test]
fn optional_missing_installed_plugin_becomes_inactive_summary() {
let config = MeshConfig {
plugins: vec![PluginConfigEntry {
name: "missing-optional".into(),
enabled: Some(true),
command: None,
args: Vec::new(),
url: None,
startup: PluginStartupConfig {
optional: true,
..PluginStartupConfig::default()
},
}],
defaults: None,
..MeshConfig::default()
};
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
assert_eq!(
resolved
.inactive
.iter()
.filter(|summary| summary.name == "missing-optional")
.count(),
1
);
let summary = resolved
.inactive
.iter()
.find(|summary| summary.name == "missing-optional")
.unwrap();
assert_eq!(summary.status, "missing");
assert_eq!(
summary.startup.as_ref().map(|startup| startup.optional),
Some(true)
);
assert!(
summary
.error
.as_deref()
.unwrap_or_default()
.contains("optional")
);
}
#[test]
fn blobstore_can_be_disabled() {
let config = MeshConfig {
@ -1968,6 +2060,7 @@ mod tests {
command: None,
args: Vec::new(),
url: None,
startup: Default::default(),
}],
defaults: None,
..MeshConfig::default()
@ -1986,6 +2079,7 @@ mod tests {
command: Some("endpoint-plugin".into()),
args: Vec::new(),
url: Some("http://gpu-box:8000/v1".into()),
startup: Default::default(),
}],
defaults: None,
..MeshConfig::default()
@ -2009,6 +2103,7 @@ mod tests {
command: Some("/opt/plugins/endpoint-plugin".into()),
args: vec!["--verbose".into()],
url: None,
startup: Default::default(),
}],
defaults: None,
..MeshConfig::default()
@ -2031,6 +2126,7 @@ mod tests {
command: None,
args: Vec::new(),
url: Some("http://gpu-box:8000/v1".into()),
startup: Default::default(),
}],
defaults: None,
..MeshConfig::default()
@ -2063,6 +2159,7 @@ mod tests {
command: Some("/tmp/demo".into()),
args: vec!["--flag".into()],
url: None,
startup: Default::default(),
}],
defaults: None,
..MeshConfig::default()
@ -2083,6 +2180,7 @@ mod tests {
args: vec!["--stdio".into()],
url: None,
env: BTreeMap::new(),
startup: PluginStartupOptions::default(),
}],
inactive: Vec::new(),
};
@ -2100,6 +2198,51 @@ mod tests {
assert!(!summaries[0].error.as_deref().unwrap_or_default().is_empty());
}
#[tokio::test]
async fn lazy_start_plugin_does_not_block_manager_startup() {
let specs = ResolvedPlugins {
externals: vec![ExternalPluginSpec {
name: "lazy".into(),
command: "mesh-llm-definitely-missing-plugin-binary".into(),
args: Vec::new(),
url: None,
env: BTreeMap::new(),
startup: PluginStartupOptions {
optional: true,
lazy_start: true,
..PluginStartupOptions::default()
},
}],
inactive: Vec::new(),
};
let (mesh_tx, _mesh_rx) = mpsc::channel(1);
let manager = PluginManager::start(&specs, private_host_mode(), mesh_tx)
.await
.expect("lazy plugin should not start during manager startup");
let summaries = manager.list().await;
manager.shutdown().await;
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].name, "lazy");
assert_eq!(summaries[0].status, "deferred");
assert_eq!(
summaries[0]
.startup
.as_ref()
.map(|startup| startup.lazy_start),
Some(true)
);
assert!(summaries[0].pid.is_none());
assert!(
summaries[0]
.error
.as_deref()
.unwrap_or_default()
.contains("lazy")
);
}
#[test]
fn instance_ids_include_pid_and_random_suffix() {
let instance_id = make_instance_id();
@ -2145,6 +2288,7 @@ mod tests {
args: Vec::new(),
tools: Vec::new(),
manifest: None,
startup: None,
error: None,
}
}

View file

@ -3,8 +3,8 @@ use super::plugin_manifest_overview;
use super::support::{plugin_error, serialize_params, summarize_capabilities};
use super::transport::{LocalListener, LocalStream, bind_local_listener, connection_loop};
use super::{
CONNECT_TIMEOUT_SECS, PROTOCOL_VERSION, PluginMeshEvent, PluginRpcBridge, PluginSummary,
REQUEST_TIMEOUT_SECS, ToolCallResult, ToolSummary, proto,
PROTOCOL_VERSION, PluginMeshEvent, PluginRpcBridge, PluginSummary, REQUEST_TIMEOUT_SECS,
ToolCallResult, ToolSummary, proto,
};
use crate::runtime_data::RuntimeDataProducer;
use anyhow::{Context, Result, bail};
@ -67,6 +67,7 @@ impl ExternalPlugin {
args: spec.args.clone(),
tools: Vec::new(),
manifest: None,
startup: Some(spec.startup.summary()),
error: None,
})),
server_info: Arc::new(Mutex::new(None)),
@ -79,6 +80,10 @@ impl ExternalPlugin {
next_request_id: AtomicU64::new(1),
next_generation: AtomicU64::new(1),
};
if spec.startup.lazy_start {
plugin.mark_deferred().await;
return Ok(plugin);
}
if let Err(err) = plugin.ensure_running().await {
if plugin.is_disabled().await {
return Ok(plugin);
@ -119,6 +124,17 @@ impl ExternalPlugin {
self.publish_summary().await;
}
async fn mark_deferred(&self) {
{
let mut summary = self.summary.lock().await;
summary.status = "deferred".into();
summary.pid = None;
summary.error =
Some("lazy start enabled; plugin will start on first direct use".to_string());
}
self.publish_summary().await;
}
fn log_waiting_for_connection(&self, listener: &LocalListener) {
let endpoint = listener.endpoint();
let transport = listener.transport_name();
@ -161,12 +177,9 @@ impl ExternalPlugin {
}
async fn await_plugin_connection(&self, listener: LocalListener) -> Result<LocalStream> {
tokio::time::timeout(
std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
listener.accept(),
)
.await
.with_context(|| format!("Timed out waiting for plugin '{}'", self.spec.name))?
tokio::time::timeout(self.spec.startup.connect_timeout(), listener.accept())
.await
.with_context(|| format!("Timed out waiting for plugin '{}'", self.spec.name))?
}
async fn install_runtime(
@ -218,7 +231,7 @@ impl ExternalPlugin {
host_info_json,
mesh_visibility: proto_mesh_visibility(self.host_mode.mesh_visibility),
}),
Some(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)),
Some(self.spec.startup.init_timeout()),
)
.await?;
self.parse_initialize_response(generation, response).await
@ -301,6 +314,9 @@ impl ExternalPlugin {
if self.is_disabled().await {
return Ok(());
}
if self.is_deferred().await {
return Ok(());
}
if self.is_stopping().await {
return Ok(());
}
@ -414,6 +430,10 @@ impl ExternalPlugin {
Ok(self.manifest.lock().await.clone())
}
pub(crate) async fn manifest_snapshot(&self) -> Option<proto::PluginManifest> {
self.manifest.lock().await.clone()
}
pub(crate) async fn open_stream(
&self,
request: proto::OpenStreamRequest,
@ -777,6 +797,13 @@ impl ExternalPlugin {
self.disabled_reason().await.is_some()
}
async fn is_deferred(&self) -> bool {
if !self.spec.startup.lazy_start || self.runtime.lock().await.is_some() {
return false;
}
self.summary.lock().await.status == "deferred"
}
async fn is_stopping(&self) -> bool {
let summary = self.summary.lock().await;
matches!(summary.status.as_str(), "shutting down" | "stopped")

View file

@ -0,0 +1,69 @@
use std::time::Duration;
use mesh_llm_config::PluginStartupConfig;
use serde::Serialize;
pub(crate) const DEFAULT_PLUGIN_CONNECT_TIMEOUT_SECS: u64 = 10;
pub(crate) const DEFAULT_PLUGIN_INIT_TIMEOUT_SECS: u64 = 30;
#[derive(Clone, Debug)]
pub struct PluginStartupOptions {
pub connect_timeout: Duration,
pub init_timeout: Duration,
pub optional: bool,
pub lazy_start: bool,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct PluginStartupSummary {
pub connect_timeout_secs: u64,
pub init_timeout_secs: u64,
pub optional: bool,
pub lazy_start: bool,
}
impl Default for PluginStartupOptions {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(DEFAULT_PLUGIN_CONNECT_TIMEOUT_SECS),
init_timeout: Duration::from_secs(DEFAULT_PLUGIN_INIT_TIMEOUT_SECS),
optional: false,
lazy_start: false,
}
}
}
impl PluginStartupOptions {
pub fn from_config(config: &PluginStartupConfig) -> Self {
let defaults = Self::default();
Self {
connect_timeout: config
.connect_timeout_secs
.map(Duration::from_secs)
.unwrap_or(defaults.connect_timeout),
init_timeout: config
.init_timeout_secs
.map(Duration::from_secs)
.unwrap_or(defaults.init_timeout),
optional: config.optional,
lazy_start: config.lazy_start,
}
}
pub fn connect_timeout(&self) -> Duration {
self.connect_timeout
}
pub fn init_timeout(&self) -> Duration {
self.init_timeout
}
pub fn summary(&self) -> PluginStartupSummary {
PluginStartupSummary {
connect_timeout_secs: self.connect_timeout.as_secs(),
init_timeout_secs: self.init_timeout.as_secs(),
optional: self.optional,
lazy_start: self.lazy_start,
}
}
}

View file

@ -1057,6 +1057,7 @@ fn legacy_proto_config_to_mesh(
command: p.command.clone(),
args: p.args.clone(),
url: None,
startup: Default::default(),
})
.collect();
let mesh_requirements = snapshot

View file

@ -2025,6 +2025,7 @@ alias = "model-alias"
command: Some("mesh-llm".to_string()),
args: vec!["--plugin".to_string()],
url: None,
startup: Default::default(),
}],
extra: Default::default(),
};

View file

@ -9074,6 +9074,7 @@ mod tests {
args: Vec::new(),
tools: Vec::new(),
manifest: None,
startup: None,
error: None,
};

View file

@ -1187,6 +1187,7 @@ pub(crate) mod tests {
mesh_event_subscriptions: 0,
capabilities: vec!["chat".into()],
}),
startup: None,
error: None,
});
alpha.publish_plugin_manifest(PluginManifestOverview {
@ -1240,6 +1241,7 @@ pub(crate) mod tests {
args: Vec::new(),
tools: Vec::new(),
manifest: None,
startup: None,
error: Some("disabled".into()),
});
beta.publish_plugin_payload("metrics", json!({"requests": 5}));
@ -1366,6 +1368,7 @@ pub(crate) mod tests {
args: Vec::new(),
tools: Vec::new(),
manifest: None,
startup: None,
error: None,
});
alpha.publish_plugin_payload("metrics", json!({"requests": 1}));
@ -1398,6 +1401,7 @@ pub(crate) mod tests {
args: Vec::new(),
tools: Vec::new(),
manifest: None,
startup: None,
error: None,
});
beta.publish_plugin_payload("metrics", json!({"requests": 7}));

View file

@ -108,8 +108,9 @@ participant.
For maintainer debugging, add `--output-dir <dir>`. The doctor bundle includes
`split-readiness.json`, management API snapshots for runtime/stage/llama status,
`skippy-diagnostics.json`, and the active instance's `skippy-native.log` when
the local runtime directory can be matched to the console port.
plugin startup/provider/endpoint snapshots, `skippy-diagnostics.json`, and the
active instance's `skippy-native.log` when the local runtime directory can be
matched to the console port.
On Windows, collect a shareable diagnostic bundle from already-running nodes:

View file

@ -609,6 +609,12 @@ command = "mesh-llm-plugin-blackboard"
# [[plugin]]
# name = "openai-endpoint"
# url = "http://localhost:8000/api/v1"
#
# [plugin.startup]
# connect_timeout_secs = 75
# init_timeout_secs = 90
# optional = true
# lazy_start = true
```
Use the default config:
@ -650,6 +656,11 @@ Config precedence:
written back into TOML.
- Changing this file affects future starts or reloads, not active sessions.
- Plugin entries stay in the same file.
- `[plugin.startup]` controls how long mesh-llm waits for an external plugin to
connect and initialize. `optional = true` records a missing installed plugin
as inactive instead of rejecting the config, and `lazy_start = true` defers
process launch until direct plugin use. This is useful for very slow legacy
hosts or emulator-assisted startup paths.
## Lemonade integration

View file

@ -35,8 +35,18 @@ mesh-llm plugins install metrics
```toml
[[plugin]]
name = "metrics"
[plugin.startup]
connect_timeout_secs = 75
init_timeout_secs = 90
optional = true
lazy_start = true
```
The startup block is optional. It is useful on slow legacy machines where the
plugin process may take longer than the default startup budget, or where metrics
should be advertised only after the plugin is actually used.
Endpoint precedence is:
1. `telemetry.metrics.endpoint`