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
266 lines
8.4 KiB
JavaScript
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,
|
|
};
|