Files

855 lines
28 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
const {
logTerminalInterruptDebug,
normalizeTrace,
} = require("../bridges/terminalInterruptDiagnostics.cjs");
const {
clearTerminalSessionPerformanceState,
} = require("../bridges/emitTerminalSessionData.cjs");
const DEFAULT_SESSION_LIFECYCLE_TOMBSTONE_TTL_MS = 60_000;
const DEFAULT_MAX_SESSION_LIFECYCLE_TOMBSTONES = 2_048;
const SESSION_START_CHANNELS = new Set([
"netcatty:start",
"netcatty:local:start",
"netcatty:telnet:start",
"netcatty:mosh:start",
"netcatty:et:start",
"netcatty:serial:start",
"netcatty:local:reconnect",
"netcatty:external:start",
]);
function createIpcMainHarness() {
const handlers = new Map();
const listeners = new Map();
return {
handlers,
listeners,
handle(channel, handler) {
handlers.set(channel, handler);
},
on(channel, listener) {
listeners.set(channel, listener);
},
};
}
function normalizeMessageEvent(eventOrMessage) {
if (eventOrMessage && typeof eventOrMessage === "object" && "data" in eventOrMessage) {
return {
message: eventOrMessage.data,
ports: eventOrMessage.ports || [],
};
}
return {
message: eventOrMessage,
ports: eventOrMessage?.ports || [],
};
}
function createOutputPortRegistry() {
const outputPorts = new Map();
function closeSession(sessionId) {
const port = outputPorts.get(sessionId);
if (!port) return;
outputPorts.delete(sessionId);
try {
port.close?.();
} catch {
// Ignore close races while tearing down a worker-owned output port.
}
}
function post(sessionId, data, meta) {
const port = outputPorts.get(sessionId);
if (!port) return false;
try {
port.postMessage(meta ? { sessionId, data, meta } : { sessionId, data });
return true;
} catch {
closeSession(sessionId);
return false;
}
}
function postControl(sessionId, message) {
const port = outputPorts.get(sessionId);
if (!port) return false;
try {
port.postMessage({ ...message, sessionId });
return true;
} catch {
closeSession(sessionId);
return false;
}
}
function open(sessionId, port) {
if (!sessionId || !port) return;
closeSession(sessionId);
outputPorts.set(sessionId, port);
try {
port.start?.();
} catch {
// Some Electron MessagePort implementations do not require start().
}
}
return {
open,
post,
postControl,
closeSession,
};
}
function addPortMessageListener(port, callback) {
if (typeof port?.on === "function") {
port.on("message", callback);
return;
}
if (port) {
port.onmessage = callback;
}
}
function createUrgentInputPortRegistry(dispatch) {
const ports = new Map();
function close(webContentsId) {
const port = ports.get(webContentsId);
if (!port) return;
ports.delete(webContentsId);
try {
port.close?.();
} catch {
// Ignore stale urgent input port close races.
}
}
function open(webContentsId, port) {
if (!webContentsId || !port) return;
close(webContentsId);
ports.set(webContentsId, port);
addPortMessageListener(port, (eventOrMessage) => {
const { message } = normalizeMessageEvent(eventOrMessage);
dispatch(webContentsId, message);
});
try {
port.start?.();
} catch {
// Some Electron MessagePort implementations do not require start().
}
}
function closeAll() {
for (const webContentsId of Array.from(ports.keys())) {
close(webContentsId);
}
}
return {
open,
close,
closeAll,
};
}
function createSender(
parentPort,
webContentsId,
outputPorts,
terminalDataPipeline,
pendingOutputBySession = new Map(),
sessionOutputGenerations = new Map(),
sessionRequestIds = new Map(),
fixedOriginRequestId = null,
onCurrentSessionExit = null,
) {
const ownedSessionGenerations = new Map();
const getOwnedSessionGeneration = (sessionId) => {
if (!ownedSessionGenerations.has(sessionId)) {
ownedSessionGenerations.set(sessionId, sessionOutputGenerations.get(sessionId) ?? 0);
}
return ownedSessionGenerations.get(sessionId);
};
const trackPendingOutput = (sessionId, pending) => {
pendingOutputBySession.set(sessionId, pending);
const clearPending = () => {
if (pendingOutputBySession.get(sessionId) === pending) {
pendingOutputBySession.delete(sessionId);
}
};
void pending.then(clearPending, clearPending);
};
const getOriginRequestId = (sessionId) => (
fixedOriginRequestId || sessionRequestIds.get(sessionId) || null
);
const postRendererEvent = (channel, payload) => {
const explicitGeneration = payload?._terminalSessionGeneration;
const sessionGeneration = Number.isSafeInteger(explicitGeneration)
? explicitGeneration
: payload?.sessionId
? getOwnedSessionGeneration(payload.sessionId)
: undefined;
const rendererPayload = explicitGeneration === undefined
? payload
: Object.freeze(Object.fromEntries(
Object.entries(payload).filter(([key]) => key !== "_terminalSessionGeneration"),
));
const originRequestId = getOriginRequestId(payload?.sessionId);
if (channel === "netcatty:exit" && payload?.sessionId) {
if ((sessionOutputGenerations.get(payload.sessionId) ?? 0) === sessionGeneration) {
sessionOutputGenerations.set(payload.sessionId, sessionGeneration + 1);
pendingOutputBySession.delete(payload.sessionId);
outputPorts?.closeSession?.(payload.sessionId);
terminalDataPipeline?.detach?.(payload.sessionId, undefined, "session-closed");
onCurrentSessionExit?.(payload.sessionId, sessionGeneration);
}
}
parentPort.postMessage({
kind: "renderer-event",
webContentsId,
channel,
payload: rendererPayload,
...(sessionGeneration === undefined ? {} : { sessionGeneration }),
...(originRequestId ? { originRequestId } : {}),
});
};
const deliverTerminalData = (payload) => {
const sessionId = payload?.sessionId;
const explicitGeneration = payload?._terminalSessionGeneration;
const outputGeneration = Number.isSafeInteger(explicitGeneration)
? explicitGeneration
: getOwnedSessionGeneration(sessionId);
const originRequestId = getOriginRequestId(sessionId);
if ((sessionOutputGenerations.get(sessionId) ?? 0) !== outputGeneration) return;
const tapMessage = {
kind: "output-tap",
sessionId: payload?.sessionId,
data: payload?.data,
sessionGeneration: outputGeneration,
...(originRequestId ? { originRequestId } : {}),
};
if (payload?.meta) tapMessage.meta = payload.meta;
if (payload?.tapped !== true) parentPort.postMessage(tapMessage);
const pipelineProcessed = payload?.pipelineProcessed === true;
const pipelineMode = terminalDataPipeline?.getOutputMode?.(payload?.sessionId) ?? 0;
let sensitiveInputState;
if (!pipelineProcessed && pipelineMode !== 0) {
sensitiveInputState = terminalDataPipeline.observeOutput?.(
payload?.sessionId,
payload?.data,
) === true;
}
const deliver = (data, transformed = false) => {
if ((sessionOutputGenerations.get(sessionId) ?? 0) !== outputGeneration) return;
const inheritedIngressBytes = payload?.meta?.pluginPipelineIngressBytes;
const replayedRawIngressBytes = !transformed
&& !pipelineProcessed
&& Number.isFinite(inheritedIngressBytes)
? Math.max(0, Number(inheritedIngressBytes)) + String(payload?.data ?? "").length
: null;
const pipelineMeta = {
...(payload?.meta ?? {}),
...(replayedRawIngressBytes == null
? {}
: { pluginPipelineIngressBytes: replayedRawIngressBytes }),
...(transformed
? {
pluginPipelineIngressBytes:
Number(payload?.meta?.pluginPipelineIngressBytes ?? 0)
+ String(payload?.data ?? "").length,
pluginPipelineProcessed: true,
}
: {}),
...(sensitiveInputState === undefined
? {}
: { pluginPipelineSensitiveInput: sensitiveInputState }),
};
const meta = Object.keys(pipelineMeta).length > 0 ? pipelineMeta : undefined;
if (outputPorts?.post?.(payload?.sessionId, data, meta)) return;
const outputMessage = {
kind: "output",
sessionId: payload?.sessionId,
data,
tapped: true,
sessionGeneration: outputGeneration,
...(originRequestId ? { originRequestId } : {}),
};
if (meta) outputMessage.meta = meta;
parentPort.postMessage(outputMessage);
};
const previous = pendingOutputBySession.get(sessionId);
if (pipelineProcessed || !terminalDataPipeline?.interceptOutput || (pipelineMode & 2) === 0) {
if (!previous) {
deliver(payload?.data, false);
return Promise.resolve();
}
const pending = previous.then(
() => deliver(payload?.data, false),
() => deliver(payload?.data, false),
);
trackPendingOutput(sessionId, pending);
return pending;
}
const interceptAndDeliver = () => {
try {
return Promise.resolve(terminalDataPipeline.interceptOutput(sessionId, payload?.data)).then(
(data) => deliver(data, true),
() => deliver(payload?.data, false),
);
} catch {
deliver(payload?.data, false);
return undefined;
}
};
// Invoke the pipeline immediately so its bounded byte window and monotonic
// deadline cover time spent waiting behind earlier transforms. The
// pipeline owns per-session transform ordering; this outer registry only
// retains the latest barrier for direct fail-open output and session exit.
const pending = Promise.resolve(interceptAndDeliver());
trackPendingOutput(sessionId, pending);
return pending;
};
return {
id: webContentsId,
claimSessionGeneration(sessionId) {
return getOwnedSessionGeneration(sessionId);
},
isDestroyed() {
return false;
},
send(channel, payload) {
if (channel === "netcatty:data") {
return deliverTerminalData(payload);
}
if (channel === "netcatty:exit" && payload?.sessionId) {
const pending = pendingOutputBySession.get(payload.sessionId);
if (pending) {
void pending.then(
() => postRendererEvent(channel, payload),
() => postRendererEvent(channel, payload),
);
return;
}
}
postRendererEvent(channel, payload);
},
};
}
function createTerminalWorkerRuntime(options = {}) {
const {
parentPort,
registerBridges,
terminalDataPipeline,
} = options;
// Optional hook for suppressed-but-diagnostic failures so they still reach
// the main process's persistent crash log via the "worker-error" channel.
const reportSuppressedError = typeof options.reportSuppressedError === "function"
? options.reportSuppressedError
: null;
const ipcMain = createIpcMainHarness();
let started = false;
const outputPorts = createOutputPortRegistry();
const pendingOutputBySession = new Map();
const sessionOutputGenerations = new Map();
const sessionRequestIds = new Map();
const sessionOperationTails = new Map();
const sessionOperationKinds = new Map();
const sessionCloseEpochs = new Map();
const sessionLifecycleTombstoneTimes = new Map();
const sessionStartMarkers = new Set();
const pendingSessionStartBootEpochs = new Map();
let urgentInputPorts = null;
const now = typeof options.now === "function" ? options.now : Date.now;
const sessionLifecycleTombstoneTtlMs = Number.isFinite(options.sessionLifecycleTombstoneTtlMs)
? Math.max(0, Number(options.sessionLifecycleTombstoneTtlMs))
: DEFAULT_SESSION_LIFECYCLE_TOMBSTONE_TTL_MS;
const maxSessionLifecycleTombstones = Number.isFinite(options.maxSessionLifecycleTombstones)
? Math.max(1, Math.floor(Number(options.maxSessionLifecycleTombstones)))
: DEFAULT_MAX_SESSION_LIFECYCLE_TOMBSTONES;
const setDefaultTransportIdleTtlMs = typeof options.setDefaultTransportIdleTtlMs === "function"
? options.setDefaultTransportIdleTtlMs
: (value) => require("../bridges/sshConnectionPool.cjs").setDefaultTransportIdleTtlMs(value);
function canPruneSessionLifecycleTombstone(sessionId) {
return !sessionOperationTails.has(sessionId)
&& !sessionStartMarkers.has(sessionId)
&& !pendingOutputBySession.has(sessionId);
}
function deleteSessionLifecycleTombstone(sessionId) {
sessionLifecycleTombstoneTimes.delete(sessionId);
sessionOutputGenerations.delete(sessionId);
sessionCloseEpochs.delete(sessionId);
pendingSessionStartBootEpochs.delete(sessionId);
}
function normalizeBootEpoch(bootEpoch) {
if (!Number.isFinite(bootEpoch)) return undefined;
return Number(bootEpoch);
}
function rememberPendingStartBootEpoch(sessionId, bootEpoch) {
const normalized = normalizeBootEpoch(bootEpoch);
if (!sessionId || normalized === undefined) return;
pendingSessionStartBootEpochs.set(sessionId, normalized);
}
function shouldSkipStaleEpochClose(sessionId, bootEpoch) {
const closeEpoch = normalizeBootEpoch(bootEpoch);
if (closeEpoch === undefined || !sessionId) return false;
const ownerEpoch = pendingSessionStartBootEpochs.get(sessionId);
return ownerEpoch !== undefined && ownerEpoch > closeEpoch;
}
function pruneSessionLifecycleTombstones() {
const currentTime = now();
for (const [sessionId, closedAt] of sessionLifecycleTombstoneTimes) {
if (currentTime - closedAt < sessionLifecycleTombstoneTtlMs) continue;
if (!canPruneSessionLifecycleTombstone(sessionId)) continue;
deleteSessionLifecycleTombstone(sessionId);
}
if (sessionLifecycleTombstoneTimes.size <= maxSessionLifecycleTombstones) return;
for (const sessionId of [...sessionLifecycleTombstoneTimes.keys()]) {
if (sessionLifecycleTombstoneTimes.size <= maxSessionLifecycleTombstones) break;
if (!canPruneSessionLifecycleTombstone(sessionId)) continue;
deleteSessionLifecycleTombstone(sessionId);
}
}
function touchSessionLifecycleTombstone(sessionId) {
if (!sessionId) return;
sessionLifecycleTombstoneTimes.delete(sessionId);
sessionLifecycleTombstoneTimes.set(sessionId, now());
}
function finalizeNaturalSessionExit(sessionId) {
if (!sessionId) return;
sessionStartMarkers.delete(sessionId);
sessionRequestIds.delete(sessionId);
clearTerminalSessionPerformanceState(sessionId);
touchSessionLifecycleTombstone(sessionId);
pruneSessionLifecycleTombstones();
}
const createWorkerOutputSender = () => createSender(
parentPort,
0,
outputPorts,
terminalDataPipeline,
pendingOutputBySession,
sessionOutputGenerations,
sessionRequestIds,
null,
finalizeNaturalSessionExit,
);
function replayWorkerOutput(sessionId, chunks) {
const sender = createWorkerOutputSender();
for (const chunk of chunks || []) {
const data = chunk && typeof chunk === "object" && "data" in chunk ? chunk.data : chunk;
const meta = chunk && typeof chunk === "object" ? chunk.meta : undefined;
const pipelineProcessed = meta?.pluginPipelineProcessed === true;
sender.send("netcatty:data", {
sessionId,
data,
meta,
tapped: true,
pipelineProcessed,
});
}
}
function invalidateSessionOutput(sessionId) {
if (!sessionId) return;
sessionOutputGenerations.set(
sessionId,
(sessionOutputGenerations.get(sessionId) ?? 0) + 1,
);
pendingOutputBySession.delete(sessionId);
outputPorts.closeSession(sessionId);
terminalDataPipeline?.detach?.(sessionId, undefined, "session-closed");
sessionRequestIds.delete(sessionId);
clearTerminalSessionPerformanceState(sessionId);
touchSessionLifecycleTombstone(sessionId);
pruneSessionLifecycleTombstones();
}
async function handleRequest(message) {
const handler = ipcMain.handlers.get(message.channel);
if (!handler) {
parentPort.postMessage({
kind: "response",
requestId: message.requestId,
error: `No terminal worker handler registered for ${message.channel}`,
});
return;
}
try {
const isSessionStart = SESSION_START_CHANNELS.has(message.channel);
const requestedSessionId = isSessionStart ? message.payload?.sessionId : null;
const naturalExitGenerations = new Map();
const requestedSessionGeneration = requestedSessionId
? (sessionOutputGenerations.get(requestedSessionId) ?? 0)
: null;
if (requestedSessionId) {
sessionRequestIds.set(requestedSessionId, message.requestId);
sessionLifecycleTombstoneTimes.delete(requestedSessionId);
}
const result = await handler({
sender: createSender(
parentPort,
message.webContentsId,
outputPorts,
terminalDataPipeline,
pendingOutputBySession,
sessionOutputGenerations,
sessionRequestIds,
isSessionStart ? message.requestId : null,
(sessionId, generation) => {
naturalExitGenerations.set(sessionId, generation);
finalizeNaturalSessionExit(sessionId);
},
),
}, message.payload);
const sessionId = result?.sessionId;
if (isSessionStart && sessionId) {
const currentGeneration = sessionOutputGenerations.get(sessionId) ?? 0;
const exitedGeneration = naturalExitGenerations.get(sessionId);
const exitedDuringRequest = exitedGeneration !== undefined
&& currentGeneration > exitedGeneration;
const requestedGenerationStillCurrent = requestedSessionGeneration === null
|| currentGeneration === requestedSessionGeneration;
if (!exitedDuringRequest && requestedGenerationStillCurrent) {
sessionRequestIds.set(sessionId, message.requestId);
sessionStartMarkers.add(sessionId);
sessionLifecycleTombstoneTimes.delete(sessionId);
} else {
sessionRequestIds.delete(sessionId);
sessionStartMarkers.delete(sessionId);
}
}
parentPort.postMessage({
kind: "response",
requestId: message.requestId,
result,
...(typeof sessionId === "string"
? { sessionGeneration: sessionOutputGenerations.get(sessionId) ?? 0 }
: {}),
});
} catch (err) {
parentPort.postMessage({
kind: "response",
requestId: message.requestId,
error: err?.message || String(err),
});
}
}
async function closeSupersededSessionStart(message) {
const sessionId = message.payload?.sessionId;
if (!sessionId) return;
parentPort.postMessage({
kind: "session-superseding",
sessionId,
sessionGeneration: sessionOutputGenerations.get(sessionId) ?? 0,
replacementRequestId: message.requestId,
});
invalidateSessionOutput(sessionId);
const closeHandler = ipcMain.handlers.get("netcatty:close:await");
if (closeHandler) {
await closeHandler({
sender: createSender(
parentPort,
message.webContentsId,
outputPorts,
terminalDataPipeline,
pendingOutputBySession,
sessionOutputGenerations,
sessionRequestIds,
null,
finalizeNaturalSessionExit,
),
}, { sessionId });
}
}
function postRequestError(message, error) {
parentPort.postMessage({
kind: "response",
requestId: message.requestId,
error: error?.message || String(error),
});
}
function trackSessionOperation(sessionId, operation, kind) {
sessionOperationTails.set(sessionId, operation);
sessionOperationKinds.set(sessionId, kind);
void operation.finally(() => {
if (sessionOperationTails.get(sessionId) === operation) {
sessionOperationTails.delete(sessionId);
sessionOperationKinds.delete(sessionId);
pruneSessionLifecycleTombstones();
}
});
}
function dispatchRequest(message) {
pruneSessionLifecycleTombstones();
const sessionId = SESSION_START_CHANNELS.has(message.channel)
? message.payload?.sessionId
: null;
if (message.channel === "netcatty:close:await" && message.payload?.sessionId) {
dispatchSessionClose(message, true);
return;
}
if (!sessionId) {
void handleRequest(message);
return;
}
rememberPendingStartBootEpoch(sessionId, message.payload?.bootEpoch);
const closeEpoch = sessionCloseEpochs.get(sessionId) ?? 0;
const previous = sessionOperationTails.get(sessionId);
const previousKind = sessionOperationKinds.get(sessionId);
const shouldClosePreviousStart = previousKind === "start" || sessionStartMarkers.has(sessionId);
const current = (previous || Promise.resolve()).catch(() => {}).then(async () => {
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) {
postRequestError(message, new Error("Terminal session start was cancelled by close"));
return;
}
try {
if (shouldClosePreviousStart) await closeSupersededSessionStart(message);
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) {
postRequestError(message, new Error("Terminal session start was cancelled by close"));
return;
}
sessionStartMarkers.add(sessionId);
await handleRequest(message);
} catch (error) {
postRequestError(message, error);
}
});
trackSessionOperation(sessionId, current, "start");
}
function dispatchSessionClose(message, expectsResponse) {
const sessionId = message.payload?.sessionId;
if (!sessionId) {
if (expectsResponse) void handleRequest(message);
else handleSend(message);
return;
}
if (shouldSkipStaleEpochClose(sessionId, message.payload?.bootEpoch)) {
if (expectsResponse) {
parentPort.postMessage({
kind: "response",
requestId: message.requestId,
result: { skipped: true, reason: "boot-epoch-mismatch" },
});
}
return;
}
sessionCloseEpochs.set(sessionId, (sessionCloseEpochs.get(sessionId) ?? 0) + 1);
pendingSessionStartBootEpochs.delete(sessionId);
touchSessionLifecycleTombstone(sessionId);
const previous = sessionOperationTails.get(sessionId);
const current = (previous || Promise.resolve()).catch(() => {}).then(async () => {
try {
if (expectsResponse) {
invalidateSessionOutput(sessionId);
await handleRequest(message);
} else {
handleSend(message);
}
sessionStartMarkers.delete(sessionId);
} catch (error) {
if (expectsResponse) postRequestError(message, error);
}
});
trackSessionOperation(sessionId, current, "close");
pruneSessionLifecycleTombstones();
}
function handleSend(message) {
const listener = ipcMain.listeners.get(message.channel);
if (!listener) return;
if (message.channel === "netcatty:interrupt") {
terminalDataPipeline?.clearSensitiveInput?.(message.payload?.sessionId);
const trace = normalizeTrace(message.payload);
logTerminalInterruptDebug("worker-received-send", {
channel: message.channel,
webContentsId: message.webContentsId,
}, trace);
}
if (message.channel === "netcatty:close" && message.payload?.sessionId) {
invalidateSessionOutput(message.payload.sessionId);
}
try {
listener({
sender: createSender(
parentPort,
message.webContentsId,
outputPorts,
terminalDataPipeline,
pendingOutputBySession,
sessionOutputGenerations,
sessionRequestIds,
null,
finalizeNaturalSessionExit,
),
}, message.payload);
} catch (err) {
// Send listeners (write/resize/flow/close) are fire-and-forget; a throw
// here is synchronous and would otherwise escape as an uncaught
// exception on the worker's message loop.
console.error(`[TerminalWorker] send listener failed for ${message.channel}:`, err);
if (reportSuppressedError) {
try {
reportSuppressedError("send-listener", err, `send listener failed for ${message.channel}`);
} catch {
// Reporting must never be able to escalate into a worker crash.
}
}
}
}
function handleUrgentInput(webContentsId, message) {
if (message?.kind !== "interrupt" || !message.sessionId) return;
handleSend({
channel: "netcatty:interrupt",
payload: {
sessionId: message.sessionId,
trace: message.trace,
urgentInputPort: true,
},
webContentsId,
});
}
function handleMessage(eventOrMessage) {
const { message, ports } = normalizeMessageEvent(eventOrMessage);
if (message?.kind === "set-ssh-transport-idle-ttl") {
setDefaultTransportIdleTtlMs(message.value);
return;
}
if (message?.kind === "urgent-input-port") {
urgentInputPorts?.open(message.webContentsId, ports?.[0]);
return;
}
if (message?.kind === "close-urgent-input-port") {
urgentInputPorts?.close(message.webContentsId);
return;
}
if (message?.kind === "output-port") {
const sessionGeneration = sessionOutputGenerations.get(message.sessionId) ?? 0;
if (Number.isSafeInteger(message.sessionGeneration)
&& message.sessionGeneration !== sessionGeneration) {
try { ports?.[0]?.close?.(); } catch {}
return;
}
outputPorts.open(message.sessionId, ports?.[0]);
replayWorkerOutput(message.sessionId, message.bufferedOutput);
const pending = pendingOutputBySession.get(message.sessionId);
const notifyReady = () => parentPort.postMessage({
kind: "output-port-ready",
sessionId: message.sessionId,
...(Number.isSafeInteger(message.sessionGeneration) ? { sessionGeneration } : {}),
...(message.outputPortRequestId
? { outputPortRequestId: message.outputPortRequestId }
: {}),
});
if (pending) {
void pending.then(
notifyReady,
notifyReady,
);
} else {
notifyReady();
}
return;
}
if (message?.kind === "terminal-interceptor-port") {
terminalDataPipeline?.attach?.(message, ports?.[0]);
return;
}
if (message?.kind === "terminal-interceptor-detach") {
terminalDataPipeline?.detach?.(message.sessionId, message.direction, "detached");
return;
}
if (message?.kind === "output-flush") {
replayWorkerOutput(message.sessionId, message.chunks);
return;
}
if (message?.kind === "close-output-port") {
invalidateSessionOutput(message.sessionId);
return;
}
if (message?.kind === "output-drain") {
outputPorts.postControl(message.sessionId, {
kind: "drain",
requestId: message.requestId,
});
return;
}
if (message?.kind === "request") {
dispatchRequest(message);
return;
}
if (message?.kind === "send") {
if (message.channel === "netcatty:close" && message.payload?.sessionId) {
dispatchSessionClose(message, false);
return;
}
handleSend(message);
}
}
function start() {
if (started) return;
started = true;
urgentInputPorts = createUrgentInputPortRegistry(handleUrgentInput);
registerBridges?.(ipcMain);
parentPort.on("message", handleMessage);
}
return {
start,
ipcMain,
createSender(webContentsId) {
return createSender(
parentPort,
webContentsId,
outputPorts,
terminalDataPipeline,
pendingOutputBySession,
sessionOutputGenerations,
sessionRequestIds,
null,
finalizeNaturalSessionExit,
);
},
closeUrgentInputPortsForTest() {
urgentInputPorts?.closeAll();
},
_getSessionLifecycleStateCountsForTests() {
pruneSessionLifecycleTombstones();
return {
outputGenerations: sessionOutputGenerations.size,
closeEpochs: sessionCloseEpochs.size,
};
},
};
}
module.exports = {
createTerminalWorkerRuntime,
createOutputPortRegistry,
};