agalwood/Motrix · error · Error

timer_quota_exceeded

Error message

timer_quota_exceeded

What it means

The worker caps simultaneously-scheduled timers at MAX_ACTIVE (100). A new setTimeout beyond that throws inside the VM; quickjs-emscripten converts the synchronous throw into a VM-level exception the plugin sees as a thrown Error. Delays are also clamped to 30s.

Source

Thrown at src/core/plugin/host/quick-js-worker.ts:569

}

// --- Globals (timers) ---------------------------------------------------

function setupGlobals(vm: QuickJSContext): () => void {
  // setTimeout / setInterval enforce caps:
  //   - MAX_ACTIVE: hard cap on simultaneously-scheduled timers per plugin
  //   - MAX_DELAY:  clamps absurdly large delays to 30s
  // When the active cap is exceeded we throw an Error from inside the
  // newFunction callback — quickjs-emscripten converts that to a VM-level
  // exception so the plugin sees a thrown error rather than a silent no-op.
  const timers = new Map<number, NodeJS.Timeout>()
  let nextId = 1
  const MAX_DELAY = 30_000
  const MAX_ACTIVE = 100

  const setTimeoutFn = vm.newFunction('setTimeout', (cbHandle, delayHandle) => {
    if (timers.size >= MAX_ACTIVE) {
      throw new Error('timer_quota_exceeded')
    }
    const delay = Math.min(vm.getNumber(delayHandle), MAX_DELAY)
    const id = nextId++
    // Persistent handle survives the synchronous callback return; we
    // dispose it when the timer fires (one-shot) or is cleared.
    const persistent = cbHandle.dup()
    timers.set(
      id,
      setTimeout(() => {
        timers.delete(id)
        const res = vm.callFunction(persistent, vm.undefined)
        persistent.dispose()
        if (res.error) {
          res.error.dispose()
        } else {
          res.value.dispose()
        }
      }, delay)

View on GitHub (pinned to 1a708ee577)

Solutions

  1. clearTimeout timers as soon as they are no longer needed.
  2. Batch work so the number of outstanding timers stays well under 100.
  3. Replace many one-shot timers with a single scheduler/interval that drains a queue.
  4. Track your own outstanding-timer count and back off when approaching the cap.

Example fix

// before — one timer per item, never cleared
items.forEach((it) => setTimeout(() => process(it), 1000))
// after — single draining scheduler
const queue = items.slice()
function pump() { const it = queue.shift(); if (it) { process(it); setTimeout(pump, 1000) } }
pump()
Defensive patterns

Strategy: validation

Validate before calling

const MAX_ACTIVE = 100
const outstanding = new Set<number>()
function safeSetTimeout(cb: () => void, delay: number): number {
  if (outstanding.size >= MAX_ACTIVE) {
    throw new Error('timer_quota_exceeded (setTimeout) — clear outstanding timers first')
  }
  const id = setTimeout(() => { outstanding.delete(id); cb() }, Math.min(delay, 30_000)) as unknown as number
  outstanding.add(id)
  return id
}

Type guard

function canScheduleTimer(outstanding: number, max = 100): boolean {
  return outstanding < max
}

Try / catch

try {
  return setTimeout(cb, delay)
} catch (e) {
  if (/timer_quota_exceeded/.test(e.message)) {
    // drain/cancel stale timers, or queue the work instead of scheduling a timer
  } else throw e
}

Prevention

When it happens

Trigger: A plugin schedules more than 100 outstanding setTimeout callbacks without clearing earlier ones (e.g. one timer per item in a large fan-out, or an unbounded retry/backoff loop).

Common situations: Retry/backoff loops that stack timers; one timer per queued task with no clearing; long-lived plugin that leaks timers over time.

Related errors


AI-assisted analysis of agalwood/Motrix@1a708ee577 (2026-08-12). Data as JSON: /api/errors/07ce42d92bcf6b2f. Report an issue: GitHub.