fix(ssh): stop false "Remote connection dropped" after sleep/App Nap pauses (#7828)

* fix(ssh): guard mux dead-link detector against sleep/App Nap timer pauses

After system sleep or App Nap timer throttling, the first post-wake
timeout-check tick saw pre-pause keepalives as >20s stale and killed a
healthy link (false 'Connection timed out (no ack received)' ->
dispose('connection_lost') -> reconnect overlay churn). Track the last
tick time; when a tick gap far exceeds the interval, reset staleness
tracking, probe with a fresh keepalive, and let the next full window
make an honest liveness determination. A genuinely dead link is still
detected within ~25s after wake.

Also adds probeLiveness(timeoutMs): a keepalive round-trip primitive
that resolves true on the first frame of any kind, used by the resume
path to distinguish surviving links from dead ones.

Refs #7773

Co-authored-by: Orca <help@stably.ai>

* fix(ssh): probe relay liveness on system resume instead of unconditional reconnect

powerMonitor 'resume' previously called connectionManager.reconnect()
for every active target, guaranteeing a teardown + reconnect overlay on
every wake even when the connection survived sleep. Now each session's
relay link is probed (keepalive round-trip, 5s timeout, one retry for
slow post-wake network); only targets whose probe fails are reconnected.
Dead-after-sleep connections still reconnect promptly. The 'suspend'
grace-time handling is unchanged.

Refs #7773

Co-authored-by: Orca <help@stably.ai>

* feat(relay): prefix relay.log diagnostic lines with ISO timestamps

The remote relay.log had no timestamps, which blocked correlating
reconnect flaps with user activity and sleep/wake windows while
diagnosing #7773. Daemon-mode diagnostic lines now carry an ISO
timestamp prefix ('<ISO> [relay] ...', grep-stable). Connect-mode and
orca-cli passthrough stderr is untouched since it goes back to the
app/user terminal and is parsed (handshake-mismatch detection).

The relay bundle is content-hashed at build time, so the versioned
install picks up the new relay automatically on next deploy.

Refs #7773

Co-authored-by: Orca <help@stably.ai>

* fix(ssh): re-check session identity before post-probe resume reconnect

The resume probe can take ~10s; if the user disconnected the target or the
session/connection was replaced during that window, reconnecting would
resurrect an intentionally torn-down connection (CodeRabbit).

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-08 13:06:39 -07:00 committed by GitHub
parent 6c988356e2
commit ace196b08e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 310 additions and 46 deletions

View File

@ -50,7 +50,8 @@ const {
onRequest: vi.fn().mockReturnValue(() => {}),
onDispose: vi.fn().mockReturnValue(() => {}),
request: vi.fn().mockResolvedValue({}),
notify: vi.fn()
notify: vi.fn(),
probeLiveness: vi.fn().mockResolvedValue(false)
},
mockPtyProvider: {
onData: vi.fn(),
@ -316,6 +317,7 @@ describe('SSH IPC handlers', () => {
mockMux.isDisposed.mockReset().mockReturnValue(false)
mockMux.onNotification.mockReset()
mockMux.onDispose.mockReset().mockReturnValue(() => {})
mockMux.probeLiveness.mockReset().mockResolvedValue(false)
mockPtyProvider.onData.mockReset()
mockPtyProvider.onExit.mockReset()
mockPtyProvider.onReplay.mockReset()
@ -1401,7 +1403,7 @@ describe('SSH IPC handlers', () => {
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
})
it('forces active SSH sessions to reconnect when the system resumes from sleep', async () => {
it('reconnects on system resume when the relay liveness probe fails', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
@ -1419,6 +1421,7 @@ describe('SSH IPC handlers', () => {
error: null,
reconnectAttempt: 0
})
mockMux.probeLiveness.mockResolvedValue(false)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
@ -1427,7 +1430,77 @@ describe('SSH IPC handlers', () => {
resumeListener()
expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')
await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1'))
// Why: a failed first probe gets one retry before teardown (slow post-wake network).
expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)
})
it('skips reconnect on system resume when the relay link is still alive', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
const conn = {}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue(conn)
mockConnectionManager.getConnection.mockReturnValue(conn)
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
mockMux.probeLiveness.mockResolvedValue(true)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1]
expect(resumeListener).toBeTypeOf('function')
resumeListener()
await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1))
// Let the async resume handler settle before asserting no teardown happened.
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockConnectionManager.reconnect).not.toHaveBeenCalled()
})
it('does not reconnect after resume when the target was disconnected during the probe', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
const conn = {}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue(conn)
mockConnectionManager.getConnection.mockReturnValue(conn)
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
mockMux.probeLiveness.mockResolvedValue(false)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1]
expect(resumeListener).toBeTypeOf('function')
resumeListener()
// Why: the probe window is seconds long; a user disconnect during it must
// win — reconnecting afterwards would resurrect the torn-down target.
mockConnectionManager.getConnection.mockReturnValue(undefined)
await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockConnectionManager.reconnect).not.toHaveBeenCalled()
})
it('extends active relay grace while the system is suspending', async () => {

View File

@ -442,6 +442,24 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null)
})
}
// Why: macOS can resume the process before the network stack is back up, so
// a failed first probe gets one retry before the link is declared dead (#7773).
const RESUME_PROBE_TIMEOUT_MS = 5_000
const RESUME_PROBE_ATTEMPTS = 2
async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise<boolean> {
const mux = session.getMux()
if (!mux || mux.isDisposed()) {
return false
}
for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) {
if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) {
return true
}
}
return false
}
function registerPowerMonitorReconnect(): void {
powerMonitorUnsubscribe?.()
const onSuspend = (): void => {
@ -450,19 +468,35 @@ function registerPowerMonitorReconnect(): void {
}
}
const onResume = (): void => {
for (const targetId of activeSessions.keys()) {
const conn = connectionManager?.getConnection(targetId)
for (const [targetId, session] of activeSessions) {
const manager = connectionManager
const conn = manager?.getConnection(targetId)
if (!conn) {
continue
}
const reconnect = connectionManager?.reconnect(targetId)
void reconnect?.catch((err) => {
console.warn(
`[ssh] Failed to reconnect ${targetId} after system resume: ${
err instanceof Error ? err.message : String(err)
}`
)
})
void (async () => {
// Why: unconditional reconnect on every wake tore down live sessions
// and flashed the reconnect overlay (#7773). Only reconnect targets
// whose relay link actually died during sleep.
if (await isRelayLinkAliveAfterResume(session)) {
return
}
// Why: the probe can take ~10s. If the user disconnected or the
// session/connection was replaced meanwhile, reconnecting would
// resurrect a connection that was intentionally torn down.
if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) {
return
}
try {
await manager?.reconnect(targetId)
} catch (err) {
console.warn(
`[ssh] Failed to reconnect ${targetId} after system resume: ${
err instanceof Error ? err.message : String(err)
}`
)
}
})()
}
}
powerMonitor.on('suspend', onSuspend)

View File

@ -307,6 +307,81 @@ describe('SshChannelMultiplexer', () => {
})
})
describe('wake guard (timer pause across system sleep, #7773)', () => {
it('does not kill a healthy link on the first tick after a long timer pause', () => {
// Reach steady state with pending unacked keepalives (<5s old at pause).
vi.advanceTimersByTime(5_000)
expect(mux.isDisposed()).toBe(false)
// Simulate sleep/App Nap: wall clock jumps far ahead with no ticks.
// Without the guard, the first post-wake tick sees lastReceivedAt and
// the pre-pause keepalive both >20s stale and disposes the mux.
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
const writesBefore = transport.written.length
vi.advanceTimersByTime(5_000)
expect(mux.isDisposed()).toBe(false)
// The guard probes immediately with a fresh keepalive.
expect(transport.written.length).toBeGreaterThan(writesBefore)
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
})
it('keeps the link alive after wake when frames resume', () => {
vi.advanceTimersByTime(5_000)
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
vi.advanceTimersByTime(5_000) // guard tick
// The relay answers the post-wake probe; the link must stay up.
let seq = 1
for (let i = 0; i < 8; i++) {
vi.advanceTimersByTime(5_000)
transport.dataCallbacks[0](encodeKeepAliveFrame(seq++, 0))
}
expect(mux.isDisposed()).toBe(false)
})
it('still detects a genuinely dead link within the next window after wake', () => {
vi.advanceTimersByTime(5_000)
vi.setSystemTime(Date.now() + 60 * 60 * 1000)
vi.advanceTimersByTime(5_000) // guard tick: reset + probe, no kill
expect(mux.isDisposed()).toBe(false)
// No frames arrive after the guard reset; the honest window expires.
vi.advanceTimersByTime(25_000)
expect(mux.isDisposed()).toBe(true)
})
})
describe('probeLiveness', () => {
it('sends a keepalive and resolves true when any frame arrives', async () => {
const writesBefore = transport.written.length
const probe = mux.probeLiveness(5_000)
expect(transport.written.length).toBe(writesBefore + 1)
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
transport.dataCallbacks[0](encodeKeepAliveFrame(1, 0))
await expect(probe).resolves.toBe(true)
})
it('resolves false when no frame arrives before the timeout', async () => {
const probe = mux.probeLiveness(5_000)
vi.advanceTimersByTime(5_000)
await expect(probe).resolves.toBe(false)
})
it('resolves false when the mux is disposed while probing', async () => {
const probe = mux.probeLiveness(5_000)
mux.dispose()
await expect(probe).resolves.toBe(false)
})
it('resolves false immediately on a disposed mux', async () => {
mux.dispose()
await expect(mux.probeLiveness(5_000)).resolves.toBe(false)
})
})
describe('dispose', () => {
it('rejects all pending requests on dispose', async () => {
const promise = mux.request('pty.spawn')

View File

@ -34,6 +34,9 @@ export type MethodNotificationHandler = (params: Record<string, unknown>) => voi
export type RequestHandler = (params: Record<string, unknown>) => Promise<unknown> | unknown
const REQUEST_TIMEOUT_MS = 30_000
// Why: a tick gap far beyond the interval means the process was paused
// (system sleep, App Nap timer throttling) — not that the link is dead (#7773).
const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3
export class SshChannelMultiplexer {
private decoder: FrameDecoder
@ -58,6 +61,10 @@ export class SshChannelMultiplexer {
// Track the oldest unacked outgoing message timestamp
private unackedTimestamps = new Map<number, number>()
// Why: liveness probes (#7773) resolve on the first frame of any kind —
// a keepalive ack proves the relay round-trip without a full RPC.
private livenessProbeWaiters: { succeed: () => void; fail: () => void }[] = []
constructor(transport: MultiplexerTransport) {
this.transport = transport
@ -232,6 +239,31 @@ export class SshChannelMultiplexer {
this.sendMessage(msg)
}
/**
* Send a fresh keepalive and resolve true when any frame arrives before the
* timeout. Used on system resume to distinguish a link that survived sleep
* from a dead one before tearing the session down (#7773).
*/
probeLiveness(timeoutMs: number): Promise<boolean> {
if (this.disposed) {
return Promise.resolve(false)
}
return new Promise<boolean>((resolve) => {
const settle = (alive: boolean): void => {
clearTimeout(timer)
const idx = this.livenessProbeWaiters.indexOf(waiter)
if (idx !== -1) {
this.livenessProbeWaiters.splice(idx, 1)
}
resolve(alive)
}
const waiter = { succeed: () => settle(true), fail: () => settle(false) }
const timer = setTimeout(() => settle(false), timeoutMs)
this.livenessProbeWaiters.push(waiter)
this.sendKeepAlive()
})
}
dispose(reason: 'shutdown' | 'connection_lost' = 'shutdown'): void {
if (this.disposed) {
return
@ -259,6 +291,10 @@ export class SshChannelMultiplexer {
reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed'
const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED'
for (const waiter of this.livenessProbeWaiters.splice(0)) {
waiter.fail()
}
for (const [id, pending] of this.pendingRequests) {
pending.cleanup()
const err = new Error(errorMessage) as Error & { code: string }
@ -322,6 +358,12 @@ export class SshChannelMultiplexer {
}
private handleFrame(frame: DecodedFrame): void {
// Why: any decoded frame proves the relay round-trip is alive; resolve
// pending resume probes before ordinary dispatch (#7773).
for (const waiter of this.livenessProbeWaiters.splice(0)) {
waiter.succeed()
}
// Update ack tracking
if (frame.id > this.highestReceivedSeq) {
this.highestReceivedSeq = frame.id
@ -453,12 +495,28 @@ export class SshChannelMultiplexer {
}
private startTimeoutCheck(): void {
let lastTickAt = Date.now()
this.timeoutTimer = setInterval(() => {
if (this.disposed) {
return
}
const now = Date.now()
const sinceLastTick = now - lastTickAt
lastTickAt = now
// Why: after sleep/App Nap the pre-pause keepalive looks stale on the
// first post-wake tick, killing a healthy link (#7773). Reset staleness
// tracking, probe with a fresh keepalive, and let the NEXT full window
// make an honest liveness determination.
if (sinceLastTick > WAKE_GAP_MS) {
this.lastReceivedAt = now
for (const seq of this.unackedTimestamps.keys()) {
this.unackedTimestamps.set(seq, now)
}
this.sendKeepAlive()
return
}
const noDataReceived = now - this.lastReceivedAt > TIMEOUT_MS
// Check oldest unacked message

View File

@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { relayLogLine } from './relay-diagnostic-log'
describe('relayLogLine', () => {
it('prefixes each log line with an ISO timestamp', () => {
const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
try {
relayLogLine('[relay] Grace started (stdin ended)')
expect(writeSpy).toHaveBeenCalledTimes(1)
const line = writeSpy.mock.calls[0][0] as string
// Why: relay.log correlation depends on a grep-stable
// "<ISO timestamp> <original line>" shape (#7773).
expect(line).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[relay\] Grace started \(stdin ended\)\n$/
)
} finally {
writeSpy.mockRestore()
}
})
})

View File

@ -0,0 +1,7 @@
// Why: the relay daemon's stderr is redirected to relay.log on the remote
// host. Without timestamps, reconnect flaps in that log cannot be correlated
// with user activity or sleep/wake windows during diagnosis (#7773). Keep the
// format grep-stable: ISO timestamp, single space, then the original line.
export function relayLogLine(message: string): void {
process.stderr.write(`${new Date().toISOString()} ${message}\n`)
}

View File

@ -17,6 +17,7 @@ import {
parseHandshakeMessage,
type DecodedFrame
} from './protocol'
import { relayLogLine } from './relay-diagnostic-log'
// Why: a unique exit code reserved for the wire-level version-mismatch terminal
// condition. The client (waitForSentinel + ssh.ts) maps this exit code to a
@ -132,22 +133,18 @@ function handleDaemonHandshakeFrame(
try {
msg = parseHandshakeMessage(frame.payload)
} catch (err) {
process.stderr.write(
`[relay] Could not parse handshake: ${(err as Error).message}; closing socket\n`
)
relayLogLine(`[relay] Could not parse handshake: ${(err as Error).message}; closing socket`)
sock.destroy()
return false
}
if (msg.type !== 'orca-relay-handshake') {
process.stderr.write(
`[relay] Unexpected handshake type from client: ${msg.type}; closing socket\n`
)
relayLogLine(`[relay] Unexpected handshake type from client: ${msg.type}; closing socket`)
sock.destroy()
return false
}
if (msg.version !== launchVersion) {
process.stderr.write(
`[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket\n`
relayLogLine(
`[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket`
)
try {
sock.write(

View File

@ -59,6 +59,7 @@ import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugi
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import { pickRemoteCliEnv } from './remote-cli-env'
import { relayLogLine } from './relay-diagnostic-log'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
@ -358,13 +359,13 @@ async function main(): Promise<void> {
// would risk silent data corruption or zombie PTYs. We log for diagnostics
// and then exit so the client can detect the disconnect and reconnect cleanly.
process.on('uncaughtException', (err) => {
process.stderr.write(`[relay] Uncaught exception: ${err.message}\n${err.stack}\n`)
relayLogLine(`[relay] Uncaught exception: ${err.message}\n${err.stack}`)
cleanupOwnedSocket()
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
process.stderr.write(`[relay] Unhandled rejection: ${reason}\n`)
relayLogLine(`[relay] Unhandled rejection: ${reason}`)
})
// Why: stdoutAlive tracks whether process.stdout is still writable.
@ -529,8 +530,8 @@ async function main(): Promise<void> {
try {
await hookServer.start({ publishEndpoint: false })
} catch (err) {
process.stderr.write(
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n`
relayLogLine(
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}`
)
}
@ -688,7 +689,7 @@ async function main(): Promise<void> {
function cancelGrace(reason: string): void {
if (ptyHandler.graceTimerActive) {
process.stderr.write(`[relay] Grace canceled: ${reason}\n`)
relayLogLine(`[relay] Grace canceled: ${reason}`)
}
graceDeadlineAt = null
graceReason = null
@ -704,8 +705,8 @@ async function main(): Promise<void> {
hasAcceptedSocketClient = true
acceptedSocketConnections++
process.stderr.write(
`[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})\n`
relayLogLine(
`[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})`
)
cancelGrace('socket client accepted')
@ -779,7 +780,7 @@ async function main(): Promise<void> {
if (clientId !== undefined) {
dispatcher.detachClient(clientId)
}
process.stderr.write(`[relay] Socket client closed (clients=${socketClients.size})\n`)
relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`)
if (!stdoutAlive && socketClients.size === 0) {
startGrace('socket client closed')
}
@ -821,9 +822,9 @@ async function main(): Promise<void> {
ownsSocketPath = true
ownedSocketIdentity = readSocketIdentity(sockPath)
server.on('error', (err) => {
process.stderr.write(`[relay] Socket server error: ${err.message}\n`)
relayLogLine(`[relay] Socket server error: ${err.message}`)
})
process.stderr.write(`[relay] Socket server listening: ${sockPath}\n`)
relayLogLine(`[relay] Socket server listening: ${sockPath}`)
resolve()
}
@ -831,11 +832,11 @@ async function main(): Promise<void> {
removeStartupListeners()
restoreUmask()
if (err.code === 'EADDRINUSE') {
process.stderr.write(
`[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.\n`
relayLogLine(
`[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.`
)
} else {
process.stderr.write(`[relay] Socket server error before listen: ${err.message}\n`)
relayLogLine(`[relay] Socket server error before listen: ${err.message}`)
}
reject(err)
}
@ -903,9 +904,7 @@ async function main(): Promise<void> {
failInitial(err)
return
}
process.stderr.write(
`[relay] Removed stale socket at ${sockPath} and retrying listen\n`
)
relayLogLine(`[relay] Removed stale socket at ${sockPath} and retrying listen`)
removeStartupListeners()
listenForStartupError(failInitial)
})
@ -958,11 +957,11 @@ async function main(): Promise<void> {
: graceTimeMs
graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs
graceReason = reason
process.stderr.write(
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}\n`
relayLogLine(
`[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}`
)
ptyHandler.startGraceTimer(() => {
process.stderr.write(`[relay] Grace expired (${reason}); shutting down\n`)
relayLogLine(`[relay] Grace expired (${reason}); shutting down`)
shutdown()
}, timeoutMs)
}
@ -1006,8 +1005,8 @@ async function main(): Promise<void> {
}
function shutdown(): void {
process.stderr.write(
`[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}\n`
relayLogLine(
`[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}`
)
graceDeadlineAt = null
graceReason = null
@ -1034,10 +1033,10 @@ async function main(): Promise<void> {
// window — a reconnecting client can then bridge to the live relay via
// --connect and reattach to the still-running PTY sessions.
process.on('SIGHUP', () => {
process.stderr.write('[relay] Received SIGHUP (SSH session dropped), ignoring\n')
relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring')
})
process.on('exit', (code) => {
process.stderr.write(`[relay] Process exiting with code ${code}\n`)
relayLogLine(`[relay] Process exiting with code ${code}`)
})
// Signal readiness to the client — the client watches for this exact
@ -1059,8 +1058,8 @@ function cleanupSocket(sockPath: string): void {
}
void main().catch((err) => {
process.stderr.write(
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
relayLogLine(
`[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`
)
process.exit(1)
})