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
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.
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 →