Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions crates/core/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1451,10 +1451,18 @@ async fn initialize_plugin_components_catching_panics(
/// default `version`/`policy`/`enabled` override the file, while `config` bodies
/// merge field-by-field. Delegates to [`initialize_plugins_exact`].
pub async fn initialize_plugins(config: PluginConfig) -> Result<ConfigReport> {
let config = resolve_plugin_config(config)?;
initialize_plugins_exact(config).await
}

/// Layers `config` over the default discovered `plugins.toml` files.
///
/// This is crate-visible so owned dynamic-plugin activation can use the same
/// one-time configuration resolution as regular harness-native initialization.
pub(crate) fn resolve_plugin_config(config: PluginConfig) -> Result<PluginConfig> {
let mut base = resolve_default_file_plugin_config()?;
layer_config(&mut base, serde_json::to_value(config)?);
let config: PluginConfig = serde_json::from_value(base)?;
initialize_plugins_exact(config).await
Ok(serde_json::from_value(base)?)
}

/// Resolves the default `plugins.toml` layering into one JSON document, or an
Expand Down
28 changes: 27 additions & 1 deletion crates/core/src/plugin/dynamic/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use serde_json::{Map, Value as Json};
use crate::plugin::{
ConfigReport, PluginComponentSpec, PluginConfig, PluginHostLease, Result,
acquire_plugin_host_lease, clear_plugin_configuration_for_host,
ensure_builtin_plugins_registered, initialize_plugins_exact_for_host,
ensure_builtin_plugins_registered, initialize_plugins_exact_for_host, resolve_plugin_config,
run_owned_plugin_mutation,
};

Expand Down Expand Up @@ -79,6 +79,32 @@ impl PluginHostActivation {
{
let dynamic_plugins = dynamic_plugins.into_iter().collect::<Vec<_>>();
validate_dynamic_plugin_specs(&dynamic_plugins)?;
Self::activate_validated(config, dynamic_plugins).await
}

/// Load dynamic plugins after layering `config` over discovered `plugins.toml` files.
///
/// This is the harness-native entrypoint for language and FFI bindings. File
/// discovery and merging happen once before activation, and the explicit
/// `config` has higher precedence. Hosts such as the Relay CLI that already
/// resolved plugin configuration should call [`Self::activate`] instead.
pub async fn activate_with_discovered_config<I>(
config: PluginConfig,
dynamic_plugins: I,
) -> Result<(Self, ConfigReport)>
where
I: IntoIterator<Item = DynamicPluginActivationSpec>,
{
let dynamic_plugins = dynamic_plugins.into_iter().collect::<Vec<_>>();
validate_dynamic_plugin_specs(&dynamic_plugins)?;
let config = resolve_plugin_config(config)?;
Self::activate_validated(config, dynamic_plugins).await
}

async fn activate_validated(
config: PluginConfig,
dynamic_plugins: Vec<DynamicPluginActivationSpec>,
) -> Result<(Self, ConfigReport)> {
run_owned_plugin_mutation("dynamic plugin activation", move || async move {
Self::activate_inner(config, dynamic_plugins).await
})
Expand Down
65 changes: 65 additions & 0 deletions crates/core/tests/integration/native_plugin_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ use tokio_stream::StreamExt;
use uuid::Uuid;

static NATIVE_PLUGIN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
const PLUGIN_DISCOVERY_TEST_CHILD: &str = "NEMO_RELAY_PLUGIN_DISCOVERY_TEST_CHILD";

struct ReplacementRegistryPlugin;

Expand Down Expand Up @@ -1184,6 +1185,70 @@ async fn plugin_host_activation_combines_static_base_and_dynamic_components() {
assert!(deregister_plugin(STATIC_BASE_PLUGIN_KIND));
}

#[tokio::test]
async fn plugin_host_activation_layers_discovered_static_base_with_dynamic_components() {
if std::env::var_os(PLUGIN_DISCOVERY_TEST_CHILD).is_none() {
let environment = TempDir::new().expect("plugin discovery environment should be created");
let project_config_dir = environment.path().join(".nemo-relay");
std::fs::create_dir_all(&project_config_dir)
.expect("project plugin config directory should be created");
std::fs::write(
project_config_dir.join("plugins.toml"),
format!(
"version = 1\n\n[[components]]\nkind = {STATIC_BASE_PLUGIN_KIND:?}\nenabled = true\n"
),
)
.expect("project plugin config should be written");
let xdg_config_home = environment.path().join("xdg");
std::fs::create_dir_all(&xdg_config_home)
.expect("isolated user config directory should be created");

let output = Command::new(std::env::current_exe().expect("test executable should resolve"))
.args([
"--exact",
"plugin_host_activation_layers_discovered_static_base_with_dynamic_components",
"--nocapture",
])
.current_dir(environment.path())
.env("XDG_CONFIG_HOME", &xdg_config_home)
.env(PLUGIN_DISCOVERY_TEST_CHILD, "1")
.output()
.expect("plugin discovery child process should run");
assert!(
output.status.success(),
"plugin discovery child process failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}

let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await;
let _ = deregister_plugin(STATIC_BASE_PLUGIN_KIND);
STATIC_BASE_REGISTRATIONS.store(0, Ordering::SeqCst);
STATIC_BASE_DEREGISTRATIONS.store(0, Ordering::SeqCst);
register_plugin(Arc::new(StaticBasePlugin)).expect("static base plugin should register");

let fixture = build_fixture_plugin();
let manifest_ref = write_manifest(&fixture);
let (activation, report) = PluginHostActivation::activate_with_discovered_config(
PluginConfig::default(),
[host_spec("fixture_native", &manifest_ref)],
)
.await
.expect("discovered static and dynamic components should activate together");

assert!(!report.has_errors());
assert_eq!(STATIC_BASE_REGISTRATIONS.load(Ordering::SeqCst), 1);
assert!(lookup_plugin(STATIC_BASE_PLUGIN_KIND).is_some());
assert!(lookup_plugin("fixture_native").is_some());

activation.clear().expect("discovered host should clear");
assert_eq!(STATIC_BASE_DEREGISTRATIONS.load(Ordering::SeqCst), 1);
assert!(lookup_plugin("fixture_native").is_none());
assert!(deregister_plugin(STATIC_BASE_PLUGIN_KIND));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[tokio::test]
async fn plugin_host_clear_allows_an_in_flight_native_callback_to_finish() {
let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await;
Expand Down
4 changes: 4 additions & 0 deletions go/nemo_relay/scope/error_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import (
)

func TestWithScopeCleanupNoopsWhenPushFails(t *testing.T) {
runWithTestScopeStack(t, testWithScopeCleanupNoopsWhenPushFails)
}

func testWithScopeCleanupNoopsWhenPushFails(t *testing.T) {
Comment thread
bbednarski9 marked this conversation as resolved.
for _, tc := range []struct {
name string
opt nemo_relay.ScopeOption
Expand Down
Loading