diff --git a/crates/node/plugin.d.ts b/crates/node/plugin.d.ts index 76a84ee28..608289762 100644 --- a/crates/node/plugin.d.ts +++ b/crates/node/plugin.d.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/// + import type { EventSanitizeFields, Json } from './index'; /** Policy behavior for unsupported configuration. */ @@ -45,6 +47,33 @@ export interface PluginConfig { policy?: ConfigPolicy; } +/** Execution lane for a dynamically loaded Relay plugin. */ +export type DynamicPluginKind = 'rust_dynamic' | 'worker'; + +/** Explicitly resolved dynamic plugin load and component configuration. */ +export interface DynamicPluginActivationSpec { + pluginId: string; + kind: DynamicPluginKind; + manifestRef: string; + environmentRef?: string | null; + config?: Record; +} + +/** Owns one process-wide dynamic plugin host activation. */ +export interface DynamicPluginActivation extends AsyncDisposable { + /** Validation report produced by the successful activation. */ + readonly report: ConfigReport; + /** + * Whether this activation handle has not begun teardown. `false` does not + * guarantee another process-wide activation can start after failed teardown. + */ + readonly active: boolean; + /** Clear callbacks before unloading libraries and workers. Idempotent. */ + close(): Promise; + /** Delegate structured `await using` cleanup to `close()`. */ + [Symbol.asyncDispose](): Promise; +} + /** A mark Relay materializes under a managed lifecycle. */ export interface PendingMarkSpec { name: string; @@ -297,6 +326,23 @@ export declare function validate(config: PluginConfig): ConfigReport; * the promise rejects with the underlying validation or setup error. */ export declare function initialize(config: PluginConfig): Promise; +/** + * Initialize with explicitly resolved dynamic plugins. + * + * The returned object owns loaded libraries and worker processes. Keep it + * alive while plugin callbacks may run and call `close()` for deterministic + * teardown. Garbage collection is a defensive fallback only. + * + * @param config - Base configuration layered over discovered `plugins.toml` files. + * @param specs - Non-empty explicit manifest and component configuration for each plugin. + * @returns The owned activation and its validation report. + * @remarks File-configured static components initialize before dynamic + * components. Use `initialize()` for a static-only configuration. + */ +export declare function initializeWithDynamicPlugins( + config: PluginConfig, + specs: DynamicPluginActivationSpec[], +): Promise; /** * Clear the active plugin configuration. * diff --git a/crates/node/plugin.js b/crates/node/plugin.js index a84c3212b..84ab6d228 100644 --- a/crates/node/plugin.js +++ b/crates/node/plugin.js @@ -78,6 +78,30 @@ function initialize(config) { return lib.initializePlugins(config); } +/** + * @typedef {object} DynamicPluginActivationSpec + * @property {string} pluginId - Manifest plugin identifier. + * @property {'rust_dynamic'|'worker'} kind - Dynamic plugin execution kind. + * @property {string} manifestRef - Path to the plugin manifest. + * @property {string|null} [environmentRef] - Optional worker environment path. + * @property {Object} [config] - Plugin component configuration. + */ + +/** + * Initialize with explicitly resolved dynamic plugins. + * + * @param {object} config - Base configuration layered over discovered `plugins.toml` files. + * @param {DynamicPluginActivationSpec[]} specs - Non-empty native-library or worker plugin specifications. + * @returns {Promise} An owned activation with `report`, `active`, `close()`, and async disposal. + * @remarks File-configured static components initialize before dynamic + * components. Keep the returned activation alive while its callbacks may run + * and call `close()` or use `await using` for deterministic teardown. Use + * `initialize()` for a static-only configuration. + */ +function initializeWithDynamicPlugins(config, specs) { + return lib.initializeWithDynamicPlugins(config, specs); +} + /** * Clear the active plugin configuration. * @@ -161,6 +185,7 @@ module.exports = { ComponentSpec, validate, initialize, + initializeWithDynamicPlugins, clear, report, listKinds, diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index 9f06b649f..ad9204973 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -46,6 +46,10 @@ use nemo_relay::api::tool::ToolAttributes; use nemo_relay::codec::request::AnnotatedLlmRequest; use nemo_relay::codec::response::Usage; use nemo_relay::error::{FlowError, Result as FlowResult}; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec as CoreDynamicPluginActivationSpec, DynamicPluginKind, + PluginHostActivation as CorePluginHostActivation, +}; use nemo_relay::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginConfig, PluginError, PluginRegistration, PluginRegistrationContext, active_plugin_report as active_plugin_report_impl, @@ -85,6 +89,21 @@ fn init() { .expect("node pii redaction plugin component registration should succeed"); } +#[cfg(not(test))] +#[napi_derive::module_exports] +fn install_well_known_symbol_methods(exports: JsObject, env: Env) -> napi::Result<()> { + let activation: JsFunction = exports.get_named_property("DynamicPluginActivation")?; + let activation = activation.coerce_to_object()?; + let mut prototype: JsObject = activation.get_named_property("prototype")?; + let symbol: JsFunction = env.get_global()?.get_named_property("Symbol")?; + let symbol = symbol.coerce_to_object()?; + let async_dispose: napi::JsSymbol = symbol.get_named_property("asyncDispose")?; + let close: JsFunction = prototype.get_named_property("close")?; + prototype.set_property(async_dispose, close)?; + prototype.delete_named_property("[Symbol.asyncDispose]")?; + Ok(()) +} + fn parse_string_map( value: Option, field_name: &str, @@ -4007,6 +4026,248 @@ pub async fn initialize_plugins(config: Json) -> napi::Result { serde_json::to_value(&report).map_err(|e| napi::Error::from_reason(e.to_string())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeDynamicPluginActivationSpec { + #[serde(alias = "plugin_id")] + plugin_id: String, + kind: DynamicPluginKind, + #[serde(alias = "manifest_ref")] + manifest_ref: String, + #[serde(default, alias = "environment_ref")] + environment_ref: Option, + #[serde(default)] + config: serde_json::Map, +} + +impl From for CoreDynamicPluginActivationSpec { + fn from(spec: NodeDynamicPluginActivationSpec) -> Self { + Self { + plugin_id: spec.plugin_id, + kind: spec.kind, + manifest_ref: spec.manifest_ref, + environment_ref: spec.environment_ref, + config: spec.config, + } + } +} + +/// Owned dynamic plugin activation. +/// +/// Keep this object alive while code may invoke callbacks registered by the +/// dynamic plugins. Call `close()` for deterministic cleanup; garbage +/// collection performs the same cleanup as a defensive fallback. +#[napi] +pub struct DynamicPluginActivation { + close_state: Arc, + report: Json, +} + +type DynamicPluginTeardownResult = std::result::Result<(), String>; + +enum DynamicPluginCloseStatus { + Active(Option), + Closing, + Closed, +} + +struct DynamicPluginCloseState { + status: StdMutex, + completion: tokio::sync::watch::Sender>, +} + +impl DynamicPluginCloseState { + fn new(activation: CorePluginHostActivation) -> Self { + let (completion, _) = tokio::sync::watch::channel(None); + Self { + status: StdMutex::new(DynamicPluginCloseStatus::Active(Some(activation))), + completion, + } + } + + fn active(&self) -> bool { + let status = self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match &*status { + DynamicPluginCloseStatus::Active(activation) => activation.is_some(), + DynamicPluginCloseStatus::Closing | DynamicPluginCloseStatus::Closed => false, + } + } + + fn begin_close(self: &Arc, log_finalizer_error: bool) { + let activation = { + let mut status = self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match &mut *status { + DynamicPluginCloseStatus::Active(activation) => { + let activation = activation.take(); + *status = DynamicPluginCloseStatus::Closing; + activation + } + DynamicPluginCloseStatus::Closing | DynamicPluginCloseStatus::Closed => None, + } + }; + let Some(activation) = activation else { + return; + }; + + // Keep the activation outside the spawned closure so a thread-spawn + // failure cannot drop it and synchronously run teardown on the JS thread. + let activation = Arc::new(StdMutex::new(Some(activation))); + let worker_activation = Arc::clone(&activation); + let close_state = Arc::clone(self); + let spawn = std::thread::Builder::new() + .name("nemo-relay-node-plugin-teardown".into()) + .spawn(move || { + let activation = worker_activation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let result = match activation { + Some(activation) => { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + activation.clear() + })) + .map_err(|_| "dynamic plugin teardown task panicked".to_string()) + .and_then(|result| result.map_err(|error| error.to_string())) + } + None => Err("dynamic plugin teardown task lost its activation".to_string()), + }; + if log_finalizer_error && let Err(error) = &result { + eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); + } + close_state.finish(result); + }); + + if let Err(error) = spawn { + // Cleanup must never fall back to the JS thread. Retain the + // activation for process lifetime if no teardown thread can start. + if let Some(activation) = activation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + std::mem::forget(activation); + } + let error = format!("failed to start dynamic plugin teardown task: {error}"); + if log_finalizer_error { + eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); + } + self.finish(Err(error)); + } + } + + fn finish(&self, result: DynamicPluginTeardownResult) { + *self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = DynamicPluginCloseStatus::Closed; + self.completion.send_replace(Some(result)); + } + + async fn wait_for_close(&self) -> DynamicPluginTeardownResult { + let mut completion = self.completion.subscribe(); + loop { + if let Some(result) = completion.borrow().clone() { + return result; + } + if completion.changed().await.is_err() { + return Err( + "dynamic plugin teardown result channel closed unexpectedly".to_string() + ); + } + } + } +} + +#[napi] +impl DynamicPluginActivation { + /// Return the validation report produced by activation. + #[napi(getter)] + pub fn report(&self) -> Json { + self.report.clone() + } + + /// Return whether this activation handle has not begun teardown. + /// + /// `false` does not guarantee another process-wide activation can start; + /// failed teardown may intentionally retain the activation owner. + #[napi(getter)] + pub fn active(&self) -> napi::Result { + Ok(self.close_state.active()) + } + + /// Clear plugin callbacks before unloading libraries and workers. + /// + /// This method is idempotent, including when concurrent callers race to + /// close the same activation. + #[napi(ts_return_type = "Promise")] + pub fn close(&self, env: Env) -> napi::Result { + let close_state = Arc::clone(&self.close_state); + close_state.begin_close(false); + env.execute_tokio_future( + async move { + close_state + .wait_for_close() + .await + .map_err(napi::Error::from_reason) + }, + |env, _| env.get_undefined(), + ) + } + + /// Supply the structured disposal signature to napi-rs declaration generation. + /// + /// Module initialization installs `close()` under the actual well-known + /// symbol and removes this string-named declaration shim from the prototype. + #[napi(js_name = "[Symbol.asyncDispose]", ts_return_type = "Promise")] + pub fn async_dispose(&self, env: Env) -> napi::Result { + self.close(env) + } +} + +impl Drop for DynamicPluginActivation { + fn drop(&mut self) { + self.close_state.begin_close(true); + } +} + +/// Initialize with explicitly resolved dynamic plugins. +/// +/// `config` is layered over discovered `plugins.toml` files and may contain +/// statically registered components; dynamic components are activated after +/// that effective base configuration. At least one dynamic plugin is required. +/// Static-only callers should use `initializePlugins`. The returned object owns +/// all loaded libraries and worker processes. Its validation report is available +/// through the `report` property. +#[napi] +pub async fn initialize_with_dynamic_plugins( + config: Json, + specs: Json, +) -> napi::Result { + let config: PluginConfig = serde_json::from_value(config) + .map_err(|error| napi::Error::from_reason(format!("invalid plugin config: {error}")))?; + let specs: Vec = + serde_json::from_value(specs).map_err(|error| { + napi::Error::from_reason(format!("invalid dynamic plugin specs: {error}")) + })?; + let specs = specs.into_iter().map(Into::into).collect::>(); + let (activation, report) = + CorePluginHostActivation::activate_with_discovered_config(config, specs) + .await + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + let report = serde_json::to_value(report) + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + Ok(DynamicPluginActivation { + close_state: Arc::new(DynamicPluginCloseState::new(activation)), + report, + }) +} + /// Clear the active global plugin configuration. #[napi] pub fn clear_plugin_configuration() -> napi::Result<()> { diff --git a/crates/node/tests/dynamic_plugin_tests.mjs b/crates/node/tests/dynamic_plugin_tests.mjs new file mode 100644 index 000000000..bf96f4387 --- /dev/null +++ b/crates/node/tests/dynamic_plugin_tests.mjs @@ -0,0 +1,542 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const lib = require('../index.js'); +const plugin = require('../plugin.js'); + +const nodeDir = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.resolve(nodeDir, '../..'); +const fixtureTarget = path.join(repoRoot, 'target', 'node-dynamic-plugin-fixtures'); +const tempRoot = mkdtempSync(path.join(tmpdir(), 'nemo-relay-node-dynamic-')); +const relayVersion = JSON.parse(readFileSync(path.join(nodeDir, 'package.json'), 'utf8')).version; + +let nativeManifestRef; +let workerManifestRef; + +function tomlString(value) { + return JSON.stringify(value); +} + +function nativeLibraryName() { + if (process.platform === 'win32') { + return 'nemo_relay_plugin_fixture.dll'; + } + if (process.platform === 'darwin') { + return 'libnemo_relay_plugin_fixture.dylib'; + } + return 'libnemo_relay_plugin_fixture.so'; +} + +function workerBinaryName() { + return process.platform === 'win32' ? 'nemo-relay-worker-plugin-fixture.exe' : 'nemo-relay-worker-plugin-fixture'; +} + +function buildFixture(manifestPath) { + execFileSync( + process.env.CARGO || 'cargo', + ['build', '--quiet', '--manifest-path', manifestPath, '--target-dir', fixtureTarget], + { stdio: 'inherit' }, + ); +} + +function buildNativeFixture(sourceManifestPath) { + const sourceDirectory = path.dirname(sourceManifestPath); + const fixtureDirectory = path.join(tempRoot, 'native-source'); + const fixtureSourceDirectory = path.join(fixtureDirectory, 'src'); + mkdirSync(fixtureSourceDirectory, { recursive: true }); + const pluginCrate = path.join(repoRoot, 'crates', 'plugin'); + const manifest = readFileSync(sourceManifestPath, 'utf8').replace( + 'nemo-relay-plugin = { path = "../../../../plugin" }', + `nemo-relay-plugin = { path = ${tomlString(pluginCrate)} }`, + ); + const fixtureManifest = path.join(fixtureDirectory, 'Cargo.toml'); + writeFileSync(fixtureManifest, manifest); + writeFileSync(path.join(fixtureSourceDirectory, 'lib.rs'), readFileSync(path.join(sourceDirectory, 'src', 'lib.rs'))); + buildFixture(fixtureManifest); +} + +function writeNativeManifest(libraryPath) { + const directory = path.join(tempRoot, 'native'); + mkdirSync(directory, { recursive: true }); + const manifestRef = path.join(directory, 'relay-plugin.toml'); + writeFileSync( + manifestRef, + `manifest_version = 1 + +[plugin] +id = "fixture_native" +kind = "rust_dynamic" + +[compat] +relay = ${tomlString(`=${relayVersion}`)} +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[load] +library = ${tomlString(libraryPath)} +symbol = "nemo_relay_fixture_native_plugin" +`, + ); + return manifestRef; +} + +function writeWorkerManifest(entrypoint, name = 'worker') { + const directory = path.join(tempRoot, name); + mkdirSync(directory, { recursive: true }); + const manifestRef = path.join(directory, 'relay-plugin.toml'); + writeFileSync( + manifestRef, + `manifest_version = 1 + +[plugin] +id = "fixture_worker" +kind = "worker" + +[compat] +relay = ${tomlString(`=${relayVersion}`)} +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "rust" +entrypoint = ${tomlString(entrypoint)} +`, + ); + return manifestRef; +} + +function writeWorkerWrapper(entrypoint, pidFile, name) { + const wrapper = path.join(tempRoot, `${name}.sh`); + writeFileSync(wrapper, `#!/bin/sh\nprintf '%s' "$$" > ${tomlString(pidFile)}\nexec ${tomlString(entrypoint)}\n`, { + mode: 0o755, + }); + return wrapper; +} + +function activationSpec(pluginId, kind, manifestRef, config = {}) { + return { + pluginId, + kind, + manifestRef, + config, + }; +} + +function nativeRequest(model = 'fixture-model') { + return { + headers: {}, + content: { + model, + messages: [], + }, + }; +} + +async function executeTool(name) { + return lib.toolCallExecute( + name, + { original: true }, + (args) => ({ ...args, downstream: true }), + null, + null, + null, + null, + ); +} + +async function executeLlm(name) { + return lib.llmCallExecute( + name, + nativeRequest(), + (request) => ({ + downstream: true, + requestContent: request.content, + }), + null, + null, + null, + null, + null, + ); +} + +before(() => { + const nativeFixture = path.join(repoRoot, 'crates', 'core', 'tests', 'fixtures', 'native_plugin', 'Cargo.toml'); + const workerFixture = path.join(repoRoot, 'crates', 'core', 'tests', 'fixtures', 'worker_plugin', 'Cargo.toml'); + buildNativeFixture(nativeFixture); + buildFixture(workerFixture); + nativeManifestRef = writeNativeManifest(path.join(fixtureTarget, 'debug', nativeLibraryName())); + workerManifestRef = writeWorkerManifest(path.join(fixtureTarget, 'debug', workerBinaryName())); +}); + +after(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('dynamic plugin host', () => { + it('rejects empty specs without taking over static initialization', async () => { + await assert.rejects( + () => plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, []), + /at least one dynamic plugin/i, + ); + + assert.deepEqual(await plugin.initialize({ version: 1, components: [] }), { diagnostics: [] }); + plugin.clear(); + }); + + it('layers plugins.toml static base components with dynamic plugins', async () => { + const staticKind = 'node.fixture.static-base'; + const projectRoot = path.join(tempRoot, 'file-static-base-project'); + const projectConfigDirectory = path.join(projectRoot, '.nemo-relay'); + const isolatedUserConfig = path.join(projectRoot, 'xdg'); + mkdirSync(projectConfigDirectory, { recursive: true }); + mkdirSync(isolatedUserConfig, { recursive: true }); + writeFileSync( + path.join(projectConfigDirectory, 'plugins.toml'), + `version = 1 + +[[components]] +kind = ${tomlString(staticKind)} +enabled = true +`, + ); + plugin.register(staticKind, { + register(_config, context) { + context.registerToolRequestIntercept('mark-static-base', 0, false, (_name, args) => ({ + ...args, + staticBase: true, + })); + }, + }); + const previousCwd = process.cwd(); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + let activation; + try { + process.chdir(projectRoot); + process.env.XDG_CONFIG_HOME = isolatedUserConfig; + activation = await plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + const result = await executeTool('node_static_and_dynamic_tool'); + assert.equal(result.staticBase, true); + assert.equal(result.native_plugin_tool_execution, true); + } finally { + await activation?.close(); + plugin.deregister(staticKind); + process.chdir(previousCwd); + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + } + }); + + it('owns native managed callbacks until idempotent close', async () => { + const activation = await plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + try { + assert.deepEqual(activation.report.diagnostics, []); + assert.equal(activation.active, true); + assert.throws(() => plugin.clear(), /active dynamic plugin host/i); + + const toolResult = await executeTool('node_native_dynamic_tool'); + assert.equal(toolResult.downstream, true); + assert.equal(toolResult.native_plugin_tool_execution_request, true); + assert.equal(toolResult.native_plugin_tool_execution, true); + + const llmResult = await executeLlm('node_native_dynamic_llm'); + assert.equal(llmResult.downstream, true); + assert.equal(llmResult.requestContent.native_plugin_llm_execution_request, true); + assert.equal(llmResult.native_plugin_llm_execution, true); + + await Promise.all([activation.close(), activation.close()]); + assert.equal(activation.active, false); + await activation.close(); + + const toolAfterClose = await executeTool('node_native_closed_tool'); + assert.deepEqual(toolAfterClose, { original: true, downstream: true }); + const llmAfterClose = await executeLlm('node_native_closed_llm'); + assert.equal(llmAfterClose.downstream, true); + assert.equal(llmAfterClose.requestContent.native_plugin_llm_execution_request, undefined); + assert.equal(llmAfterClose.native_plugin_llm_execution, undefined); + } finally { + await activation.close(); + } + }); + + it('supports structured async disposal when the managed scope throws', async () => { + let disposedActivation; + await assert.rejects(async () => { + await using activation = await plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + disposedActivation = activation; + + assert.equal(activation[Symbol.asyncDispose], lib.DynamicPluginActivation.prototype.close); + assert.equal('[Symbol.asyncDispose]' in lib.DynamicPluginActivation.prototype, false); + const toolResult = await executeTool('node_native_async_dispose_tool'); + assert.equal(toolResult.native_plugin_tool_execution, true); + throw new Error('managed activation scope failed'); + }, /managed activation scope failed/); + + assert.equal(disposedActivation.active, false); + await disposedActivation[Symbol.asyncDispose](); + const toolAfterDispose = await executeTool('node_native_async_disposed_tool'); + assert.deepEqual(toolAfterDispose, { original: true, downstream: true }); + }); + + it('owns worker managed callbacks until close', async () => { + const activation = await plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_worker', 'worker', workerManifestRef), + ]); + try { + assert.deepEqual(activation.report.diagnostics, []); + const toolResult = await executeTool('node_worker_dynamic_tool'); + assert.equal(toolResult.worker_plugin_tool_execution_request, true); + assert.equal(toolResult.worker_plugin_tool_execution, true); + + const llmResult = await executeLlm('node_worker_dynamic_llm'); + assert.equal(llmResult.requestContent.worker_plugin_llm_execution_request, true); + assert.equal(llmResult.worker_plugin_llm_execution, true); + } finally { + await activation.close(); + } + + const toolAfterClose = await executeTool('node_worker_closed_tool'); + assert.deepEqual(toolAfterClose, { original: true, downstream: true }); + const llmAfterClose = await executeLlm('node_worker_closed_llm'); + assert.equal(llmAfterClose.requestContent.worker_plugin_llm_execution_request, undefined); + assert.equal(llmAfterClose.worker_plugin_llm_execution, undefined); + }); + + it( + 'keeps every concurrent close pending until the shared teardown completes', + { skip: process.platform === 'win32' }, + async () => { + const workerBinary = path.join(fixtureTarget, 'debug', workerBinaryName()); + const pidFile = path.join(tempRoot, 'concurrent-close-worker.pid'); + const wrapper = writeWorkerWrapper(workerBinary, pidFile, 'concurrent-close-worker'); + const manifestRef = writeWorkerManifest(wrapper, 'concurrent-close-worker'); + const activation = await plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_worker', 'worker', manifestRef), + ]); + const workerPid = Number(readFileSync(pidFile, 'utf8')); + process.kill(workerPid, 'SIGSTOP'); + + const firstClose = activation.close(); + const secondClose = activation.close(); + let earlyResult; + let operationFailed = false; + let operationError; + let cleanupFailed = false; + let cleanupError; + try { + earlyResult = await Promise.race([ + firstClose.then(() => 'first'), + secondClose.then(() => 'second'), + new Promise((resolve) => setTimeout(() => resolve('pending'), 200)), + ]); + } catch (error) { + operationFailed = true; + operationError = error; + } finally { + try { + process.kill(workerPid, 'SIGCONT'); + } catch (error) { + if (error.code !== 'ESRCH') { + cleanupFailed = true; + cleanupError = error; + } + } + const closeResults = await Promise.allSettled([firstClose, secondClose]); + const rejectedClose = closeResults.find((result) => result.status === 'rejected'); + if (!cleanupFailed && rejectedClose !== undefined) { + cleanupFailed = true; + cleanupError = rejectedClose.reason; + } + } + if (operationFailed) { + throw operationError; + } + if (cleanupFailed) { + throw cleanupError; + } + + assert.equal(earlyResult, 'pending'); + assert.equal(activation.active, false); + await activation.close(); + }, + ); + + it('preserves manifest and validation diagnostics in rejected promises', async () => { + const missingManifest = path.join(tempRoot, 'missing', 'relay-plugin.toml'); + await assert.rejects( + () => + plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + activationSpec('missing_native', 'rust_dynamic', missingManifest), + ]), + (error) => { + assert.match(error.message, /native plugin load failed/i); + assert.match(error.message, /relay-plugin\.toml/); + assert.match(error.message, /does not exist/i); + return true; + }, + ); + + await assert.rejects( + () => + plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef, { reject: true }), + ]), + /fixture rejection requested/i, + ); + + const recovered = await plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + await recovered.close(); + }); + + it('defensively clears a native activation during garbage collection', () => { + const pluginModule = path.join(nodeDir, 'plugin.js'); + const script = ` + import { createRequire } from 'node:module'; + const require = createRequire(${JSON.stringify(path.join(nodeDir, 'package.json'))}); + const plugin = require(${JSON.stringify(pluginModule)}); + const config = { version: 1, components: [] }; + const specs = [${JSON.stringify(activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef))}]; + let activation = await plugin.initializeWithDynamicPlugins(config, specs); + const weak = new WeakRef(activation); + activation = null; + let collected = false; + for (let index = 0; index < 100; index += 1) { + global.gc(); + await new Promise((resolve) => setImmediate(resolve)); + if (weak.deref() === undefined) { + collected = true; + break; + } + await new Promise((resolve) => setImmediate(resolve)); + } + if (!collected) { + throw new Error('dynamic activation was not garbage collected'); + } + let replacement; + let lastError; + for (let index = 0; index < 100; index += 1) { + global.gc(); + await new Promise((resolve) => setImmediate(resolve)); + try { + replacement = await plugin.initializeWithDynamicPlugins(config, specs); + break; + } catch (error) { + lastError = error; + } + } + if (!replacement) { + throw lastError ?? new Error('dynamic activation finalizer did not release ownership'); + } + await replacement.close(); + `; + execFileSync(process.execPath, ['--expose-gc', '--input-type=module', '--eval', script], { + stdio: 'inherit', + timeout: 30_000, + }); + }); + + it( + 'never waits for worker teardown on the JavaScript thread during garbage collection', + { skip: process.platform === 'win32' }, + () => { + const workerBinary = path.join(fixtureTarget, 'debug', workerBinaryName()); + const pidFile = path.join(tempRoot, 'finalizer-worker.pid'); + const wrapper = writeWorkerWrapper(workerBinary, pidFile, 'finalizer-worker'); + const manifestRef = writeWorkerManifest(wrapper, 'finalizer-worker'); + const pluginModule = path.join(nodeDir, 'plugin.js'); + const script = ` + import { spawn } from 'node:child_process'; + import { readFileSync } from 'node:fs'; + import { performance } from 'node:perf_hooks'; + import { createRequire } from 'node:module'; + const require = createRequire(${JSON.stringify(path.join(nodeDir, 'package.json'))}); + const plugin = require(${JSON.stringify(pluginModule)}); + const config = { version: 1, components: [] }; + const specs = [${JSON.stringify(activationSpec('fixture_worker', 'worker', manifestRef))}]; + let activation = await plugin.initializeWithDynamicPlugins(config, specs); + const weak = new WeakRef(activation); + activation = null; + await new Promise((resolve) => setImmediate(resolve)); + + const workerPid = Number(readFileSync(${JSON.stringify(pidFile)}, 'utf8')); + process.kill(workerPid, 'SIGSTOP'); + const resumer = spawn( + '/bin/sh', + ['-c', 'sleep 0.8; kill -CONT "$1"', 'resume-worker', String(workerPid)], + { detached: true, stdio: 'ignore' }, + ); + resumer.unref(); + + let collected = false; + for (let index = 0; index < 20; index += 1) { + const startedAt = performance.now(); + global.gc(); + const elapsed = performance.now() - startedAt; + if (elapsed >= 400) { + throw new Error(\`dynamic activation finalizer blocked the JavaScript thread for \${elapsed}ms\`); + } + if (weak.deref() === undefined) { + collected = true; + break; + } + await new Promise((resolve) => setImmediate(resolve)); + } + if (!collected) { + throw new Error('dynamic activation was not garbage collected'); + } + + let replacement; + let lastError; + for (let index = 0; index < 500; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + try { + replacement = await plugin.initializeWithDynamicPlugins(config, specs); + break; + } catch (error) { + lastError = error; + } + } + if (!replacement) { + throw lastError ?? new Error('dynamic activation finalizer did not release ownership'); + } + await replacement.close(); + `; + execFileSync(process.execPath, ['--expose-gc', '--input-type=module', '--eval', script], { + stdio: 'inherit', + timeout: 30_000, + }); + }, + ); +});