Skip to content

Commit 7dd064e

Browse files
authored
fix(sandbox): kill children that fail init and surface refusal errors fixes #4283 (#4284)
1 parent bbf0844 commit 7dd064e

6 files changed

Lines changed: 193 additions & 9 deletions

File tree

src/classes/child-pool.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,22 @@ export class ChildPool {
6767
return child;
6868
} catch (err) {
6969
console.error(err);
70-
this.release(child);
70+
// A child that failed to initialize (or exited during init) must never
71+
// be released back into the free pool, otherwise it becomes a "zombie"
72+
// that is reused for every subsequent job and fails them instantly.
73+
// Kill and remove it so a fresh child is forked on the next retain.
74+
// The child also exits itself after a failed init (see ChildProcessor),
75+
// so this is normally a no-op; log any kill failure instead of silently
76+
// swallowing it so a lingering child would not go unnoticed.
77+
if (child.childProcess || child.worker) {
78+
try {
79+
this.kill(child, 'SIGKILL').catch(killErr => {
80+
console.error('Failed to kill child after init error:', killErr);
81+
});
82+
} catch (killErr) {
83+
console.error('Failed to kill child after init error:', killErr);
84+
}
85+
}
7186
throw err;
7287
}
7388
}

src/classes/child-processor.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,21 @@ export class ChildProcessor {
5252
}
5353
} catch (err) {
5454
this.status = ChildStatus.Errored;
55-
return this.send({
56-
cmd: ParentCommand.InitFailed,
57-
err: errorToJSON(err),
58-
});
55+
try {
56+
await this.send({
57+
cmd: ParentCommand.InitFailed,
58+
err: errorToJSON(err),
59+
});
60+
} finally {
61+
// A child that failed to initialize cannot recover, and because the open
62+
// IPC channel keeps its event loop alive it would never exit on its own.
63+
// Exit explicitly (after attempting to send InitFailed) so the parent
64+
// can never reuse a half-initialized "zombie" child. This is a
65+
// belt-and-braces measure: ChildPool also kills the child, but exiting
66+
// here guarantees termination even if the parent-side kill were to fail.
67+
// In a worker thread this stops only the current worker, not the process.
68+
process.exit(process.exitCode ?? 1);
69+
}
5970
}
6071

6172
const origProcessor = processor;

src/classes/sandbox.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ const sandbox = <T, R, N extends string>(
4141
case ParentCommand.Failed:
4242
case ParentCommand.Error: {
4343
const err = new Error();
44-
Object.assign(err, msg.value);
44+
// ParentCommand.Failed carries the error under `value`,
45+
// while ParentCommand.Error carries it under `err`. Read
46+
// from either key so the failure reason is never lost.
47+
Object.assign(err, msg.value ?? msg.err);
4548
reject(err);
4649
break;
4750
}

tests/child-pool.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { ChildPool } from '../src/classes';
1+
import { Child, ChildPool } from '../src/classes';
22
import { join } from 'path';
3-
import { describe, beforeEach, afterEach, it, expect } from 'vitest';
3+
import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest';
44

55
const NoopProc = () => {};
66
describe('Child pool for Child Processes', () => {
@@ -115,5 +115,27 @@ function sandboxProcessTests(
115115
expect(child.childProcess.spawnargs).toContain('--no-warnings');
116116
}
117117
});
118+
119+
it('should preserve init errors when the child never creates a process', async () => {
120+
const processor = __dirname + '/fixtures/fixture_processor_bar.js';
121+
const initError = new Error('init failed before fork');
122+
const consoleErrorSpy = vi
123+
.spyOn(console, 'error')
124+
.mockImplementation(() => undefined);
125+
const initSpy = vi
126+
.spyOn(Child.prototype, 'init')
127+
.mockRejectedValue(initError);
128+
129+
try {
130+
await expect(pool.retain(processor)).rejects.toThrow(
131+
'init failed before fork',
132+
);
133+
expect(consoleErrorSpy).toHaveBeenCalledWith(initError);
134+
expect(pool.getAllFree()).toHaveLength(0);
135+
} finally {
136+
initSpy.mockRestore();
137+
consoleErrorSpy.mockRestore();
138+
}
139+
});
118140
});
119141
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* A processor that fails to import (initialize) only the first time, simulating
3+
* a transient failure during module load (e.g. ENOMEM, EACCES, a momentarily
4+
* missing file). A flag file controls whether this import should throw.
5+
*
6+
* On the first import the flag file exists: we delete it and throw, so the
7+
* child fails to initialize. Every subsequent import (in a freshly forked
8+
* child) finds no flag file and exports a working processor.
9+
*/
10+
'use strict';
11+
12+
const { existsSync, unlinkSync } = require('fs');
13+
const path = require('path');
14+
15+
const flag = path.join(__dirname, 'fail-init-once.flag');
16+
17+
if (existsSync(flag)) {
18+
unlinkSync(flag);
19+
throw new Error('transient module load failure');
20+
}
21+
22+
module.exports = function (job) {
23+
return Promise.resolve('ok');
24+
};

