11import { pathToFileURL } from 'url' ;
22import { default as IORedis } from 'ioredis' ;
33import { after } from 'lodash' ;
4+ import { EventEmitter } from 'events' ;
45import {
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' ;
2534const { stdout, stderr } = require ( 'test-console' ) ;
2635
2736describe ( '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+
231279function 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