Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion src/classes/child-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export class ChildPool {
return child;
} catch (err) {
console.error(err);
this.release(child);
// A child that failed to initialize (or exited during init) must never
// be released back into the free pool, otherwise it becomes a "zombie"
// that is reused for every subsequent job and fails them instantly.
// Kill and remove it so a fresh child is forked on the next retain.
this.kill(child, 'SIGKILL').catch(() => {});
throw err;
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/classes/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ const sandbox = <T, R, N extends string>(
case ParentCommand.Failed:
case ParentCommand.Error: {
const err = new Error();
Object.assign(err, msg.value);
// ParentCommand.Failed carries the error under `value`,
// while ParentCommand.Error carries it under `err`. Read
// from either key so the failure reason is never lost.
Object.assign(err, msg.value ?? msg.err);
reject(err);
break;
}
Expand Down
24 changes: 24 additions & 0 deletions tests/fixtures/fixture_processor_fail_init_once.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* A processor that fails to import (initialize) only the first time, simulating
* a transient failure during module load (e.g. ENOMEM, EACCES, a momentarily
* missing file). A flag file controls whether this import should throw.
*
* On the first import the flag file exists: we delete it and throw, so the
* child fails to initialize. Every subsequent import (in a freshly forked
* child) finds no flag file and exports a working processor.
*/
'use strict';

const { existsSync, unlinkSync } = require('fs');
const path = require('path');

const flag = path.join(__dirname, 'fail-init-once.flag');

if (existsSync(flag)) {
unlinkSync(flag);
throw new Error('transient module load failure');
}

module.exports = function (job) {
return Promise.resolve('ok');
};
62 changes: 62 additions & 0 deletions tests/sandboxed_process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '../src/classes';

import { delay, randomUUID, removeAllQueueData } from '../src/utils';
import { existsSync, unlinkSync, writeFileSync } from 'fs';
const { stdout, stderr } = require('test-console');

describe('Sandboxed process using child processes', () => {
Expand Down Expand Up @@ -1752,6 +1753,67 @@ function sandboxProcessTests(
await worker.close();
});

describe('when a child fails to initialize once (transient error)', () => {
it('does not reuse the broken child and recovers on the next job', async () => {
const processFile =
__dirname + '/fixtures/fixture_processor_fail_init_once.js';
const flagFile = __dirname + '/fixtures/fail-init-once.flag';

// Arm the transient failure: the first import of the processor file
// will throw and then remove this flag.
writeFileSync(flagFile, '1');

const worker = new Worker(queueName, processFile, {
connection,
prefix,
concurrency: 1,
drainDelay: 1,
useWorkerThreads,
});

try {
await worker.waitUntilReady();

// First job: the child fails during init with the transient error.
const failedReason = await new Promise<string>((resolve, reject) => {
worker.once('failed', (_job, error) => {
try {
resolve(error.message);
} catch (err) {
reject(err);
}
});
queue.add('test', { i: 0 }, { attempts: 1 }).catch(reject);
});

expect(failedReason).toBe('transient module load failure');

// The broken child must not be released back into the free pool.
expect(worker['childPool'].getAllFree()).toHaveLength(0);

// Second job: a fresh child is forked and processes the job normally.
const completedValue = await new Promise<any>((resolve, reject) => {
worker.once('completed', (_job, value) => resolve(value));
worker.once('failed', (_job, error) =>
reject(
new Error(
`expected job to complete but it failed with: "${error.message}"`,
),
),
);
queue.add('test', { i: 1 }, { attempts: 1 }).catch(reject);
});

expect(completedValue).toBe('ok');
} finally {
if (existsSync(flagFile)) {
unlinkSync(flagFile);
}
await worker.close();
}
});
});

describe('when child process a job and its killed direcly after completing', () => {
it('should process the next job in a new child process', async () => {
const processFile = __dirname + '/fixtures/fixture_processor.js';
Expand Down
Loading