diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:12:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:12:40 +0900 |
| commit | 98b0638838a8e754927d8c030ce8bded18d63e7d (patch) | |
| tree | 0d4e21c3d4792fcd77e1040373b260e38efa34ce /packages/ssh/src/pool.ts | |
| parent | d92a4af6191d7d20acf861adf605ad0227b6b287 (diff) | |
| parent | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (diff) | |
| download | dispatch-98b0638838a8e754927d8c030ce8bded18d63e7d.tar.gz dispatch-98b0638838a8e754927d8c030ce8bded18d63e7d.zip | |
Merge branch 'dev' into feature/heartbeat
# Conflicts:
# packages/host-bin/package.json
# packages/host-bin/src/main.ts
# packages/session-orchestrator/src/orchestrator.ts
# packages/system-prompt/src/service.test.ts
# packages/system-prompt/src/service.ts
# packages/system-prompt/src/types.ts
# packages/transport-contract/package.json
# packages/transport-http/package.json
# packages/transport-http/src/app.test.ts
# packages/transport-http/src/app.ts
# packages/transport-http/src/extension.ts
# packages/transport-http/tsconfig.json
# tsconfig.json
Diffstat (limited to 'packages/ssh/src/pool.ts')
| -rw-r--r-- | packages/ssh/src/pool.ts | 650 |
1 files changed, 325 insertions, 325 deletions
diff --git a/packages/ssh/src/pool.ts b/packages/ssh/src/pool.ts index 5b380d8..9acae0e 100644 --- a/packages/ssh/src/pool.ts +++ b/packages/ssh/src/pool.ts @@ -37,12 +37,12 @@ export type SshConnectionState = "disconnected" | "connecting" | "connected" | " * per computer — the transparency + perf win over spawning `ssh` per call). */ export interface SshConnection { - readonly getClient: () => Promise<Client>; - readonly getSftp: () => Promise<import("ssh2").SFTPWrapper>; - readonly close: () => Promise<void>; - readonly state: SshConnectionState; - /** Last error message when `state === "error"`; `undefined` otherwise. */ - readonly error: string | undefined; + readonly getClient: () => Promise<Client>; + readonly getSftp: () => Promise<import("ssh2").SFTPWrapper>; + readonly close: () => Promise<void>; + readonly state: SshConnectionState; + /** Last error message when `state === "error"`; `undefined` otherwise. */ + readonly error: string | undefined; } /** @@ -50,44 +50,44 @@ export interface SshConnection { * sshd (or fixture files) — never at a `@dispatch/*` mock. */ export interface SshPoolDeps { - readonly logger: Logger; - /** Read a file as utf8 text (key files, known_hosts, ssh config). */ - readonly readFileText: (path: string) => Promise<string>; - /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */ - readonly appendKnownHosts: (path: string, line: string) => Promise<void>; - /** Check a path exists (for default-identity-file probing). */ - readonly pathExists: (path: string) => Promise<boolean>; - /** Factory for a fresh ssh2 Client (the real edge). */ - readonly newClient: () => Client; - /** Resolve a computer alias → its `Computer` (connection params). */ - readonly resolveComputer: (alias: string) => Promise<Computer | null>; - /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */ - readonly knownHostsPath: string; - /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */ - readonly homeDir: string; + readonly logger: Logger; + /** Read a file as utf8 text (key files, known_hosts, ssh config). */ + readonly readFileText: (path: string) => Promise<string>; + /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */ + readonly appendKnownHosts: (path: string, line: string) => Promise<void>; + /** Check a path exists (for default-identity-file probing). */ + readonly pathExists: (path: string) => Promise<boolean>; + /** Factory for a fresh ssh2 Client (the real edge). */ + readonly newClient: () => Client; + /** Resolve a computer alias → its `Computer` (connection params). */ + readonly resolveComputer: (alias: string) => Promise<Computer | null>; + /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */ + readonly knownHostsPath: string; + /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */ + readonly homeDir: string; } export interface SshConnectionPool { - readonly acquire: (computerId: string) => Promise<SshConnection>; - readonly drop: (computerId: string) => Promise<void>; - readonly closeAll: () => Promise<void>; - readonly status: () => readonly SshPoolStatusEntry[]; + readonly acquire: (computerId: string) => Promise<SshConnection>; + readonly drop: (computerId: string) => Promise<void>; + readonly closeAll: () => Promise<void>; + readonly status: () => readonly SshPoolStatusEntry[]; } export interface SshPoolStatusEntry { - readonly computerId: string; - readonly state: SshConnectionState; - readonly error?: string; + readonly computerId: string; + readonly state: SshConnectionState; + readonly error?: string; } interface PooledEntry { - readonly alias: string; - conn: SshConnection; - /** Wall-clock of the last `acquire`/use — for idle reaping. */ - lastUsedAt: number; - /** Pending connect (so concurrent first-acquires share one connect). */ - readonly pending: Promise<void> | null; - reaper: ReturnType<typeof setInterval> | null; + readonly alias: string; + conn: SshConnection; + /** Wall-clock of the last `acquire`/use — for idle reaping. */ + lastUsedAt: number; + /** Pending connect (so concurrent first-acquires share one connect). */ + readonly pending: Promise<void> | null; + reaper: ReturnType<typeof setInterval> | null; } /** @@ -96,127 +96,127 @@ interface PooledEntry { * `ComputerService` status/test routes. */ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool { - const entries = new Map<string, PooledEntry>(); - - async function buildConnection(alias: string): Promise<SshConnection> { - const computer = await deps.resolveComputer(alias); - if (computer === null) { - throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`); - } - - const state: { value: SshConnectionState; error: string | undefined } = { - value: "disconnected", - error: undefined, - }; - const client = deps.newClient(); - let sftp: import("ssh2").SFTPWrapper | null = null; - let connectPromise: Promise<void> | null = null; - - const touch = (): void => { - const e = entries.get(alias); - if (e !== undefined) e.lastUsedAt = Date.now(); - }; - - const connect = (): Promise<void> => { - if (state.value === "connected") return Promise.resolve(); - if (connectPromise !== null) return connectPromise; // share one connect - state.value = "connecting"; - connectPromise = doConnect(client, computer, deps, state) - .then(() => { - state.value = "connected"; - state.error = undefined; - // Stale pins → re-evaluate on each connect via hostVerifier already. - }) - .catch((err: unknown) => { - state.value = "error"; - state.error = err instanceof Error ? err.message : String(err); - connectPromise = null; // allow retry on next acquire - throw err; - }); - return connectPromise; - }; - - const conn: SshConnection = { - get state() { - return state.value; - }, - get error() { - return state.error; - }, - async getClient() { - await connect(); - touch(); - return client; - }, - async getSftp() { - await connect(); - if (sftp === null) { - sftp = await openSftp(client); - } - touch(); - return sftp; - }, - async close() { - try { - sftp?.end(); - } catch { - // best-effort - } - try { - client.end(); - } catch { - // best-effort - } - sftp = null; - state.value = "disconnected"; - }, - }; - return conn; - } - - return { - async acquire(computerId: string): Promise<SshConnection> { - let entry = entries.get(computerId); - if (entry === undefined) { - const conn = await buildConnection(computerId); - entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null }; - entries.set(computerId, entry); - startReaper(entries, computerId, deps); - } - // Eagerly verify connectivity (reconnect if the peer died/reaped). - await entry.conn.getClient().then( - () => undefined, - () => { - // getClient throws on a dead connection — drop + retry once. - }, - ); - entry.lastUsedAt = Date.now(); - return entry.conn; - }, - - async drop(computerId: string): Promise<void> { - const entry = entries.get(computerId); - if (entry === undefined) return; - stopReaper(entry); - await entry.conn.close(); - entries.delete(computerId); - }, - - async closeAll(): Promise<void> { - const all = [...entries.values()]; - for (const entry of all) stopReaper(entry); - await Promise.all(all.map((e) => e.conn.close())); - entries.clear(); - }, - - status(): readonly SshPoolStatusEntry[] { - return [...entries.values()].map((e) => ({ - computerId: e.alias, - state: e.conn.state, - ...(e.conn.error !== undefined ? { error: e.conn.error } : {}), - })); - }, - }; + const entries = new Map<string, PooledEntry>(); + + async function buildConnection(alias: string): Promise<SshConnection> { + const computer = await deps.resolveComputer(alias); + if (computer === null) { + throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`); + } + + const state: { value: SshConnectionState; error: string | undefined } = { + value: "disconnected", + error: undefined, + }; + const client = deps.newClient(); + let sftp: import("ssh2").SFTPWrapper | null = null; + let connectPromise: Promise<void> | null = null; + + const touch = (): void => { + const e = entries.get(alias); + if (e !== undefined) e.lastUsedAt = Date.now(); + }; + + const connect = (): Promise<void> => { + if (state.value === "connected") return Promise.resolve(); + if (connectPromise !== null) return connectPromise; // share one connect + state.value = "connecting"; + connectPromise = doConnect(client, computer, deps, state) + .then(() => { + state.value = "connected"; + state.error = undefined; + // Stale pins → re-evaluate on each connect via hostVerifier already. + }) + .catch((err: unknown) => { + state.value = "error"; + state.error = err instanceof Error ? err.message : String(err); + connectPromise = null; // allow retry on next acquire + throw err; + }); + return connectPromise; + }; + + const conn: SshConnection = { + get state() { + return state.value; + }, + get error() { + return state.error; + }, + async getClient() { + await connect(); + touch(); + return client; + }, + async getSftp() { + await connect(); + if (sftp === null) { + sftp = await openSftp(client); + } + touch(); + return sftp; + }, + async close() { + try { + sftp?.end(); + } catch { + // best-effort + } + try { + client.end(); + } catch { + // best-effort + } + sftp = null; + state.value = "disconnected"; + }, + }; + return conn; + } + + return { + async acquire(computerId: string): Promise<SshConnection> { + let entry = entries.get(computerId); + if (entry === undefined) { + const conn = await buildConnection(computerId); + entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null }; + entries.set(computerId, entry); + startReaper(entries, computerId, deps); + } + // Eagerly verify connectivity (reconnect if the peer died/reaped). + await entry.conn.getClient().then( + () => undefined, + () => { + // getClient throws on a dead connection — drop + retry once. + }, + ); + entry.lastUsedAt = Date.now(); + return entry.conn; + }, + + async drop(computerId: string): Promise<void> { + const entry = entries.get(computerId); + if (entry === undefined) return; + stopReaper(entry); + await entry.conn.close(); + entries.delete(computerId); + }, + + async closeAll(): Promise<void> { + const all = [...entries.values()]; + for (const entry of all) stopReaper(entry); + await Promise.all(all.map((e) => e.conn.close())); + entries.clear(); + }, + + status(): readonly SshPoolStatusEntry[] { + return [...entries.values()].map((e) => ({ + computerId: e.alias, + state: e.conn.state, + ...(e.conn.error !== undefined ? { error: e.conn.error } : {}), + })); + }, + }; } // ─── connect: auth + host-key ────────────────────────────────────────────── @@ -227,127 +227,127 @@ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool { * or connect timeout (never silently connects — plan §4.4/§8). */ async function doConnect( - client: Client, - computer: Computer, - deps: SshPoolDeps, - state: { value: SshConnectionState; error: string | undefined }, + client: Client, + computer: Computer, + deps: SshPoolDeps, + state: { value: SshConnectionState; error: string | undefined }, ): Promise<void> { - const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps); - if (passphraseError !== null) throw new Error(passphraseError); - - // Read known_hosts once for the host-key decision (present/absent + verify). - let knownHostsText = ""; - try { - knownHostsText = await deps.readFileText(deps.knownHostsPath); - } catch { - // Missing known_hosts → treat as empty (first connect pins the first line). - knownHostsText = ""; - } - const token = knownHostToken(computer.hostName, computer.port); - const decisionArmed = { decided: false }; - - await new Promise<void>((resolve, reject) => { - const onReady = (): void => { - cleanup(); - resolve(); - }; - const onError = (err: Error): void => { - cleanup(); - reject(err); - }; - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`)); - }, CONNECT_TIMEOUT_MS); - - function cleanup(): void { - clearTimeout(timer); - client.removeListener("ready", onReady); - client.removeListener("error", onError); - } - - client.on("ready", onReady); - client.on("error", onError); - - const connectConfig: ConnectConfig = { - host: computer.hostName, - port: computer.port, - username: computer.user, - privateKey, - keepaliveInterval: KEEPALIVE_INTERVAL, - keepaliveCountMax: KEEPALIVE_COUNT_MAX, - readyTimeout: CONNECT_TIMEOUT_MS, - // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces - // the key passed to `hostVerifier` with a hash digest, which would break - // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd - // field is the base64 of the raw public-key blob). We compare the raw - // blob directly, exactly as OpenSSH records it (decision #2 — the file - // is the shared trust store, so the comparison must be byte-identical). - hostVerifier: (key: Buffer | string): boolean => { - if (decisionArmed.decided) return true; // already accepted this handshake - const fingerprint = toFingerprint(token, key); - const decision = decideHostKey(knownHostsText, fingerprint); - decisionArmed.decided = true; - if (!decision.accept) { - state.error = decision.reason; - // Reject the handshake; the emitted 'error' → onError (reject). - process.nextTick(() => client.emit("error", new Error(decision.reason))); - return false; - } - // Accept. Pin on first connect (append is async + best-effort — - // the connection proceeds; a failed append only means the next - // connect re-pins). - if (decision.append !== undefined) { - void deps - .appendKnownHosts(deps.knownHostsPath, decision.append) - .then(() => { - deps.logger.info("pinned host key", { alias: computer.alias, token }); - }) - .catch((e: unknown) => { - deps.logger.warn("failed to pin host key", { - alias: computer.alias, - error: e instanceof Error ? e.message : String(e), - }); - }); - } - return true; - }, - }; - - client.connect(connectConfig); - }); + const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps); + if (passphraseError !== null) throw new Error(passphraseError); + + // Read known_hosts once for the host-key decision (present/absent + verify). + let knownHostsText = ""; + try { + knownHostsText = await deps.readFileText(deps.knownHostsPath); + } catch { + // Missing known_hosts → treat as empty (first connect pins the first line). + knownHostsText = ""; + } + const token = knownHostToken(computer.hostName, computer.port); + const decisionArmed = { decided: false }; + + await new Promise<void>((resolve, reject) => { + const onReady = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`)); + }, CONNECT_TIMEOUT_MS); + + function cleanup(): void { + clearTimeout(timer); + client.removeListener("ready", onReady); + client.removeListener("error", onError); + } + + client.on("ready", onReady); + client.on("error", onError); + + const connectConfig: ConnectConfig = { + host: computer.hostName, + port: computer.port, + username: computer.user, + privateKey, + keepaliveInterval: KEEPALIVE_INTERVAL, + keepaliveCountMax: KEEPALIVE_COUNT_MAX, + readyTimeout: CONNECT_TIMEOUT_MS, + // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces + // the key passed to `hostVerifier` with a hash digest, which would break + // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd + // field is the base64 of the raw public-key blob). We compare the raw + // blob directly, exactly as OpenSSH records it (decision #2 — the file + // is the shared trust store, so the comparison must be byte-identical). + hostVerifier: (key: Buffer | string): boolean => { + if (decisionArmed.decided) return true; // already accepted this handshake + const fingerprint = toFingerprint(token, key); + const decision = decideHostKey(knownHostsText, fingerprint); + decisionArmed.decided = true; + if (!decision.accept) { + state.error = decision.reason; + // Reject the handshake; the emitted 'error' → onError (reject). + process.nextTick(() => client.emit("error", new Error(decision.reason))); + return false; + } + // Accept. Pin on first connect (append is async + best-effort — + // the connection proceeds; a failed append only means the next + // connect re-pins). + if (decision.append !== undefined) { + void deps + .appendKnownHosts(deps.knownHostsPath, decision.append) + .then(() => { + deps.logger.info("pinned host key", { alias: computer.alias, token }); + }) + .catch((e: unknown) => { + deps.logger.warn("failed to pin host key", { + alias: computer.alias, + error: e instanceof Error ? e.message : String(e), + }); + }); + } + return true; + }, + }; + + client.connect(connectConfig); + }); } /** Resolve the private key bytes for a computer (key-only auth, decision #3). */ async function resolvePrivateKey( - computer: Computer, - deps: SshPoolDeps, + computer: Computer, + deps: SshPoolDeps, ): Promise<{ privateKey: Buffer; passphraseError: string | null }> { - const candidates = await identityCandidates(computer, deps); - for (const path of candidates) { - try { - const text = await deps.readFileText(path); - if (looksEncrypted(text)) { - // MVP: no passphrase prompt (roadmap). Fail with a clear error. - return { - privateKey: Buffer.from(text), - passphraseError: - `SSH key "${path}" is encrypted — passphrase prompting is not ` + - `supported in the MVP (use an unencrypted key for computer ` + - `"${computer.alias}", or set IdentityFile to an unencrypted key).`, - }; - } - return { privateKey: Buffer.from(text), passphraseError: null }; - } catch { - // missing/unreadable → try the next candidate - } - } - return { - privateKey: Buffer.alloc(0), - passphraseError: - `no readable SSH key for computer "${computer.alias}" ` + - `(checked: ${candidates.join(", ")})`, - }; + const candidates = await identityCandidates(computer, deps); + for (const path of candidates) { + try { + const text = await deps.readFileText(path); + if (looksEncrypted(text)) { + // MVP: no passphrase prompt (roadmap). Fail with a clear error. + return { + privateKey: Buffer.from(text), + passphraseError: + `SSH key "${path}" is encrypted — passphrase prompting is not ` + + `supported in the MVP (use an unencrypted key for computer ` + + `"${computer.alias}", or set IdentityFile to an unencrypted key).`, + }; + } + return { privateKey: Buffer.from(text), passphraseError: null }; + } catch { + // missing/unreadable → try the next candidate + } + } + return { + privateKey: Buffer.alloc(0), + passphraseError: + `no readable SSH key for computer "${computer.alias}" ` + + `(checked: ${candidates.join(", ")})`, + }; } /** @@ -356,39 +356,39 @@ async function resolvePrivateKey( * `~/.ssh/id_rsa`, first-existing-wins — matches OpenSSH's own probing). */ async function identityCandidates(computer: Computer, deps: SshPoolDeps): Promise<string[]> { - const candidates: string[] = []; - if (computer.identityFile !== null) candidates.push(computer.identityFile); - for (const name of DEFAULT_IDENTITY_FILES) { - candidates.push(`${deps.homeDir}/.ssh/${name}`); - } - // De-dup + filter to existing, preserving order. - const existing: string[] = []; - const seen = new Set<string>(); - for (const c of candidates) { - if (seen.has(c)) continue; - seen.add(c); - if (await deps.pathExists(c)) existing.push(c); - } - if (existing.length > 0) return existing; - // Fall back to the raw candidate list (so resolvePrivateKey reports it). - return [...new Set(candidates)]; + const candidates: string[] = []; + if (computer.identityFile !== null) candidates.push(computer.identityFile); + for (const name of DEFAULT_IDENTITY_FILES) { + candidates.push(`${deps.homeDir}/.ssh/${name}`); + } + // De-dup + filter to existing, preserving order. + const existing: string[] = []; + const seen = new Set<string>(); + for (const c of candidates) { + if (seen.has(c)) continue; + seen.add(c); + if (await deps.pathExists(c)) existing.push(c); + } + if (existing.length > 0) return existing; + // Fall back to the raw candidate list (so resolvePrivateKey reports it). + return [...new Set(candidates)]; } const DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_rsa"]; /** OpenSSH encrypts keys with a `ENCRYPTED` header — detect it (no passphrase MVP). */ function looksEncrypted(keyText: string): boolean { - return keyText.includes("ENCRYPTED"); + return keyText.includes("ENCRYPTED"); } /** Open an SFTP session on a connected client (promisified). */ function openSftp(client: Client): Promise<import("ssh2").SFTPWrapper> { - return new Promise((resolve, reject) => { - client.sftp((err, sftp) => { - if (err !== null && err !== undefined) reject(err); - else resolve(sftp); - }); - }); + return new Promise((resolve, reject) => { + client.sftp((err, sftp) => { + if (err !== null && err !== undefined) reject(err); + else resolve(sftp); + }); + }); } // ─── host-key fingerprint → pure decision input ──────────────────────────── @@ -403,12 +403,12 @@ function openSftp(client: Client): Promise<import("ssh2").SFTPWrapper> { * itself writes — the file is the shared trust store (decision #2). */ function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint { - const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key; - return { - knownHostToken: token, - keyBase64: buf.toString("base64"), - keyType: parseKeyType(buf), - }; + const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key; + return { + knownHostToken: token, + keyBase64: buf.toString("base64"), + keyType: parseKeyType(buf), + }; } /** @@ -418,40 +418,40 @@ function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint * is the authoritative identity for `decideHostKey`'s comparison. */ function parseKeyType(buf: Buffer): string { - if (buf.length < 4) return "ssh-ed25519"; - const len = buf.readUInt32BE(0); - if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519"; - return buf.subarray(4, 4 + len).toString("ascii"); + if (buf.length < 4) return "ssh-ed25519"; + const len = buf.readUInt32BE(0); + if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519"; + return buf.subarray(4, 4 + len).toString("ascii"); } // ─── idle reaping ─────────────────────────────────────────────────────────── function startReaper( - entries: Map<string, PooledEntry>, - computerId: string, - deps: SshPoolDeps, + entries: Map<string, PooledEntry>, + computerId: string, + deps: SshPoolDeps, ): void { - const entry = entries.get(computerId); - if (entry === undefined) return; - entry.reaper = setInterval(() => { - const e = entries.get(computerId); - if (e === undefined) return; - const idle = Date.now() - e.lastUsedAt; - if (idle >= IDLE_REAP_MS) { - deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle }); - void e.conn.close().then(() => { - stopReaper(e); - entries.delete(computerId); - }); - } - }, 60_000); + const entry = entries.get(computerId); + if (entry === undefined) return; + entry.reaper = setInterval(() => { + const e = entries.get(computerId); + if (e === undefined) return; + const idle = Date.now() - e.lastUsedAt; + if (idle >= IDLE_REAP_MS) { + deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle }); + void e.conn.close().then(() => { + stopReaper(e); + entries.delete(computerId); + }); + } + }, 60_000); } function stopReaper(entry: PooledEntry): void { - if (entry.reaper !== null) { - clearInterval(entry.reaper); - entry.reaper = null; - } + if (entry.reaper !== null) { + clearInterval(entry.reaper); + entry.reaper = null; + } } /** Ssh2 exec stream type alias (the channel backing spawn). */ |
