Skip to content

Fire-and-forget async calls cause unhandled promise rejections during shutdown #1260

@evantahler

Description

@evantahler

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

  1. Timer callback fires and calls ping() or poll()
  2. The async function issues a Redis command (e.g., SET in ping())
  3. Meanwhile, worker.end() is called — clears timers, calls connection.end()
  4. Later, the shared Redis client is closed (.quit())
  5. ioredis's close handler flushes its command queue, rejecting pending commands
  6. The Promises from step 2 reject — but nobody called .catch() on them
  7. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions