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
39 changes: 39 additions & 0 deletions __tests__/fixtures/stdio-lifecycle-child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const { createStdIoState, handleStdIoStreamError } = require('../../server-lifecycle');

const mode = process.argv[2];
const stdioState = createStdIoState();

function shutdown(reason, code) {
process.stderr.write(`shutdown:${reason}:${code}\n`);
process.exit(code);
}

process.stdout.on('error', (error) => {
handleStdIoStreamError('stdout', error, {
stdioState,
addBreadcrumb: () => {},
captureException: () => {},
shutdown
});
});

process.stderr.on('error', (error) => {
handleStdIoStreamError('stderr', error, {
stdioState,
addBreadcrumb: () => {},
captureException: () => {},
shutdown
});
});

if (mode === 'stdout-broken') {
console.log('first');
const interval = setInterval(() => {
console.log(`tick-${Date.now()}`);
}, 10);

setTimeout(() => {
clearInterval(interval);
process.exit(99);
}, 500);
}
191 changes: 191 additions & 0 deletions __tests__/server-lifecycle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, test, expect } from 'bun:test';
import { spawn } from 'child_process';
import path from 'path';
import {
createStdIoState,
handleProcessFailure,
handleStdIoStreamError,
isBrokenPipeError
} from '../server-lifecycle.js';

function createBrokenPipeError() {
const error = new Error('write EPIPE');
error.code = 'EPIPE';
error.errno = 'EPIPE';
return error;
}

function runBrokenPipeFixture(fixturePath, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const child = spawn('node', [fixturePath, 'stdout-broken'], {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
let closedStdout = false;
let settled = false;

const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`child timed out after ${timeoutMs}ms`));
}, timeoutMs);

function closeStdoutAfterFirstLine() {
if (closedStdout || !stdout.includes('\n')) {
return;
}

closedStdout = true;
child.stdout.pause();
child.stdout.destroy();
}

child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
closeStdoutAfterFirstLine();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});

child.on('error', (error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
reject(error);
});

child.on('close', (code, signal) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve({ code, signal, stdout, stderr, closedStdout });
});
});
}

describe('isBrokenPipeError', () => {
test('detects common broken-pipe shapes', () => {
expect(isBrokenPipeError(createBrokenPipeError())).toBe(true);
expect(isBrokenPipeError({ errno: 'EPIPE' })).toBe(true);
expect(isBrokenPipeError(new Error('stream failed with EPIPE'))).toBe(true);
});

test('ignores unrelated errors', () => {
expect(isBrokenPipeError(new Error('permission denied'))).toBe(false);
expect(isBrokenPipeError({ code: 'ENOENT' })).toBe(false);
});
});

describe('createStdIoState', () => {
test('suppresses repeated writes after an stdout broken pipe and emits telemetry once', () => {
const telemetry = [];
const stdioState = createStdIoState({
onBrokenPipeTelemetry: (details) => telemetry.push(details)
});
const fakeConsole = {
log() {
throw createBrokenPipeError();
}
};

expect(stdioState.writeLog('log', 'stdout', 'first', [], fakeConsole)).toBe(false);
expect(stdioState.writeLog('log', 'stdout', 'second', [], fakeConsole)).toBe(false);
expect(telemetry).toHaveLength(1);
expect(stdioState.state).toMatchObject({
stdoutBrokenPipe: true,
stderrBrokenPipe: false,
brokenPipeTelemetrySent: true
});
});
});

describe('handleStdIoStreamError', () => {
test('gracefully shuts down on stdout EPIPE', () => {
const telemetry = [];
const shutdownCalls = [];
const stdioState = createStdIoState({
onBrokenPipeTelemetry: (details) => telemetry.push(details)
});

handleStdIoStreamError('stdout', createBrokenPipeError(), {
stdioState,
addBreadcrumb: () => {},
captureException: () => {},
shutdown: (reason, code) => shutdownCalls.push({ reason, code })
});

expect(shutdownCalls).toEqual([{ reason: 'stdout-epipe', code: 0 }]);
expect(telemetry).toHaveLength(1);
});

test('keeps non-broken-pipe stdio errors fatal', () => {
const breadcrumbs = [];
const exceptions = [];
const shutdownCalls = [];
const error = new Error('stream failure');

handleStdIoStreamError('stderr', error, {
stdioState: createStdIoState(),
addBreadcrumb: (...args) => breadcrumbs.push(args),
captureException: (...args) => exceptions.push(args),
shutdown: (reason, code) => shutdownCalls.push({ reason, code })
});

expect(breadcrumbs).toHaveLength(1);
expect(exceptions).toHaveLength(1);
expect(shutdownCalls).toEqual([{ reason: 'stderr-error', code: 1 }]);
});
});

describe('handleProcessFailure', () => {
test('keeps uncaught EPIPE fatal outside stdio handlers', () => {
const shutdownCalls = [];
const exceptions = [];
const logCalls = [];

handleProcessFailure('uncaughtException', createBrokenPipeError(), {
addBreadcrumb: () => {},
captureException: (...args) => exceptions.push(args),
logError: (...args) => logCalls.push(args),
shutdown: (reason, code) => shutdownCalls.push({ reason, code })
});

expect(exceptions).toHaveLength(1);
expect(logCalls).toHaveLength(1);
expect(shutdownCalls).toEqual([{ reason: 'uncaughtException', code: 1 }]);
});

test('keeps unhandledRejection EPIPE fatal outside stdio handlers', () => {
const shutdownCalls = [];
const exceptions = [];
const logCalls = [];

handleProcessFailure('unhandledRejection', createBrokenPipeError(), {
addBreadcrumb: () => {},
captureException: (...args) => exceptions.push(args),
logError: (...args) => logCalls.push(args),
shutdown: (reason, code) => shutdownCalls.push({ reason, code })
});

expect(exceptions).toHaveLength(1);
expect(logCalls).toHaveLength(1);
expect(shutdownCalls).toEqual([{ reason: 'unhandledRejection', code: 1 }]);
});
});

describe('broken pipe integration', () => {
test('exits cleanly after stdout closes', async () => {
const fixturePath = path.join(process.cwd(), '__tests__', 'fixtures', 'stdio-lifecycle-child.js');
const result = await runBrokenPipeFixture(fixturePath);
expect(result.closedStdout).toBe(true);
expect(result.stdout.startsWith('first\n')).toBe(true);
expect(result.code).toBe(0);
expect(result.stderr).toContain('shutdown:stdout-epipe:0');
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"server": "cross-env FONT_DATA_DIR=assets/onlyoffice-fontdata node server.js",
"test": "bun run test:unit && bun run test:e2e",
"test:all": "bun run test:unit && bun run test:e2e",
"test:unit": "bun test __tests__/server-utils.test.js __tests__/desktop-stub-utils.test.js __tests__/generate-office-fonts-path.test.js __tests__/offline-loader-config.test.js && node test-url-scheme.js",
"test:unit": "bun test __tests__/server-utils.test.js __tests__/server-lifecycle.test.js __tests__/desktop-stub-utils.test.js __tests__/generate-office-fonts-path.test.js __tests__/offline-loader-config.test.js && node test-url-scheme.js",
"test:e2e": "bun run test:console-batch && bun run test:logo && bun run test:save",
"test:console-batch": "node test-console-batch.js",
"test:logo": "node __tests__/logo-header.test.js",
Expand Down
Loading
Loading