diff --git a/packages/insomnia-smoke-test/tests/smoke/stdout-epipe.test.ts b/packages/insomnia-smoke-test/tests/smoke/stdout-epipe.test.ts new file mode 100644 index 00000000000..fc4c11f42d9 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/stdout-epipe.test.ts @@ -0,0 +1,37 @@ +import { expect } from '@playwright/test'; + +import { test } from '../../playwright/test'; + +// Regression tests for https://github.com/Kong/insomnia/issues/9951 +// On Linux, launching from a .desktop entry (or any context without a writable stdout) +// could crash the main process via an async EPIPE error event on process.stdout. +test.describe('stdout EPIPE resilience', () => { + test('EPIPE error handler is registered on process.stdout at startup', async ({ app }) => { + const listenerCount = await app.evaluate(() => process.stdout.listenerCount('error')); + expect.soft(listenerCount).toBeGreaterThan(0); + }); + + test('app survives an async EPIPE error on stdout', async ({ app }) => { + // Schedule the emit via nextTick so it fires outside evaluate()'s synchronous + // context — mirroring the real scenario where EPIPE arrives as an async I/O + // event from the OS. Without the fix, the throw escapes into the event loop + // as an uncaught exception and crashes the main process. + await app.evaluate(() => { + process.nextTick(() => { + const err: NodeJS.ErrnoException = new Error('write EPIPE'); + err.code = 'EPIPE'; + process.stdout.emit('error', err); + }); + }); + + // Flush the main-process event loop before asserting — setImmediate fires + // after all I/O events in the current iteration, confirming the error event + // was fully handled without triggering an uncaught exception. + await app.evaluate(() => new Promise(resolve => setImmediate(resolve))); + + // The app must still be alive. If the main process crashed from an uncaught + // EPIPE exception, evaluate() would throw with a "Target closed" error. + const alive = await app.evaluate(() => true); + expect.soft(alive).toBe(true); + }); +}); diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index c1c5e58d578..85a925b48d2 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -50,9 +50,9 @@ const dataPath = path.join(app.getPath('userData'), '../', isDevelopment() ? 'insomnia-app' : userDataFolder); app.setPath('userData', dataPath); -initElectronStorage(dataPath); initializeLogging(); +initElectronStorage(dataPath); initializeSentry(); diff --git a/packages/insomnia/src/main/log.ts b/packages/insomnia/src/main/log.ts index 1f6e627ce7e..8dbab67c370 100644 --- a/packages/insomnia/src/main/log.ts +++ b/packages/insomnia/src/main/log.ts @@ -8,6 +8,17 @@ import { isDevelopment } from '../common/constants'; log.initialize(); export const initializeLogging = () => { + // EPIPE is emitted asynchronously on process.stdout when the read end of the + // pipe is closed (e.g. launched from a desktop entry, or `app | head -0`). + // It surfaces as an 'error' event after the write is dispatched, so it + // escapes any try/catch around the console.log call itself. Without a + // handler it becomes an uncaught exception that crashes the main process. + process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code !== 'EPIPE') { + throw err; + } + }); + if (isDevelopment()) { // Disable file logging during development log.transports.file.level = false;