Files
orca/src/main/daemon/client.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

380 lines
12 KiB
TypeScript

/* eslint-disable max-lines -- Why: daemon handshake, RPC, stream events, and reconnect cleanup share one socket lifecycle. */
import { connect, type Socket } from 'node:net'
import { readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { StringDecoder } from 'node:string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types'
import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
const CONNECT_TIMEOUT_MS = 5000
const REQUEST_TIMEOUT_MS = 30000
export type DaemonClientOptions = {
socketPath: string
tokenPath: string
protocolVersion?: number
}
type PendingRequest = {
resolve: (value: unknown) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
export class DaemonClient {
private socketPath: string
private tokenPath: string
private protocolVersion: number
private clientId = randomUUID()
private controlSocket: Socket | null = null
private streamSocket: Socket | null = null
private connected = false
private disconnectArmed = false
// Why: after a disconnect + reconnect (daemon respawn), a stale 'close'
// event from the old sockets can fire. Without a generation check, that
// event would tear down the fresh connection. Each doConnect() increments
// the generation; handleDisconnect ignores events from old generations.
private connectionGeneration = 0
// Why: multiple concurrent spawn() calls from simultaneous pane mounts
// all call ensureConnected(). Without a lock, each starts a separate
// connection attempt, overwriting sockets and triggering "Connection lost".
private connectingPromise: Promise<void> | null = null
private pendingRequests = new Map<string, PendingRequest>()
private eventListeners: ((event: unknown) => void)[] = []
private disconnectedListeners: (() => void)[] = []
private requestCounter = 0
private cleanupSocketListeners: (() => void) | null = null
constructor(opts: DaemonClientOptions) {
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
}
isConnected(): boolean {
return this.connected
}
async ensureConnected(): Promise<void> {
if (this.connected) {
return
}
if (this.connectingPromise) {
return this.connectingPromise
}
this.connectingPromise = this.doConnect()
try {
await this.connectingPromise
} finally {
this.connectingPromise = null
}
}
private async doConnect(): Promise<void> {
const token = readFileSync(this.tokenPath, 'utf-8').trim()
const pendingListenerCleanups: (() => void)[] = []
const cleanupPendingListeners = (): void => {
for (const cleanup of pendingListenerCleanups.splice(0)) {
cleanup()
}
}
try {
// Sequential: control first, then stream
this.controlSocket = await this.connectSocket()
await this.sendHello(this.controlSocket, token, 'control')
pendingListenerCleanups.push(this.setupControlParser(this.controlSocket))
this.streamSocket = await this.connectSocket()
await this.sendHello(this.streamSocket, token, 'stream')
pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket))
this.connected = true
this.disconnectArmed = true
this.connectionGeneration++
const gen = this.connectionGeneration
const handleClose = () => this.handleDisconnect(gen)
const controlSocket = this.controlSocket
const streamSocket = this.streamSocket
controlSocket.on('close', handleClose)
controlSocket.on('error', handleClose)
streamSocket.on('close', handleClose)
streamSocket.on('error', handleClose)
pendingListenerCleanups.push(() => {
controlSocket.off('close', handleClose)
controlSocket.off('error', handleClose)
streamSocket.off('close', handleClose)
streamSocket.off('error', handleClose)
})
this.cleanupSocketListeners = cleanupPendingListeners
} catch (error) {
cleanupPendingListeners()
this.controlSocket?.destroy()
this.streamSocket?.destroy()
this.controlSocket = null
this.streamSocket = null
this.connected = false
this.disconnectArmed = false
throw error
}
}
async request<T = unknown>(type: string, payload: unknown): Promise<T> {
if (!this.connected || !this.controlSocket) {
throw new DaemonProtocolError('Not connected')
}
const id = `req-${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new DaemonProtocolError(`Request ${type} timed out after ${REQUEST_TIMEOUT_MS}ms`))
}, REQUEST_TIMEOUT_MS)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer
})
this.controlSocket!.write(encodeNdjson(msg))
})
}
notify(type: string, payload: unknown): void {
if (!this.connected || !this.controlSocket) {
return
}
const id = `${NOTIFY_PREFIX}${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
this.controlSocket.write(encodeNdjson(msg))
}
onEvent(listener: (event: unknown) => void): () => void {
this.eventListeners.push(listener)
return () => {
const idx = this.eventListeners.indexOf(listener)
if (idx !== -1) {
this.eventListeners.splice(idx, 1)
}
}
}
onDisconnected(listener: () => void): () => void {
this.disconnectedListeners.push(listener)
return () => {
const idx = this.disconnectedListeners.indexOf(listener)
if (idx !== -1) {
this.disconnectedListeners.splice(idx, 1)
}
}
}
disconnect(): void {
this.connected = false
this.disconnectArmed = false
this.cleanupActiveSocketListeners()
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new DaemonProtocolError('Disconnected'))
this.pendingRequests.delete(id)
}
this.controlSocket?.destroy()
this.streamSocket?.destroy()
this.controlSocket = null
this.streamSocket = null
}
private connectSocket(): Promise<Socket> {
return new Promise((resolve, reject) => {
const socket = connect(this.socketPath)
const cleanup = (): void => {
clearTimeout(timer)
socket.removeListener('connect', onConnect)
socket.removeListener('error', onError)
}
const onConnect = (): void => {
cleanup()
resolve(socket)
}
const onError = (err: Error): void => {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
socket.destroy()
reject(new DaemonProtocolError('Connection timed out'))
}, CONNECT_TIMEOUT_MS)
socket.on('connect', onConnect)
socket.on('error', onError)
})
}
private sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise<void> {
return new Promise((resolve, reject) => {
const hello: HelloMessage = {
type: 'hello',
version: this.protocolVersion,
token,
clientId: this.clientId,
role
}
let buffer = ''
let settled = false
let timer: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
}
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
cleanup()
if (error) {
reject(error)
return
}
resolve()
}
// Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes.
// Decoding each Buffer independently would permanently inject U+FFFD.
const decoder = new StringDecoder('utf8')
const onData = (chunk: Buffer): void => {
buffer += decoder.write(chunk)
const newlineIdx = buffer.indexOf('\n')
if (newlineIdx === -1) {
return
}
const line = buffer.slice(0, newlineIdx)
try {
const response = JSON.parse(line) as HelloResponse
if (response.ok) {
finish()
} else {
finish(
new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected'))
)
}
} catch {
finish(new DaemonProtocolError('Invalid hello response'))
}
}
const onError = (error: Error): void => finish(error)
const onClose = (): void =>
finish(new DaemonProtocolError('Connection closed before hello response'))
timer = setTimeout(() => {
// Why: a stale daemon can accept the socket but never answer hello;
// without a handshake timeout, startup waits forever on ensureConnected().
finish(new DaemonProtocolError('Hello response timed out'))
socket.destroy()
}, CONNECT_TIMEOUT_MS)
socket.on('data', onData)
socket.on('error', onError)
socket.on('close', onClose)
socket.write(encodeNdjson(hello))
})
}
private setupControlParser(socket: Socket): () => void {
// Why: control responses may contain terminal/startup data with multibyte
// text; keep incomplete UTF-8 bytes until the next socket chunk.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => {
const response = msg as RpcResponse
if (response.id) {
const pending = this.pendingRequests.get(response.id)
if (pending) {
this.pendingRequests.delete(response.id)
clearTimeout(pending.timer)
if (response.ok) {
pending.resolve(response.payload)
} else {
pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error)))
}
}
}
},
() => {} // Ignore parse errors on control socket
)
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
socket.on('data', onData)
return () => socket.off('data', onData)
}
private setupStreamParser(socket: Socket): () => void {
// Why: PTY output streams include emoji/box-drawing tables; socket chunks
// can split those UTF-8 sequences across packets.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => {
const event = msg as DaemonEvent
if (event.type === 'event') {
for (const listener of this.eventListeners) {
listener(event)
}
}
},
() => {} // Ignore parse errors on stream socket
)
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
socket.on('data', onData)
return () => socket.off('data', onData)
}
private handleDisconnect(generation: number): void {
if (!this.disconnectArmed || generation !== this.connectionGeneration) {
return
}
this.disconnectArmed = false
this.connected = false
this.cleanupActiveSocketListeners()
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new DaemonProtocolError('Connection lost'))
this.pendingRequests.delete(id)
}
this.controlSocket?.destroy()
this.streamSocket?.destroy()
this.controlSocket = null
this.streamSocket = null
for (const listener of this.disconnectedListeners) {
listener()
}
}
private cleanupActiveSocketListeners(): void {
const cleanup = this.cleanupSocketListeners
this.cleanupSocketListeners = null
cleanup?.()
}
}