[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
711
electron/plugins/runtimeSupervisor.cjs
Normal file
711
electron/plugins/runtimeSupervisor.cjs
Normal file
@@ -0,0 +1,711 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const { BrowserPluginRuntime } = require("./browserPluginRuntime.cjs");
|
||||
const {
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_CRASH_QUARANTINE_THRESHOLD,
|
||||
PLUGIN_CRASH_WINDOW_MS,
|
||||
} = require("./constants.cjs");
|
||||
const { PluginLogger } = require("./pluginLogger.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
||||
const {
|
||||
assertHostMethod,
|
||||
createDefaultPluginHostRpcRegistry,
|
||||
} = require("./hostRpcRegistry.cjs");
|
||||
const {
|
||||
assertSecurityPrincipal,
|
||||
defaultSecurityPrincipal,
|
||||
} = require("./permissionResources.cjs");
|
||||
const {
|
||||
PLUGIN_CONTAINMENT_ERROR_CODE,
|
||||
UtilityPluginRuntime,
|
||||
} = require("./utilityPluginRuntime.cjs");
|
||||
|
||||
function assertStorageParams(params, options = {}) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw new TypeError("Plugin storage parameters must be an object");
|
||||
}
|
||||
if (typeof params.key !== "string" || params.key.length < 1 || params.key.length > 256 || params.key.includes("\0")) {
|
||||
throw new TypeError("Plugin storage key is invalid");
|
||||
}
|
||||
if (options.value && !Object.hasOwn(params, "value")) throw new TypeError("Plugin storage value is required");
|
||||
return params;
|
||||
}
|
||||
|
||||
function freezeJson(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
for (const item of Array.isArray(value) ? value : Object.values(value)) freezeJson(item);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function resolveDefaultRuntimeKind({ plugin }) {
|
||||
const requiresPrivilegedTerminalPipeline = plugin.manifest.contributes?.providers?.some((provider) => (
|
||||
provider.kind === "terminal.interceptor.input"
|
||||
|| provider.kind === "terminal.interceptor.output"
|
||||
));
|
||||
if (plugin.manifest.companionExecutables?.length || requiresPrivilegedTerminalPipeline) {
|
||||
return "utility";
|
||||
}
|
||||
return plugin.manifest.main.browser ? "browser" : "utility";
|
||||
}
|
||||
|
||||
class RuntimeSupervisor {
|
||||
constructor(options) {
|
||||
this.electron = options.electron;
|
||||
this.database = options.database;
|
||||
this.packageStore = options.packageStore;
|
||||
this.protocol = options.protocol;
|
||||
this.paths = options.paths;
|
||||
this.netcattyVersion = options.netcattyVersion;
|
||||
this.apiVersion = options.apiVersion ?? PLUGIN_API_VERSION;
|
||||
this.supportedFeatures = [...(options.supportedFeatures ?? [])];
|
||||
this.runtimeDirectory = options.runtimeDirectory;
|
||||
this.appRoot = options.appRoot;
|
||||
this.rpcRegistry = options.rpcRegistry ?? createDefaultPluginHostRpcRegistry({
|
||||
assertStorageParams,
|
||||
database: this.database,
|
||||
});
|
||||
this.runtimeMessageGuard = options.runtimeMessageGuard ?? null;
|
||||
if (this.runtimeMessageGuard != null && typeof this.runtimeMessageGuard !== "function") {
|
||||
throw new TypeError("Plugin runtime message guard must be a function");
|
||||
}
|
||||
this.getInitialEnvironment = options.getInitialEnvironment ?? (() => null);
|
||||
if (typeof this.getInitialEnvironment !== "function") {
|
||||
throw new TypeError("Plugin initial-environment provider must be a function");
|
||||
}
|
||||
this.resolveRuntimeKind = options.resolveRuntimeKind ?? resolveDefaultRuntimeKind;
|
||||
this.resolveSecurityPrincipal = options.resolveSecurityPrincipal
|
||||
?? (({ plugin }) => defaultSecurityPrincipal(plugin.manifest, plugin.archiveSha256));
|
||||
if (typeof this.resolveRuntimeKind !== "function" || typeof this.resolveSecurityPrincipal !== "function") {
|
||||
throw new TypeError("Plugin runtime placement and security-principal resolvers must be functions");
|
||||
}
|
||||
this.utilityModuleMappings = options.utilityModuleMappings ?? {
|
||||
"@netcatty/plugin-sdk": pathToFileURL(path.join(
|
||||
this.appRoot, "node_modules", "@netcatty", "plugin-sdk", "dist", "index.js",
|
||||
)).href,
|
||||
"@netcatty/plugin-contract": pathToFileURL(path.join(
|
||||
this.appRoot, "node_modules", "@netcatty", "plugin-contract", "dist", "index.js",
|
||||
)).href,
|
||||
};
|
||||
this.runtimeFactories = options.runtimeFactories ?? {
|
||||
browser: (runtimeOptions) => new BrowserPluginRuntime(runtimeOptions),
|
||||
utility: (runtimeOptions) => new UtilityPluginRuntime(runtimeOptions),
|
||||
};
|
||||
this.runtimeResourceMonitor = options.runtimeResourceMonitor ?? null;
|
||||
this.runtimeCleanup = options.runtimeCleanup ?? null;
|
||||
if (this.runtimeCleanup != null && typeof this.runtimeCleanup !== "function") {
|
||||
throw new TypeError("Plugin runtime cleanup must be a function");
|
||||
}
|
||||
this.resourceMonitors = new Map();
|
||||
this.runtimes = new Map();
|
||||
this.runtimeIdentities = new Map();
|
||||
this.starting = new Map();
|
||||
this.startControllers = new Map();
|
||||
this.stopping = new Map();
|
||||
this.runtimeListeners = new Set();
|
||||
this.progressListeners = new Set();
|
||||
this.uncontainedPlugins = new Set();
|
||||
this.shuttingDown = false;
|
||||
this.shutdownPromise = null;
|
||||
}
|
||||
|
||||
onDidChangeRuntime(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin runtime listener must be a function");
|
||||
this.runtimeListeners.add(listener);
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
this.runtimeListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onDidReportProgress(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Plugin progress listener must be a function");
|
||||
this.progressListeners.add(listener);
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
this.progressListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#emitProgress(identity, params) {
|
||||
identity.assertCurrent();
|
||||
const event = Object.freeze({
|
||||
type: "runtime-progress",
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
token: params.token,
|
||||
value: freezeJson(structuredClone(params.value)),
|
||||
});
|
||||
for (const listener of [...this.progressListeners]) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
#emitRuntimeState(identity, status, details = {}) {
|
||||
const event = Object.freeze({
|
||||
type: "runtime-state",
|
||||
pluginId: identity?.pluginId ?? details.pluginId,
|
||||
pluginVersion: identity?.pluginVersion ?? details.pluginVersion ?? null,
|
||||
runtimeId: identity?.runtimeId ?? null,
|
||||
runtimeKind: identity?.runtimeKind ?? details.kind ?? null,
|
||||
securityPrincipal: identity?.securityPrincipal ?? null,
|
||||
status,
|
||||
error: details.error == null ? null : String(details.error),
|
||||
quarantinedAt: details.quarantinedAt ?? null,
|
||||
});
|
||||
for (const listener of [...this.runtimeListeners]) {
|
||||
try { listener(event); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
#snapshotInitialEnvironment(identity) {
|
||||
identity.assertCurrent();
|
||||
const providedEnvironment = this.getInitialEnvironment(Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
}));
|
||||
if (providedEnvironment && typeof providedEnvironment.then === "function") {
|
||||
throw new TypeError("Plugin initial-environment provider must be synchronous");
|
||||
}
|
||||
if (providedEnvironment != null
|
||||
&& (typeof providedEnvironment !== "object" || Array.isArray(providedEnvironment))) {
|
||||
throw new TypeError("Plugin initial environment must be an object or null");
|
||||
}
|
||||
return providedEnvironment == null
|
||||
? null
|
||||
: freezeJson(structuredClone(providedEnvironment));
|
||||
}
|
||||
|
||||
#isInstalledVersion(identity) {
|
||||
return Boolean(identity && this.database.getVersion(identity.pluginId, identity.pluginVersion));
|
||||
}
|
||||
|
||||
#setRuntimeState(identity, status, details = {}) {
|
||||
const pluginId = identity?.pluginId ?? details.pluginId;
|
||||
if (!identity || this.#isInstalledVersion(identity)) {
|
||||
this.database.setRuntimeState(pluginId, status, {
|
||||
pluginVersion: identity?.pluginVersion ?? details.pluginVersion,
|
||||
kind: identity?.runtimeKind ?? details.kind,
|
||||
error: details.error,
|
||||
quarantinedAt: details.quarantinedAt,
|
||||
});
|
||||
}
|
||||
this.#emitRuntimeState(identity, status, details);
|
||||
}
|
||||
|
||||
async startEnabled() {
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled || plugin.runtime.quarantinedAt != null) continue;
|
||||
try { await this.start(plugin.id); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async start(pluginId) {
|
||||
if (this.shuttingDown) throw new Error("Plugin runtime supervisor is shutting down");
|
||||
if (this.uncontainedPlugins.has(pluginId)) {
|
||||
throw this.#restoreContainmentBlock(pluginId);
|
||||
}
|
||||
const stopping = this.stopping.get(pluginId);
|
||||
if (stopping) await stopping;
|
||||
if (this.shuttingDown) throw new Error("Plugin runtime supervisor is shutting down");
|
||||
if (this.starting.has(pluginId)) return this.starting.get(pluginId);
|
||||
if (this.runtimes.has(pluginId)) {
|
||||
await this.#getRunningRuntime(pluginId);
|
||||
return this.getRuntimeIdentity(pluginId);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const promise = this.#start(pluginId, controller.signal).finally(() => {
|
||||
this.starting.delete(pluginId);
|
||||
this.startControllers.delete(pluginId);
|
||||
});
|
||||
this.starting.set(pluginId, promise);
|
||||
this.startControllers.set(pluginId, controller);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async #start(pluginId, signal) {
|
||||
signal.throwIfAborted();
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin?.manifest || !plugin.packageRelativePath) throw new Error(`Plugin is not installed: ${pluginId}`);
|
||||
if (!plugin.enabled) throw new Error(`Plugin is disabled: ${pluginId}`);
|
||||
if (plugin.runtime.quarantinedAt != null) throw new Error(`Plugin is quarantined: ${pluginId}`);
|
||||
const pluginCli = await import("@netcatty/plugin-cli");
|
||||
signal.throwIfAborted();
|
||||
const compatibility = pluginCli.checkPluginCompatibility(plugin.manifest, {
|
||||
netcattyVersion: this.netcattyVersion,
|
||||
apiVersion: this.apiVersion,
|
||||
features: this.supportedFeatures,
|
||||
});
|
||||
if (!compatibility.compatible) throw new Error(`Plugin is incompatible: ${compatibility.errors.join("; ")}`);
|
||||
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
||||
signal.throwIfAborted();
|
||||
const availableKinds = Object.freeze([
|
||||
...(plugin.manifest.main.browser ? ["browser"] : []),
|
||||
...(plugin.manifest.main.node ? ["utility"] : []),
|
||||
]);
|
||||
const placementPlugin = freezeJson(structuredClone(plugin));
|
||||
const securityPrincipal = assertSecurityPrincipal(await raceWithAbort(Promise.resolve(
|
||||
this.resolveSecurityPrincipal(Object.freeze({ plugin: placementPlugin, signal })),
|
||||
), signal));
|
||||
const kind = await raceWithAbort(Promise.resolve(this.resolveRuntimeKind(Object.freeze({
|
||||
plugin: placementPlugin,
|
||||
availableKinds,
|
||||
securityPrincipal,
|
||||
signal,
|
||||
}))), signal);
|
||||
signal.throwIfAborted();
|
||||
if ((kind !== "browser" && kind !== "utility") || !availableKinds.includes(kind)) {
|
||||
throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
|
||||
}
|
||||
const currentPlugin = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
!currentPlugin?.enabled
|
||||
|| currentPlugin.activeVersion !== plugin.activeVersion
|
||||
|| currentPlugin.runtime.quarantinedAt != null
|
||||
) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin changed while runtime placement was resolved");
|
||||
}
|
||||
const logger = new PluginLogger({ pluginId, logsDirectory: this.paths.logs });
|
||||
let runtime;
|
||||
const identity = Object.freeze({
|
||||
pluginId,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
runtimeId: randomUUID(),
|
||||
runtimeKind: kind,
|
||||
securityPrincipal,
|
||||
manifest: freezeJson(structuredClone(plugin.manifest)),
|
||||
packageRoot,
|
||||
logger,
|
||||
assertCurrent: () => {
|
||||
const active = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
this.runtimes.get(pluginId) !== runtime
|
||||
|| !active?.enabled
|
||||
|| active.activeVersion !== plugin.activeVersion
|
||||
|| (active.runtime.status !== "starting" && active.runtime.status !== "running")
|
||||
) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime identity is stale or inactive");
|
||||
}
|
||||
},
|
||||
});
|
||||
const routes = this.rpcRegistry.createRoutes(identity);
|
||||
const onExit = (details) => { void this.#handleExit(pluginId, runtime, identity, details); };
|
||||
const onProtocolError = (error) => logger.write("error", "Plugin protocol violation", {
|
||||
error: error?.message ?? String(error),
|
||||
});
|
||||
runtime = kind === "browser"
|
||||
? this.runtimeFactories.browser({
|
||||
electron: this.electron,
|
||||
protocol: this.protocol,
|
||||
plugin,
|
||||
packageRoot,
|
||||
preloadPath: path.join(this.runtimeDirectory, "browserPreload.cjs"),
|
||||
requestHandlers: routes.requestHandlers,
|
||||
notificationHandlers: routes.notificationHandlers,
|
||||
onBeforeMessage: this.runtimeMessageGuard
|
||||
? (message) => this.runtimeMessageGuard(identity, message)
|
||||
: undefined,
|
||||
onIncomingStream: routes.onIncomingStream,
|
||||
onProgress: (params) => this.#emitProgress(identity, params),
|
||||
onProcessReady: () => this.#installRuntimeResourceMonitor(identity, runtime),
|
||||
logger,
|
||||
onExit,
|
||||
onProtocolError,
|
||||
})
|
||||
: this.runtimeFactories.utility({
|
||||
utilityProcess: this.electron.utilityProcess,
|
||||
plugin,
|
||||
packageRoot,
|
||||
bootstrapPath: path.join(this.runtimeDirectory, "utilityRuntime.mjs"),
|
||||
moduleMappings: this.utilityModuleMappings,
|
||||
requestHandlers: routes.requestHandlers,
|
||||
notificationHandlers: routes.notificationHandlers,
|
||||
onBeforeMessage: this.runtimeMessageGuard
|
||||
? (message) => this.runtimeMessageGuard(identity, message)
|
||||
: undefined,
|
||||
onIncomingStream: routes.onIncomingStream,
|
||||
onProgress: (params) => this.#emitProgress(identity, params),
|
||||
onProcessReady: () => this.#installRuntimeResourceMonitor(identity, runtime),
|
||||
logger,
|
||||
onExit,
|
||||
onProtocolError,
|
||||
});
|
||||
this.runtimes.set(pluginId, runtime);
|
||||
this.runtimeIdentities.set(pluginId, identity);
|
||||
this.#setRuntimeState(identity, "starting");
|
||||
try {
|
||||
const environment = this.#snapshotInitialEnvironment(identity);
|
||||
const initialized = await raceWithAbort(Promise.resolve(runtime.start({
|
||||
pluginId,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
netcattyVersion: this.netcattyVersion,
|
||||
apiVersion: this.apiVersion,
|
||||
supportedFeatures: this.supportedFeatures,
|
||||
enabledFeatures: compatibility.enabledFeatures,
|
||||
environment,
|
||||
}, {
|
||||
signal,
|
||||
getActivationEnvironment: () => this.#snapshotInitialEnvironment(identity),
|
||||
})), signal);
|
||||
if (
|
||||
initialized.pluginId !== pluginId
|
||||
|| initialized.pluginVersion !== plugin.activeVersion
|
||||
|| initialized.apiVersion !== this.apiVersion
|
||||
|| JSON.stringify([...initialized.enabledFeatures].sort())
|
||||
!== JSON.stringify([...compatibility.enabledFeatures].sort())
|
||||
) {
|
||||
throw new Error("Plugin initialization identity or feature negotiation mismatch");
|
||||
}
|
||||
identity.assertCurrent();
|
||||
this.#setRuntimeState(identity, "running");
|
||||
this.#installRuntimeResourceMonitor(identity, runtime);
|
||||
return this.getRuntimeIdentity(pluginId);
|
||||
} catch (error) {
|
||||
const stillOwned = this.runtimes.get(pluginId) === runtime;
|
||||
let stopError;
|
||||
let cleanupError;
|
||||
if (stillOwned) {
|
||||
this.runtimes.delete(pluginId);
|
||||
this.runtimeIdentities.delete(pluginId);
|
||||
this.#releaseRuntimeResources(identity);
|
||||
try { await runtime.stop(); } catch (caughtError) { stopError = caughtError; }
|
||||
try { await this.#cleanupRuntime(identity); } catch (caughtError) { cleanupError = caughtError; }
|
||||
}
|
||||
const containmentError = cleanupError
|
||||
?? (stopError?.code === PLUGIN_CONTAINMENT_ERROR_CODE ? stopError : null);
|
||||
if (stillOwned && containmentError) {
|
||||
this.#recordContainmentFailure(identity, containmentError);
|
||||
throw containmentError;
|
||||
}
|
||||
if (stillOwned) await this.#recordFailure(identity, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#restoreContainmentBlock(pluginId) {
|
||||
const error = new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
`Plugin runtime containment failed; restart Netcatty before starting it again: ${pluginId}`,
|
||||
);
|
||||
const active = this.database.getActivePlugin(pluginId);
|
||||
if (active?.activeVersion) {
|
||||
if (active.enabled) this.database.setEnabled(pluginId, false);
|
||||
this.#setRuntimeState(null, "quarantined", {
|
||||
pluginId,
|
||||
pluginVersion: active.activeVersion,
|
||||
kind: active.runtime.kind,
|
||||
error: active.runtime.lastError ?? error.message,
|
||||
quarantinedAt: active.runtime.quarantinedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
#recordContainmentFailure(identity, error) {
|
||||
this.uncontainedPlugins.add(identity.pluginId);
|
||||
const active = this.database.getActivePlugin(identity.pluginId);
|
||||
if (active?.activeVersion === identity.pluginVersion && active.enabled) {
|
||||
this.database.setEnabled(identity.pluginId, false);
|
||||
}
|
||||
this.#setRuntimeState(identity, "quarantined", {
|
||||
error: error?.message ?? String(error),
|
||||
quarantinedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async #handleExit(pluginId, runtime, identity, details) {
|
||||
if (this.runtimes.get(pluginId) !== runtime) return;
|
||||
this.runtimes.delete(pluginId);
|
||||
this.runtimeIdentities.delete(pluginId);
|
||||
this.#releaseRuntimeResources(identity);
|
||||
try {
|
||||
await this.#cleanupRuntime(identity);
|
||||
} catch (error) {
|
||||
this.#recordContainmentFailure(identity, error);
|
||||
return;
|
||||
}
|
||||
if (details.containmentFailed) {
|
||||
this.#recordContainmentFailure(identity, details.error);
|
||||
return;
|
||||
}
|
||||
if (details.expected || this.shuttingDown) {
|
||||
this.#setRuntimeState(identity, "stopped");
|
||||
return;
|
||||
}
|
||||
await this.#recordFailure(identity, details.error);
|
||||
}
|
||||
|
||||
async #recordFailure(identity, error) {
|
||||
const pluginId = identity.pluginId;
|
||||
if (!this.#isInstalledVersion(identity)) {
|
||||
this.#emitRuntimeState(identity, "error", {
|
||||
error: error?.message ?? String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const crash = this.database.recordCrash(
|
||||
pluginId,
|
||||
identity.pluginVersion,
|
||||
PLUGIN_CRASH_WINDOW_MS,
|
||||
PLUGIN_CRASH_QUARANTINE_THRESHOLD,
|
||||
);
|
||||
this.#setRuntimeState(identity, crash.quarantined ? "quarantined" : "error", {
|
||||
error: error?.message ?? String(error),
|
||||
quarantinedAt: crash.quarantinedAt,
|
||||
});
|
||||
}
|
||||
|
||||
stop(pluginId) {
|
||||
if (this.stopping.has(pluginId)) return this.stopping.get(pluginId);
|
||||
const promise = this.#stop(pluginId).finally(() => this.stopping.delete(pluginId));
|
||||
this.stopping.set(pluginId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async #stop(pluginId) {
|
||||
const starting = this.starting.get(pluginId);
|
||||
this.startControllers.get(pluginId)?.abort(new PluginRpcError(
|
||||
RPC_ERRORS.cancelled,
|
||||
`Plugin startup was stopped: ${pluginId}`,
|
||||
));
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
const runtime = this.runtimes.get(pluginId);
|
||||
const identity = this.runtimeIdentities.get(pluginId);
|
||||
if (!runtime) {
|
||||
if (starting) {
|
||||
try { await starting; } catch {}
|
||||
}
|
||||
if (plugin && plugin.runtime.quarantinedAt == null && plugin.runtime.status !== "stopped") {
|
||||
this.#setRuntimeState(identity, "stopped", {
|
||||
pluginId,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
kind: plugin.runtime.kind,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.runtimes.delete(pluginId);
|
||||
this.runtimeIdentities.delete(pluginId);
|
||||
this.#releaseRuntimeResources(identity);
|
||||
let stopError;
|
||||
let cleanupError;
|
||||
try {
|
||||
await runtime.stop();
|
||||
} catch (error) {
|
||||
stopError = error;
|
||||
}
|
||||
try {
|
||||
await this.#cleanupRuntime(identity);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
} finally {
|
||||
const containmentError = cleanupError
|
||||
?? (stopError?.code === PLUGIN_CONTAINMENT_ERROR_CODE ? stopError : null);
|
||||
if (containmentError) {
|
||||
this.#recordContainmentFailure(identity, containmentError);
|
||||
} else {
|
||||
this.#setRuntimeState(identity, "stopped", {
|
||||
kind: plugin?.runtime?.kind,
|
||||
error: stopError?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (cleanupError) throw cleanupError;
|
||||
if (stopError?.code === PLUGIN_CONTAINMENT_ERROR_CODE) throw stopError;
|
||||
}
|
||||
|
||||
async restart(pluginId) {
|
||||
await this.stop(pluginId);
|
||||
this.database.clearQuarantine(pluginId);
|
||||
return this.start(pluginId);
|
||||
}
|
||||
|
||||
#releaseRuntimeResources(identity) {
|
||||
if (!identity) return;
|
||||
this.resourceMonitors.get(identity.runtimeId)?.dispose?.();
|
||||
this.resourceMonitors.delete(identity.runtimeId);
|
||||
this.runtimeResourceMonitor?.releaseRuntime?.(identity.runtimeId);
|
||||
}
|
||||
|
||||
async #cleanupRuntime(identity) {
|
||||
if (!identity || !this.runtimeCleanup) return;
|
||||
await this.runtimeCleanup(Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
}));
|
||||
}
|
||||
|
||||
#installRuntimeResourceMonitor(identity, runtime) {
|
||||
if (!this.runtimeResourceMonitor || this.resourceMonitors.has(identity.runtimeId)) return;
|
||||
const monitor = this.runtimeResourceMonitor.trackRuntime(identity, runtime);
|
||||
this.resourceMonitors.set(identity.runtimeId, monitor);
|
||||
}
|
||||
|
||||
async enforcePolicyViolation(identity, error) {
|
||||
const current = this.runtimeIdentities.get(identity.pluginId);
|
||||
if (!current || current.runtimeId !== identity.runtimeId) return;
|
||||
const plugin = this.database.getActivePlugin(identity.pluginId);
|
||||
if (plugin?.enabled) this.database.setEnabled(identity.pluginId, false);
|
||||
await this.stop(identity.pluginId);
|
||||
this.#setRuntimeState(identity, "error", { error: error?.message ?? String(error) });
|
||||
}
|
||||
|
||||
getRuntimeIdentity(pluginId) {
|
||||
const identity = this.runtimeIdentities.get(pluginId);
|
||||
if (!identity) return null;
|
||||
return Object.freeze({
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
});
|
||||
}
|
||||
|
||||
#assertExpectedRuntimeIdentity(identity, expectedIdentity) {
|
||||
if (!expectedIdentity) return;
|
||||
if (
|
||||
identity.pluginId !== expectedIdentity.pluginId
|
||||
|| identity.pluginVersion !== expectedIdentity.pluginVersion
|
||||
|| identity.runtimeId !== expectedIdentity.runtimeId
|
||||
|| identity.runtimeKind !== expectedIdentity.runtimeKind
|
||||
|| identity.securityPrincipal !== expectedIdentity.securityPrincipal
|
||||
) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
`Plugin runtime no longer matches the authorized activation: ${identity.pluginId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #getRunningRuntime(pluginId) {
|
||||
const starting = this.starting.get(pluginId);
|
||||
if (starting) await starting;
|
||||
const runtime = this.runtimes.get(pluginId);
|
||||
const identity = this.runtimeIdentities.get(pluginId);
|
||||
if (!runtime || !identity) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime is unavailable: ${pluginId}`);
|
||||
}
|
||||
const active = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
!active?.enabled
|
||||
|| active.activeVersion !== identity.pluginVersion
|
||||
|| active.runtime.status !== "running"
|
||||
) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime identity is stale or inactive: ${pluginId}`);
|
||||
}
|
||||
return { identity, runtime };
|
||||
}
|
||||
|
||||
async request(pluginId, method, params, options) {
|
||||
assertHostMethod(method);
|
||||
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
||||
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
||||
if (typeof runtime.request !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive requests: ${pluginId}`);
|
||||
}
|
||||
const runtimeOptions = options == null
|
||||
? undefined
|
||||
: Object.fromEntries(Object.entries(options).filter(([key]) => key !== "expectedIdentity"));
|
||||
const result = await runtime.request(method, params, runtimeOptions);
|
||||
const current = await this.#getRunningRuntime(pluginId);
|
||||
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime changed while handling request: ${pluginId}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async notify(pluginId, method, params, options) {
|
||||
assertHostMethod(method);
|
||||
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
||||
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
||||
if (typeof runtime.notify !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive notifications: ${pluginId}`);
|
||||
}
|
||||
runtime.notify(method, params);
|
||||
const current = await this.#getRunningRuntime(pluginId);
|
||||
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime changed while sending notification: ${pluginId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async openStream(pluginId, streamId, windowBytes, options) {
|
||||
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
||||
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
||||
if (typeof runtime.openStream !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive streams: ${pluginId}`);
|
||||
}
|
||||
const stream = await runtime.openStream(streamId, windowBytes);
|
||||
try {
|
||||
const current = await this.#getRunningRuntime(pluginId);
|
||||
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime changed while opening stream: ${pluginId}`);
|
||||
}
|
||||
return stream;
|
||||
} catch (error) {
|
||||
try { stream?.cancel?.(); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async attachTerminalInterceptor(pluginId, descriptor, port, options) {
|
||||
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
||||
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
||||
if (identity.runtimeKind !== "utility" || typeof runtime.attachTerminalInterceptor !== "function") {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.failedPrecondition,
|
||||
`Terminal interceptors require an advanced utility runtime: ${pluginId}`,
|
||||
);
|
||||
}
|
||||
await runtime.attachTerminalInterceptor(descriptor, port);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.shutdownPromise ??= this.#shutdown();
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
async #shutdown() {
|
||||
this.shuttingDown = true;
|
||||
for (const [pluginId, controller] of this.startControllers) {
|
||||
controller.abort(new PluginRpcError(
|
||||
RPC_ERRORS.unavailable,
|
||||
`Plugin runtime supervisor is shutting down: ${pluginId}`,
|
||||
));
|
||||
}
|
||||
await Promise.allSettled([...this.runtimes.keys()].map((pluginId) => this.stop(pluginId)));
|
||||
await Promise.allSettled([...this.starting.values()]);
|
||||
await Promise.allSettled([...this.stopping.values()]);
|
||||
for (const identity of this.runtimeIdentities.values()) this.#releaseRuntimeResources(identity);
|
||||
this.resourceMonitors.clear();
|
||||
this.runtimeListeners.clear();
|
||||
this.progressListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RuntimeSupervisor,
|
||||
assertStorageParams,
|
||||
freezeJson,
|
||||
resolveDefaultRuntimeKind,
|
||||
};
|
||||
Reference in New Issue
Block a user