Files
NetMesh/electron/bridges/terminalInterruptDiagnostics.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

266 lines
8.4 KiB
JavaScript

"use strict";
const DEBUG_ENV_KEYS = [
"NETCATTY_CTRL_C_DEBUG",
"NETCATTY_TERMINAL_DEBUG",
];
function isEnvEnabled() {
return DEBUG_ENV_KEYS.some((key) => process.env[key] === "1");
}
function normalizeTrace(payload = {}) {
const trace = payload?.trace && typeof payload.trace === "object" ? payload.trace : null;
if (!trace && !isEnvEnabled()) return null;
const now = Date.now();
return {
debug: trace?.debug === true || isEnvEnabled(),
traceId: typeof trace?.traceId === "string" && trace.traceId
? trace.traceId
: `ctrlc-backend-${now.toString(36)}`,
source: typeof trace?.source === "string" ? trace.source : "backend",
sessionId: typeof trace?.sessionId === "string" ? trace.sessionId : payload?.sessionId,
rendererKeyAt: Number.isFinite(trace?.rendererKeyAt) ? trace.rendererKeyAt : undefined,
rendererSendAt: Number.isFinite(trace?.rendererSendAt) ? trace.rendererSendAt : undefined,
rendererStatus: typeof trace?.rendererStatus === "string" ? trace.rendererStatus : undefined,
rendererHasSelection: trace?.rendererHasSelection === true,
rendererPriority: trace?.rendererPriority && typeof trace.rendererPriority === "object"
? trace.rendererPriority
: undefined,
};
}
function isTraceEnabled(trace) {
return Boolean(trace?.debug || isEnvEnabled());
}
function getStreamState(stream) {
if (!stream) return null;
let isPaused;
try {
isPaused = typeof stream.isPaused === "function" ? stream.isPaused() : undefined;
} catch {
isPaused = undefined;
}
return {
destroyed: Boolean(stream.destroyed),
readableEnded: Boolean(stream.readableEnded),
readableFlowing: stream.readableFlowing,
isPaused,
writableEnded: Boolean(stream.writableEnded),
writableDestroyed: Boolean(stream.writableDestroyed),
writableNeedDrain: Boolean(stream.writableNeedDrain),
};
}
function getSessionSnapshot(session) {
if (!session) return { exists: false };
const flowState = session.flowState
? {
rendererPaused: Boolean(session.flowState.rendererPaused),
unackedBytes: Number(session.flowState.unackedBytes) || 0,
appliedPause: Boolean(session.flowState.appliedPause),
}
: null;
return {
exists: true,
type: session.type || null,
protocol: session.protocol || null,
hostname: session.hostname || null,
hasStream: Boolean(session.stream),
hasProc: Boolean(session.proc),
hasSocket: Boolean(session.socket),
hasSerialPort: Boolean(session.serialPort),
stream: getStreamState(session.stream),
flowState,
pendingAutomatedWriteTimers: Array.isArray(session.pendingAutomatedWriteTimers)
? session.pendingAutomatedWriteTimers.length
: 0,
hasDiscardPendingData: typeof session.discardPendingData === "function",
closed: Boolean(session.closed),
};
}
function safeJson(value) {
return JSON.stringify(value, (_key, nested) => {
if (typeof nested === "bigint") return nested.toString();
if (typeof nested === "function") return "[function]";
return nested;
});
}
function logTerminalInterruptDebug(event, details = {}, trace = null) {
if (!isTraceEnabled(trace)) return;
const now = Date.now();
const payload = {
event,
traceId: trace?.traceId,
sessionId: trace?.sessionId || details.sessionId,
at: now,
deltaFromRendererKeyMs: Number.isFinite(trace?.rendererKeyAt)
? now - trace.rendererKeyAt
: undefined,
deltaFromRendererSendMs: Number.isFinite(trace?.rendererSendAt)
? now - trace.rendererSendAt
: undefined,
...details,
};
try {
console.info(`[Netcatty Ctrl+C] ${safeJson(payload)}`);
} catch {
// Diagnostics must never affect terminal control flow.
}
}
function rememberInterruptTrace(session, trace) {
if (!session || !trace) return;
session.lastInterruptTrace = trace;
session.lastInterruptTraceAt = Date.now();
}
function getRecentInterruptTrace(session, maxAgeMs = 10000) {
if (!session?.lastInterruptTrace || !Number.isFinite(session.lastInterruptTraceAt)) return null;
return Date.now() - session.lastInterruptTraceAt <= maxAgeMs ? session.lastInterruptTrace : null;
}
function logTerminalOutputDropSample(session, details = {}) {
const trace = getRecentInterruptTrace(session);
if (!trace || !isTraceEnabled(trace)) return;
const now = Date.now();
const bytes = Number(details.bytes) || 0;
if (!session._interruptDropDiagnostics) {
session._interruptDropDiagnostics = {
count: 0,
bytes: 0,
lastLogAt: 0,
};
}
const state = session._interruptDropDiagnostics;
state.count += 1;
state.bytes += bytes;
const shouldLog = state.count === 1 || now - state.lastLogAt >= 500;
if (!shouldLog) return;
state.lastLogAt = now;
logTerminalInterruptDebug("ssh-output-drop-while-paused", {
...details,
dropCountSinceSessionStart: state.count,
dropBytesSinceSessionStart: state.bytes,
session: getSessionSnapshot(session),
}, trace);
}
function logTerminalInterruptDrainDropSample(session, details = {}) {
const trace = getRecentInterruptTrace(session);
if (!trace || !isTraceEnabled(trace)) return;
const now = Date.now();
const bytes = Number(details.droppedBytes ?? details.bytes) || 0;
if (!session._interruptDrainDiagnostics) {
session._interruptDrainDiagnostics = {
count: 0,
bytes: 0,
lastLogAt: 0,
};
}
const state = session._interruptDrainDiagnostics;
state.count += 1;
state.bytes += bytes;
const shouldLog = state.count === 1 || now - state.lastLogAt >= 500 || details.accepted === true;
if (!shouldLog) return;
state.lastLogAt = now;
logTerminalInterruptDebug("ssh-output-drain-after-interrupt", {
...details,
drainDropCountSinceInterrupt: state.count,
drainDropBytesSinceInterrupt: state.bytes,
session: getSessionSnapshot(session),
}, trace);
}
function logTerminalFlowAckSample(session, details = {}) {
const trace = getRecentInterruptTrace(session);
if (!trace || !isTraceEnabled(trace)) return;
const now = Date.now();
const bytes = Number(details.bytes) || 0;
if (!session._interruptAckDiagnostics) {
session._interruptAckDiagnostics = {
countSinceLastLog: 0,
bytesSinceLastLog: 0,
totalCount: 0,
totalBytes: 0,
lastLogAt: 0,
};
}
const state = session._interruptAckDiagnostics;
state.countSinceLastLog += 1;
state.bytesSinceLastLog += bytes;
state.totalCount += 1;
state.totalBytes += bytes;
const shouldLog = state.totalCount === 1 || now - state.lastLogAt >= 500;
if (!shouldLog) return;
const countSinceLastLog = state.countSinceLastLog;
const bytesSinceLastLog = state.bytesSinceLastLog;
state.countSinceLastLog = 0;
state.bytesSinceLastLog = 0;
state.lastLogAt = now;
logTerminalInterruptDebug("flow-ack-sample", {
...details,
ackCountSinceLastLog: countSinceLastLog,
ackBytesSinceLastLog: bytesSinceLastLog,
ackCountSinceInterrupt: state.totalCount,
ackBytesSinceInterrupt: state.totalBytes,
session: getSessionSnapshot(session),
}, trace);
}
function logTerminalFlowPauseSample(session, details = {}) {
const trace = getRecentInterruptTrace(session);
if (!trace || !isTraceEnabled(trace)) return;
const now = Date.now();
if (!session._interruptPauseDiagnostics) {
session._interruptPauseDiagnostics = {
countSinceLastLog: 0,
totalCount: 0,
lastLogAt: 0,
};
}
const state = session._interruptPauseDiagnostics;
state.countSinceLastLog += 1;
state.totalCount += 1;
const shouldLog = state.totalCount === 1 || now - state.lastLogAt >= 500;
if (!shouldLog) return;
const countSinceLastLog = state.countSinceLastLog;
state.countSinceLastLog = 0;
state.lastLogAt = now;
logTerminalInterruptDebug("flow-pause-sample", {
...details,
pauseChangeCountSinceLastLog: countSinceLastLog,
pauseChangeCountSinceInterrupt: state.totalCount,
session: getSessionSnapshot(session),
}, trace);
}
function resetTerminalFlowAckSample(session) {
if (!session) return;
session._interruptAckDiagnostics = null;
session._interruptDrainDiagnostics = null;
session._interruptPauseDiagnostics = null;
}
module.exports = {
getSessionSnapshot,
getRecentInterruptTrace,
logTerminalFlowAckSample,
logTerminalFlowPauseSample,
logTerminalInterruptDrainDropSample,
logTerminalInterruptDebug,
logTerminalOutputDropSample,
normalizeTrace,
rememberInterruptTrace,
resetTerminalFlowAckSample,
};