Files
NetMesh/electron/plugins/utilityPluginRuntime.cjs

307 lines
11 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
const { pathToFileURL } = require("node:url");
const fs = require("node:fs/promises");
const path = require("node:path");
const {
PLUGIN_ACTIVATION_TIMEOUT_MS,
PLUGIN_DEACTIVATION_TIMEOUT_MS,
PLUGIN_UTILITY_FORCE_EXIT_TIMEOUT_MS,
PLUGIN_UTILITY_TERMINATION_GRACE_MS,
} = require("./constants.cjs");
const { PluginRpcRouter } = require("./rpcRouter.cjs");
const {
assertTerminalInterceptorAttachmentParams,
assertTerminalInterceptorAttachmentResult,
} = require("./contractValidator.cjs");
const { isPathInside } = require("./paths.cjs");
const PLUGIN_CONTAINMENT_ERROR_CODE = "ERR_PLUGIN_RUNTIME_CONTAINMENT_FAILED";
function createContainmentError(message, cause) {
const error = new Error(message, cause === undefined ? undefined : { cause });
error.code = PLUGIN_CONTAINMENT_ERROR_CODE;
return error;
}
async function resolveUtilityEntrypoint(packageRoot, packagePath) {
const candidate = path.resolve(packageRoot, ...packagePath.split("/"));
const [realRoot, realCandidate] = await Promise.all([fs.realpath(packageRoot), fs.realpath(candidate)]);
if (!isPathInside(realRoot, realCandidate)) throw new Error("Plugin utility entrypoint escapes its package");
const stats = await fs.lstat(realCandidate);
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Plugin utility entrypoint is not a regular file");
return realCandidate;
}
function waitForUtilityReady(child, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("Plugin utility process did not become ready"));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timer);
child.removeListener("message", onMessage);
child.removeListener("exit", onExit);
};
const onMessage = (event) => {
const message = event?.data ?? event;
if (message?.type !== "netcatty-plugin:ready") return;
cleanup();
resolve();
};
const onExit = (code) => {
cleanup();
reject(new Error(`Plugin utility process exited during startup (${code})`));
};
child.on("message", onMessage);
child.once("exit", onExit);
});
}
function waitForExit(exitPromise, timeoutMs) {
let timer;
return Promise.race([
exitPromise.then(() => true),
new Promise((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]).finally(() => clearTimeout(timer));
}
class UtilityPluginRuntime {
constructor(options) {
this.utilityProcess = options.utilityProcess;
this.plugin = options.plugin;
this.packageRoot = options.packageRoot;
this.bootstrapPath = options.bootstrapPath;
this.moduleMappings = options.moduleMappings;
this.requestHandlers = options.requestHandlers ?? options.handlers;
this.notificationHandlers = options.notificationHandlers ?? options.handlers;
this.onIncomingStream = options.onIncomingStream;
this.onBeforeMessage = options.onBeforeMessage;
this.onProgress = options.onProgress;
this.onProcessReady = options.onProcessReady ?? (() => {});
this.logger = options.logger;
this.onExit = options.onExit ?? (() => {});
this.onProtocolError = options.onProtocolError ?? (() => {});
this.forceKillProcess = options.forceKillProcess ?? ((pid) => process.kill(pid, "SIGKILL"));
this.terminationGraceMs = options.terminationGraceMs ?? PLUGIN_UTILITY_TERMINATION_GRACE_MS;
this.forceExitTimeoutMs = options.forceExitTimeoutMs ?? PLUGIN_UTILITY_FORCE_EXIT_TIMEOUT_MS;
this.child = null;
this.router = null;
this.stopping = false;
this.stopPromise = null;
this.exitPromise = null;
this.resolveExit = null;
this.exited = false;
this.terminationError = null;
this.gracefulTerminationError = null;
this.terminationRequested = false;
this.terminationPromise = null;
this.exitReported = false;
}
#assertStarting(signal) {
signal?.throwIfAborted();
if (this.stopping) throw new Error("Plugin utility runtime startup was stopped");
if (this.terminationError) throw this.terminationError;
if (this.exited) throw new Error("Plugin utility process exited during startup");
}
async start(runtimeConfig, options = {}) {
const { signal, getActivationEnvironment } = options;
this.#assertStarting(signal);
if (!this.utilityProcess?.fork) throw new Error("Electron utility plugin runtime is unavailable");
const entryUrl = pathToFileURL(await resolveUtilityEntrypoint(
this.packageRoot,
this.plugin.manifest.main.node,
)).href;
this.#assertStarting(signal);
const config = { ...runtimeConfig, entryUrl, moduleMappings: this.moduleMappings };
this.child = this.utilityProcess.fork(this.bootstrapPath, [], {
cwd: this.packageRoot,
env: {
LANG: process.env.LANG ?? "C.UTF-8",
LC_ALL: process.env.LC_ALL ?? "",
PATH: process.env.PATH ?? "",
TMPDIR: process.env.TMPDIR ?? "",
TEMP: process.env.TEMP ?? "",
TMP: process.env.TMP ?? "",
},
stdio: ["ignore", "pipe", "pipe"],
serviceName: `Netcatty Plugin: ${this.plugin.id}`,
allowLoadingUnsignedLibraries: false,
disclaim: process.platform === "darwin",
});
this.onProcessReady();
this.exitPromise = new Promise((resolve) => { this.resolveExit = resolve; });
this.child.on("error", (_type, location) => {
this.#beginTermination(new Error(`Plugin utility fatal error at ${location}`));
});
this.child.on("exit", (code) => this.#finishExit(code));
this.child.stdout?.on("data", (chunk) => this.logger.write("info", "utility stdout", { output: String(chunk).slice(0, 8_192) }));
this.child.stderr?.on("data", (chunk) => this.logger.write("warn", "utility stderr", { output: String(chunk).slice(0, 8_192) }));
this.router = new PluginRpcRouter({
pluginId: this.plugin.id,
send: (message, transfer = []) => this.child?.postMessage(message, transfer),
requestHandlers: this.requestHandlers,
notificationHandlers: this.notificationHandlers,
onBeforeMessage: this.onBeforeMessage,
onIncomingStream: this.onIncomingStream,
onProgress: this.onProgress,
onProtocolError: (error) => {
this.onProtocolError(error);
this.#beginTermination(error);
},
});
this.child.on("message", (event) => {
const message = event?.data ?? event;
if (message?.type === "netcatty-plugin:ready") return;
this.router?.accept(message);
});
const ready = waitForUtilityReady(this.child, PLUGIN_ACTIVATION_TIMEOUT_MS);
this.child.postMessage({ type: "netcatty-plugin:bootstrap", config });
await ready;
this.#assertStarting(signal);
const initialized = await this.router.request("plugin.initialize", {
netcattyVersion: config.netcattyVersion,
apiVersion: config.apiVersion,
supportedFeatures: config.supportedFeatures,
}, { timeoutMs: PLUGIN_ACTIVATION_TIMEOUT_MS });
this.#assertStarting(signal);
const activationEnvironment = typeof getActivationEnvironment === "function"
? getActivationEnvironment()
: config.environment;
await this.router.request("plugin.activate", activationEnvironment == null
? {}
: { environment: activationEnvironment }, { timeoutMs: PLUGIN_ACTIVATION_TIMEOUT_MS });
this.#assertStarting(signal);
return initialized;
}
stop() {
this.stopPromise ??= this.#stop();
return this.stopPromise;
}
async #stop() {
this.stopping = true;
try {
await this.router?.request("plugin.deactivate", {}, { timeoutMs: PLUGIN_DEACTIVATION_TIMEOUT_MS });
} finally {
const router = this.router;
this.router = null;
router?.close();
await this.#terminateAndWait();
}
}
request(method, params, options) {
if (!this.router) return Promise.reject(new Error("Plugin utility runtime is not connected"));
return this.router.request(method, params, options);
}
notify(method, params) {
if (!this.router) throw new Error("Plugin utility runtime is not connected");
this.router.notify(method, params);
}
openStream(streamId, windowBytes) {
if (!this.router) return Promise.reject(new Error("Plugin utility runtime is not connected"));
return this.router.streams.openOutgoing(streamId, windowBytes);
}
attachTerminalInterceptor(descriptor, port) {
if (!this.router) throw new Error("Plugin utility runtime is not connected");
if (!port) throw new TypeError("Terminal interceptor port is required");
const params = assertTerminalInterceptorAttachmentParams({ descriptor });
return this.router.request("plugin.terminal.interceptor.attach", params, {
timeoutMs: PLUGIN_ACTIVATION_TIMEOUT_MS,
transfer: [port],
validateResult(result) {
assertTerminalInterceptorAttachmentResult(result);
return undefined;
},
});
}
getProcessId() {
return this.child?.pid ?? null;
}
#beginTermination(error) {
this.terminationError ??= error;
const router = this.router;
this.router = null;
router?.close(error);
void this.#terminateAndWait().catch((terminationError) => {
if (this.stopping || this.exitReported) return;
this.exitReported = true;
this.onExit({ expected: false, error: terminationError, containmentFailed: true });
});
}
#requestTermination() {
if (!this.child || this.exited || this.terminationRequested) return;
this.terminationRequested = true;
try {
this.child.kill();
} catch (error) {
this.gracefulTerminationError = error;
}
}
#terminateAndWait() {
this.terminationPromise ??= this.#terminateAndWaitOnce();
return this.terminationPromise;
}
async #terminateAndWaitOnce() {
this.#requestTermination();
if (!this.exitPromise || this.exited) return;
if (await waitForExit(this.exitPromise, this.terminationGraceMs)) return;
const pid = this.child?.pid;
if (!Number.isSafeInteger(pid) || pid <= 0) {
throw createContainmentError(
"Plugin utility process did not expose a valid PID for forced termination",
this.gracefulTerminationError ?? undefined,
);
}
try {
this.forceKillProcess(pid);
} catch (error) {
if (error?.code !== "ESRCH") {
throw createContainmentError("Plugin utility process could not be forcibly terminated", error);
}
}
if (await waitForExit(this.exitPromise, this.forceExitTimeoutMs)) return;
throw createContainmentError("Plugin utility process did not exit after forced termination");
}
#finishExit(code) {
if (this.exited) return;
this.exited = true;
this.resolveExit?.(code);
this.resolveExit = null;
if (this.stopping || this.exitReported) return;
this.exitReported = true;
const error = this.terminationError ?? new Error(`Plugin utility exited (${code})`);
const router = this.router;
this.router = null;
router?.close(error);
this.onExit({ expected: false, error });
}
}
module.exports = {
PLUGIN_CONTAINMENT_ERROR_CODE,
UtilityPluginRuntime,
createContainmentError,
resolveUtilityEntrypoint,
waitForExit,
waitForUtilityReady,
};