From a4672bd2f8e82e8401527fecf7e6a44bd5471bdb Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:40:42 -0700 Subject: [PATCH 1/6] feat: start local Pane daemon server --- main/src/core/importBoundary.test.ts | 9 + main/src/daemon/server.test.ts | 173 +++++++++++++++++++ main/src/daemon/server.ts | 238 +++++++++++++++++++++++++++ main/src/index.ts | 42 ++++- 4 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 main/src/daemon/server.test.ts create mode 100644 main/src/daemon/server.ts diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index 942b870..4881387 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -44,4 +44,13 @@ describe('daemon/client import boundary', () => { expect(source, relativePath).not.toContain('mainWindow'); } }); + + it('keeps the daemon server free of Electron bootstrap imports', () => { + const source = readMainSrcFile('daemon/server.ts'); + + expect(source).not.toContain("from 'electron'"); + expect(source).not.toContain('mainWindow'); + expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/); + expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/); + }); }); diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts new file mode 100644 index 0000000..679f5ab --- /dev/null +++ b/main/src/daemon/server.test.ts @@ -0,0 +1,173 @@ +import fs from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PaneDaemonFrame } from '../../../shared/types/daemon'; +import { PaneCommandRegistry } from './commandRegistry'; +import { encodePaneDaemonFrame, PaneDaemonFrameDecoder } from './socketFraming'; +import { PaneDaemonServer } from './server'; + +interface TestClient { + socket: net.Socket; + nextFrame(timeoutMs?: number): Promise; +} + +const activeServers: PaneDaemonServer[] = []; +const activeSockets: net.Socket[] = []; +const activeTempDirs: string[] = []; + +afterEach(async () => { + for (const socket of activeSockets.splice(0)) { + if (!socket.destroyed) { + socket.destroy(); + } + } + + for (const server of activeServers.splice(0)) { + await server.stop(); + } + + for (const tempDir of activeTempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +function createTempAppDirectory(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-daemon-server-')); + activeTempDirs.push(tempDir); + return tempDir; +} + +async function connectClient(server: PaneDaemonServer): Promise { + const endpoint = server.getEndpoint(); + const socket = await new Promise((resolve, reject) => { + const client = net.createConnection(endpoint.path, () => resolve(client)); + client.once('error', reject); + }); + + activeSockets.push(socket); + + const decoder = new PaneDaemonFrameDecoder(); + const queuedFrames: PaneDaemonFrame[] = []; + const waiters: Array<(frame: PaneDaemonFrame) => void> = []; + + socket.on('data', (chunk) => { + const frames = decoder.push(chunk); + for (const frame of frames) { + const waiter = waiters.shift(); + if (waiter) { + waiter(frame); + } else { + queuedFrames.push(frame); + } + } + }); + + return { + socket, + nextFrame(timeoutMs = 1000) { + if (queuedFrames.length > 0) { + return Promise.resolve(queuedFrames.shift() as PaneDaemonFrame); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for Pane daemon frame')); + }, timeoutMs); + + waiters.push((frame) => { + clearTimeout(timeout); + resolve(frame); + }); + }); + }, + }; +} + +describe('PaneDaemonServer', () => { + it('serves registered daemon commands over the local endpoint', async () => { + const registry = new PaneCommandRegistry(); + registry.register('sessions:get-all', async () => [{ id: 'session-1' }]); + + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + client.socket.write(encodePaneDaemonFrame({ + type: 'request', + id: 1, + channel: 'sessions:get-all', + args: [], + })); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'response', + id: 1, + ok: true, + result: [{ id: 'session-1' }], + }); + }); + + it('returns structured errors for unknown daemon channels', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + client.socket.write(encodePaneDaemonFrame({ + type: 'request', + id: 2, + channel: 'sessions:missing', + args: [], + })); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'response', + id: 2, + ok: false, + error: { + message: 'No Pane daemon command registered for channel "sessions:missing"', + code: 'ERR_UNKNOWN_CHANNEL', + }, + }); + }); + + it('broadcasts daemon-owned events and filters Electron-only events', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + server.getEventSink().send('session:created', { id: 'session-1' }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'session:created', + args: [{ id: 'session-1' }], + }); + + server.getEventSink().send('version:update-available', { version: '1.2.3' }); + await expect(client.nextFrame(100)).rejects.toThrow('Timed out waiting for Pane daemon frame'); + }); + + it('cleans up the Unix socket file when stopped', async () => { + if (process.platform === 'win32') { + return; + } + + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory(), 'linux'); + await server.start(); + + const socketPath = server.getEndpoint().path; + expect(fs.existsSync(socketPath)).toBe(true); + + await server.stop(); + + expect(fs.existsSync(socketPath)).toBe(false); + }); +}); diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts new file mode 100644 index 0000000..36024c1 --- /dev/null +++ b/main/src/daemon/server.ts @@ -0,0 +1,238 @@ +import fs from 'fs'; +import net from 'net'; +import path from 'path'; +import type { PaneEventSink } from '../core/eventSink'; +import type { PaneCommandRegistry } from './commandRegistry'; +import { encodePaneDaemonFrame, PaneDaemonFrameDecoder } from './socketFraming'; +import { getPaneDaemonEndpoint, type PaneDaemonEndpoint } from './socketPath'; +import type { + PaneDaemonErrorResponseFrame, + PaneDaemonRequestFrame, + PaneDaemonSuccessResponseFrame, +} from '../../../shared/types/daemon'; + +const DAEMON_EVENT_PREFIXES = [ + 'archive:', + 'folder:', + 'panel:', + 'project:', + 'resource-monitor:', + 'session:', + 'sessions:', + 'terminal:', +] as const; + +const DAEMON_EVENT_EXACT_CHANNELS = new Set([ + 'git-status-loading', + 'git-status-updated', + 'session-log', + 'session-logs-cleared', +]); + +function isPaneDaemonEventChannel(channel: string): boolean { + if (DAEMON_EVENT_EXACT_CHANNELS.has(channel)) { + return true; + } + + return DAEMON_EVENT_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} + +interface ConnectedPaneDaemonClient { + socket: net.Socket; + decoder: PaneDaemonFrameDecoder; +} + +export class PaneDaemonServer { + private server: net.Server | null = null; + private readonly clients = new Map(); + private readonly endpoint: PaneDaemonEndpoint; + private nextClientId = 1; + + private readonly daemonEventSink: PaneEventSink = { + send: (channel: string, ...args: unknown[]) => { + if (!isPaneDaemonEventChannel(channel) || this.clients.size === 0) { + return; + } + + const encodedFrame = encodePaneDaemonFrame({ + type: 'event', + channel, + args, + }); + + for (const [clientId, client] of this.clients) { + try { + client.socket.write(encodedFrame); + } catch { + this.dropClient(clientId); + } + } + }, + }; + + constructor( + private readonly commandRegistry: PaneCommandRegistry, + appDirectory: string, + platform: NodeJS.Platform = process.platform, + ) { + this.endpoint = getPaneDaemonEndpoint(appDirectory, platform); + } + + getEndpoint(): PaneDaemonEndpoint { + return this.endpoint; + } + + getEventSink(): PaneEventSink { + return this.daemonEventSink; + } + + hasSubscribers(): boolean { + return this.clients.size > 0; + } + + async start(): Promise { + if (this.server) { + throw new Error('Pane daemon server is already running'); + } + + if (this.endpoint.transport === 'unix') { + fs.mkdirSync(path.dirname(this.endpoint.path), { recursive: true }); + if (fs.existsSync(this.endpoint.path)) { + fs.unlinkSync(this.endpoint.path); + } + } + + const server = net.createServer((socket) => { + this.attachClient(socket); + }); + + await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.removeListener('listening', handleListening); + this.server = null; + reject(error); + }; + + const handleListening = () => { + server.removeListener('error', handleError); + resolve(); + }; + + server.once('error', handleError); + server.once('listening', handleListening); + server.listen(this.endpoint.path); + }); + + server.on('error', (error) => { + console.error('[Pane daemon] Server error:', error); + }); + this.server = server; + } + + async stop(): Promise { + const server = this.server; + this.server = null; + + for (const clientId of [...this.clients.keys()]) { + this.dropClient(clientId); + } + + if (server) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + + if (this.endpoint.transport === 'unix' && fs.existsSync(this.endpoint.path)) { + fs.unlinkSync(this.endpoint.path); + } + } + + private attachClient(socket: net.Socket): void { + const clientId = String(this.nextClientId++); + const client: ConnectedPaneDaemonClient = { + socket, + decoder: new PaneDaemonFrameDecoder(), + }; + + this.clients.set(clientId, client); + + socket.on('data', (chunk) => { + try { + const frames = client.decoder.push(chunk); + for (const frame of frames) { + if (frame.type !== 'request') { + socket.destroy(new Error(`Pane daemon clients must send request frames, received "${frame.type}"`)); + return; + } + + void this.handleRequest(socket, frame); + } + } catch (error) { + socket.destroy(error instanceof Error ? error : new Error(String(error))); + } + }); + + socket.on('error', () => { + this.dropClient(clientId); + }); + + socket.on('close', () => { + this.clients.delete(clientId); + try { + client.decoder.finish(); + } catch { + // The client closed mid-frame. Treat it as a disconnected subscriber. + } + }); + } + + private dropClient(clientId: string): void { + const client = this.clients.get(clientId); + if (!client) { + return; + } + + this.clients.delete(clientId); + if (!client.socket.destroyed) { + client.socket.destroy(); + } + } + + private async handleRequest(socket: net.Socket, frame: PaneDaemonRequestFrame): Promise { + const response = await this.buildResponseFrame(frame); + + if (!socket.destroyed) { + socket.write(encodePaneDaemonFrame(response)); + } + } + + private async buildResponseFrame( + frame: PaneDaemonRequestFrame, + ): Promise { + try { + const result = await this.commandRegistry.invoke(frame.channel, frame.args); + return { + type: 'response', + id: frame.id, + ok: true, + result, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = message.includes('No Pane daemon command registered') + ? 'ERR_UNKNOWN_CHANNEL' + : 'ERR_DAEMON_REQUEST_FAILED'; + + return { + type: 'response', + id: frame.id, + ok: false, + error: { + message, + code, + }, + }; + } + } +} diff --git a/main/src/index.ts b/main/src/index.ts index 6792e85..ac59914 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -45,7 +45,7 @@ import { getCurrentWorktreeName } from './utils/worktreeUtils'; import { registerIpcHandlers } from './ipc'; import { setupAutoUpdater } from './autoUpdater'; import { setupEventListeners } from './events'; -import type { PaneEventSink } from './core/eventSink'; +import { createFanoutEventSink, type PaneEventSink } from './core/eventSink'; import { setPaneRuntime } from './core/runtime'; import { AppServices } from './ipc/types'; import { getCloudVmManager } from './ipc/cloud'; @@ -58,6 +58,7 @@ import { panelManager } from './services/panelManager'; import { TerminalPanelState } from '../../shared/types/panels'; import { worktreePoolManager } from './services/worktreePoolManager'; import { PtyHostSupervisor } from './ptyHost/ptyHostSupervisor'; +import { PaneDaemonServer } from './daemon/server'; export let mainWindow: BrowserWindow | null = null; @@ -76,6 +77,15 @@ const electronPaneEventSink: PaneEventSink = { }, }; +function installPaneRuntime(eventSink: PaneEventSink): void { + setPaneRuntime({ + eventSink, + getConfigManager: () => configManager, + getPtyHostRuntime: () => ptyHostSupervisor, + getWebviewContextMap: () => webviewContextMap, + }); +} + // Active DevTools WebContentsViews, keyed by the page webContentsId they inspect const activeDevToolsViews = new Map(); let devToolsHandlersRegistered = false; @@ -177,6 +187,7 @@ let worktreeNameGenerator: WorktreeNameGenerator; let databaseService: DatabaseService; let runCommandManager: RunCommandManager; let permissionIpcServer: PermissionIpcServer | null; +let paneDaemonServer: PaneDaemonServer | null = null; let versionChecker: VersionChecker; let archiveProgressManager: ArchiveProgressManager; let analyticsManager: AnalyticsManager; @@ -965,12 +976,7 @@ async function createWindow() { async function initializeServices() { configManager = new ConfigManager(); await configManager.initialize(); - setPaneRuntime({ - eventSink: electronPaneEventSink, - getConfigManager: () => configManager, - getPtyHostRuntime: () => ptyHostSupervisor, - getWebviewContextMap: () => webviewContextMap, - }); + installPaneRuntime(electronPaneEventSink); // Initialize logger early so it can capture all logs logger = new Logger(configManager); @@ -1126,7 +1132,20 @@ async function initializeServices() { }; // Initialize IPC handlers first so managers (like ClaudePanelManager) are ready - registerIpcHandlers(services); + const commandRegistry = registerIpcHandlers(services); + + try { + paneDaemonServer = new PaneDaemonServer(commandRegistry, getAppDirectory()); + await paneDaemonServer.start(); + installPaneRuntime(createFanoutEventSink([ + electronPaneEventSink, + paneDaemonServer.getEventSink(), + ])); + } catch (error) { + paneDaemonServer = null; + console.error('[Pane daemon] Failed to start local daemon server; continuing with Electron-only runtime events', error); + } + // Then set up event listeners that may rely on initialized managers setupEventListeners(services); @@ -1560,6 +1579,13 @@ app.on('before-quit', async (event) => { console.log('[Main] Permission IPC server stopped'); } + if (paneDaemonServer) { + console.log('[Main] Stopping Pane daemon server...'); + await paneDaemonServer.stop(); + paneDaemonServer = null; + console.log('[Main] Pane daemon server stopped'); + } + // Stop version checker if (versionChecker) { versionChecker.stopPeriodicCheck(); From 6fa7e22933ff74e213b3cd3fdb42f2d67ce70898 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:27:21 -0700 Subject: [PATCH 2/6] fix: harden local daemon socket startup --- main/src/daemon/server.test.ts | 89 ++++++++++++++++++++++++++++++++++ main/src/daemon/server.ts | 31 ++++++++++++ 2 files changed, 120 insertions(+) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 679f5ab..2fd3089 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -154,6 +154,44 @@ describe('PaneDaemonServer', () => { await expect(client.nextFrame(100)).rejects.toThrow('Timed out waiting for Pane daemon frame'); }); + it('forwards logs panel runtime events to daemon clients', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + server.getEventSink().send('logs:output', { + panelId: 'panel-1', + content: 'ready\n', + type: 'stdout', + }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'logs:output', + args: [{ + panelId: 'panel-1', + content: 'ready\n', + type: 'stdout', + }], + }); + + server.getEventSink().send('process:ended', { + panelId: 'panel-1', + exitCode: 0, + }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'process:ended', + args: [{ + panelId: 'panel-1', + exitCode: 0, + }], + }); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; @@ -170,4 +208,55 @@ describe('PaneDaemonServer', () => { expect(fs.existsSync(socketPath)).toBe(false); }); + + it('rejects replacing an active Unix socket listener at the same path', async () => { + if (process.platform === 'win32') { + return; + } + + const appDirectory = createTempAppDirectory(); + const firstServer = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + activeServers.push(firstServer); + await firstServer.start(); + + const secondServer = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + await expect(secondServer.start()).rejects.toThrow( + `Pane daemon server is already listening on ${firstServer.getEndpoint().path}`, + ); + + const client = await connectClient(firstServer); + client.socket.write(encodePaneDaemonFrame({ + type: 'request', + id: 9, + channel: 'sessions:get-all', + args: [], + })); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'response', + id: 9, + ok: false, + error: { + message: 'No Pane daemon command registered for channel "sessions:get-all"', + code: 'ERR_UNKNOWN_CHANNEL', + }, + }); + }); + + it('replaces stale non-socket files at the Unix socket path', async () => { + if (process.platform === 'win32') { + return; + } + + const appDirectory = createTempAppDirectory(); + const probeServer = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + const socketPath = probeServer.getEndpoint().path; + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, 'stale'); + + const server = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + activeServers.push(server); + await expect(server.start()).resolves.toBeUndefined(); + expect(fs.existsSync(socketPath)).toBe(true); + }); }); diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index 36024c1..fd78c93 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -25,6 +25,8 @@ const DAEMON_EVENT_PREFIXES = [ const DAEMON_EVENT_EXACT_CHANNELS = new Set([ 'git-status-loading', 'git-status-updated', + 'logs:output', + 'process:ended', 'session-log', 'session-logs-cleared', ]); @@ -98,6 +100,11 @@ export class PaneDaemonServer { if (this.endpoint.transport === 'unix') { fs.mkdirSync(path.dirname(this.endpoint.path), { recursive: true }); if (fs.existsSync(this.endpoint.path)) { + const unixSocketStatus = await probeUnixSocketPath(this.endpoint.path); + if (unixSocketStatus === 'active') { + throw new Error(`Pane daemon server is already listening on ${this.endpoint.path}`); + } + fs.unlinkSync(this.endpoint.path); } } @@ -236,3 +243,27 @@ export class PaneDaemonServer { } } } + +async function probeUnixSocketPath(socketPath: string): Promise<'active' | 'stale'> { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + + const settle = (result: 'active' | 'stale') => { + socket.removeAllListeners(); + if (!socket.destroyed) { + socket.destroy(); + } + resolve(result); + }; + + socket.once('connect', () => settle('active')); + socket.once('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT' || error.code === 'ENOTSOCK') { + settle('stale'); + return; + } + + reject(error); + }); + }); +} From aa735e1ac4916c9a73817ecc2ba611e9c2a39154 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:33:12 -0700 Subject: [PATCH 3/6] fix: forward script state daemon events --- main/src/daemon/server.test.ts | 37 ++++++++++++++++++++++++++++++++++ main/src/daemon/server.ts | 4 ++++ 2 files changed, 41 insertions(+) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 2fd3089..ef00985 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -192,6 +192,43 @@ describe('PaneDaemonServer', () => { }); }); + it('forwards script state events to daemon clients', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + server.getEventSink().send('script-closing', 'session-1'); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'script-closing', + args: ['session-1'], + }); + + server.getEventSink().send('project-script-closing', { projectId: 12 }); + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'project-script-closing', + args: [{ projectId: 12 }], + }); + + server.getEventSink().send('script-session-changed', 'session-2'); + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'script-session-changed', + args: ['session-2'], + }); + + server.getEventSink().send('project-script-changed', { projectId: null }); + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'project-script-changed', + args: [{ projectId: null }], + }); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index fd78c93..e7b7cc8 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -27,8 +27,12 @@ const DAEMON_EVENT_EXACT_CHANNELS = new Set([ 'git-status-updated', 'logs:output', 'process:ended', + 'project-script-changed', + 'project-script-closing', 'session-log', 'session-logs-cleared', + 'script-closing', + 'script-session-changed', ]); function isPaneDaemonEventChannel(channel: string): boolean { From 56daf2ee3fc0b2064ca935e74b9db164b63bdd57 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:39:25 -0700 Subject: [PATCH 4/6] fix: drop backpressured daemon clients --- main/src/daemon/server.test.ts | 45 +++++++++++++++++++++++++++++++++- main/src/daemon/server.ts | 25 ++++++++++++++----- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index ef00985..72e6259 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { PaneDaemonFrame } from '../../../shared/types/daemon'; import { PaneCommandRegistry } from './commandRegistry'; import { encodePaneDaemonFrame, PaneDaemonFrameDecoder } from './socketFraming'; @@ -229,6 +229,49 @@ describe('PaneDaemonServer', () => { }); }); + it('drops backpressured daemon event subscribers while continuing to deliver to healthy clients', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + await connectClient(server); + const healthyClient = await connectClient(server); + const stalledServerSocket = (server as unknown as { clients: Map }).clients.get('1')?.socket; + expect(stalledServerSocket).toBeDefined(); + const stalledWriteSpy = vi.spyOn(stalledServerSocket as net.Socket, 'write').mockImplementation(() => false); + + server.getEventSink().send('terminal:output', { + panelId: 'panel-1', + data: 'hello\n', + }); + + await expect(healthyClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'hello\n', + }], + }); + + server.getEventSink().send('terminal:output', { + panelId: 'panel-1', + data: 'world\n', + }); + + await expect(healthyClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'world\n', + }], + }); + expect(stalledWriteSpy).toHaveBeenCalledTimes(1); + expect(server.hasSubscribers()).toBe(true); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index e7b7cc8..6e2ec5e 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -67,11 +67,7 @@ export class PaneDaemonServer { }); for (const [clientId, client] of this.clients) { - try { - client.socket.write(encodedFrame); - } catch { - this.dropClient(clientId); - } + this.writeFrame(clientId, client.socket, encodedFrame); } }, }; @@ -214,7 +210,24 @@ export class PaneDaemonServer { const response = await this.buildResponseFrame(frame); if (!socket.destroyed) { - socket.write(encodePaneDaemonFrame(response)); + const clientId = [...this.clients.entries()].find(([, client]) => client.socket === socket)?.[0]; + const encodedFrame = encodePaneDaemonFrame(response); + if (clientId) { + this.writeFrame(clientId, socket, encodedFrame); + } else { + socket.write(encodedFrame); + } + } + } + + private writeFrame(clientId: string, socket: net.Socket, encodedFrame: string): void { + try { + const accepted = socket.write(encodedFrame); + if (!accepted) { + this.dropClient(clientId); + } + } catch { + this.dropClient(clientId); } } From 46f9cce89b89a6b1f942e2ac5d4e16d12a335840 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:49:07 -0700 Subject: [PATCH 5/6] fix: harden local daemon socket server --- main/src/daemon/server.test.ts | 58 +++++++++++++++++-- main/src/daemon/server.ts | 101 +++++++++++++++++++++++++++------ 2 files changed, 139 insertions(+), 20 deletions(-) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 72e6259..1f44db8 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -229,23 +229,45 @@ describe('PaneDaemonServer', () => { }); }); - it('drops backpressured daemon event subscribers while continuing to deliver to healthy clients', async () => { + it('keeps backpressured daemon event subscribers connected until queued frames drain', async () => { + if (process.platform === 'win32') { + return; + } + const registry = new PaneCommandRegistry(); - const server = new PaneDaemonServer(registry, createTempAppDirectory()); + const server = new PaneDaemonServer(registry, createTempAppDirectory(), 'linux'); activeServers.push(server); await server.start(); - await connectClient(server); + const stalledClient = await connectClient(server); const healthyClient = await connectClient(server); const stalledServerSocket = (server as unknown as { clients: Map }).clients.get('1')?.socket; expect(stalledServerSocket).toBeDefined(); - const stalledWriteSpy = vi.spyOn(stalledServerSocket as net.Socket, 'write').mockImplementation(() => false); + const originalWrite = (stalledServerSocket as net.Socket).write.bind(stalledServerSocket); + let shouldBackpressure = true; + const stalledWriteSpy = vi.spyOn(stalledServerSocket as net.Socket, 'write').mockImplementation(((...args: Parameters) => { + const result = originalWrite(...args); + if (shouldBackpressure) { + shouldBackpressure = false; + return false; + } + + return result; + }) as typeof net.Socket.prototype.write); server.getEventSink().send('terminal:output', { panelId: 'panel-1', data: 'hello\n', }); + await expect(stalledClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'hello\n', + }], + }); await expect(healthyClient.nextFrame()).resolves.toEqual({ type: 'event', channel: 'terminal:output', @@ -268,10 +290,38 @@ describe('PaneDaemonServer', () => { data: 'world\n', }], }); + expect(stalledWriteSpy).toHaveBeenCalledTimes(1); + stalledServerSocket?.emit('drain'); + await expect(stalledClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'world\n', + }], + }); + + expect(stalledWriteSpy).toHaveBeenCalledTimes(2); expect(server.hasSubscribers()).toBe(true); }); + it('creates the Unix socket directory and socket file with user-only permissions', async () => { + if (process.platform === 'win32') { + return; + } + + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory(), 'linux'); + activeServers.push(server); + await server.start(); + + const socketPath = server.getEndpoint().path; + const socketDirectory = path.dirname(socketPath); + expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o700); + expect(fs.statSync(socketPath).mode & 0o777).toBe(0o600); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index 6e2ec5e..096d987 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -46,8 +46,15 @@ function isPaneDaemonEventChannel(channel: string): boolean { interface ConnectedPaneDaemonClient { socket: net.Socket; decoder: PaneDaemonFrameDecoder; + pendingFrames: string[]; + pendingBytes: number; + waitingForDrain: boolean; } +const UNIX_SOCKET_DIRECTORY_MODE = 0o700; +const UNIX_SOCKET_FILE_MODE = 0o600; +const MAX_PENDING_BYTES_PER_CLIENT = 4 * 1024 * 1024; + export class PaneDaemonServer { private server: net.Server | null = null; private readonly clients = new Map(); @@ -66,8 +73,8 @@ export class PaneDaemonServer { args, }); - for (const [clientId, client] of this.clients) { - this.writeFrame(clientId, client.socket, encodedFrame); + for (const [clientId] of this.clients) { + this.writeFrame(clientId, encodedFrame); } }, }; @@ -98,7 +105,9 @@ export class PaneDaemonServer { } if (this.endpoint.transport === 'unix') { - fs.mkdirSync(path.dirname(this.endpoint.path), { recursive: true }); + const socketDirectory = path.dirname(this.endpoint.path); + fs.mkdirSync(socketDirectory, { recursive: true, mode: UNIX_SOCKET_DIRECTORY_MODE }); + fs.chmodSync(socketDirectory, UNIX_SOCKET_DIRECTORY_MODE); if (fs.existsSync(this.endpoint.path)) { const unixSocketStatus = await probeUnixSocketPath(this.endpoint.path); if (unixSocketStatus === 'active') { @@ -122,6 +131,9 @@ export class PaneDaemonServer { const handleListening = () => { server.removeListener('error', handleError); + if (this.endpoint.transport === 'unix') { + fs.chmodSync(this.endpoint.path, UNIX_SOCKET_FILE_MODE); + } resolve(); }; @@ -160,6 +172,9 @@ export class PaneDaemonServer { const client: ConnectedPaneDaemonClient = { socket, decoder: new PaneDaemonFrameDecoder(), + pendingFrames: [], + pendingBytes: 0, + waitingForDrain: false, }; this.clients.set(clientId, client); @@ -173,13 +188,17 @@ export class PaneDaemonServer { return; } - void this.handleRequest(socket, frame); + void this.handleRequest(clientId, frame); } } catch (error) { socket.destroy(error instanceof Error ? error : new Error(String(error))); } }); + socket.on('drain', () => { + this.flushPendingFrames(clientId); + }); + socket.on('error', () => { this.dropClient(clientId); }); @@ -201,36 +220,86 @@ export class PaneDaemonServer { } this.clients.delete(clientId); + client.pendingFrames.length = 0; + client.pendingBytes = 0; + client.waitingForDrain = false; if (!client.socket.destroyed) { client.socket.destroy(); } } - private async handleRequest(socket: net.Socket, frame: PaneDaemonRequestFrame): Promise { + private async handleRequest(clientId: string, frame: PaneDaemonRequestFrame): Promise { const response = await this.buildResponseFrame(frame); + const client = this.clients.get(clientId); - if (!socket.destroyed) { - const clientId = [...this.clients.entries()].find(([, client]) => client.socket === socket)?.[0]; - const encodedFrame = encodePaneDaemonFrame(response); - if (clientId) { - this.writeFrame(clientId, socket, encodedFrame); - } else { - socket.write(encodedFrame); - } + if (!client || client.socket.destroyed) { + return; } + + this.writeFrame(clientId, encodePaneDaemonFrame(response)); } - private writeFrame(clientId: string, socket: net.Socket, encodedFrame: string): void { + private writeFrame(clientId: string, encodedFrame: string): void { + const client = this.clients.get(clientId); + if (!client || client.socket.destroyed) { + return; + } + + if (client.waitingForDrain || client.pendingFrames.length > 0) { + this.queuePendingFrame(clientId, encodedFrame); + return; + } + try { - const accepted = socket.write(encodedFrame); + const accepted = client.socket.write(encodedFrame); if (!accepted) { - this.dropClient(clientId); + client.waitingForDrain = true; } } catch { this.dropClient(clientId); } } + private queuePendingFrame(clientId: string, encodedFrame: string): void { + const client = this.clients.get(clientId); + if (!client) { + return; + } + + client.pendingFrames.push(encodedFrame); + client.pendingBytes += Buffer.byteLength(encodedFrame); + if (client.pendingBytes > MAX_PENDING_BYTES_PER_CLIENT) { + this.dropClient(clientId); + } + } + + private flushPendingFrames(clientId: string): void { + const client = this.clients.get(clientId); + if (!client || client.socket.destroyed) { + return; + } + + client.waitingForDrain = false; + while (client.pendingFrames.length > 0) { + const nextFrame = client.pendingFrames.shift(); + if (!nextFrame) { + break; + } + + client.pendingBytes -= Buffer.byteLength(nextFrame); + try { + const accepted = client.socket.write(nextFrame); + if (!accepted) { + client.waitingForDrain = true; + return; + } + } catch { + this.dropClient(clientId); + return; + } + } + } + private async buildResponseFrame( frame: PaneDaemonRequestFrame, ): Promise { From 6daa502d54f261b64258e67702ccbd085d754280 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 17:08:04 -0700 Subject: [PATCH 6/6] fix: shorten unix daemon socket paths --- main/src/daemon/server.test.ts | 14 ++++++++++++++ main/src/daemon/socketPath.test.ts | 22 +++++++++++++++------- main/src/daemon/socketPath.ts | 17 +++++++++++++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 1f44db8..32fa1c1 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -389,4 +389,18 @@ describe('PaneDaemonServer', () => { await expect(server.start()).resolves.toBeUndefined(); expect(fs.existsSync(socketPath)).toBe(true); }); + + it('starts successfully for deeply nested app directories on Unix', async () => { + if (process.platform === 'win32') { + return; + } + + const appDirectory = path.posix.join('/tmp', 'pane-root', 'nested'.repeat(40), '.pane'); + const server = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + activeServers.push(server); + + await expect(server.start()).resolves.toBeUndefined(); + expect(Buffer.byteLength(server.getEndpoint().path)).toBeLessThan(100); + expect(fs.existsSync(server.getEndpoint().path)).toBe(true); + }); }); diff --git a/main/src/daemon/socketPath.test.ts b/main/src/daemon/socketPath.test.ts index 6877ca9..8254351 100644 --- a/main/src/daemon/socketPath.test.ts +++ b/main/src/daemon/socketPath.test.ts @@ -2,14 +2,13 @@ import { describe, expect, it } from 'vitest'; import { getPaneDaemonEndpoint, getPaneDaemonSocketDirectory } from './socketPath'; describe('Pane daemon socket path', () => { - it('uses a sockets subdirectory and stable socket file on Unix-like platforms', () => { + it('uses a short hashed temp directory and stable socket file on Unix-like platforms', () => { const endpoint = getPaneDaemonEndpoint('/Users/parsa/.pane', 'darwin'); + const socketDirectory = getPaneDaemonSocketDirectory('/Users/parsa/.pane', 'darwin'); - expect(endpoint).toEqual({ - transport: 'unix', - path: '/Users/parsa/.pane/sockets/daemon.sock', - }); - expect(getPaneDaemonSocketDirectory('/Users/parsa/.pane', 'darwin')).toBe('/Users/parsa/.pane/sockets'); + expect(endpoint.transport).toBe('unix'); + expect(endpoint.path).toBe(`${socketDirectory}/daemon.sock`); + expect(socketDirectory).toMatch(/^\/tmp\/pane-daemon(?:-\d+)?-[0-9a-f]{16}$/); }); it('uses a stable named pipe on Windows', () => { @@ -31,6 +30,15 @@ describe('Pane daemon socket path', () => { const endpoint = getPaneDaemonEndpoint('.pane-test', 'linux'); expect(endpoint.transport).toBe('unix'); - expect(endpoint.path.endsWith('/sockets/daemon.sock')).toBe(true); + expect(endpoint.path.startsWith('/tmp/pane-daemon')).toBe(true); + expect(endpoint.path.endsWith('/daemon.sock')).toBe(true); + }); + + it('keeps Unix socket paths short even for deeply nested app directories', () => { + const deepPath = `/Users/parsa/${'very-nested-directory/'.repeat(20)}.pane`; + const endpoint = getPaneDaemonEndpoint(deepPath, 'linux'); + + expect(endpoint.transport).toBe('unix'); + expect(Buffer.byteLength(endpoint.path)).toBeLessThan(100); }); }); diff --git a/main/src/daemon/socketPath.ts b/main/src/daemon/socketPath.ts index 76a90c0..b39ecf5 100644 --- a/main/src/daemon/socketPath.ts +++ b/main/src/daemon/socketPath.ts @@ -6,7 +6,7 @@ export interface PaneDaemonEndpoint { path: string; } -const DAEMON_SOCKET_DIRECTORY = 'sockets'; +const UNIX_SOCKET_BASE_DIRECTORY = '/tmp'; const DAEMON_SOCKET_FILENAME = 'daemon.sock'; function resolveAppDirectory(appDirectory: string, platform: NodeJS.Platform): string { @@ -26,13 +26,26 @@ function getWindowsPipeName(appDirectory: string): string { return `\\\\.\\pipe\\pane-daemon-${hash}`; } +function getUnixSocketDirectoryName(appDirectory: string): string { + const hash = createHash('sha256') + .update(appDirectory) + .digest('hex') + .slice(0, 16); + const uidSuffix = typeof process.getuid === 'function' ? `-${process.getuid()}` : ''; + + return `pane-daemon${uidSuffix}-${hash}`; +} + export function getPaneDaemonSocketDirectory(appDirectory: string, platform: NodeJS.Platform = process.platform): string | null { const resolvedAppDirectory = resolveAppDirectory(appDirectory, platform); if (platform === 'win32') { return null; } - return path.posix.join(resolvedAppDirectory, DAEMON_SOCKET_DIRECTORY); + return path.posix.join( + UNIX_SOCKET_BASE_DIRECTORY, + getUnixSocketDirectoryName(resolvedAppDirectory), + ); } export function getPaneDaemonEndpoint(appDirectory: string, platform: NodeJS.Platform = process.platform): PaneDaemonEndpoint {