"use strict"; const { releaseAttachedSessionState } = require("./terminalAttachRestore.cjs"); const path = require("node:path"); const { randomUUID } = require("node:crypto"); const { logTerminalInterruptDebug, normalizeTrace, } = require("./terminalInterruptDiagnostics.cjs"); const { TERMINAL_URGENT_INPUT_PORT_CHANNEL, } = require("./terminalUrgentInputChannel.cjs"); const { clearTerminalSessionPerformanceState, } = require("./emitTerminalSessionData.cjs"); const DEFAULT_CLOSED_SESSION_TOMBSTONE_TTL_MS = 60_000; const DEFAULT_MAX_CLOSED_SESSION_TOMBSTONES = 2_048; const SSH_TRANSPORT_IDLE_TTL_ENV = "NETCATTY_SSH_TRANSPORT_IDLE_TTL_MS"; function resolveInitialTransportIdleTtlMs(options = {}) { if (Number.isFinite(options.initialTransportIdleTtlMs) && options.initialTransportIdleTtlMs >= 0) { return Number(options.initialTransportIdleTtlMs); } try { const { getDefaultTransportIdleTtlMs } = require("./sshConnectionPool.cjs"); return getDefaultTransportIdleTtlMs(); } catch { return 5 * 60_000; } } function registerTransportIdleTtlSettingsSync(ipcMain, terminalWorkerManager) { if (!ipcMain?.on || !terminalWorkerManager?.setDefaultTransportIdleTtlMs) { return () => {}; } const { STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS } = require("./sshConnectionPool.cjs"); const listener = (_event, payload) => { if (payload?.key !== STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS) return; terminalWorkerManager.setDefaultTransportIdleTtlMs(payload.value); }; ipcMain.on("netcatty:settings:changed", listener); return () => ipcMain.removeListener?.("netcatty:settings:changed", listener); } function isTerminalWorkerEnabled(options = {}) { const env = options.env || process.env; return env.NETCATTY_TERMINAL_WORKER !== "0"; } const TRANSFER_RENDERER_CHANNELS = new Set([ "netcatty:transfer:queued", "netcatty:transfer:started", "netcatty:transfer:progress", "netcatty:transfer:complete", "netcatty:transfer:cancelled", "netcatty:transfer:error", "netcatty:compress:progress", "netcatty:compress:complete", "netcatty:compress:cancelled", "netcatty:compress:error", ]); function shouldForwardWorkerRendererEvent(channel) { return !TRANSFER_RENDERER_CHANNELS.has(channel); } /** * Lift worker-local lifecycleEpoch into main-process space when transferBridge * has advanced the control epoch (pause rollback / soft-resume). */ function translateWorkerTransferLifecycleEpoch(transferId, workerEpoch) { try { // Lazy require avoids circular init with transferBridge. const transferBridge = require("./transferBridge.cjs"); if (typeof transferBridge.resolveWorkerTransferLifecycleEpoch === "function") { return transferBridge.resolveWorkerTransferLifecycleEpoch(transferId, workerEpoch); } } catch { // transferBridge unavailable in pure unit fixtures } return workerEpoch; } /** * Map worker transfer IPC onto the global transfer-center channel. * The utilityProcess cannot call BrowserWindow; main must fan these out. * Exported for unit tests. */ function mapWorkerTransferChannelToGlobalEvent(channel, payload) { const transferId = payload?.transferId || payload?.compressionId; if (!payload || !transferId) return null; if (channel === "netcatty:transfer:progress") { return { type: "progress", transferId, transferred: payload.transferred, totalBytes: payload.totalBytes, speed: payload.speed, phase: payload.phase, checkpointBytes: payload.checkpointBytes, resumeStage: payload.resumeStage, downloadCheckpointBytes: payload.downloadCheckpointBytes, uploadCheckpointBytes: payload.uploadCheckpointBytes, sourceFingerprint: payload.sourceFingerprint, lifecycleEpoch: translateWorkerTransferLifecycleEpoch(transferId, payload.lifecycleEpoch), lifecycleState: payload.lifecycleState, resumable: payload.resumable, pauseUnavailableReason: payload.pauseUnavailableReason, parentTaskId: payload.parentTaskId, directoryEntryIndex: payload.directoryEntryIndex, directoryEntryIdentity: payload.directoryEntryIdentity, }; } if (channel === "netcatty:transfer:queued" || channel === "netcatty:transfer:started") { return { ...payload, type: channel.endsWith(":queued") ? "queued" : "started", transferId, lifecycleEpoch: translateWorkerTransferLifecycleEpoch(transferId, payload.lifecycleEpoch), }; } if (channel === "netcatty:transfer:complete") { return { type: "completed", transferId, endedAt: Date.now(), transferred: payload.transferred, totalBytes: payload.totalBytes, parentTaskId: payload.parentTaskId, directoryEntryIndex: payload.directoryEntryIndex, directoryEntryIdentity: payload.directoryEntryIdentity, }; } if (channel === "netcatty:transfer:cancelled") { return { type: "cancelled", transferId, endedAt: Date.now(), parentTaskId: payload.parentTaskId, }; } if (channel === "netcatty:transfer:error") { const message = payload.error || String(payload.error || "Transfer failed"); const cancelled = /cancel/i.test(message); return { type: cancelled ? "cancelled" : "failed", transferId, endedAt: Date.now(), error: message, parentTaskId: payload.parentTaskId, }; } if (channel === "netcatty:compress:progress") { return { type: "progress", transferId, phase: payload.phase, transferred: payload.transferredBytes, totalBytes: payload.totalBytes, fileName: payload.fileName, sourcePath: payload.sourcePath, targetPath: payload.targetPath, direction: "upload", isDirectory: true, controlKind: "compressed-upload", lifecycleEpoch: payload.lifecycleEpoch, lifecycleState: payload.lifecycleState, }; } if (channel === "netcatty:compress:complete") { return { type: "completed", transferId, endedAt: Date.now() }; } if (channel === "netcatty:compress:cancelled") { return { type: "cancelled", transferId, endedAt: Date.now() }; } if (channel === "netcatty:compress:error") { return { type: "failed", transferId, endedAt: Date.now(), error: payload.error || "Compressed upload failed", }; } return null; } function broadcastGlobalTransferEventOnMain(electronModule, eventPayload) { if (!eventPayload) return 0; const BrowserWindow = electronModule?.BrowserWindow; if (!BrowserWindow?.getAllWindows) return 0; let sent = 0; for (const win of BrowserWindow.getAllWindows()) { try { if (win?.isDestroyed?.()) continue; const wc = win.webContents; if (!wc || wc.isDestroyed?.()) continue; wc.send("netcatty:sftp:global-transfer", eventPayload); sent += 1; } catch { // best-effort } } return sent; } function fanoutGlobalTransferFromWorkerEvent(electronModule, channel, payload) { const eventPayload = mapWorkerTransferChannelToGlobalEvent(channel, payload); return broadcastGlobalTransferEventOnMain(electronModule, eventPayload); } function defaultWorkerScriptPath() { return path.join(__dirname, "..", "terminalWorker", "process.cjs"); } const ESC = "\x1b"; const ALT_SCREEN_MODES = new Set([47, 1047, 1049]); function readCsiSequence(input, startIndex) { if (input[startIndex] !== ESC || input[startIndex + 1] !== "[") return null; for (let index = startIndex + 2; index < input.length; index += 1) { const code = input.charCodeAt(index); if (code >= 0x40 && code <= 0x7e) { return { sequence: input.slice(startIndex, index + 1), end: index + 1 }; } } return null; } function getAlternateScreenAction(sequence) { if (!sequence.startsWith("\x1b[") || sequence.length < 3) return null; const final = sequence.at(-1); if (final !== "h" && final !== "l") return null; const params = sequence.slice(2, -1); if (!params.startsWith("?")) return null; const modes = params .slice(1) .split(";") .map((part) => Number.parseInt(part, 10)) .filter(Number.isFinite); if (!modes.some((mode) => ALT_SCREEN_MODES.has(mode))) return null; return final === "h" ? "enter" : "leave"; } function inspectAlternateScreenWindow(text) { let mayAffectTerminalState = false; let finalAction = null; let hasIncompleteSequence = false; for (let index = 0; index < text.length; index += 1) { if (text[index] !== ESC) continue; const sequence = readCsiSequence(text, index); if (sequence) { const action = getAlternateScreenAction(sequence.sequence); if (action) { mayAffectTerminalState = true; finalAction = action; } index = sequence.end - 1; continue; } if (text.slice(index).startsWith("\x1b[?")) { mayAffectTerminalState = true; hasIncompleteSequence = true; finalAction = null; } } return { mayAffectTerminalState, finalAction, hasIncompleteSequence }; } function inspectAlternateScreenSequenceStartedBeforeBoundary(text, boundary) { let mayAffectTerminalState = false; let finalAction = null; for (let index = 0; index < Math.min(boundary, text.length); index += 1) { if (text[index] !== ESC) continue; const sequence = readCsiSequence(text, index); if (!sequence) { if (text.slice(index).startsWith("\x1b[?")) { mayAffectTerminalState = true; } continue; } if (sequence.end > boundary) { const action = getAlternateScreenAction(sequence.sequence); if (action) { mayAffectTerminalState = true; finalAction = action; } } index = sequence.end - 1; } return { mayAffectTerminalState, finalAction }; } function inspectDroppedTerminalState({ droppedHead, droppedTail, retainedHead, droppedBytes, }) { if (droppedBytes <= 0) { return { mayAffectTerminalState: false, finalAlternateScreenAction: undefined }; } const headInspection = inspectAlternateScreenWindow(droppedHead); const tailContext = droppedTail === droppedHead ? droppedHead : droppedTail; const tailInspection = inspectAlternateScreenWindow(tailContext); const tailWithRetainedInspection = inspectAlternateScreenWindow(`${tailContext}${retainedHead}`); const splitTailInspection = inspectAlternateScreenSequenceStartedBeforeBoundary( `${tailContext}${retainedHead}`, tailContext.length, ); const sampledBytes = droppedHead.length + (droppedTail === droppedHead ? 0 : droppedTail.length); const hasUninspectedMiddle = droppedBytes > sampledBytes; const finalAlternateScreenAction = splitTailInspection.finalAction || tailInspection.finalAction || (!hasUninspectedMiddle ? headInspection.finalAction : undefined) || undefined; return { mayAffectTerminalState: Boolean( headInspection.hasIncompleteSequence || tailWithRetainedInspection.hasIncompleteSequence || hasUninspectedMiddle || headInspection.finalAction || tailInspection.finalAction || splitTailInspection.mayAffectTerminalState || tailWithRetainedInspection.finalAction ), finalAlternateScreenAction, }; } function mergeTerminalOutputMeta(previous, next) { if (!next) return previous; const nextAction = next.droppedOutputMayAffectTerminalState ? next.droppedOutputAlternateScreenAction : (next.droppedOutputAlternateScreenAction ?? previous?.droppedOutputAlternateScreenAction); const merged = { ...(previous || {}), ...next, droppedOutputMayAffectTerminalState: Boolean( previous?.droppedOutputMayAffectTerminalState || next.droppedOutputMayAffectTerminalState ), droppedOutputAlternateScreenAction: nextAction, }; const pluginPipelineIngressBytes = Number(previous?.pluginPipelineIngressBytes ?? 0) + Number(next.pluginPipelineIngressBytes ?? 0); if (pluginPipelineIngressBytes > 0) merged.pluginPipelineIngressBytes = pluginPipelineIngressBytes; else delete merged.pluginPipelineIngressBytes; if (typeof next.pluginPipelineSensitiveInput === "boolean") { merged.pluginPipelineSensitiveInput = next.pluginPipelineSensitiveInput; } else { delete merged.pluginPipelineSensitiveInput; } if (next.pluginPipelineProcessed === true) { merged.pluginPipelineProcessed = true; } else { delete merged.pluginPipelineProcessed; } if (!merged.droppedOutputMayAffectTerminalState) { delete merged.droppedOutputMayAffectTerminalState; } if (!merged.droppedOutputAlternateScreenAction) { delete merged.droppedOutputAlternateScreenAction; } return merged; } 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 createTerminalWorkerManager(options = {}) { const { utilityProcess, terminalOutputChannel = null, MessageChannelMain = null, workerScriptPath = defaultWorkerScriptPath(), electronModule = null, onRendererEvent = null, } = options; let child = null; const pending = new Map(); const pendingOutput = new Map(); const pendingOutputBytes = new Map(); const closedSessions = new Set(); const closedSessionSequences = new Map(); const closedSessionTombstoneTimes = new Map(); let sessionLifecycleSequence = 0; const outputPortPending = new Map(); const outputPortReady = new Set(); const outputRoutePending = new Map(); const pendingSessionStartSequences = new Map(); const latestSessionStartSequences = new Map(); // bootEpoch for the latest pending / opened same-id start. Stale StrictMode // orphan closes carry an older epoch and must not cancel a newer remount. const pendingSessionStartBootEpochs = new Map(); const sessionBootEpochs = new Map(); const suppressedPendingOutputSessions = new Set(); const workerSessionIds = new Set(); const sessionWebContentsIds = new Map(); const sessionHostIds = new Map(); const sessionGenerations = new Map(); const closedSessionGenerations = new Map(); const supersedingSessionGenerations = new Map(); const urgentInputPorts = new Map(); const outputTaps = new Set(); const terminalInterceptorWarningListeners = new Set(); const sessionOwnedListeners = new Set(); const sessionClosedListeners = new Set(); const workerExitListeners = new Set(); const workerRendererEventListeners = new Set(); const externalSessions = new Map(); const maxPendingOutputChunks = Number.isFinite(options.maxPendingOutputChunks) ? Math.max(0, Math.trunc(options.maxPendingOutputChunks)) : 512; const maxPendingOutputBytes = Number.isFinite(options.maxPendingOutputBytes) ? Math.max(0, Math.trunc(options.maxPendingOutputBytes)) : 2 * 1024 * 1024; const maxDroppedStateScanBytes = Math.max(256, options.maxDroppedStateScanBytes ?? 2048); const now = typeof options.now === "function" ? options.now : Date.now; const closedSessionTombstoneTtlMs = Number.isFinite(options.closedSessionTombstoneTtlMs) ? Math.max(0, Number(options.closedSessionTombstoneTtlMs)) : DEFAULT_CLOSED_SESSION_TOMBSTONE_TTL_MS; const maxClosedSessionTombstones = Number.isFinite(options.maxClosedSessionTombstones) ? Math.max(1, Math.floor(Number(options.maxClosedSessionTombstones))) : DEFAULT_MAX_CLOSED_SESSION_TOMBSTONES; let transportIdleTtlMs = resolveInitialTransportIdleTtlMs(options); function rejectAllPending(error) { for (const { reject } of pending.values()) { reject(error); } pending.clear(); } function clearBufferedOutput(sessionId) { if (!sessionId) return; pendingOutput.delete(sessionId); pendingOutputBytes.delete(sessionId); } function takeBufferedOutput(sessionId) { const chunks = pendingOutput.get(sessionId) || []; pendingOutput.delete(sessionId); pendingOutputBytes.delete(sessionId); return chunks; } function normalizeOutputChunk(data, meta) { if (data && typeof data === "object" && "data" in data) { const mergedMeta = mergeTerminalOutputMeta(data.meta, meta); return mergedMeta ? { data: data.data, meta: mergedMeta } : data.data; } return meta ? { data, meta } : data; } function getOutputChunkData(chunk) { return chunk && typeof chunk === "object" && "data" in chunk ? chunk.data : chunk; } function getOutputChunkMeta(chunk) { return chunk && typeof chunk === "object" ? chunk.meta : undefined; } function getOutputChunkLength(chunk) { const data = getOutputChunkData(chunk); const displayLength = typeof data === "string" ? data.length : 0; // A suppressed interceptor result can carry flow-accounting metadata with // an empty display string. Count that record as one bounded buffer unit so // the chunk/byte trimming loop cannot stop on a zero-length head. return displayLength || (getOutputChunkMeta(chunk) ? 1 : 0); } function withOutputChunkMeta(chunk, meta) { // Dropped metadata describes older chunks. Merge the retained chunk last // so latest-chunk state such as sensitive-input classification cannot be // revived by an earlier prompt while additive ingress/state metadata is // still preserved. const mergedMeta = mergeTerminalOutputMeta(meta, getOutputChunkMeta(chunk) || {}); if (!mergedMeta) return chunk; return { data: getOutputChunkData(chunk), meta: mergedMeta }; } function readBufferedOutputHead(chunks, limit) { if (limit <= 0) return ""; let head = ""; for (const chunk of chunks) { if (head.length >= limit) break; const data = getOutputChunkData(chunk); if (typeof data !== "string") continue; head += data.slice(0, limit - head.length); } return head; } function trimBufferedOutput(sessionId, chunks) { let totalBytes = pendingOutputBytes.get(sessionId) || 0; let droppedBytes = 0; let droppedHead = ""; let droppedTail = ""; let droppedMeta; const recordDropped = (text, meta) => { if (meta) { droppedMeta = mergeTerminalOutputMeta(droppedMeta, meta); } if (!text) return; if (droppedHead.length < maxDroppedStateScanBytes) { droppedHead += text.slice(0, maxDroppedStateScanBytes - droppedHead.length); } droppedTail = `${droppedTail}${text}`.slice(-maxDroppedStateScanBytes); }; while ( chunks.length > 0 && ( chunks.length > maxPendingOutputChunks || totalBytes > maxPendingOutputBytes ) ) { const chunk = chunks[0]; const data = getOutputChunkData(chunk); const dataLength = getOutputChunkLength(chunk); const displayLength = typeof data === "string" ? data.length : 0; // Metadata-only suppressed output still represents consumed host input. // Drop it atomically when it contributes virtual ingress bytes; partial // string slicing cannot safely reduce that metadata accounting. const dropWholeChunk = chunks.length > maxPendingOutputChunks || dataLength !== displayLength; const overLimitBytes = Math.max(0, totalBytes - maxPendingOutputBytes); const bytesToDrop = dropWholeChunk ? dataLength : Math.min(dataLength, overLimitBytes); if (bytesToDrop <= 0) break; if (typeof data !== "string" || bytesToDrop >= dataLength) { chunks.shift(); totalBytes = Math.max(0, totalBytes - dataLength); droppedBytes += displayLength; recordDropped(typeof data === "string" ? data : "", getOutputChunkMeta(chunk)); continue; } const droppedText = data.slice(0, bytesToDrop); const retainedText = data.slice(bytesToDrop); const retainedMeta = getOutputChunkMeta(chunk); const adjustedRetainedMeta = retainedMeta?.pluginPipelineProcessed !== true && Number.isFinite(retainedMeta?.pluginPipelineIngressBytes) ? mergeTerminalOutputMeta( { pluginPipelineIngressBytes: bytesToDrop }, retainedMeta, ) : retainedMeta; chunks[0] = adjustedRetainedMeta ? { data: retainedText, meta: adjustedRetainedMeta } : retainedText; totalBytes = Math.max(0, totalBytes - bytesToDrop); droppedBytes += bytesToDrop; recordDropped(droppedText); } const terminalState = inspectDroppedTerminalState({ droppedHead, droppedTail, retainedHead: readBufferedOutputHead(chunks, maxDroppedStateScanBytes), droppedBytes, }); if (terminalState.mayAffectTerminalState) { droppedMeta = mergeTerminalOutputMeta(droppedMeta, { droppedOutputMayAffectTerminalState: true, droppedOutputAlternateScreenAction: terminalState.finalAlternateScreenAction, }); } if (droppedMeta && chunks.length > 0) { chunks[0] = withOutputChunkMeta(chunks[0], droppedMeta); } pendingOutputBytes.set(sessionId, totalBytes); } function bufferOutput(sessionId, data, meta) { if ( !sessionId || (closedSessions.has(sessionId) && !hasPendingSessionLifecycle(sessionId)) || maxPendingOutputChunks === 0 || maxPendingOutputBytes === 0 ) { return; } const chunks = pendingOutput.get(sessionId) || []; const chunk = normalizeOutputChunk(data, meta); chunks.push(chunk); pendingOutputBytes.set( sessionId, (pendingOutputBytes.get(sessionId) || 0) + getOutputChunkLength(chunk), ); trimBufferedOutput(sessionId, chunks); pendingOutput.set(sessionId, chunks); } function flushBufferedOutput(sessionId) { const chunks = pendingOutput.get(sessionId); if (!sessionId || !chunks?.length) return; pendingOutput.delete(sessionId); pendingOutputBytes.delete(sessionId); for (let index = 0; index < chunks.length; index += 1) { const chunk = chunks[index]; const data = chunk && typeof chunk === "object" && "data" in chunk ? chunk.data : chunk; const meta = chunk && typeof chunk === "object" ? chunk.meta : undefined; if (!deliverOutputToRenderer(sessionId, data, meta)) { for (let retryIndex = index; retryIndex < chunks.length; retryIndex += 1) { bufferOutput(sessionId, chunks[retryIndex]); } break; } } } function sendOutputOverLegacyIpc(sessionId, data, meta) { const webContentsId = sessionWebContentsIds.get(sessionId); if (!webContentsId) return false; const contents = electronModule?.webContents?.fromId?.(webContentsId); if (!contents?.send) return false; contents.send("netcatty:data", meta ? { sessionId, data, meta } : { sessionId, data }); return true; } function deliverOutputToRenderer(sessionId, data, meta) { if (terminalOutputChannel?.send?.(sessionId, data, meta)) return true; return sendOutputOverLegacyIpc(sessionId, data, meta); } function notifyOutputTaps(sessionId, data) { if (!sessionId || !data || outputTaps.size === 0) return; for (const tap of outputTaps) { try { tap(sessionId, data); } catch (err) { console.warn("[terminalWorkerManager] output tap failed", err); } } } function isLiveWebContentsId(webContentsId) { if (typeof webContentsId !== "number") return false; try { const contents = electronModule?.webContents?.fromId?.(webContentsId); return Boolean(contents && !contents.isDestroyed?.()); } catch { return false; } } async function openOutputSession(sessionId, webContentsId, isStillCurrent = null) { if (!sessionId || !webContentsId) return false; if (closedSessions.has(sessionId)) { clearBufferedOutput(sessionId); return false; } const contents = electronModule?.webContents?.fromId?.(webContentsId); if (!contents || contents.isDestroyed?.()) { return false; } const previousWebContentsId = sessionWebContentsIds.get(sessionId) ?? null; const openToken = Object.freeze({ webContentsId }); outputRoutePending.set(sessionId, openToken); const onDestroyed = () => { if (outputRoutePending.get(sessionId) === openToken) { outputRoutePending.delete(sessionId); } }; contents.once?.("destroyed", onDestroyed); try { await Promise.allSettled([...sessionOwnedListeners].map((listener) => ( Promise.resolve().then(() => listener(Object.freeze({ sessionId, webContentsId }))) ))); } finally { contents.removeListener?.("destroyed", onDestroyed); } if (closedSessions.has(sessionId)) return false; if (isStillCurrent && !isStillCurrent()) { if (outputRoutePending.get(sessionId) === openToken) { outputRoutePending.delete(sessionId); } return false; } if (outputRoutePending.get(sessionId) !== openToken) { return outputRoutePending.get(sessionId)?.webContentsId === webContentsId; } if (contents.isDestroyed?.()) { outputRoutePending.delete(sessionId); if (previousWebContentsId == null) { send("netcatty:close", { sessionId }, { webContentsId }); } return false; } let openedUrgentInputPort = false; let replacedOutputChannel = false; let transferringBufferedOutput = []; let workerIpcFailed = false; try { sessionWebContentsIds.set(sessionId, webContentsId); openedUrgentInputPort = openUrgentInputPort(webContentsId, contents); const outputPort = terminalOutputChannel?.openSession?.(sessionId, contents, { transferToWorker: true, }); replacedOutputChannel = Boolean(outputPort); if (outputPort && outputPort !== true && child?.postMessage) { const worker = child; const outputPortRequestId = randomUUID(); const sessionGeneration = sessionGenerations.get(sessionId) ?? 0; outputPortReady.delete(sessionId); outputPortPending.set(sessionId, outputPortRequestId); transferringBufferedOutput = takeBufferedOutput(sessionId); try { worker.postMessage({ kind: "output-port", sessionId, outputPortRequestId, sessionGeneration, bufferedOutput: transferringBufferedOutput, }, [outputPort]); } catch (error) { workerIpcFailed = true; retireWorkerAfterIpcFailure(worker, error); throw error; } transferringBufferedOutput = []; if (outputRoutePending.get(sessionId) === openToken) { outputRoutePending.delete(sessionId); } return true; } if (outputRoutePending.get(sessionId) === openToken) { outputRoutePending.delete(sessionId); } flushBufferedOutput(sessionId); return true; } catch { if (workerIpcFailed) return false; for (const chunk of transferringBufferedOutput) { bufferOutput(sessionId, chunk); } outputPortPending.delete(sessionId); outputPortReady.delete(sessionId); if (replacedOutputChannel) { terminalOutputChannel?.closeSession?.(sessionId); } if ( openedUrgentInputPort && previousWebContentsId !== webContentsId ) { closeUrgentInputPort(webContentsId); } if (isLiveWebContentsId(previousWebContentsId)) { sessionWebContentsIds.set(sessionId, previousWebContentsId); } else { sessionWebContentsIds.delete(sessionId); } if (outputRoutePending.get(sessionId) === openToken) { outputRoutePending.delete(sessionId); } if (isLiveWebContentsId(previousWebContentsId)) { flushOutputToWorker(sessionId); } else { clearBufferedOutput(sessionId); send("netcatty:close", { sessionId }, { webContentsId }); } return false; } } /** * Move a live session's renderer output route to another webContents * (AI silent-session observe popup). Same PTY; only the display target changes. */ /** sessionId -> home webContentsId while an attach/observe popup owns display */ const attachHomeWebContentsIds = new Map(); async function rebindOutputSession(sessionId, webContentsId) { if (!sessionId || !webContentsId) { return { success: false, error: "Missing sessionId or webContentsId" }; } if (closedSessions.has(sessionId) || !sessionWebContentsIds.has(sessionId)) { return { success: false, error: "Session not found" }; } const previousWebContentsId = sessionWebContentsIds.get(sessionId) ?? null; // Remember the first home target so popup destruction / session exit can // restore or dual-notify the original owner. let rememberedAttachHome = false; if ( previousWebContentsId != null && previousWebContentsId !== webContentsId && !attachHomeWebContentsIds.has(sessionId) ) { attachHomeWebContentsIds.set(sessionId, previousWebContentsId); rememberedAttachHome = true; } const ok = await openOutputSession(sessionId, webContentsId); if (!ok) { if (rememberedAttachHome) { attachHomeWebContentsIds.delete(sessionId); } return { success: false, error: "Failed to rebind session output" }; } return { success: true, previousWebContentsId, webContentsId, }; } function findFallbackHomeWebContentsId(preferredId) { if (isLiveWebContentsId(preferredId)) return preferredId; // Only fall back to registered main app windows — never settings/prewarm/ // tray/popup renderers, which cannot host the hidden silent Terminal. try { const wm = require("./windowManager.cjs"); const mains = typeof wm.getMainWindows === "function" ? wm.getMainWindows() : (typeof wm.getMainWindow === "function" ? [wm.getMainWindow()].filter(Boolean) : []); for (const win of mains) { const wc = win?.webContents; if (!wc || wc.isDestroyed?.()) continue; if (typeof wc.id === "number") return wc.id; } } catch { // ignore } return null; } async function restoreAttachHome(sessionId, preferredHomeWebContentsId = null) { if (!sessionId) return { success: false, restored: false }; const savedHomeId = attachHomeWebContentsIds.get(sessionId); if (savedHomeId == null) { return { success: true, restored: false }; } if (closedSessions.has(sessionId)) { attachHomeWebContentsIds.delete(sessionId); return { success: true, restored: false }; } const homeId = findFallbackHomeWebContentsId(preferredHomeWebContentsId ?? savedHomeId); if (homeId == null) { // Keep the attach-home mapping so a later tray re-open can still recover. return { success: false, restored: false, error: "Home renderer unavailable", }; } const ok = await openOutputSession(sessionId, homeId); if (ok) { attachHomeWebContentsIds.delete(sessionId); } return { success: ok, restored: ok, webContentsId: ok ? homeId : undefined, error: ok ? undefined : "Failed to restore attach home output", }; } function resolveDialogWebContentsId(webContentsId, sessionId) { // Dialog requests are interactive session work. During attach/rebind the // pending renderer already owns that interaction even though the committed // output mapping is intentionally not published until port transfer. if (typeof sessionId === "string") { const pending = outputRoutePending.get(sessionId)?.webContentsId; if (isLiveWebContentsId(pending)) return pending; const current = sessionWebContentsIds.get(sessionId); if (isLiveWebContentsId(current)) return current; } // Worker dialogs often still carry the original home id after rebind. // Prefer the current display route when this id is a remembered attach home. if (typeof webContentsId !== "number") return webContentsId; for (const [sessionId, homeId] of attachHomeWebContentsIds.entries()) { if (homeId !== webContentsId) continue; const current = sessionWebContentsIds.get(sessionId); if (isLiveWebContentsId(current)) return current; } return webContentsId; } function getAttachHomeWebContentsId(sessionId) { if (!sessionId) return null; return attachHomeWebContentsIds.get(sessionId) ?? null; } function clearAttachHome(sessionId) { if (sessionId) attachHomeWebContentsIds.delete(sessionId); } function deleteClosedSessionTombstone(sessionId) { closedSessionTombstoneTimes.delete(sessionId); closedSessions.delete(sessionId); closedSessionSequences.delete(sessionId); closedSessionGenerations.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 promotePendingStartBootEpoch(sessionId, bootEpoch) { const normalized = normalizeBootEpoch(bootEpoch) ?? pendingSessionStartBootEpochs.get(sessionId); if (!sessionId || normalized === undefined) return; sessionBootEpochs.set(sessionId, normalized); } function clearSessionBootEpochTracking(sessionId) { if (!sessionId) return; pendingSessionStartBootEpochs.delete(sessionId); sessionBootEpochs.delete(sessionId); } function resolveOwnerBootEpoch(sessionId) { if (!sessionId) return undefined; if (pendingSessionStartBootEpochs.has(sessionId)) { return pendingSessionStartBootEpochs.get(sessionId); } return sessionBootEpochs.get(sessionId); } /** * Epoch-scoped closes dispose one specific boot. When a newer remount already * owns the same sessionId, the stale close must be a no-op for lifecycle * bookkeeping — otherwise StrictMode abort → orphan close kills the remount * with "closed before its output route opened". */ function shouldSkipStaleEpochClose(sessionId, bootEpoch) { const closeEpoch = normalizeBootEpoch(bootEpoch); if (closeEpoch === undefined || !sessionId) return false; const ownerEpoch = resolveOwnerBootEpoch(sessionId); return ownerEpoch !== undefined && ownerEpoch > closeEpoch; } function canPruneClosedSessionTombstone(sessionId) { return !pendingSessionStartSequences.has(sessionId) && !sessionGenerations.has(sessionId) && !workerSessionIds.has(sessionId) && !sessionWebContentsIds.has(sessionId) && !outputRoutePending.has(sessionId); } function pruneClosedSessionTombstones() { const currentTime = now(); for (const [sessionId, closedAt] of closedSessionTombstoneTimes) { if (currentTime - closedAt < closedSessionTombstoneTtlMs) continue; if (!canPruneClosedSessionTombstone(sessionId)) continue; deleteClosedSessionTombstone(sessionId); } if (closedSessionTombstoneTimes.size <= maxClosedSessionTombstones) return; for (const sessionId of [...closedSessionTombstoneTimes.keys()]) { if (closedSessionTombstoneTimes.size <= maxClosedSessionTombstones) break; if (!canPruneClosedSessionTombstone(sessionId)) continue; deleteClosedSessionTombstone(sessionId); } } function touchClosedSessionTombstone(sessionId) { if (!sessionId) return; closedSessionTombstoneTimes.delete(sessionId); closedSessionTombstoneTimes.set(sessionId, now()); } function updateClosedSessionSequence(sessionId) { closedSessionSequences.set(sessionId, ++sessionLifecycleSequence); touchClosedSessionTombstone(sessionId); } function markSessionClosed(sessionId) { if (!sessionId || closedSessions.has(sessionId)) return; closedSessions.add(sessionId); updateClosedSessionSequence(sessionId); } function cancelPendingSessionStart(sessionId) { if (!pendingSessionStartSequences.delete(sessionId)) return; if (closedSessions.has(sessionId)) { updateClosedSessionSequence(sessionId); } } function finishPendingSessionStart(entry) { if (entry?.requestedSessionId && pendingSessionStartSequences.get(entry.requestedSessionId) === entry.requestSequence) { pendingSessionStartSequences.delete(entry.requestedSessionId); pendingSessionStartBootEpochs.delete(entry.requestedSessionId); } } function hasPendingSessionLifecycle(sessionId) { const pendingStartSequence = pendingSessionStartSequences.get(sessionId); const closedSequence = closedSessionSequences.get(sessionId); return pendingStartSequence != null && (closedSequence == null || pendingStartSequence > closedSequence); } function recordClosedSessionGeneration(sessionId, generation) { if (!sessionId || !Number.isSafeInteger(generation)) return; const previous = closedSessionGenerations.get(sessionId); if (previous == null || generation > previous) { closedSessionGenerations.set(sessionId, generation); touchClosedSessionTombstone(sessionId); } } function recordSupersedingSessionGeneration(message) { if (!message?.sessionId || !Number.isSafeInteger(message.sessionGeneration) || !message.replacementRequestId) return; const entry = pending.get(message.replacementRequestId); if (!entry?.requestedSessionId || entry.requestedSessionId !== message.sessionId) return; let generations = supersedingSessionGenerations.get(message.sessionId); if (!generations) { generations = new Map(); supersedingSessionGenerations.set(message.sessionId, generations); } generations.set(message.sessionGeneration, { requestId: message.replacementRequestId, requestSequence: entry.requestSequence, }); } function takeSupersedingSessionGeneration(sessionId, generation) { if (!sessionId || !Number.isSafeInteger(generation)) return null; const generations = supersedingSessionGenerations.get(sessionId); const marker = generations?.get(generation) ?? null; if (!marker) return null; generations.delete(generation); if (generations.size === 0) supersedingSessionGenerations.delete(sessionId); return marker; } function clearOlderSupersedingSessionGenerations(sessionId, generation) { if (!sessionId || !Number.isSafeInteger(generation)) return; const generations = supersedingSessionGenerations.get(sessionId); if (!generations) return; for (const oldGeneration of generations.keys()) { if (oldGeneration < generation) generations.delete(oldGeneration); } if (generations.size === 0) supersedingSessionGenerations.delete(sessionId); } function isOutputFromNewerGeneration(message) { if (!Number.isSafeInteger(message?.sessionGeneration)) return false; const closedGeneration = closedSessionGenerations.get(message.sessionId); return message.sessionGeneration > (closedGeneration ?? 0); } function isOutputFromCurrentPendingStart(message) { if (!Number.isSafeInteger(message?.sessionGeneration)) return false; if (!message?.originRequestId) return isOutputFromNewerGeneration(message); const entry = pending.get(message.originRequestId); const latestStartSequence = latestSessionStartSequences.get(message.sessionId); const isCurrentRequest = entry?.requestedSessionId === message.sessionId && latestStartSequence != null && entry.requestSequence === latestStartSequence; if (!isCurrentRequest) return false; const closedGeneration = closedSessionGenerations.get(message.sessionId); return closedGeneration == null || message.sessionGeneration > closedGeneration; } function closeOutputSession(sessionId) { if (!sessionId) return; markSessionClosed(sessionId); latestSessionStartSequences.delete(sessionId); clearSessionBootEpochTracking(sessionId); suppressedPendingOutputSessions.delete(sessionId); workerSessionIds.delete(sessionId); clearBufferedOutput(sessionId); outputRoutePending.delete(sessionId); outputPortPending.delete(sessionId); outputPortReady.delete(sessionId); sessionWebContentsIds.delete(sessionId); sessionHostIds.delete(sessionId); const activeGeneration = sessionGenerations.get(sessionId); recordClosedSessionGeneration(sessionId, activeGeneration); sessionGenerations.delete(sessionId); supersedingSessionGenerations.delete(sessionId); clearAttachHome(sessionId); clearTerminalSessionPerformanceState(sessionId); releaseAttachedSessionState(sessionId); pruneClosedSessionTombstones(); const worker = child; try { worker?.postMessage?.({ kind: "close-output-port", sessionId }); } catch (error) { retireWorkerAfterIpcFailure(worker, error); } terminalOutputChannel?.closeSession?.(sessionId); } function notifyExplicitSessionClose(sessionId) { if (!sessionId) return; const targets = new Set([ sessionWebContentsIds.get(sessionId), attachHomeWebContentsIds.get(sessionId), ]); for (const targetId of targets) { if (typeof targetId !== "number") continue; try { electronModule?.webContents?.fromId?.(targetId)?.send?.("netcatty:exit", { sessionId, exitCode: 0, reason: "closed", }); } catch { // A closing renderer may disappear between lookup and notification. } } } function notifySessionClosed(sessionId, reason, options = {}) { if (!sessionId) return; const explicit = options?.explicit === true; const event = explicit ? { sessionId, reason, explicit: true } : { sessionId, reason }; for (const listener of [...sessionClosedListeners]) { try { listener(Object.freeze(event)); } catch {} } } function notifyRendererSessionClosed(sessionId, payload) { // Disconnect / reconnect keep the tab and session id, so they must not // look like an explicit tab close to host_open ownership. if (payload?.retainOwnership === true) { notifySessionClosed(sessionId, "closed"); return; } notifySessionClosed(sessionId, "closed", { explicit: true }); } function drainOutputSession(sessionId, requestId) { if (!sessionId || !requestId || !outputPortReady.has(sessionId)) return false; const worker = ensureStarted(); try { worker.postMessage({ kind: "output-drain", sessionId, requestId }); return true; } catch (error) { retireWorkerAfterIpcFailure(worker, error); return false; } } function openUrgentInputPort(webContentsId, contents) { if (!webContentsId || urgentInputPorts.has(webContentsId)) return false; if (typeof MessageChannelMain !== "function" || !contents?.postMessage || !child?.postMessage) { return false; } const { port1, port2 } = new MessageChannelMain(); try { child.postMessage({ kind: "urgent-input-port", webContentsId, }, [port1]); contents.postMessage(TERMINAL_URGENT_INPUT_PORT_CHANNEL, {}, [port2]); const onDestroyed = () => closeUrgentInputPort(webContentsId); urgentInputPorts.set(webContentsId, { port1, port2, contents, onDestroyed }); contents.once?.("destroyed", onDestroyed); return true; } catch { try { port1?.close?.(); } catch {} try { port2?.close?.(); } catch {} try { child?.postMessage?.({ kind: "close-urgent-input-port", webContentsId }); } catch {} return false; } } function closeUrgentInputPort(webContentsId) { const entry = urgentInputPorts.get(webContentsId); if (!entry) return; urgentInputPorts.delete(webContentsId); entry.contents?.removeListener?.("destroyed", entry.onDestroyed); try { entry.port1?.close?.(); } catch {} try { entry.port2?.close?.(); } catch {} try { child?.postMessage?.({ kind: "close-urgent-input-port", webContentsId }); } catch {} } function closeAllUrgentInputPorts() { for (const webContentsId of Array.from(urgentInputPorts.keys())) { closeUrgentInputPort(webContentsId); } } function flushOutputToWorker(sessionId) { const chunks = takeBufferedOutput(sessionId); if (!chunks.length || closedSessions.has(sessionId)) return; const worker = child; try { worker?.postMessage?.({ kind: "output-flush", sessionId, chunks }); } catch (error) { for (const chunk of chunks) bufferOutput(sessionId, chunk); retireWorkerAfterIpcFailure(worker, error); } } function deliverReadyPortFallbackOutput(sessionId, data, meta) { outputPortReady.delete(sessionId); terminalOutputChannel?.closeSession?.(sessionId); if (!sendOutputOverLegacyIpc(sessionId, data, meta)) { bufferOutput(sessionId, data, meta); } } function unwrapMessageEvent(eventOrMessage) { if ( eventOrMessage && typeof eventOrMessage === "object" && !("kind" in eventOrMessage) && "data" in eventOrMessage ) { return eventOrMessage.data; } return eventOrMessage; } function handleMessage(eventOrMessage) { const message = unwrapMessageEvent(eventOrMessage); if (!message || typeof message !== "object") return; if (message.kind === "external-session-event") { const external = externalSessions.get(message.sessionId); if (!external) return; if (message.event === "input") { const runInput = async () => { if (externalSessions.get(message.sessionId) !== external) return; await external.onInput?.(message.data); }; const inputPromise = external.inputChain ? external.inputChain.then(runInput, runInput) : Promise.resolve(runInput()); const settledInput = inputPromise.catch(async (error) => { const notifyClose = external.onClose; await finishExternalSession(message.sessionId, { reason: "error", error: error?.message || String(error), }, external.ownerToken).finally(() => Promise.resolve(notifyClose?.("input-error")).catch(() => {})); }); external.inputChain = settledInput; void settledInput.finally(() => { if (external.inputChain === settledInput) external.inputChain = null; }); return; } if (message.event === "resize") { void Promise.resolve(external.onResize?.({ columns: message.columns, rows: message.rows, })).catch(() => {}); return; } if (message.event === "flow") { external.paused = message.paused === true; if (!external.paused) { for (const resolve of external.resumeWaiters.splice(0)) resolve(); } return; } if (message.event === "close") { externalSessions.delete(message.sessionId); for (const resolve of external.resumeWaiters.splice(0)) resolve(); void Promise.resolve(external.onClose?.(message.reason || "closed")).catch(() => {}); } return; } if (message.kind === "session-superseding") { recordSupersedingSessionGeneration(message); return; } if (message.kind === "worker-error") { recordWorkerErrorReport(message); return; } if (message.kind === "response") { const entry = pending.get(message.requestId); if (!entry) return; if (message.error) { if (entry.opensOutputSession && entry.requestedSessionId && latestSessionStartSequences.get(entry.requestedSessionId) === entry.requestSequence) { clearBufferedOutput(entry.requestedSessionId); suppressedPendingOutputSessions.add(entry.requestedSessionId); if (!sessionWebContentsIds.has(entry.requestedSessionId) && !outputRoutePending.has(entry.requestedSessionId)) { workerSessionIds.delete(entry.requestedSessionId); } } finishPendingSessionStart(entry); pending.delete(message.requestId); entry.reject(new Error(message.error)); } else { const sessionId = message.result?.sessionId; if (!entry.opensOutputSession || !sessionId) { finishPendingSessionStart(entry); pending.delete(message.requestId); entry.resolve(message.result); return; } const latestStartSequence = latestSessionStartSequences.get(sessionId); if (latestStartSequence != null && entry.requestSequence < latestStartSequence) { if (Number.isSafeInteger(message.sessionGeneration)) { recordClosedSessionGeneration(sessionId, message.sessionGeneration); } finishPendingSessionStart(entry); pending.delete(message.requestId); entry.reject(new Error("Terminal session was superseded by a newer start request")); return; } const closedSequence = closedSessionSequences.get(sessionId); if (closedSequence != null && closedSequence >= entry.requestSequence) { recordClosedSessionGeneration(sessionId, message.sessionGeneration); finishPendingSessionStart(entry); pending.delete(message.requestId); entry.reject(new Error("Terminal session closed before its output route opened")); return; } suppressedPendingOutputSessions.delete(sessionId); closedSessionGenerations.delete(sessionId); closedSessions.delete(sessionId); closedSessionSequences.delete(sessionId); closedSessionTombstoneTimes.delete(sessionId); workerSessionIds.add(sessionId); if (Number.isSafeInteger(message.sessionGeneration)) { sessionGenerations.set(sessionId, message.sessionGeneration); clearOlderSupersedingSessionGenerations(sessionId, message.sessionGeneration); } const isStillCurrent = () => { if (!entry.requestedSessionId) return true; const latestSequence = latestSessionStartSequences.get(sessionId); const currentClosedSequence = closedSessionSequences.get(sessionId); return latestSequence === entry.requestSequence && !(currentClosedSequence != null && currentClosedSequence >= entry.requestSequence); }; void openOutputSession(sessionId, entry.webContentsId, isStillCurrent).then((opened) => { if (pending.get(message.requestId) !== entry) return; finishPendingSessionStart(entry); pending.delete(message.requestId); if (!opened) { const currentClosedSequence = closedSessionSequences.get(sessionId); if (currentClosedSequence != null && currentClosedSequence >= entry.requestSequence) { entry.reject(new Error("Terminal session closed before its output route opened")); } else if (entry.requestedSessionId && latestSessionStartSequences.get(sessionId) !== entry.requestSequence) { recordClosedSessionGeneration(sessionId, message.sessionGeneration); if (sessionGenerations.get(sessionId) === message.sessionGeneration) { sessionGenerations.delete(sessionId); } entry.reject(new Error("Terminal session was superseded by a newer start request")); } else { entry.reject(new Error("Terminal session closed before its output route opened")); } return; } promotePendingStartBootEpoch(sessionId, entry.bootEpoch); entry.resolve(message.result); }, (error) => { if (pending.get(message.requestId) !== entry) return; finishPendingSessionStart(entry); pending.delete(message.requestId); entry.reject(error); }); } return; } if (message.kind === "output") { if (suppressedPendingOutputSessions.has(message.sessionId) && !isOutputFromCurrentPendingStart(message)) return; if (closedSessions.has(message.sessionId)) { if (!hasPendingSessionLifecycle(message.sessionId) || !isOutputFromCurrentPendingStart(message)) return; bufferOutput(message.sessionId, message.data, message.meta); return; } if (message.sessionId) workerSessionIds.add(message.sessionId); // Chunks sent through the runtime sender's port fallback were already // announced via a dedicated output-tap message (message.tapped); // notifying taps again here would double-write session logs and // script output buffers. Plain output messages (e.g. from the early // worker sender) still need the notification. if (!message.tapped) { notifyOutputTaps(message.sessionId, message.data); } if (outputRoutePending.has(message.sessionId) || outputPortPending.has(message.sessionId)) { bufferOutput(message.sessionId, message.data, message.meta); return; } if (outputPortReady.has(message.sessionId)) { deliverReadyPortFallbackOutput(message.sessionId, message.data, message.meta); return; } if (!deliverOutputToRenderer(message.sessionId, message.data, message.meta)) { bufferOutput(message.sessionId, message.data, message.meta); } return; } if (message.kind === "output-tap") { if (suppressedPendingOutputSessions.has(message.sessionId) && !isOutputFromCurrentPendingStart(message)) return; if (!closedSessions.has(message.sessionId) || (hasPendingSessionLifecycle(message.sessionId) && isOutputFromCurrentPendingStart(message))) { if (message.sessionId) workerSessionIds.add(message.sessionId); notifyOutputTaps(message.sessionId, message.data); } return; } if (message.kind === "output-port-ready") { if (!message.outputPortRequestId || outputPortPending.get(message.sessionId) !== message.outputPortRequestId || !Number.isSafeInteger(message.sessionGeneration) || (sessionGenerations.get(message.sessionId) ?? 0) !== message.sessionGeneration) return; outputPortPending.delete(message.sessionId); if (!closedSessions.has(message.sessionId)) { outputPortReady.add(message.sessionId); flushOutputToWorker(message.sessionId); } return; } if (message.kind === "terminal-interceptor-warning") { for (const listener of [...terminalInterceptorWarningListeners]) { try { listener(message.warning); } catch {} } return; } if (message.kind === "renderer-event") { for (const listener of [...workerRendererEventListeners]) { try { listener(message); } catch {} } // Transfer lifecycle from the utilityProcess cannot use BrowserWindow // inside the worker. Fan global-center events from the main process so // progress survives SFTP panel hide/unmount. fanoutGlobalTransferFromWorkerEvent(electronModule, message.channel, message.payload); // Transfer events have one renderer-facing route: global-transfer. Do not // also forward the worker's internal channel to the origin window. if (!shouldForwardWorkerRendererEvent(message.channel)) return; // Prefer the currently rebound display target. Worker-captured // webContentsId is from session start and goes stale after attach/rebind. const sessionId = message.payload?.sessionId; const originEntry = message.originRequestId ? pending.get(message.originRequestId) : null; const latestStartSequence = latestSessionStartSequences.get(sessionId); if (message.channel === "netcatty:exit" && Number.isSafeInteger(message.sessionGeneration) ) { const closedGeneration = closedSessionGenerations.get(sessionId); if (closedGeneration != null && message.sessionGeneration <= closedGeneration) return; if (sessionGenerations.has(sessionId) && sessionGenerations.get(sessionId) !== message.sessionGeneration) { return; } } const supersedingMarker = message.channel === "netcatty:exit" ? takeSupersedingSessionGeneration(sessionId, message.sessionGeneration) : null; const isSupersededActiveExit = Boolean( supersedingMarker && latestStartSequence != null && latestStartSequence >= supersedingMarker.requestSequence && hasPendingSessionLifecycle(sessionId), ); if (isSupersededActiveExit) { const displayWebContentsId = sessionWebContentsIds.get(sessionId) ?? message.webContentsId; const homeWebContentsId = attachHomeWebContentsIds.get(sessionId) ?? null; recordClosedSessionGeneration(sessionId, message.sessionGeneration); clearBufferedOutput(sessionId); outputRoutePending.delete(sessionId); outputPortPending.delete(sessionId); outputPortReady.delete(sessionId); workerSessionIds.delete(sessionId); sessionWebContentsIds.delete(sessionId); if (sessionGenerations.get(sessionId) === message.sessionGeneration) { sessionGenerations.delete(sessionId); } clearAttachHome(sessionId); terminalOutputChannel?.closeSession?.(sessionId); suppressedPendingOutputSessions.add(sessionId); // Always label superseded exits as such — payload reason may be // "closed"/"error" and must not be mistaken for an authoritative end. notifySessionClosed(sessionId, "superseded"); const targets = new Set([displayWebContentsId, homeWebContentsId]); if (onRendererEvent) { for (const webContentsId of targets) { if (webContentsId == null) continue; onRendererEvent({ ...message, webContentsId }); } } else { for (const webContentsId of targets) { if (webContentsId == null) continue; electronModule?.webContents?.fromId?.(webContentsId)?.send?.( message.channel, message.payload, ); } } return; } const isSupersededStartExit = message.channel === "netcatty:exit" && originEntry?.requestedSessionId === sessionId && latestStartSequence != null && originEntry.requestSequence < latestStartSequence; if (isSupersededStartExit) { recordClosedSessionGeneration(sessionId, message.sessionGeneration); clearBufferedOutput(sessionId); workerSessionIds.delete(sessionId); finishPendingSessionStart(originEntry); pending.delete(message.originRequestId); originEntry.reject(new Error("Terminal session was superseded by a newer start request")); notifySessionClosed(sessionId, "superseded"); return; } const displayWebContentsId = (typeof sessionId === "string" && outputRoutePending.get(sessionId)?.webContentsId) || (typeof sessionId === "string" && sessionWebContentsIds.get(sessionId)) || message.webContentsId; const homeWebContentsId = (typeof sessionId === "string" && attachHomeWebContentsIds.get(sessionId)) || null; const wasExplicitlyClosed = Boolean( message.channel === "netcatty:exit" && sessionId && closedSessions.has(sessionId) && !hasPendingSessionLifecycle(sessionId), ); if (message.channel === "netcatty:exit" && sessionId) { if (Number.isSafeInteger(message.sessionGeneration)) { recordClosedSessionGeneration(sessionId, message.sessionGeneration); } cancelPendingSessionStart(sessionId); closeOutputSession(sessionId); clearAttachHome(sessionId); if (!wasExplicitlyClosed) { notifySessionClosed(sessionId, message.payload?.reason); } } // Explicit close already notified both display and home before removing // their routes. Ignore the worker's later transport-level exit event. if (wasExplicitlyClosed) return; const targets = new Set(); if (displayWebContentsId != null) targets.add(displayWebContentsId); // Keep the original owner in the loop only for terminal lifecycle. Other // renderer events may be interactive and must have a single responder. if (message.channel === "netcatty:exit" && homeWebContentsId != null) { targets.add(homeWebContentsId); } if (targets.size === 0 && message.webContentsId != null) { targets.add(message.webContentsId); } if (onRendererEvent) { for (const webContentsId of targets) { onRendererEvent({ ...message, webContentsId, }); } return; } for (const webContentsId of targets) { const contents = electronModule?.webContents?.fromId?.(webContentsId); contents?.send?.(message.channel, message.payload); } return; } if (message.kind === "zmodem-upload-dialog") { void handleZmodemUploadDialogRequest(message); return; } if (message.kind === "zmodem-download-dialog") { void handleZmodemDownloadDialogRequest(message); } } async function handleZmodemUploadDialogRequest(message) { try { const webContentsId = resolveDialogWebContentsId(message.webContentsId, message.sessionId); const contents = electronModule?.webContents?.fromId?.(webContentsId); const win = contents && electronModule?.BrowserWindow?.fromWebContents ? electronModule.BrowserWindow.fromWebContents(contents) : null; const result = await electronModule?.dialog?.showOpenDialog?.(win || undefined, { properties: ["openFile", "multiSelections"], title: "Select files to upload (ZMODEM)", }); child?.postMessage?.({ kind: "zmodem-upload-dialog-result", requestId: message.requestId, result: result || { canceled: true, filePaths: [] }, }); } catch (err) { child?.postMessage?.({ kind: "zmodem-upload-dialog-result", requestId: message.requestId, error: err?.message || String(err), }); } } async function handleZmodemDownloadDialogRequest(message) { try { const webContentsId = resolveDialogWebContentsId(message.webContentsId, message.sessionId); const contents = electronModule?.webContents?.fromId?.(webContentsId); const win = contents && electronModule?.BrowserWindow?.fromWebContents ? electronModule.BrowserWindow.fromWebContents(contents) : null; const result = await electronModule?.dialog?.showOpenDialog?.(win || undefined, { properties: ["openDirectory", "createDirectory"], title: "Select download directory (ZMODEM)", }); child?.postMessage?.({ kind: "zmodem-download-dialog-result", requestId: message.requestId, result: result || { canceled: true, filePaths: [] }, }); } catch (err) { child?.postMessage?.({ kind: "zmodem-download-dialog-result", requestId: message.requestId, error: err?.message || String(err), }); } } function retireWorkerAfterIpcFailure(worker, error) { if (!worker || child !== worker) return; handleExit(1, error); try { worker.kill?.(); } catch {} } function recordWorkerErrorReport(message) { try { const crashLogBridge = require("./crashLogBridge.cjs"); const err = new Error(message?.message || "Terminal worker async error"); if (message?.stack) err.stack = message.stack; if (message?.code) err.code = message.code; if (message?.level) err.level = message.level; crashLogBridge.captureError("terminal-worker-async-error", err, { origin: message?.origin, classification: message?.reason, activeSessionCount: workerSessionIds.size, }); } catch { // Crash logging must never be able to escalate the worker failure. } } function handleExit(code, cause = null) { const error = cause instanceof Error ? cause : new Error(`Terminal worker exited${Number.isFinite(code) ? ` with code ${code}` : ""}`); const exitCode = Number.isFinite(code) ? code : 1; try { const crashLogBridge = require("./crashLogBridge.cjs"); crashLogBridge.captureError("terminal-worker-exit", error, { exitCode }); } catch { // Crash logging must never interfere with session teardown. } for (const listener of [...workerExitListeners]) { try { listener(error); } catch {} } const affectedSessionIds = new Set([ ...sessionWebContentsIds.keys(), ...outputRoutePending.keys(), ...workerSessionIds, ...pendingOutput.keys(), ...pendingSessionStartSequences.keys(), ]); const sessionNotifications = []; for (const sessionId of affectedSessionIds) { const hasNewPendingLifecycle = hasPendingSessionLifecycle(sessionId); if (closedSessions.has(sessionId) && !hasNewPendingLifecycle) continue; if (closedSessions.has(sessionId)) { updateClosedSessionSequence(sessionId); } else { markSessionClosed(sessionId); } const targets = new Set(); const webContentsId = sessionWebContentsIds.get(sessionId); if (webContentsId != null) targets.add(webContentsId); const pendingWebContentsId = outputRoutePending.get(sessionId)?.webContentsId; if (pendingWebContentsId != null) targets.add(pendingWebContentsId); const homeId = attachHomeWebContentsIds.get(sessionId); if (homeId != null) targets.add(homeId); sessionNotifications.push({ sessionId, targets }); } child = null; pendingOutput.clear(); pendingOutputBytes.clear(); outputPortPending.clear(); outputPortReady.clear(); outputRoutePending.clear(); pendingSessionStartSequences.clear(); latestSessionStartSequences.clear(); suppressedPendingOutputSessions.clear(); workerSessionIds.clear(); sessionWebContentsIds.clear(); sessionHostIds.clear(); sessionGenerations.clear(); closedSessionGenerations.clear(); supersedingSessionGenerations.clear(); attachHomeWebContentsIds.clear(); pruneClosedSessionTombstones(); closeAllUrgentInputPorts(); rejectAllPending(error); terminalOutputChannel?.closeAll?.(); const retiredExternalSessions = [...externalSessions.entries()]; externalSessions.clear(); for (const [, external] of retiredExternalSessions) { for (const resolve of external.resumeWaiters.splice(0)) resolve(); void Promise.resolve(external.onClose?.("worker-exit")).catch(() => {}); } for (const listener of [...terminalInterceptorWarningListeners]) { try { listener(Object.freeze({ code: "worker-exit", message: error.message })); } catch {} } for (const { sessionId, targets } of sessionNotifications) { for (const targetId of targets) { try { const contents = electronModule?.webContents?.fromId?.(targetId); contents?.send?.("netcatty:exit", { sessionId, exitCode, error: error.message, reason: "error", }); } catch { // Ignore renderer notification failures while unwinding a crashed worker. } } notifySessionClosed(sessionId, "worker-exit"); } } function ensureStarted() { if (child) return child; if (!utilityProcess?.fork) { throw new Error("Electron utilityProcess is unavailable"); } // The worker loads the SSH pool before it can receive IPC. Seed the latest // value through its environment so a first request (and every replacement // worker) can never briefly use the product default instead of the setting. const worker = utilityProcess.fork(workerScriptPath, [], { env: { ...process.env, [SSH_TRANSPORT_IDLE_TTL_ENV]: String(transportIdleTtlMs), }, }); child = worker; worker.on?.("message", (message) => { if (child === worker) handleMessage(message); }); worker.on?.("exit", (code) => { if (child === worker) handleExit(code); }); return worker; } function setDefaultTransportIdleTtlMs(value) { if (!Number.isFinite(value) || value < 0) return transportIdleTtlMs; const normalized = Number(value); if (normalized === transportIdleTtlMs) return transportIdleTtlMs; transportIdleTtlMs = normalized; const worker = child; if (!worker) return transportIdleTtlMs; try { worker.postMessage({ kind: "set-ssh-transport-idle-ttl", value: transportIdleTtlMs, }); } catch (error) { retireWorkerAfterIpcFailure(worker, error); } return transportIdleTtlMs; } function request(channel, payload, optionsForRequest = {}) { pruneClosedSessionTombstones(); if (channel === "netcatty:close:await" && payload?.sessionId && closedSessions.has(payload.sessionId) && !pendingSessionStartSequences.has(payload.sessionId)) { // Worker already tombstoned the session (clean exit / crash). A later // renderer close still has to notify host listeners as explicit so // auto-close can drop host_open ownership, unless this is a disconnect // that keeps the tab for reconnect. notifyRendererSessionClosed(payload.sessionId, payload); return Promise.resolve(undefined); } const requestId = randomUUID(); const worker = ensureStarted(); const requestSequence = ++sessionLifecycleSequence; const requestedSessionId = payload?.sessionId && SESSION_START_CHANNELS.has(channel) ? payload.sessionId : null; const promise = new Promise((resolve, reject) => { pending.set(requestId, { resolve, reject, requestSequence, requestedSessionId, bootEpoch: normalizeBootEpoch(payload?.bootEpoch), webContentsId: optionsForRequest.webContentsId, opensOutputSession: channel === "netcatty:start" || channel === "netcatty:local:reconnect" || channel === "netcatty:external:start" || /^(?:netcatty:)(?:local|telnet|mosh|et|serial):start$/u.test(channel), }); }); let notifyClosedAfterPost = false; if (channel === "netcatty:close:await" && payload?.sessionId) { if (shouldSkipStaleEpochClose(payload.sessionId, payload?.bootEpoch)) { pending.delete(requestId); return Promise.resolve({ skipped: true, reason: "boot-epoch-mismatch" }); } const closesPendingLifecycle = hasPendingSessionLifecycle(payload.sessionId); notifyClosedAfterPost = !closedSessions.has(payload.sessionId) || closesPendingLifecycle; cancelPendingSessionStart(payload.sessionId); notifyExplicitSessionClose(payload.sessionId); closeOutputSession(payload.sessionId); } else if (requestedSessionId) { if (pendingSessionStartSequences.has(requestedSessionId)) { clearBufferedOutput(requestedSessionId); suppressedPendingOutputSessions.add(requestedSessionId); } pendingSessionStartSequences.set(requestedSessionId, requestSequence); latestSessionStartSequences.set(requestedSessionId, requestSequence); rememberPendingStartBootEpoch(requestedSessionId, payload?.bootEpoch); // Track host id for SFTP transfer session leases (global transfer center). if (typeof payload?.hostId === "string" && payload.hostId) { sessionHostIds.set(requestedSessionId, payload.hostId); } } try { worker.postMessage({ kind: "request", requestId, channel, payload, webContentsId: optionsForRequest.webContentsId, }); } catch (error) { retireWorkerAfterIpcFailure(worker, error); const entry = pending.get(requestId); pending.delete(requestId); finishPendingSessionStart(entry); entry?.reject(error); } if (notifyClosedAfterPost) { notifyRendererSessionClosed(payload.sessionId, payload); } return promise; } async function startExternalSession(optionsForSession = {}) { const sessionId = optionsForSession.sessionId; const webContentsId = optionsForSession.webContentsId; if (typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 128) { throw new TypeError("External terminal session ID is invalid"); } if (!Number.isSafeInteger(webContentsId) || externalSessions.has(sessionId)) { throw new TypeError("External terminal session owner is invalid or already registered"); } const ownerToken = optionsForSession.ownerToken ?? Symbol(`external-session:${sessionId}`); const external = { ownerToken, onInput: optionsForSession.onInput, onResize: optionsForSession.onResize, onClose: optionsForSession.onClose, inputChain: null, paused: false, resumeWaiters: [], }; externalSessions.set(sessionId, external); try { return await request("netcatty:external:start", { sessionId, columns: optionsForSession.columns, rows: optionsForSession.rows, protocol: optionsForSession.protocol, hostLabel: optionsForSession.hostLabel, hostname: optionsForSession.hostname, sessionLog: optionsForSession.sessionLog, }, { webContentsId }); } catch (error) { if (externalSessions.get(sessionId) === external) externalSessions.delete(sessionId); throw error; } } async function pushExternalOutput(sessionId, data, meta, ownerToken) { const external = externalSessions.get(sessionId); if (!external) throw new Error("External terminal session is not registered"); if (ownerToken !== undefined && external.ownerToken !== ownerToken) return false; if (external.paused) { await new Promise((resolve) => external.resumeWaiters.push(resolve)); if (externalSessions.get(sessionId) !== external) return false; } await request("netcatty:external:output", { sessionId, data, ...(meta === undefined ? {} : { meta }), }); return true; } async function finishExternalSession(sessionId, details = {}, ownerToken) { const external = externalSessions.get(sessionId); if (!external) return false; if (ownerToken !== undefined && external.ownerToken !== ownerToken) return false; externalSessions.delete(sessionId); for (const resolve of external.resumeWaiters.splice(0)) resolve(); try { await request("netcatty:external:finish", { sessionId, reason: details.reason || "closed", ...(details.error ? { error: details.error } : {}), ...(Array.isArray(details.diagnostics) ? { diagnostics: details.diagnostics } : {}), }); } catch { closeOutputSession(sessionId); } return true; } function send(channel, payload, optionsForSend = {}) { pruneClosedSessionTombstones(); if (channel === "netcatty:close" && payload?.sessionId && shouldSkipStaleEpochClose(payload.sessionId, payload?.bootEpoch)) { return; } if (channel === "netcatty:close" && payload?.sessionId && closedSessions.has(payload.sessionId) && !pendingSessionStartSequences.has(payload.sessionId)) { closeOutputSession(payload.sessionId); notifyRendererSessionClosed(payload.sessionId, payload); return; } if (channel === "netcatty:close" && payload?.sessionId) { const closesPendingLifecycle = hasPendingSessionLifecycle(payload.sessionId); const shouldNotifyClosed = !closedSessions.has(payload.sessionId) || closesPendingLifecycle; cancelPendingSessionStart(payload.sessionId); notifyExplicitSessionClose(payload.sessionId); closeOutputSession(payload.sessionId); let postError = null; const worker = ensureStarted(); try { worker.postMessage({ kind: "send", channel, payload, webContentsId: optionsForSend.webContentsId, }); } catch (error) { retireWorkerAfterIpcFailure(worker, error); postError = error; } if (shouldNotifyClosed) { notifyRendererSessionClosed(payload.sessionId, payload); } if (postError) throw postError; return; } if (channel === "netcatty:interrupt") { const trace = normalizeTrace(payload); logTerminalInterruptDebug("main-to-worker-send", { channel, webContentsId: optionsForSend.webContentsId, hasChild: Boolean(child), }, trace); } const worker = ensureStarted(); try { worker.postMessage({ kind: "send", channel, payload, webContentsId: optionsForSend.webContentsId, }); } catch (error) { retireWorkerAfterIpcFailure(worker, error); throw error; } } function attachTerminalInterceptor(descriptor, port) { if (!port) throw new TypeError("Terminal interceptor port is required"); const worker = ensureStarted(); try { worker.postMessage({ kind: "terminal-interceptor-port", ...descriptor, }, [port]); } catch (error) { retireWorkerAfterIpcFailure(worker, error); throw error; } } function detachTerminalInterceptor(sessionId, direction) { if (!child) return; const worker = child; try { worker.postMessage({ kind: "terminal-interceptor-detach", sessionId, direction }); } catch (error) { retireWorkerAfterIpcFailure(worker, error); } } function onTerminalInterceptorWarning(listener) { if (typeof listener !== "function") throw new TypeError("Terminal interceptor warning listener is required"); terminalInterceptorWarningListeners.add(listener); return Object.freeze({ dispose: () => terminalInterceptorWarningListeners.delete(listener) }); } function onSessionOwned(listener) { if (typeof listener !== "function") throw new TypeError("Terminal session owner listener is required"); sessionOwnedListeners.add(listener); return Object.freeze({ dispose: () => sessionOwnedListeners.delete(listener) }); } function onSessionClosed(listener) { if (typeof listener !== "function") throw new TypeError("Terminal session close listener is required"); sessionClosedListeners.add(listener); return Object.freeze({ dispose: () => sessionClosedListeners.delete(listener) }); } function onWorkerExit(listener) { if (typeof listener !== "function") throw new TypeError("Worker exit listener is required"); workerExitListeners.add(listener); return Object.freeze({ dispose: () => workerExitListeners.delete(listener) }); } function onWorkerRendererEvent(listener) { if (typeof listener !== "function") throw new TypeError("Worker renderer event listener is required"); workerRendererEventListeners.add(listener); return Object.freeze({ dispose: () => workerRendererEventListeners.delete(listener) }); } function stop() { if (!child) return; const current = child; child = null; try { current.kill?.(); } finally { pendingOutput.clear(); pendingOutputBytes.clear(); closedSessions.clear(); closedSessionSequences.clear(); closedSessionTombstoneTimes.clear(); outputPortPending.clear(); outputPortReady.clear(); outputRoutePending.clear(); pendingSessionStartSequences.clear(); latestSessionStartSequences.clear(); pendingSessionStartBootEpochs.clear(); sessionBootEpochs.clear(); suppressedPendingOutputSessions.clear(); workerSessionIds.clear(); sessionWebContentsIds.clear(); sessionHostIds.clear(); sessionGenerations.clear(); closedSessionGenerations.clear(); supersedingSessionGenerations.clear(); closeAllUrgentInputPorts(); terminalInterceptorWarningListeners.clear(); sessionOwnedListeners.clear(); sessionClosedListeners.clear(); workerExitListeners.clear(); workerRendererEventListeners.clear(); for (const external of externalSessions.values()) { for (const resolve of external.resumeWaiters.splice(0)) resolve(); } externalSessions.clear(); terminalOutputChannel?.closeAll?.(); rejectAllPending(new Error("Terminal worker stopped")); } } return { ensureStarted, /** True when the utilityProcess child is currently alive. */ isRunning() { return Boolean(child); }, request, send, startExternalSession, pushExternalOutput, finishExternalSession, openOutputSession, rebindOutputSession, drainOutputSession, restoreAttachHome, getAttachHomeWebContentsId, clearAttachHome, attachTerminalInterceptor, detachTerminalInterceptor, onTerminalInterceptorWarning, onSessionOwned, onSessionClosed, onWorkerExit, onWorkerRendererEvent, hasOpenSession(sessionId) { return Boolean( sessionId && sessionWebContentsIds.has(sessionId) && !closedSessions.has(sessionId), ); }, getSessionWebContentsId(sessionId) { if (!sessionId) return null; return sessionWebContentsIds.get(sessionId) ?? null; }, getSessionHostId(sessionId) { if (!sessionId) return null; return sessionHostIds.get(sessionId) ?? null; }, getSessionOwnerWebContentsId(sessionId) { if (!sessionId || closedSessions.has(sessionId)) return null; const pendingWebContentsId = outputRoutePending.get(sessionId)?.webContentsId; if (isLiveWebContentsId(pendingWebContentsId)) return pendingWebContentsId; const currentWebContentsId = sessionWebContentsIds.get(sessionId); return isLiveWebContentsId(currentWebContentsId) ? currentWebContentsId : null; }, ownsSession(sessionId, webContentsId) { const pendingWebContentsId = outputRoutePending.get(sessionId)?.webContentsId; return Boolean( sessionId && Number.isSafeInteger(webContentsId) && ( (sessionWebContentsIds.get(sessionId) === webContentsId && isLiveWebContentsId(webContentsId)) || (pendingWebContentsId === webContentsId && isLiveWebContentsId(webContentsId)) ) && !closedSessions.has(sessionId), ); }, addOutputTap(listener) { if (typeof listener !== "function") return () => {}; outputTaps.add(listener); return () => outputTaps.delete(listener); }, setDefaultTransportIdleTtlMs, getDefaultTransportIdleTtlMs() { return transportIdleTtlMs; }, _getClosedSessionTombstoneCountsForTests() { pruneClosedSessionTombstones(); return { sessions: closedSessions.size, sequences: closedSessionSequences.size, generations: closedSessionGenerations.size, }; }, stop, }; } module.exports = { createTerminalWorkerManager, isTerminalWorkerEnabled, mapWorkerTransferChannelToGlobalEvent, fanoutGlobalTransferFromWorkerEvent, broadcastGlobalTransferEventOnMain, shouldForwardWorkerRendererEvent, registerTransportIdleTtlSettingsSync, };