Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 215 additions & 66 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"postinstall": "electron-rebuild -f -w node-pty"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/resources": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
"@opentelemetry/sdk-trace-node": "^2.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^6.0.0",
Expand All @@ -25,6 +29,8 @@
"highlight.js": "^11.11.1",
"monaco-editor": "^0.52.2",
"node-pty": "^1.0.0",
"pidtree": "^0.6.0",
"pidusage": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
Expand Down
28 changes: 28 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { app, BrowserWindow } from 'electron'
import { readFile } from 'fs/promises'
import { performance } from 'perf_hooks'

import { SessionManager } from '@main/sessionManager.js'
import { LspManager } from '@main/lspManager.js'
Expand All @@ -12,6 +13,7 @@ import { cleanupClaudeImageCacheDir } from '@main/storage/claudeImageCache.js'
import { createMainWindow } from '@main/window/mainWindow.js'
import { wireSessionForwarder } from '@main/sessions/forwarder.js'
import { registerAllIpc } from '@main/ipc/index.js'
import { performanceService } from '@main/performance/PerformanceService.js'

// Main process — thin Electron host.
//
Expand Down Expand Up @@ -52,18 +54,33 @@ const ghostJournals = new GhostJournalRegistry()
let manager: SessionManager = null as unknown as SessionManager
let tmuxRegistry: TmuxRegistry | null = null

void performanceService.start().catch(err => {
console.warn('[performance] failed to start:', err)
})

// ---------- App lifecycle ----------

