Worked review 3: a job runner
A small job runner: four jobs, each emitting progress events on a shared EventEmitter as they go, one of them deliberately designed to fail. This is the kind of “background job” scaffolding an AI assistant produces readily — and it has four bugs from the AI bug catalog planted across about 35 lines: a listener leak, a forEach(async ...), an unhandled rejection, and a process.exit() that does not wait for anything.
The program
Section titled “The program”Output (verified on Node 24)
job 1 at 0%
job 2 at 0%
job 3 at 0%
job 4 at 0%
all jobs queued
progress listener count: 4
job 1 at 50%
job 2 at 50%Job 4 never reaches its 50% line, finished job never prints for anyone, and shutting down never prints either — the process crashes before any of that.
Real output (stderr, after the lines above):
Error: job 3 failed at runJob (...) at runNextTicks (node:internal/process/task_queues:64:5) at listOnTimeout (node:internal/timers:567:9)Find the bugs before reading on
Section titled “Find the bugs before reading on”Four planted bugs — one lifetime, one async-iteration mistake, one unhandled failure, one abrupt exit. Try to name all four before opening the answers below.
Bug 1 — bus.on('progress', ...) called inside runJob, once per call
Symptom: every call to runJob registers a brand-new listener on the shared bus, and none of them are ever removed.
Evidence: the real output’s own progress listener count: 4 line — after only 4 jobs, the emitter already has 4 listeners for the same event, each one still checking if (jobId === id) and doing nothing for every event that isn’t “theirs.” A long-running job queue processing thousands of jobs would accumulate thousands of dead listeners the same way.
Fix: register one listener a single time, outside runJob entirely, and let it handle every job’s progress events — it needs no per-job filtering once it is not living inside a per-job closure.
Bug 2 — ids.forEach(async function (id) { ... })
Symptom: forEach never waits for its callback’s returned promise — runAll moves on to console.log('all jobs queued') immediately, before a single job has made any real progress.
Evidence: all jobs queued prints right after the four 0% lines and before any 50% line — proof that runAll considered its work “done” the instant it started four jobs, not when they finished.
Fix: await Promise.all(ids.map(runJob)) (concurrent, but actually awaited) or a for...of loop with await runJob(id) inside it (sequential).
Bug 3 — const result = await runJob(id); with no try/catch
Symptom: job 3 is designed to throw, which makes runJob(3)’s promise reject — and nothing in the forEach callback catches it.
Evidence: the real crash. Since bug 2 already made this an un-awaited callback from runAll’s point of view, the rejection has no path to any catch anywhere in the program, so it becomes an unhandled rejection and crashes the whole process — taking jobs 1, 2, and 4 down with it, mid-flight.
Fix: Promise.allSettled instead of Promise.all, so one job’s failure is reported as a { status: 'rejected', reason } entry instead of aborting everything else.
Bug 4 — process.exit(0) inside a setTimeout
Symptom: even setting bugs 1–3 aside, this line assumes 20ms is “enough time” for every job to finish — a guess, not a guarantee — and calls process.exit() regardless of whether that guess was right.
Evidence: in this run, bug 3 crashes the process before bug 4 even gets a chance to run — but had jobs 1–4 all been fixed to succeed, a slow job (a real database call, a large file) could easily still be in flight when this arbitrary timer fires, silently truncating its work.
Fix: delete the setTimeout/process.exit() entirely, and let Node exit naturally once runAll’s returned promise actually settles and there is nothing left to do.
The fixed version
Section titled “The fixed version”Output (verified on Node 24)
job 1 at 0%
job 2 at 0%
job 3 at 0%
job 4 at 0%
job 1 at 50%
job 2 at 50%
job 4 at 50%
job 1 at 100%
job 2 at 100%
job 4 at 100%
finished job 1
finished job 2
job 3 failed: job 3 failed
finished job 4
progress listener count: 1
all jobs settled, exiting naturallyAll four jobs actually run to completion (or a reported failure) this time: jobs 1, 2, and 4 finish successfully, job 3’s failure is reported without taking anything else down with it, the listener count stays at a constant 1 no matter how many jobs run, and the process exits on its own once there is genuinely nothing left to do.
What the tools caught
Section titled “What the tools caught”npx eslint on the buggy version: zero problems. A per-job listener registration, a forEach(async ...), and a process.exit() inside a setTimeout are all outside what eslint-plugin-n/eslint-plugin-promise check for in this course’s ladder.
node --trace-warnings on the buggy version: nothing printed before the crash — the listener count here (4) never actually reaches the default MaxListenersExceededWarning threshold of 10, so this specific run produces no warning at all, even though the leak is real and would eventually cross that threshold with more jobs.
Running the file caught bug 3 immediately (the crash) and, indirectly, bug 2 (all jobs queued printing suspiciously early) and bug 1 (the listener count line, added specifically to make an otherwise invisible leak visible). Bug 4 never even got a chance to run in this trace, since the crash from bug 3 happened first — a reminder that one loud bug can hide others sitting quietly behind it.