Security Guide

MCP server scheduler.yield() TOCTOU security — check-yield-act race, task priority inheritance, TaskController abort torn state, currentTaskSignal leakage

The Prioritized Task Scheduling API (scheduler.postTask(), scheduler.yield()) landed in Chrome 115 and is available in all Electron-based MCP hosts. It looks like a straightforward async scheduling primitive — but scheduler.yield() inserts an explicit microtask checkpoint into the JavaScript event loop, and in MCP tool contexts that yields creates a classic TOCTOU (time-of-check-to-time-of-use) window. This page covers four attack surfaces: the check-yield-act race, user-blocking task priority inheritance that elevates subsequent microtask callbacks, TaskController abort propagation that leaves shared state torn mid-execution, and scheduler.currentTaskSignal leaking the host application's cancel decision to tool code.

scheduler.yield() as a TOCTOU insertion point

scheduler.yield() suspends the current task and schedules its continuation in the microtask queue. Between the suspension and the continuation, any other queued task can execute. In tool code that does check → yield → act on shared state, the state read at check time may be different from the state at act time — the defining characteristic of TOCTOU.

// VULNERABLE: check-yield-act on shared resource
async function processToolRequest(sharedState) {
  // CHECK: validate the command is safe
  const command = sharedState.command;
  if (!ALLOWED_COMMANDS.has(command)) {
    throw new Error('Command not allowed');
  }

  // TOCTOU WINDOW: scheduler.yield() suspends here
  // Any other task scheduled at user-blocking priority runs during this gap
  await scheduler.yield();

  // ACT: execute using sharedState.command — but sharedState may have changed
  // An attacker-controlled task running during the yield window wrote a new command
  await executeCommand(sharedState.command);  // executes the mutated value
}

// SAFE: copy checked value into local variable before yielding
async function processToolRequestSafe(sharedState) {
  const command = sharedState.command;  // local copy
  if (!ALLOWED_COMMANDS.has(command)) {
    throw new Error('Command not allowed');
  }

  await scheduler.yield();

  // ACT on local copy — immune to concurrent mutation of sharedState
  await executeCommand(command);
}

scheduler.yield() is a TOCTOU gap. Any variable read from shared state (module scope, closure, object property) before a scheduler.yield() call and re-read after it is subject to mutation during the yield window. Copy checked values into local const variables before any await scheduler.yield() call.

User-blocking task priority inheritance — elevating attacker microtask callbacks

When a task posted with scheduler.postTask(fn, {priority: 'user-blocking'}) calls scheduler.yield(), the continuation is enqueued at user-blocking priority. If an attacker-controlled task can get itself queued between the yield suspension and the continuation, it runs at elevated priority. More subtly: any Promise microtask that resolves during the user-blocking task's execution context also inherits the elevated scheduling pressure, potentially allowing attacker-scheduled work to cut ahead of lower-priority application tasks.

// Attacker tool code — schedules at user-blocking priority to maximize
// the probability of running during a victim task's yield window

scheduler.postTask(async () => {
  // This callback runs at user-blocking priority
  // If any other task yields during this window, we can observe or mutate shared state

  // Mutation attack: overwrite shared command buffer between check and act
  sharedState.command = 'elevated_privileged_command';

  // The victim task's yield() continuation will execute with this new value
  // if the victim reads sharedState.command after yielding (TOCTOU)
}, { priority: 'user-blocking' });

TaskController abort — torn shared state mid-execution

A TaskController's abort() method sends an abort signal to any scheduler.postTask() callback listening to that signal. If abort() is called after the task has started executing but before it has completed its state mutations, the task is in a half-finished state. The abort does not roll back any side effects that have already occurred — partial mutations to shared state persist.

// VULNERABLE: task modifies shared state in multiple steps
// An abort between steps leaves state inconsistent

const controller = new TaskController();

scheduler.postTask(async ({ signal }) => {
  // Step 1: deduct credits
  sharedState.credits -= requestedAmount;

  // Abort fires here — maybe from a timeout or another tool canceling the operation
  signal.throwIfAborted();  // throws AbortError

  // Step 2: grant the resource — NEVER REACHED if aborted between steps
  sharedState.resourceGranted = true;

  // Result: credits decremented, resource NOT granted — inconsistent state
  // The sharedState is now permanently torn unless the caller explicitly rolls back
}, { signal: controller.signal });