tests/sandboxed_process.test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { pathToFileURL } from 'url';
22
import { default as IORedis } from 'ioredis';
33
import { after } from 'lodash';
4+
import { EventEmitter } from 'events';
45
import {
56
describe,
67
beforeEach,
@@ -20,8 +21,16 @@ import {
2021
UNRECOVERABLE_ERROR,
2122
Worker,
2223
} from '../src/classes';
24+
import sandbox from '../src/classes/sandbox';
25+
import { ParentCommand } from '../src/enums';
2326

24-
import { delay, randomUUID, removeAllQueueData } from '../src/utils';
27+
import {
28+
delay,
29+
errorToJSON,
30+
randomUUID,
31+
removeAllQueueData,
32+
} from '../src/utils';
33+
import { existsSync, unlinkSync, writeFileSync } from 'fs';
2534
const { stdout, stderr } = require('test-console');
2635

2736
describe('Sandboxed process using child processes', () => {
@@ -228,6 +237,45 @@ describe('Sandboxed process using worker threads', () => {
228237
});
229238
});
230239

240+
describe('Sandbox error message handling', () => {
241+
// A child that refuses a Start (e.g. 'cannot start a not idling child
242+
// process') reports the reason via ParentCommand.Error, whose payload is
243+
// carried under the `err` key — unlike ParentCommand.Failed which uses
244+
// `value`. The sandbox message handler must read from either key so the
245+
// reason is never lost as an empty-message error. This guards the
246+
// `msg.value ?? msg.err` fix independently of the child lifecycle, since the
247+
// refusal path is otherwise hard to reach once init-failed children exit.
248+
it('preserves error message when child reports via ParentCommand.Error', async () => {
249+
const reason = 'cannot start a not idling child process';
250+
251+
const fakeChild: any = new EventEmitter();
252+
fakeChild.exitCode = null;
253+
fakeChild.signalCode = null;
254+
fakeChild.processFile = 'fake-process-file';
255+
fakeChild.pid = 1;
256+
fakeChild.send = () => {
257+
// Simulate the child refusing the Start command and reporting the reason
258+
// under `err` (ParentCommand.Error), not `value` (ParentCommand.Failed).
259+
queueMicrotask(() => {
260+
fakeChild.emit('message', {
261+
cmd: ParentCommand.Error,
262+
err: errorToJSON(new Error(reason)),
263+
});
264+
});
265+
};
266+
267+
const fakeChildPool: any = {
268+
retain: async () => fakeChild,
269+
release: () => {},
270+
};
271+
272+
const processFn = sandbox('fake-process-file', fakeChildPool);
273+
const fakeJob: any = { asJSONSandbox: () => ({}) };
274+
275+
await expect(processFn(fakeJob)).rejects.toThrow(reason);
276+
});
277+
});
278+
231279
function sandboxProcessTests(
232280
{ useWorkerThreads } = { useWorkerThreads: false },
233281
) {
@@ -1752,6 +1800,67 @@ function sandboxProcessTests(
17521800
await worker.close();
17531801
});
17541802

1803+
describe('when a child fails to initialize once (transient error)', () => {
1804+
it('does not reuse the broken child and recovers on the next job', async () => {
1805+
const processFile =
1806+
__dirname + '/fixtures/fixture_processor_fail_init_once.js';
1807+
const flagFile = __dirname + '/fixtures/fail-init-once.flag';
1808+
1809+
// Arm the transient failure: the first import of the processor file
1810+
// will throw and then remove this flag.
1811+
writeFileSync(flagFile, '1');
1812+
1813+
const worker = new Worker(queueName, processFile, {
1814+
connection,
1815+
prefix,
1816+
concurrency: 1,
1817+
drainDelay: 1,
1818+
useWorkerThreads,
1819+
});
1820+
1821+
try {
1822+
await worker.waitUntilReady();
1823+
1824+
// First job: the child fails during init with the transient error.
1825+
const failedReason = await new Promise<string>((resolve, reject) => {
1826+
worker.once('failed', (_job, error) => {
1827+
try {
1828+
resolve(error.message);
1829+
} catch (err) {
1830+
reject(err);
1831+
}
1832+
});
1833+
queue.add('test', { i: 0 }, { attempts: 1 }).catch(reject);
1834+
});
1835+
1836+
expect(failedReason).toBe('transient module load failure');
1837+
1838+
// The broken child must not be released back into the free pool.
1839+
expect(worker['childPool'].getAllFree()).toHaveLength(0);
1840+
1841+
// Second job: a fresh child is forked and processes the job normally.
1842+
const completedValue = await new Promise<any>((resolve, reject) => {
1843+
worker.once('completed', (_job, value) => resolve(value));
1844+
worker.once('failed', (_job, error) =>
1845+
reject(
1846+
new Error(
1847+
`expected job to complete but it failed with: "${error.message}"`,
1848+
),
1849+
),
1850+
);
1851+
queue.add('test', { i: 1 }, { attempts: 1 }).catch(reject);
1852+
});
1853+
1854+
expect(completedValue).toBe('ok');
1855+
} finally {
1856+
if (existsSync(flagFile)) {
1857+
unlinkSync(flagFile);
1858+
}
1859+
await worker.close();
1860+
}
1861+
});
1862+
});
1863+
17551864
describe('when child process a job and its killed direcly after completing', () => {
17561865
it('should process the next job in a new child process', async () => {
17571866
const processFile = __dirname + '/fixtures/fixture_processor.js';

0 commit comments

Comments
 (0)