Files
NetMesh/electron/plugins/runtimeSupervisor.test.cjs

954 lines
35 KiB
JavaScript
Raw Normal View History

"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const { RuntimeSupervisor } = require("./runtimeSupervisor.cjs");
const { createContainmentError } = require("./utilityPluginRuntime.cjs");
function pluginManifest(overrides = {}) {
return {
manifestVersion: 1,
id: "com.example.runtime-test",
name: "runtime-test",
version: "1.0.0",
publisher: "example",
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
main: { browser: "dist/browser.js", node: "dist/node.js" },
...overrides,
};
}
function createFixture(context, runtimeFactory, supervisorOptions = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-supervisor-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const paths = createPluginPaths(root);
fs.mkdirSync(paths.logs, { recursive: true });
const database = new PluginDatabase(paths.database);
context.after(() => {
try { database.close(); } catch {}
});
const manifest = pluginManifest();
database.installVersion({
pluginId: manifest.id,
version: manifest.version,
manifest,
archiveSha256: "a".repeat(64),
packageRelativePath: `${manifest.id}/${manifest.version}/package`,
}, { enable: true });
const packageRoot = path.join(paths.packages, manifest.id, manifest.version, "package");
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
const runtimeOptions = [];
const factory = (options) => {
runtimeOptions.push(options);
return runtimeFactory(options);
};
const supervisor = new RuntimeSupervisor({
electron: {},
database,
packageStore: { async preparePackageRoot() { return packageRoot; } },
protocol: {},
paths,
netcattyVersion: "0.0.0",
apiVersion: "0.1.0-internal",
runtimeDirectory: path.join(root, "runtime"),
appRoot: process.cwd(),
runtimeFactories: { browser: factory, utility: factory },
...supervisorOptions,
});
return { database, manifest, runtimeOptions, supervisor };
}
test("supervisor prefers the ordinary browser runtime and enforces negotiated identity", async (context) => {
const calls = [];
const initialEnvironment = { locale: "en", theme: "light", reducedMotion: false, highContrast: false };
const activationEnvironment = { locale: "zh-CN", theme: "dark", reducedMotion: true, highContrast: true };
let environment = initialEnvironment;
const fixture = createFixture(context, () => ({
async start(config, options) {
calls.push(["start", config]);
environment = activationEnvironment;
assert.deepEqual(options.getActivationEnvironment(), activationEnvironment);
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() { calls.push(["stop"]); },
}), {
getInitialEnvironment(identity) {
assert.equal(identity.pluginId, "com.example.runtime-test");
assert.match(identity.runtimeId, /^[0-9a-f-]+$/u);
return environment;
},
});
const started = await fixture.supervisor.start(fixture.manifest.id);
assert.deepEqual(started, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id));
assert.match(started.securityPrincipal, /^unsigned-package:/u);
assert.equal(Object.hasOwn(started, "request"), false);
assert.equal(Object.hasOwn(started, "stop"), false);
assert.equal(fixture.runtimeOptions[0].plugin.manifest.main.browser, "dist/browser.js");
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.kind, "browser");
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "running");
assert.deepEqual(calls[0][1].environment, initialEnvironment);
assert.equal(Object.isFrozen(calls[0][1].environment), true);
await fixture.supervisor.stop(fixture.manifest.id);
assert.deepEqual(calls.map(([kind]) => kind), ["start", "stop"]);
});
test("supervisor verifies immutable package contents before runtime placement", async (context) => {
let placementCalls = 0;
const fixture = createFixture(context, () => {
throw new Error("runtime factory must not run");
}, {
packageStore: {
async preparePackageRoot() {
throw new Error("installed package integrity check failed");
},
},
resolveRuntimeKind() {
placementCalls += 1;
return "browser";
},
});
await assert.rejects(
fixture.supervisor.start(fixture.manifest.id),
/installed package integrity check failed/,
);
assert.equal(placementCalls, 0);
assert.deepEqual(fixture.runtimeOptions, []);
});
test("supervisor attaches the runtime quota monitor before activation completes", async (context) => {
const events = [];
let releaseActivation;
let signalProcessReady;
const activation = new Promise((resolve) => { releaseActivation = resolve; });
const processReady = new Promise((resolve) => { signalProcessReady = resolve; });
const fixture = createFixture(context, (options) => ({
async start(config) {
events.push("start");
options.onProcessReady();
events.push("process-ready");
signalProcessReady();
await activation;
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
}), {
runtimeResourceMonitor: {
trackRuntime() {
events.push("monitor");
return { dispose() {} };
},
releaseRuntime() {},
},
});
const starting = fixture.supervisor.start(fixture.manifest.id);
await processReady;
try {
assert.deepEqual(events, ["start", "monitor", "process-ready"]);
} finally {
releaseActivation();
}
await starting;
assert.equal(events.filter((event) => event === "monitor").length, 1);
await fixture.supervisor.stop(fixture.manifest.id);
});
test("repeated activation failures quarantine after the third crash window event", async (context) => {
const fixture = createFixture(context, () => ({
async start() { throw new Error("activation failed"); },
async stop() {},
}));
for (let attempt = 0; attempt < 3; attempt += 1) {
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /activation failed/);
}
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(plugin.runtime.status, "quarantined");
assert.ok(plugin.runtime.quarantinedAt != null);
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /quarantined/);
});
test("unexpected process exit is contained and recorded without touching other runtimes", async (context) => {
let exit;
const fixture = createFixture(context, (options) => {
exit = options.onExit;
return {
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
};
});
await fixture.supervisor.start(fixture.manifest.id);
exit({ expected: false, error: new Error("process crashed") });
await new Promise((resolve) => setImmediate(resolve));
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(plugin.runtime.status, "error");
assert.match(plugin.runtime.lastError, /process crashed/);
});
test("a startup exit and its rejected start count as one crash", async (context) => {
let attempts = 0;
const fixture = createFixture(context, (options) => ({
async start() {
attempts += 1;
options.onExit({ expected: false, error: new Error("startup process exit") });
throw new Error("startup rejected");
},
async stop() {},
}));
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /startup rejected/);
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /startup rejected/);
assert.equal(attempts, 2);
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "error");
await assert.rejects(fixture.supervisor.start(fixture.manifest.id), /startup rejected/);
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "quarantined");
});
test("deactivation failure still leaves the runtime stopped", async (context) => {
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() { throw new Error("deactivation timed out"); },
}));
await fixture.supervisor.start(fixture.manifest.id);
await fixture.supervisor.stop(fixture.manifest.id);
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(plugin.runtime.status, "stopped");
assert.match(plugin.runtime.lastError, /deactivation timed out/);
});
test("supervisor composes host routes with immutable activation identity for downstream capabilities", async (context) => {
const registry = new PluginHostRpcRegistry();
registry.registerRequest("settings.get", async (_params, identity) => ({
manifestFrozen: Object.isFrozen(identity.manifest) && Object.isFrozen(identity.manifest.main),
pluginId: identity.pluginId,
pluginVersion: identity.pluginVersion,
runtimeId: identity.runtimeId,
}));
let runtimeOptions;
const fixture = createFixture(context, (options) => {
runtimeOptions = options;
return {
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
};
}, { rpcRegistry: registry });
await fixture.supervisor.start(fixture.manifest.id);
const result = await runtimeOptions.requestHandlers["settings.get"]({}, {
requestId: 1,
signal: AbortSignal.timeout(100),
});
assert.equal(result.pluginId, fixture.manifest.id);
assert.equal(result.manifestFrozen, true);
assert.equal(result.pluginVersion, "1.0.0");
assert.equal(typeof result.runtimeId, "string");
assert.equal(result.runtimeId, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id).runtimeId);
});
test("supervisor binds the raw transport guard to the host-owned activation identity", async (context) => {
const guarded = [];
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
}), {
runtimeMessageGuard(identity, message) {
guarded.push({ identity, message });
},
});
await fixture.supervisor.start(fixture.manifest.id);
const message = { jsonrpc: "2.0", method: "$/progress" };
fixture.runtimeOptions[0].onBeforeMessage(message);
assert.equal(guarded.length, 1);
assert.equal(guarded[0].message, message);
assert.equal(guarded[0].identity.pluginId, fixture.manifest.id);
assert.equal(guarded[0].identity.runtimeId, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id).runtimeId);
assert.equal(Object.isFrozen(guarded[0].identity), true);
assert.equal(Object.isFrozen(guarded[0].identity.manifest), true);
});
test("supervisor exposes bounded host-to-plugin calls and rejects stale active versions", async (context) => {
const calls = [];
let runtimeOptions;
const fixture = createFixture(context, (options) => {
runtimeOptions = options;
return {
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
async request(method, params, requestOptions) {
calls.push(["request", method, params, requestOptions]);
return { status: "ok" };
},
notify(method, params) { calls.push(["notify", method, params]); },
async openStream(streamId, windowBytes) {
calls.push(["stream", streamId, windowBytes]);
return { streamId };
},
};
});
await fixture.supervisor.start(fixture.manifest.id);
const currentIdentity = fixture.supervisor.getRuntimeIdentity(fixture.manifest.id);
assert.deepEqual(
await fixture.supervisor.request(fixture.manifest.id, "commands.execute", { command: "run" }),
{ status: "ok" },
);
await fixture.supervisor.notify(fixture.manifest.id, "settings.changed", { key: "theme" });
assert.deepEqual(
await fixture.supervisor.openStream(fixture.manifest.id, "provider-output", 1024, {
expectedIdentity: currentIdentity,
}),
{ streamId: "provider-output" },
);
assert.deepEqual(calls.map(([kind]) => kind), ["request", "notify", "stream"]);
const staleIdentity = { ...currentIdentity, runtimeId: "runtime:stale" };
await assert.rejects(
fixture.supervisor.request(fixture.manifest.id, "provider.invoke", {}, {
expectedIdentity: staleIdentity,
}),
(error) => error?.code === RPC_ERRORS.unavailable,
);
await assert.rejects(
fixture.supervisor.notify(fixture.manifest.id, "plugin.terminal.event", {}, {
expectedIdentity: staleIdentity,
}),
(error) => error?.code === RPC_ERRORS.unavailable,
);
await assert.rejects(
fixture.supervisor.openStream(fixture.manifest.id, "stale-output", 1024, {
expectedIdentity: staleIdentity,
}),
(error) => error?.code === RPC_ERRORS.unavailable,
);
assert.deepEqual(calls.map(([kind]) => kind), ["request", "notify", "stream"]);
await assert.rejects(
fixture.supervisor.request(fixture.manifest.id, "plugin.activate", {}),
/reserved/,
);
const replacement = pluginManifest({ version: "2.0.0" });
fixture.database.installVersion({
pluginId: replacement.id,
version: replacement.version,
manifest: replacement,
archiveSha256: "b".repeat(64),
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
});
await assert.rejects(
fixture.supervisor.request(fixture.manifest.id, "commands.execute", {}),
/stale or inactive/,
);
await assert.rejects(
runtimeOptions.requestHandlers["storage.keys"]({}, { signal: AbortSignal.timeout(100) }),
(error) => error?.code === -32014,
);
});
test("runtime placement and state events are injectable without changing lifecycle ownership", async (context) => {
const events = [];
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
}), {
resolveRuntimeKind({ availableKinds, plugin }) {
assert.deepEqual(availableKinds, ["browser", "utility"]);
assert.equal(Object.isFrozen(plugin), true);
assert.equal(Object.isFrozen(plugin.manifest), true);
return "utility";
},
});
const disposable = fixture.supervisor.onDidChangeRuntime((event) => events.push(event));
await fixture.supervisor.start(fixture.manifest.id);
await fixture.supervisor.stop(fixture.manifest.id);
disposable.dispose();
assert.deepEqual(events.map(({ status }) => status), ["starting", "running", "stopped"]);
assert.equal(events[0].runtimeKind, "utility");
assert.equal(events[0].runtimeId, events[2].runtimeId);
});
test("supervisor transfers privileged terminal ports only to the exact utility runtime identity", async (context) => {
const attached = [];
let releaseAttachment;
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
attachTerminalInterceptor(descriptor, port) {
attached.push({ descriptor, port });
return new Promise((resolve) => { releaseAttachment = resolve; });
},
}), {
resolveRuntimeKind: () => "utility",
});
await fixture.supervisor.start(fixture.manifest.id);
const identity = fixture.supervisor.getRuntimeIdentity(fixture.manifest.id);
const port = { close() {} };
let attachmentFinished = false;
const attachment = fixture.supervisor.attachTerminalInterceptor(
fixture.manifest.id,
{ providerId: "com.example.input", direction: "input" },
port,
{ expectedIdentity: identity },
).then(() => { attachmentFinished = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(attached[0].port, port);
assert.equal(attachmentFinished, false);
releaseAttachment();
await attachment;
assert.equal(attachmentFinished, true);
await assert.rejects(
fixture.supervisor.attachTerminalInterceptor(
fixture.manifest.id,
{ providerId: "com.example.input", direction: "input" },
port,
{ expectedIdentity: { ...identity, runtimeId: "stale-runtime" } },
),
(error) => error?.code === RPC_ERRORS.unavailable,
);
});
test("progress events carry immutable activation identity for downstream provider correlation", async (context) => {
let runtimeOptions;
const fixture = createFixture(context, (options) => {
runtimeOptions = options;
return {
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
};
});
const events = [];
const disposable = fixture.supervisor.onDidReportProgress((event) => events.push(event));
await fixture.supervisor.start(fixture.manifest.id);
const value = { kind: "report", message: "Parsing", percentage: 25 };
runtimeOptions.onProgress({ token: "import-1", value });
value.message = "spoofed";
disposable.dispose();
assert.equal(events.length, 1);
assert.equal(events[0].runtimeId, fixture.supervisor.getRuntimeIdentity(fixture.manifest.id).runtimeId);
assert.equal(events[0].token, "import-1");
assert.deepEqual(events[0].value, { kind: "report", message: "Parsing", percentage: 25 });
assert.equal(Object.isFrozen(events[0].value), true);
});
test("host-to-plugin results are discarded when the active runtime changes in flight", async (context) => {
let resolveRequest;
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
async request() {
return new Promise((resolve) => { resolveRequest = resolve; });
},
}));
await fixture.supervisor.start(fixture.manifest.id);
const pending = fixture.supervisor.request(fixture.manifest.id, "provider.invoke", {});
await new Promise((resolve) => setImmediate(resolve));
const replacement = pluginManifest({ version: "2.0.0" });
fixture.database.installVersion({
pluginId: replacement.id,
version: replacement.version,
manifest: replacement,
archiveSha256: "b".repeat(64),
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
});
resolveRequest({ status: "ok", stale: true });
await assert.rejects(pending, (error) => error?.code === -32014);
});
test("a host stream is cancelled when the active runtime changes while it opens", async (context) => {
let resolveOpen;
let cancelled = 0;
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
async openStream() {
return new Promise((resolve) => { resolveOpen = resolve; });
},
}));
await fixture.supervisor.start(fixture.manifest.id);
const pending = fixture.supervisor.openStream(fixture.manifest.id, "import-output", 1024);
await new Promise((resolve) => setImmediate(resolve));
const replacement = pluginManifest({ version: "2.0.0" });
fixture.database.installVersion({
pluginId: replacement.id,
version: replacement.version,
manifest: replacement,
archiveSha256: "b".repeat(64),
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
});
resolveOpen({ cancel() { cancelled += 1; } });
await assert.rejects(pending, (error) => error?.code === -32014);
assert.equal(cancelled, 1);
});
test("runtime placement cannot start a version replaced while policy is pending", async (context) => {
let resolvePlacement;
let factoryCalls = 0;
let markPlacementEntered;
const placementEntered = new Promise((resolve) => { markPlacementEntered = resolve; });
const fixture = createFixture(context, () => {
factoryCalls += 1;
return {};
}, {
resolveRuntimeKind: async () => {
markPlacementEntered();
return new Promise((resolve) => { resolvePlacement = resolve; });
},
});
const pending = fixture.supervisor.start(fixture.manifest.id);
await placementEntered;
const replacement = pluginManifest({ version: "2.0.0" });
fixture.database.installVersion({
pluginId: replacement.id,
version: replacement.version,
manifest: replacement,
archiveSha256: "b".repeat(64),
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
});
resolvePlacement("browser");
await assert.rejects(pending, (error) => error?.code === -32014);
assert.equal(factoryCalls, 0);
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).activeVersion, "2.0.0");
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.lastError, null);
});
test("stop cancels a pending runtime placement decision before a process is created", async (context) => {
let placementSignal;
let markPlacementEntered;
const placementEntered = new Promise((resolve) => { markPlacementEntered = resolve; });
let factoryCalls = 0;
const fixture = createFixture(context, () => {
factoryCalls += 1;
return {};
}, {
resolveRuntimeKind({ signal }) {
placementSignal = signal;
markPlacementEntered();
return new Promise(() => {});
},
});
const pending = fixture.supervisor.start(fixture.manifest.id);
await placementEntered;
await fixture.supervisor.stop(fixture.manifest.id);
await assert.rejects(pending, (error) => error?.code === -32001);
assert.equal(placementSignal.aborted, true);
assert.equal(factoryCalls, 0);
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "stopped");
});
test("stop cancels activation without recording a plugin crash", async (context) => {
let rejectActivation;
let markActivationEntered;
const activationEntered = new Promise((resolve) => { markActivationEntered = resolve; });
const fixture = createFixture(context, () => ({
start() {
markActivationEntered();
return new Promise((_resolve, reject) => { rejectActivation = reject; });
},
async stop() {
rejectActivation(new Error("activation stopped"));
},
}));
const pending = fixture.supervisor.start(fixture.manifest.id);
await activationEntered;
await fixture.supervisor.stop(fixture.manifest.id);
await assert.rejects(pending, (error) => error?.code === -32001);
const active = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(active.runtime.status, "stopped");
assert.equal(active.runtime.lastError, null);
assert.equal(Number(fixture.database.db.prepare(
"SELECT COUNT(*) AS count FROM plugin_crashes WHERE plugin_id = ?",
).get(fixture.manifest.id).count), 0);
});
test("stop finishes even when plugin activation never settles", async (context) => {
let markActivationEntered;
const activationEntered = new Promise((resolve) => { markActivationEntered = resolve; });
let stops = 0;
const fixture = createFixture(context, () => ({
start() {
markActivationEntered();
return new Promise(() => {});
},
async stop() { stops += 1; },
}));
const pending = fixture.supervisor.start(fixture.manifest.id);
await activationEntered;
await fixture.supervisor.stop(fixture.manifest.id);
await assert.rejects(pending, (error) => error?.code === -32001);
assert.equal(stops, 1);
assert.equal(fixture.supervisor.getRuntimeIdentity(fixture.manifest.id), null);
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "stopped");
});
test("lazy activation waits until the previous runtime has fully stopped", async (context) => {
let factoryCalls = 0;
let releaseFirstStop;
let markFirstStopEntered;
const firstStopEntered = new Promise((resolve) => { markFirstStopEntered = resolve; });
const fixture = createFixture(context, () => {
const instance = factoryCalls;
factoryCalls += 1;
return {
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {
if (instance !== 0) return;
markFirstStopEntered();
await new Promise((resolve) => { releaseFirstStop = resolve; });
},
};
});
const firstIdentity = await fixture.supervisor.start(fixture.manifest.id);
const stopping = fixture.supervisor.stop(fixture.manifest.id);
await firstStopEntered;
const restarting = fixture.supervisor.start(fixture.manifest.id);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(factoryCalls, 1);
releaseFirstStop();
await stopping;
const secondIdentity = await restarting;
assert.equal(factoryCalls, 2);
assert.notEqual(secondIdentity.runtimeId, firstIdentity.runtimeId);
});
test("runtime stop waits for companion cleanup before it resolves", async (context) => {
const events = [];
let markCleanupEntered;
let releaseCleanup;
const cleanupEntered = new Promise((resolve) => { markCleanupEntered = resolve; });
const cleanupReleased = new Promise((resolve) => { releaseCleanup = resolve; });
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() { events.push("runtime-stop"); },
}), {
async runtimeCleanup(identity) {
events.push(`cleanup:${identity.runtimeId}`);
markCleanupEntered(identity);
await cleanupReleased;
},
});
const identity = await fixture.supervisor.start(fixture.manifest.id);
let settled = false;
const stopping = fixture.supervisor.stop(fixture.manifest.id).finally(() => { settled = true; });
const cleanupIdentity = await cleanupEntered;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.deepEqual(cleanupIdentity, identity);
assert.deepEqual(events, ["runtime-stop", `cleanup:${identity.runtimeId}`]);
releaseCleanup();
await stopping;
assert.equal(fixture.database.getActivePlugin(fixture.manifest.id).runtime.status, "stopped");
});
test("companion cleanup containment failure is persisted and blocks restart", async (context) => {
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
}), {
async runtimeCleanup() { throw new Error("companion process tree survived"); },
});
await fixture.supervisor.start(fixture.manifest.id);
await assert.rejects(
fixture.supervisor.stop(fixture.manifest.id),
/companion process tree survived/,
);
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(plugin.enabled, false);
assert.equal(plugin.runtime.status, "quarantined");
assert.match(plugin.runtime.lastError, /companion process tree survived/);
assert.notEqual(plugin.runtime.quarantinedAt, null);
fixture.database.setEnabled(fixture.manifest.id, true);
fixture.database.clearQuarantine(fixture.manifest.id);
await assert.rejects(
fixture.supervisor.start(fixture.manifest.id),
/restart Netcatty before starting it again/,
);
});
test("containment failure disables the plugin and blocks replacement activation until restart", async (context) => {
let factoryCalls = 0;
const fixture = createFixture(context, () => {
factoryCalls += 1;
return {
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {
throw createContainmentError("advanced process was not reaped");
},
};
});
await fixture.supervisor.start(fixture.manifest.id);
await assert.rejects(
fixture.supervisor.stop(fixture.manifest.id),
/advanced process was not reaped/,
);
const plugin = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(plugin.enabled, false);
assert.equal(plugin.runtime.status, "quarantined");
assert.notEqual(plugin.runtime.quarantinedAt, null);
fixture.database.setEnabled(fixture.manifest.id, true);
fixture.database.clearQuarantine(fixture.manifest.id);
await assert.rejects(
fixture.supervisor.start(fixture.manifest.id),
/restart Netcatty before starting it again/,
);
assert.equal(factoryCalls, 1);
const stillQuarantined = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(stillQuarantined.enabled, false);
assert.equal(stillQuarantined.runtime.status, "quarantined");
assert.notEqual(stillQuarantined.runtime.quarantinedAt, null);
});
test("startup cleanup containment failure blocks replacement activation", async (context) => {
let factoryCalls = 0;
const fixture = createFixture(context, () => {
factoryCalls += 1;
return {
async start() {
throw new Error("activation failed");
},
async stop() {
throw createContainmentError("failed startup process was not reaped");
},
};
});
await assert.rejects(
fixture.supervisor.start(fixture.manifest.id),
/failed startup process was not reaped/,
);
let plugin = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(plugin.enabled, false);
assert.equal(plugin.runtime.status, "quarantined");
assert.match(plugin.runtime.lastError, /failed startup process was not reaped/);
assert.notEqual(plugin.runtime.quarantinedAt, null);
fixture.database.setEnabled(fixture.manifest.id, true);
fixture.database.clearQuarantine(fixture.manifest.id);
await assert.rejects(
fixture.supervisor.start(fixture.manifest.id),
/restart Netcatty before starting it again/,
);
assert.equal(factoryCalls, 1);
plugin = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(plugin.enabled, false);
assert.equal(plugin.runtime.status, "quarantined");
assert.notEqual(plugin.runtime.quarantinedAt, null);
});
test("shutdown cancels unresolved placement and waits for startup cleanup", async (context) => {
let signal;
let markPlacementEntered;
const placementEntered = new Promise((resolve) => { markPlacementEntered = resolve; });
const fixture = createFixture(context, () => {
throw new Error("runtime must not be created after shutdown");
}, {
resolveRuntimeKind(contextValue) {
signal = contextValue.signal;
markPlacementEntered();
return new Promise(() => {});
},
});
const pending = fixture.supervisor.start(fixture.manifest.id);
await placementEntered;
await fixture.supervisor.shutdown();
await assert.rejects(pending, (error) => error?.code === -32014);
assert.equal(signal.aborted, true);
assert.equal(fixture.supervisor.getRuntimeIdentity(fixture.manifest.id), null);
});
test("concurrent supervisor shutdown callers share complete runtime teardown", async (context) => {
let releaseStop;
let stopCalls = 0;
const stopReleased = new Promise((resolve) => { releaseStop = resolve; });
const fixture = createFixture(context, () => ({
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {
stopCalls += 1;
await stopReleased;
},
}));
await fixture.supervisor.start(fixture.manifest.id);
const first = fixture.supervisor.shutdown();
const second = fixture.supervisor.shutdown();
assert.equal(first, second);
let secondSettled = false;
void second.finally(() => { secondSettled = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(secondSettled, false);
releaseStop();
await Promise.all([first, second]);
assert.equal(stopCalls, 1);
assert.equal(fixture.supervisor.getRuntimeIdentity(fixture.manifest.id), null);
});
test("a stale runtime crash cannot quarantine or overwrite a replacement version", async (context) => {
let exit;
const fixture = createFixture(context, (options) => {
exit = options.onExit;
return {
async start(config) {
return {
pluginId: config.pluginId,
pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion,
enabledFeatures: config.enabledFeatures,
};
},
async stop() {},
};
});
await fixture.supervisor.start(fixture.manifest.id);
const replacement = pluginManifest({ version: "2.0.0" });
fixture.database.installVersion({
pluginId: replacement.id,
version: replacement.version,
manifest: replacement,
archiveSha256: "b".repeat(64),
packageRelativePath: `${replacement.id}/${replacement.version}/package`,
});
await assert.rejects(
fixture.supervisor.start(fixture.manifest.id),
(error) => error?.code === -32014,
);
exit({ expected: false, error: new Error("stale process crashed") });
await new Promise((resolve) => setImmediate(resolve));
const active = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(active.activeVersion, "2.0.0");
assert.equal(active.runtime.lastError, null);
assert.equal(active.runtime.quarantinedAt, null);
fixture.database.installVersion({
pluginId: fixture.manifest.id,
version: fixture.manifest.version,
manifest: fixture.manifest,
archiveSha256: "a".repeat(64),
packageRelativePath: `${fixture.manifest.id}/${fixture.manifest.version}/package`,
});
const priorVersion = fixture.database.getActivePlugin(fixture.manifest.id);
assert.equal(priorVersion.runtime.status, "error");
assert.match(priorVersion.runtime.lastError, /stale process crashed/);
});