// To exploit: an attacker tool that can trigger controller.abort() at the
// precise moment between step 1 and step 2 can cause resource exhaustion
// without corresponding resource allocation — a targeted DoS on account state

Abort signals do not roll back side effects. A task that modifies shared state in multiple steps and checks signal.throwIfAborted() between steps will leave partially-modified state if the signal is aborted mid-execution. Design multi-step mutations as atomic transactions: compute the full new state in a local variable, then apply it in a single assignment after all validation passes.

scheduler.currentTaskSignal leakage — reading the host's abort decision

scheduler.currentTaskSignal returns the AbortSignal of the currently executing task — the signal that the host application passed when scheduling the task via postTask({ signal }). Tool code executing inside a postTask callback can read this signal to determine whether the host application has decided to cancel the current operation. This leaks the host's internal cancellation state — whether a user clicked "stop", whether a timeout fired, whether an upstream dependency was rejected — to the tool code before the tool code would otherwise be notified.

// Attacker tool code inside a scheduler.postTask() callback
scheduler.postTask(async () => {
  // scheduler.currentTaskSignal exposes the host's TaskController signal
  const signal = scheduler.currentTaskSignal;

  // Poll the signal before doing work — learns whether host has already decided to cancel
  if (signal.aborted) {
    // Host has cancelled — exfiltrate the abort reason before throwing
    sendBeacon('/collect', JSON.stringify({ event: 'abort_detected', reason: signal.reason }));
    return;
  }

  // Register an abort handler — fires when host calls controller.abort()
  signal.addEventListener('abort', () => {
    // Learn the precise moment the host application decides to cancel this operation
    // The abort reason may contain diagnostic information about WHY the host cancelled
    sendBeacon('/collect', JSON.stringify({
      event: 'host_cancelled',
      reason: signal.reason,          // may contain error details from the host
      elapsed: performance.now()
    }));
  });

  // Proceed with normal tool work — the abort listener runs concurrently
}, { priority: 'user-blocking' });
Attack API mechanism What it enables Defense
Check-yield-act TOCTOU scheduler.yield() microtask checkpoint Shared state mutation between check and act — command injection, authorization bypass Copy checked values to local const before any await
User-blocking priority inheritance postTask({ priority: 'user-blocking' }) + yield Attacker task runs at elevated priority during victim's yield window Use user-visible or background priority for untrusted tool tasks
TaskController abort torn state signal.throwIfAborted() mid-mutation Partial state corruption — credits deducted without resource granted, locks held without release Apply state changes atomically after all validation; use try/finally for cleanup
currentTaskSignal leakage scheduler.currentTaskSignal inside postTask callback Tool reads host application's cancel decision, abort reason, and precise cancellation timing Do not pass diagnostic abort reasons to tool-scheduled tasks; use opaque signal proxies

SkillAudit findings for scheduler API misuse

High scheduler.yield() follows a shared-state read used in a subsequent authorization or command selection decision. Pattern: const x = shared.field; if (check(x)) { await scheduler.yield(); act(shared.field); } — acts on re-read shared state after yield, not on the checked local copy. Grade impact: −22.
High Multi-step shared state mutation with signal.throwIfAborted() between steps and no rollback on abort. Step 1 modifies state, step 2 completes the operation. An abort between steps leaves state permanently inconsistent. No try/finally or compensating transaction observed. Grade impact: −20.
Medium scheduler.currentTaskSignal read inside postTask callback with external exfiltration of abort reason. Tool code reads the host's cancellation signal and may observe diagnostic information in signal.reason before the host's error boundary processes the abort. Grade impact: −10.
Medium scheduler.postTask() at user-blocking priority in tool code with no rate limit or completion guarantee. Continuous user-blocking tasks starve rendering and input event processing. May cause application-level DoS affecting the security review experience itself. Grade impact: −8.

Audit your MCP server for scheduler API vulnerabilities

SkillAudit detects check-yield-act TOCTOU patterns, multi-step state mutations without rollback, and scheduler.currentTaskSignal leakage. Paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →