Problem
When a shared ioredis client is passed to Worker/Scheduler via { connection: { redis: existingClient } }, shutting down the worker and then closing the Redis connection causes "Connection is closed" unhandled rejections. This makes test runners (bun:test, jest, etc.) report failures even though the shutdown is intentionally happening.
The root cause: several async methods (ping(), poll()) are called from timer callbacks (setInterval, setTimeout) without .catch() handlers. Since timer callbacks discard return values, any Promise rejection from these async calls becomes an unhandled rejection.
Fire-and-forget locations in Worker (src/core/worker.ts)
1. start() — line 151
this.poll(); // async, no await, Promise discarded
2. init() — line 162
this.pingTimer = setInterval(this.ping.bind(this), this.options.timeout);
// setInterval discards the Promise returned by ping()
3. completeJob() — line 402
this.poll(); // async, no await, Promise discarded
4. pause() — line 446
this.pollTimer = setTimeout(() => {
this.poll(); // async, Promise discarded
resolve(null);
}, this.options.timeout);
Fire-and-forget location in Scheduler (src/core/scheduler.ts)
5. pollAgainLater() — line 162
this.timer = setTimeout(() => {
this.poll(); // async, Promise discarded
}, this.options.timeout);
Why this causes problems during shutdown
- Timer callback fires and calls
ping() or poll()
- The async function issues a Redis command (e.g.,
SET in ping())
- Meanwhile,
worker.end() is called — clears timers, calls connection.end()
- Later, the shared Redis client is closed (
.quit())
- ioredis's close handler flushes its command queue, rejecting pending commands
- The Promises from step 2 reject — but nobody called
.catch() on them
- Unhandled promise rejection is reported by the runtime
Both ping() and poll() do check !this.running early and bail out, but there's a race window where the timer fires and enters the function before running is set to false.
Suggested fix
Add .catch() to all fire-and-forget async calls. Errors are already emitted via the "error" event, so the catch handler just needs to prevent unhandled rejections:
// Worker.start() - line 151
this.poll().catch((e) => this.emit("error", e));
// Worker.init() - line 162
this.pingTimer = setInterval(() => {
this.ping().catch((e) => this.emit("error", e));
}, this.options.timeout);
// Worker.completeJob() - line 402
this.poll().catch((e) => this.emit("error", e));
// Worker.pause() - line 446
this.pollTimer = setTimeout(() => {
this.poll().catch((e) => this.emit("error", e));
resolve(null);
}, this.options.timeout);
// Scheduler.pollAgainLater() - line 162
this.timer = setTimeout(() => {
this.poll().catch((e) => this.emit("error", e));
}, this.options.timeout);
Reproduction
This is intermittent and timing-dependent. It's most easily reproduced in CI environments (GitHub Actions) where the event loop may be slower. A test suite that calls start() / stop() between test files while using a shared Redis client will intermittently trigger the unhandled rejection.
Error output:
# Unhandled error between tests
error: Connection is closed.
at close (node_modules/ioredis/built/redis/event_handler.js:214:29)
Environment
- node-resque: 9.4.0
- ioredis: 5.x
- Runtime: Bun 1.x (also reproducible in Node.js)
- Test runner: bun:test (but applies to any runner)
Problem
When a shared ioredis client is passed to Worker/Scheduler via
{ connection: { redis: existingClient } }, shutting down the worker and then closing the Redis connection causes "Connection is closed" unhandled rejections. This makes test runners (bun:test, jest, etc.) report failures even though the shutdown is intentionally happening.The root cause: several async methods (
ping(),poll()) are called from timer callbacks (setInterval,setTimeout) without.catch()handlers. Since timer callbacks discard return values, any Promise rejection from these async calls becomes an unhandled rejection.Fire-and-forget locations in Worker (
src/core/worker.ts)1.
start()— line 1512.
init()— line 1623.
completeJob()— line 4024.
pause()— line 446Fire-and-forget location in Scheduler (
src/core/scheduler.ts)5.
pollAgainLater()— line 162Why this causes problems during shutdown
ping()orpoll()SETinping())worker.end()is called — clears timers, callsconnection.end().quit()).catch()on themBoth
ping()andpoll()do check!this.runningearly and bail out, but there's a race window where the timer fires and enters the function beforerunningis set tofalse.Suggested fix
Add
.catch()to all fire-and-forget async calls. Errors are already emitted via the"error"event, so the catch handler just needs to prevent unhandled rejections:Reproduction
This is intermittent and timing-dependent. It's most easily reproduced in CI environments (GitHub Actions) where the event loop may be slower. A test suite that calls
start()/stop()between test files while using a shared Redis client will intermittently trigger the unhandled rejection.Error output:
Environment