diff --git a/src/secrets/run.ts b/src/secrets/run.ts index cf4f62d..1acac94 100644 --- a/src/secrets/run.ts +++ b/src/secrets/run.ts @@ -45,6 +45,7 @@ export async function runSecret( let total = 0; let failure: SecretCode | undefined; let killTimer: ReturnType | undefined; + let stopRequested = false; const child = spawn(executor.executable, [executor.entry], { cwd: executor.cwd, shell: false, @@ -62,12 +63,17 @@ export async function runSecret( try { process.kill(-child.pid, sig); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') - failure = 'EXECUTOR_FAILED'; + const errno = (error as NodeJS.ErrnoException).code; + // macOS reports EPERM for a group whose leader exited but is not yet reaped. + const gone = + errno === 'ESRCH' || + (errno === 'EPERM' && (stopRequested || done)); + if (!gone) failure ??= 'EXECUTOR_FAILED'; } }; const stop = (why: SecretCode) => { - if (done) return; + if (done || stopRequested) return; + stopRequested = true; failure ??= why; child.stdin.destroy(); kill('SIGTERM'); diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts index 7a601a4..5f35057 100644 --- a/tests/secrets.test.ts +++ b/tests/secrets.test.ts @@ -505,6 +505,27 @@ describe.skipIf(process.platform !== 'darwin')( }); }, ); + it('keeps the stop reason when a later group signal reports EPERM', async () => { + await writeFile( + path.join(root, 'package/main.cjs'), + "process.stdout.write('x'.repeat(100000));setInterval(()=>{},1000)", + ); + await setup(); + const kill = process.kill.bind(process); + let groupSignals = 0; + // macOS returns EPERM when signalling a group whose leader exited but is not yet reaped. + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (typeof pid === 'number' && pid < 0 && ++groupSignals > 1) { + throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' }); + } + return kill(pid, signal); + }); + expect(await run()).toMatchObject({ + status: 'outcome_unknown', + code: 'OUTPUT_LIMIT', + }); + expect(groupSignals).toBeGreaterThan(1); + }); it('honors cancellation before decrypting', async () => { await setup(); const c = new AbortController();