app.whenReady().then(async () => {
performanceService.mark('app.main.whenReady.start')
await cleanupClaudeImageCacheDir().catch(err => {
console.warn('[images] failed to clean Claude image cache:', err)
performanceService.error('app.main.imageCache.cleanup.error', err)
})
// Tmux availability is checked once at startup. The cost is a
// child-process roundtrip on `tmux -V` — cheap enough to await
// before any IPC is wired. Result is cached on the registry; call
// sites use isAvailable() synchronously thereafter.
tmuxRegistry = new TmuxRegistry()
const tmuxDetectStarted = performance.now()
const tmuxAvailable = await tmuxRegistry.detectAvailability()
performanceService.record({
kind: 'span_end',
process: 'main',
area: 'app.tmux',
name: 'app.tmux.detect',
durationMs: performance.now() - tmuxDetectStarted,
data: { available: tmuxAvailable },
})
console.log(
tmuxAvailable
? '[tmux] available — terminals will persist across restarts'
Expand Down Expand Up @@ -95,6 +112,11 @@ app.whenReady().then(async () => {
.filter(([, meta]) => meta?.kind === 'terminal' && typeof meta?.tmuxName === 'string')
.map(([sessionId, meta]) => ({ sessionId, tmuxName: meta!.tmuxName! }))
const recoveryReport = await reconcile(tmuxRegistry, persisted)
performanceService.mark('app.tmux.recovery.complete', {
recoverable: recoveryReport.recoverable.length,
lost: recoveryReport.lost.length,
orphans: recoveryReport.orphans.length,
})
console.log(
`[tmux] recovery: ${recoveryReport.recoverable.length} recoverable, ${recoveryReport.lost.length} lost, ${recoveryReport.orphans.length} orphans cleaned`,
)
Expand All @@ -103,15 +125,19 @@ app.whenReady().then(async () => {
// through with empty buckets. Log so a real failure is visible.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[tmux] recovery failed (treating all sessions as fresh):', err)
performanceService.error('app.tmux.recovery.error', err)
}
}
}

manager = new SessionManager(tmuxAvailable ? tmuxRegistry : null)
performanceService.mark('app.main.sessionManager.created')

wireSessionForwarder(manager, lspManager)
registerAllIpc({ manager, lspManager, ghostJournals })
performanceService.mark('app.main.ipc.registered')
createMainWindow()
performanceService.mark('app.main.window.created')

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
Expand All @@ -125,11 +151,13 @@ app.on('window-all-closed', () => {
})

app.on('before-quit', () => {
performanceService.mark('app.main.beforeQuit')
void manager.killAll()
void lspManager.dispose()
// Flush pending ghost writes. Fire-and-forget is fine — Electron's
// quit path gives us a tick before teardown. 100 ms queue depth is
// worst-case; in practice drains are empty at quit time because
// streaming is idle.
void ghostJournals.flushAll()
performanceService.stop()
})
4 changes: 4 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { registerWorkspaceIpc } from '@main/ipc/workspace.js'
import { registerGhostIpc } from '@main/ipc/ghost.js'
import { registerDebugIpc } from '@main/ipc/debug.js'
import { registerGitIpc } from '@main/ipc/git.js'
import { registerPerformanceIpc } from '@main/ipc/performance.js'
import { installPerformanceIpcInstrumentation } from '@main/performance/instrumentIpc.js'

// IPC registration aggregator.
//
Expand All @@ -28,6 +30,8 @@ export type IpcDeps = {
}

export function registerAllIpc(deps: IpcDeps): void {
registerPerformanceIpc(deps.manager)
installPerformanceIpcInstrumentation()
registerSessionIpc(deps.manager)
registerProviderIpc()
registerLspIpc(deps.lspManager)
Expand Down
27 changes: 27 additions & 0 deletions src/main/ipc/performance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ipcMain } from 'electron'

import { performanceService } from '@main/performance/PerformanceService.js'
import { ProcessTelemetry } from '@main/performance/ProcessTelemetry.js'
import type { SessionManager } from '@main/sessionManager.js'
import type { PerformanceRecord } from '@shared/performance/types.js'

export function registerPerformanceIpc(manager: SessionManager): void {
const processTelemetry = new ProcessTelemetry(manager)

ipcMain.handle('performance:get-config', () => performanceService.getConfig())

ipcMain.handle('performance:batch', async (_evt, records: PerformanceRecord[]) => {
if (!Array.isArray(records) || records.length === 0) return
performanceService.recordBatch(records)
})

ipcMain.handle('performance:flush', async () => {
await performanceService.flush()
})

ipcMain.handle('performance:snapshot', async () => performanceService.snapshot())

ipcMain.handle('performance:pane-stats', async (_evt, sessionIds?: string[]) =>
processTelemetry.snapshot(Array.isArray(sessionIds) ? sessionIds : undefined),
)
}
64 changes: 64 additions & 0 deletions src/main/performance/LocalJsonlSpanExporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ExportResultCode } from '@opentelemetry/core'
import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'

import { queuePerformanceAppend } from '@main/storage/performanceLog.js'

function hrTimeToUnixMs(hrTime: readonly [number, number]): number {
return (hrTime[0] * 1000) + (hrTime[1] / 1e6)
}

function hrDurationToMs(hrTime: readonly [number, number]): number {
return (hrTime[0] * 1000) + (hrTime[1] / 1e6)
}

export class LocalJsonlSpanExporter implements SpanExporter {
constructor(
private readonly runDir: string,
private readonly slowSpanMs: number,
) {}

export(spans: ReadableSpan[], resultCallback: (result: { code: ExportResultCode; error?: Error }) => void): void {
const spanLines: string[] = []
const slowLines: string[] = []

for (const span of spans) {
const startMs = hrTimeToUnixMs(span.startTime)
const durationMs = hrDurationToMs(span.duration)
const payload = {
kind: 'otel_span',
traceId: span.spanContext().traceId,
spanId: span.spanContext().spanId,
parentSpanId: span.parentSpanContext?.spanId,
name: span.name,
status: span.status,
attributes: span.attributes,
events: span.events.map(event => ({
name: event.name,
ts: hrTimeToUnixMs(event.time),
tsIso: new Date(hrTimeToUnixMs(event.time)).toISOString(),
attributes: event.attributes,
})),
startedAt: startMs,
startedAtIso: new Date(startMs).toISOString(),
endedAt: hrTimeToUnixMs(span.endTime),
durationMs,
resource: span.resource.attributes,
instrumentationScope: span.instrumentationScope,
}
const line = JSON.stringify(payload)
spanLines.push(line)
if (durationMs >= this.slowSpanMs) slowLines.push(line)
}

Promise.all([
queuePerformanceAppend(this.runDir, 'spans', spanLines),
queuePerformanceAppend(this.runDir, 'slow', slowLines),
])
.then(() => resultCallback({ code: ExportResultCode.SUCCESS }))
.catch(error => resultCallback({ code: ExportResultCode.FAILED, error }))
}

async shutdown(): Promise<void> {}

async forceFlush(): Promise<void> {}
}
Loading