From d09a4f8ae041536dc7d37a384971058248d7b995 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 28 Jun 2026 12:31:18 +0900 Subject: fix(ssh,host-bin): permanent pooled-client error listener + uncaughtException/unhandledRejection guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the live production crash (exit-1 'Timed out while waiting for handshake'): the pooled ssh2.Client had no permanent 'error' listener after connect, so a post-connect ssh2 error escaped as an uncaught EventEmitter 'error' with no process-level guard. See notes/crash-investigation-findings.md §1. - packages/ssh/src/pool.ts: attach a permanent 'error' listener to the pooled client in buildConnection that sets state=error, logs (alias, message, level), and does not throw; cleanup() no longer removes it. - packages/host-bin/src/main.ts: add process.on('uncaughtException') (graceful shutdown after logging) and process.on('unhandledRejection') (log + continue), both logging message/stack, memory snapshot, activeConversations count, and timestamp so the failure site is observable. --- packages/ssh/src/pool.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) (limited to 'packages/ssh/src/pool.ts') diff --git a/packages/ssh/src/pool.ts b/packages/ssh/src/pool.ts index 9acae0e..5d1eedd 100644 --- a/packages/ssh/src/pool.ts +++ b/packages/ssh/src/pool.ts @@ -112,6 +112,24 @@ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool { let sftp: import("ssh2").SFTPWrapper | null = null; let connectPromise: Promise | null = null; + // Permanent error listener — without it, a post-connect ssh2 'error' + // (re-key/keepalive timeout) escapes uncaught → process crash. cleanup() + // in doConnect only removes the connect-time onReady/onError; this persists. + client.on("error", (err: unknown) => { + const from = state.value; + state.value = "error"; + state.error = err instanceof Error ? err.message : String(err); + connectPromise = null; + deps.logger.error("ssh: pooled client error", { + err, + alias, + message: state.error, + level: (err as { level?: string } | null)?.level, + from, + to: "error", + }); + }); + const touch = (): void => { const e = entries.get(alias); if (e !== undefined) e.lastUsedAt = Date.now(); -- cgit v1.2.3