diff --git a/packages/desktop/src/main/debug/index.ts b/packages/desktop/src/main/debug/index.ts index a8962369..61455b59 100644 --- a/packages/desktop/src/main/debug/index.ts +++ b/packages/desktop/src/main/debug/index.ts @@ -34,6 +34,7 @@ let debugLogger: DebugLogger = { log: (input) => record(input.level, input), getRecentEvents: () => [], currentFilePath: () => '', + flush: () => Promise.resolve(), }; export function initDebugLogger(debugDir: string): DebugLogger { diff --git a/packages/desktop/src/main/debug/logger.ts b/packages/desktop/src/main/debug/logger.ts index e87c31de..474dbf6f 100644 --- a/packages/desktop/src/main/debug/logger.ts +++ b/packages/desktop/src/main/debug/logger.ts @@ -1,4 +1,4 @@ -import { appendFileSync, existsSync, mkdirSync } from 'node:fs'; +import { createWriteStream, existsSync, mkdirSync, openSync, type WriteStream } from 'node:fs'; import { join } from 'node:path'; import type { DebugDomain, DebugEvent, DebugLevel } from '@clawwork/shared'; import { sanitizeForLog } from '@clawwork/shared'; @@ -29,6 +29,7 @@ export interface DebugLogger { log: (input: LogEventInput & { level: DebugLevel }) => DebugEvent; getRecentEvents: (filter?: DebugLogFilter) => DebugEvent[]; currentFilePath: () => string; + flush: () => Promise; } export interface LogEventInput { @@ -55,8 +56,38 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge const maxEvents = options.maxEvents ?? 1000; const writeConsole = options.console ?? true; const recentEvents: DebugEvent[] = []; + const debugDir = options.debugDir; - ensureDir(options.debugDir); + // Write stream state — cached fd avoids open/write/close per event + let writeStream: WriteStream | null = null; + let currentLogDate = ''; + + ensureDir(debugDir); + + function currentFilePath(): string { + const day = new Date().toISOString().slice(0, 10); + return join(debugDir, `debug-${day}.ndjson`); + } + + function ensureStream(): void { + const today = new Date().toISOString().slice(0, 10); + if (writeStream && currentLogDate === today) return; + + // Close previous day's stream + if (writeStream) { + writeStream.end(); + writeStream = null; + } + + ensureDir(debugDir); + currentLogDate = today; + const filePath = currentFilePath(); + // Open fd synchronously so the file exists immediately and is append-only + const fd = openSync(filePath, 'a'); + writeStream = createWriteStream(filePath, { fd, autoClose: true }).on('error', (err) => { + console.error('[debug] logger stream error:', err); + }); + } function log(input: LogEventInput & { level: DebugLevel }): DebugEvent { const event: DebugEvent = sanitizeForLog({ @@ -69,7 +100,9 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge recentEvents.splice(0, recentEvents.length - maxEvents); } - appendFileSync(currentFilePath(), `${JSON.stringify(event)}\n`, 'utf8'); + // Async write via persistent stream — no more blocking the event loop + ensureStream(); + writeStream!.write(`${JSON.stringify(event)}\n`, 'utf8'); if (writeConsole) { const line = `[${event.level}] [${event.domain}] ${event.event}`; @@ -82,6 +115,19 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge return event; } + async function flush(): Promise { + if (!writeStream || writeStream.closed || writeStream.destroyed) return; + // Write an empty chunk to serve as a flush marker. + // Writable maintains ordering — this callback fires only after + // all previously enqueued data has been written to the OS. + return new Promise((resolve, reject) => { + writeStream!.write(Buffer.alloc(0), (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + return { debug: (input) => log({ ...input, level: 'debug' }), info: (input) => log({ ...input, level: 'info' }), @@ -90,12 +136,8 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge log, getRecentEvents: (filter) => filterEvents(recentEvents, filter), currentFilePath, + flush, }; - - function currentFilePath(): string { - const day = new Date().toISOString().slice(0, 10); - return join(options.debugDir, `debug-${day}.ndjson`); - } } function filterEvents(events: DebugEvent[], filter?: DebugLogFilter): DebugEvent[] { diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 74617e64..3a7ee8be 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -277,13 +277,25 @@ app.on('window-all-closed', () => { } }); -app.on('before-quit', () => { +app.on('before-quit', (event) => { + if (isQuitting) return; + event.preventDefault(); isQuitting = true; - getDebugLogger().info({ domain: 'app', event: 'app.before-quit', data: { installingUpdate: isInstallingUpdate() } }); - globalShortcut.unregisterAll(); - unwatchAll(); - destroyAllGateways(); - destroyTray(); - destroyQuickLaunch(); - closeDatabase(); + + const logger = getDebugLogger(); + logger.info({ domain: 'app', event: 'app.before-quit', data: { installingUpdate: isInstallingUpdate() } }); + + // Flush pending debug log writes before exit, then complete shutdown + logger + .flush() + .catch(() => {}) + .finally(() => { + globalShortcut.unregisterAll(); + unwatchAll(); + destroyAllGateways(); + destroyTray(); + destroyQuickLaunch(); + closeDatabase(); + app.quit(); + }); }); diff --git a/packages/desktop/test/debug-observability.test.ts b/packages/desktop/test/debug-observability.test.ts index e524bcdb..6f5b3f3a 100644 --- a/packages/desktop/test/debug-observability.test.ts +++ b/packages/desktop/test/debug-observability.test.ts @@ -42,7 +42,7 @@ describe('debug observability foundation', () => { rmSync(dir, { recursive: true, force: true }); }); - it('stores recent events and writes ndjson to disk', () => { + it('stores recent events and writes ndjson to disk', async () => { const seen: string[] = []; const logger = createDebugLogger({ debugDir: dir, @@ -56,6 +56,8 @@ describe('debug observability foundation', () => { logger.info({ domain: 'gateway', event: 'gateway.connect.start', gatewayId: 'gw-1' }); logger.error({ domain: 'ipc', event: 'ipc.ws.send-message.failed', error: { message: 'not connected' } }); + await logger.flush(); + const events = logger.getRecentEvents(); expect(events).toHaveLength(2); expect(events[0]?.event).toBe('gateway.connect.start');