#1394·voltagent

BackgroundQueue leaves a pending timeout timer for every failed attempt, blocking process exit

Author: LHMQ878Created Aug 3, 2026Updated Aug 16, 2026

What is the current behavior?

BackgroundQueue clears an attempt's timeout timer only on the success path, so every failed attempt leaves a pending timer behind. In packages/core/src/utils/queue/queue.ts:

typescript
const result = await Promise.race([task.operation(), timeoutPromise]);

// Clear timeout if task completed
if (timeoutId) {
  clearTimeout(timeoutId);
}

When task.operation() rejects, the await throws and control jumps to the catch block, so clearTimeout is never reached. The timer keeps the Node event loop alive for its full duration, which means a process cannot exit until the timeout elapses. With retries, one timer is left per attempt.

MemoryManager constructs its queue with defaultTimeout: 30000 and defaultRetries: 5 (packages/core/src/memory/manager/memory-manager.ts:94), so a memory operation that keeps failing — unreachable database, bad credentials — leaves six 30-second timers pending.

Reproduction

BackgroundQueue reaches into ../../logger, so this uses a copy of the three relevant methods (enqueue, processNext, executeTask) verbatim from queue.ts with the logger stubbed out, to keep the repro runnable as a single file with no build step.

queue.mjs
javascript
const logger = { trace() {}, debug() {}, error() {} };

export class BackgroundQueue {
  tasks = [];
  activeTasks = new Set();
  constructor(options = {}) {
    this.options = {
      maxConcurrency: options.maxConcurrency ?? 3,
      defaultTimeout: options.defaultTimeout ?? 10000,
      defaultRetries: options.defaultRetries ?? 2,
    };
  }
  enqueue(task) {
    task.timeout = task.timeout ?? this.options.defaultTimeout;
    task.retries = task.retries ?? this.options.defaultRetries;
    this.tasks.push(task);
    logger.trace(`Enqueued task ${task.id}`);
    setTimeout(() => this.processNext(), 0);
  }
  processNext() {
    while (this.tasks.length > 0 && this.activeTasks.size < this.options.maxConcurrency) {
      const task = this.tasks.shift();
      if (!task) break;
      const taskPromise = this.executeTask(task);
      this.activeTasks.add(taskPromise);
      taskPromise.finally(() => {
        this.activeTasks.delete(taskPromise);
        setTimeout(() => this.processNext(), 0);
      });
    }
  }
  async executeTask(task) {
    let lastError;
    const maxAttempts = (task.retries ?? 0) + 1;
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        let timeoutId;
        const timeoutPromise = new Promise((_, reject) => {
          timeoutId = setTimeout(() => {
            reject(new Error(`Task ${task.id} timeout`));
          }, task.timeout);
        });
        const result = await Promise.race([task.operation(), timeoutPromise]);
        if (timeoutId) clearTimeout(timeoutId);
        logger.trace(`Task ${task.id} completed (attempt ${attempt}/${maxAttempts}`);
        return result;
      } catch (error) {
        lastError = error instanceof Error ? error : new Error(String(error));
        if (attempt < maxAttempts) {
          await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
        } else {
          logger.error(`Task ${task.id} failed after ${maxAttempts} attempts`, { error: lastError });
        }
      }
    }
    return undefined;
  }
}
javascript
// repro.mjs
import { BackgroundQueue } from "./queue.mjs";

const started = Date.now();
process.on("exit", () => console.log(`process exited after ${Date.now() - started}ms`));

// The options MemoryManager uses for memory operations.
const queue = new BackgroundQueue({ maxConcurrency: 10, defaultTimeout: 30000, defaultRetries: 0 });

queue.enqueue({
  id: "save-message",
  operation: async () => {
    throw new Error("storage unavailable");
  },
});

node repro.mjs, Node 24.13.0:

process exited after 30017ms

The same script with operation: async () => "saved":

process exited after 14ms

With defaultRetries: 5 (the MemoryManager value) the failing case still exits after 30807ms, since the six timers overlap.

Measured against the real class instead, vi.getTimerCount() after a rejecting task settles is 1 with defaultRetries: 0 and 3 with defaultRetries: 2, versus 0 after a task that succeeds.

What is the expected behavior?

The timeout timer is cleared however the attempt ends, so a failing task leaves nothing pending and the process exits once the queue is idle.

I have a fix and tests ready and will open a PR.

Environment

  • main at 9aedd49
  • Node 24.13.0, pnpm 8.10.5, Windows 11