[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:
306
electron/plugins/utilityPluginRuntime.cjs
Normal file
306
electron/plugins/utilityPluginRuntime.cjs
Normal file
@@ -0,0 +1,306 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user