vitest-dev/vitest · error · Error

[vitest-pool-runner]: Cannot start a stopped runner

Error message

[vitest-pool-runner]: Cannot start a stopped runner

What it means

Thrown by PoolRunner.start when the runner's state is already STOPPED. A runner that has been stopped has torn down its worker process/thread and closed its RPC channel, so it cannot be restarted; Vitest always creates a new PoolRunner for a fresh task rather than reusing a stopped one.

Source

Thrown at packages/vitest/src/node/pools/poolRunner.ts:180

  private getOTELCarrier() {
    const activeContext = this._otel?.currentContext || this._otel?.workerContext
    return activeContext
      ? this._traces.getContextCarrier(activeContext)
      : undefined
  }

  async start(options: { workerId: number }): Promise<void> {
    // Wait for any ongoing operation to complete
    if (this._operationLock) {
      await this._operationLock
    }

    if (this._state === RunnerState.STARTED || this._state === RunnerState.STARTING) {
      return
    }

    if (this._state === RunnerState.STOPPED) {
      throw new Error('[vitest-pool-runner]: Cannot start a stopped runner')
    }

    // Create operation lock to prevent concurrent start/stop
    this._operationLock = createDefer()

    let startSpan: Span | undefined
    try {
      this._state = RunnerState.STARTING

      await this._traces.$(
        `vitest.${this.worker.name}.start`,
        { context: this._otel?.workerContext },
        () => this.worker.start(),
      )

      // Attach event listeners AFTER starting worker to avoid issues
      // if worker.start() fails
      this.worker.on('error', this.emitWorkerError)

View on GitHub (pinned to d568f8ce37)

Solutions

  1. Report as a Vitest bug if seen during normal CLI usage (this is an internal invariant).
  2. If using PoolRunner directly (experimental API), never call start() after stop(); construct a new PoolRunner instead.
  3. Retry — transient teardown races during cancellation can trigger this once.

Example fix

// before (experimental API misuse): reusing a stopped runner
await runner.stop()
await runner.start({ workerId: 1 }) // throws

// after: construct a new runner
await runner.stop()
const fresh = new PoolRunner(options, new ForksPoolWorker(options))
await fresh.start({ workerId: 1 })
Defensive patterns

Strategy: validation

Validate before calling

// Experimental PoolRunner API: never start a stopped runner
function assertCanStart(runner) {
  if (runner.isTerminated) {
    throw new Error('Runner is stopped; create a new PoolRunner instead of restarting.')
  }
}

Type guard

function isReusableRunner(runner) {
  return !runner.isTerminated
}

Try / catch

try {
  await runner.start({ workerId: 1 })
} catch (e) {
  if (e.message === '[vitest-pool-runner]: Cannot start a stopped runner') {
    // construct a fresh runner instead of retrying
    throw new Error('Runner was stopped; recreate it before starting')
  }
  throw e
}

Prevention

When it happens

Trigger: runner.start() is called after runner.stop() completed (state === STOPPED), at packages/vitest/src/node/pools/poolRunner.ts:179-180. Normally Pool.getPoolRunner constructs a new runner for stopped workers, so this implies reuse of a stopped instance.

Common situations: An internal bug where a stopped runner is retained in sharedRunners and picked up again; programmatic misuse of the @experimental PoolRunner API calling start() after stop(); a cancellation/teardown race that re-schedules onto a stopped runner.

Related errors


AI-assisted analysis of vitest-dev/vitest@d568f8ce37 (2026-08-03). Data as JSON: /data/errors/1ec775041a810130.json. Report an issue: GitHub.