[BUG] createCanUseTool() Promise.race causes intermittent tool denial via orphaned permission responses

Resolved 💬 2 comments Opened Apr 6, 2026 by BikiBikiBik Closed May 17, 2026

[BUG] createCanUseTool() Promise.race causes intermittent tool denial via orphaned permission responses

Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

During long-running sessions, tool calls intermittently fail with "Tool permission request failed" errors. The root cause is a race condition in createCanUseTool() where sendRequest() rejects (due to abort signal or stream close) before the permission response arrives. This causes:

  1. The tool is denied despite the user never rejecting it
  2. An orphaned control_response arrives later but the pendingRequests entry is already deleted
  3. The recovery mechanism (unexpectedResponseCallback) attempts re-injection, but the tool call has already been recorded as failed

What Should Happen?

Tool permission requests should either:

  • Retry on transient failures (abort/stream close) before returning a deny decision
  • Or hold the permission response in a durable queue so that late-arriving control_response messages can still resolve the original tool call

Root Cause Analysis

Location

createCanUseTool() in structuredIO.ts (bundled in cli.js).

Mechanism

The deobfuscated logic:

createCanUseTool(onControlRequestSent) {
  return async (tool, input, state, options, toolUseId, precomputed) => {
    let decision = precomputed ?? await getPermissionDecision(tool, input, state, options, toolUseId);
    if (decision.behavior === "allow" || decision.behavior === "deny") return decision;

    let abortController = new AbortController();
    let parentSignal = state.abortController.signal;
    parentSignal.addEventListener("abort", () => abortController.abort(), { once: true });

    try {
      // J = hook decision, X = SDK permission response
      let J = runHook(tool.name, toolUseId, input, state, decision.suggestions)
                .then(w => ({ source: "hook", decision: w }));
      
      onControlRequestSent?.(buildRequest(tool, input, toolUseId, requestId));
      
      let X = this.sendRequest(
        { subtype: "can_use_tool", tool_name: tool.name, input, /* ... */ tool_use_id: toolUseId },
        schema, abortController.signal, requestId
      ).then(w => ({ source: "sdk", result: w }));

      let P = await Promise.race([J, X]);  // ← RACE CONDITION HERE
      
      if (P.source === "hook") {
        if (P.decision) { X.catch(() => {}); abortController.abort(); return P.decision; }
        let W = await X;
        return processResult(W.result, tool, input, state);
      }
      return processResult(P.result, tool, input, state);
    } catch (error) {
      // ANY rejection from Promise.race lands here → automatic DENY
      return processResult(
        { behavior: "deny", message: `Tool permission request failed: ${error}`, toolUseID: toolUseId },
        tool, input, state
      );
    } finally {
      if (this.getPendingPermissionRequests().length === 0) setStatus("running");
      parentSignal.removeEventListener("abort", abortHandler);
    }
  };
}

sendRequest() has three rejection paths

sendRequest(request, schema, signal, requestId) {
  if (this.inputClosed) throw Error("Stream closed");        // Path 1
  if (signal?.aborted) throw Error("Request aborted");       // Path 2

  this.outbound.enqueue(request);

  let abortHandler = () => {
    this.outbound.enqueue({ type: "control_cancel_request", request_id: requestId });
    let pending = this.pendingRequests.get(requestId);
    if (pending) { this.trackResolvedToolUseId(pending.request); pending.reject(new AbortError()); }
    // Path 3: abort during wait
  };
  if (signal) signal.addEventListener("abort", abortHandler, { once: true });

  try {
    return await new Promise((resolve, reject) => {
      this.pendingRequests.set(requestId, { request, resolve, reject, schema });
    });
  } finally {
    if (signal) signal.removeEventListener("abort", abortHandler);
    this.pendingRequests.delete(requestId);  // ← CRITICAL: entry removed in finally
  }
}

The orphaned response problem

When sendRequest() rejects and its finally block deletes the pendingRequests entry, a control_response that arrives moments later finds no matching entry:

// In the message handler for control_response:
let pending = this.pendingRequests.get(response.request_id);
if (!pending) {
  // Check resolvedToolUseIds as fallback
  let toolUseID = response.response?.toolUseID;
  if (typeof toolUseID === "string" && this.resolvedToolUseIds.has(toolUseID)) {
    // Duplicate — ignore silently
    return;
  }
  // Not found anywhere → call unexpectedResponseCallback
  if (this.unexpectedResponseCallback) await this.unexpectedResponseCallback(response);
  return;
}

The unexpectedResponseCallback handler (handleOrphanedPermissionResponse) attempts to re-inject the permission, but the damage is done: the tool call was already denied and recorded as failed in the transcript.

Design flaws

  1. No timeout or retry on sendRequest() — A transient abort immediately becomes a permanent deny
  2. pendingRequests.delete() in finally — Removes the entry before a late response can match it
  3. No linkage between pendingRequests (keyed by request_id) and resolvedToolUseIds (keyed by tool_use_id) — These two data structures track the same lifecycle but have no direct connection
  4. resolvedToolUseIds has an LRU cap of 1,000 — In extremely long sessions (1,000+ tool calls), eviction could cause duplicate detection to fail. However, this is not the primary cause in our observed cases (97 tool calls).

Steps to Reproduce

  1. Start a Claude Code session and work through an extended task (50+ tool calls)
  2. Use tools that require permission prompts (Write, Edit, Bash, etc. — not auto-allowed)
  3. During periods of high tool activity (e.g., multiple parallel tool calls), an abort signal or stream interruption can trigger the race
  4. The tool call fails with "Tool permission request failed" despite the user not denying it

Note: This is intermittent and timing-dependent. It is more likely to occur in long sessions with many tool calls, but has been observed as early as 97 tool calls.

Error Messages/Logs

In JSONL transcript

// Line N: assistant issues tool_use
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01ABC...","name":"Write","input":{...}}]}}

// Line N+1: tool_result with error (tool was denied, not by user)
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_01ABC...","is_error":true,"content":"Tool permission request failed: Error: ..."}]}}

// Line N+2: orphaned permission recovery attempt
// (handleOrphanedPermissionResponse logs internally but transcript shows model continuing)

In internal logs

handleOrphanedPermissionResponse: received orphaned control_response for toolUseID=toolu_01ABC... request_id=req_123
handleOrphanedPermissionResponse: enqueuing orphaned permission for toolUseID=toolu_01ABC... messageID=msg_456

Suggested Fix

Option A: Retry before deny (minimal change)

In createCanUseTool(), catch transient errors and retry sendRequest() once before returning deny:

catch (error) {
  if (isTransientError(error) && retryCount < 1) {
    retryCount++;
    // retry sendRequest with a fresh abort controller
  }
  return processResult({ behavior: "deny", message: `...` }, ...);
}

Option B: Deferred cleanup in sendRequest() (structural fix)

Instead of deleting from pendingRequests in finally, keep the entry alive for a short grace period to allow late-arriving responses to match:

finally {
  // Instead of immediate delete, mark as "awaiting late response" 
  // and delete after a timeout (e.g., 5 seconds)
  setTimeout(() => this.pendingRequests.delete(requestId), 5000);
}

Option C: Unified tracking (ideal)

Link pendingRequests and resolvedToolUseIds through a single data structure that tracks the full lifecycle of a permission request, preventing the orphaned state entirely.

Environment

  • Claude Code Version: 2.1.92
  • Claude Model: Claude Opus 4.6 (1M context)
  • Platform: Anthropic API (direct)
  • Operating System: Windows 11 Pro 10.0.26200
  • Terminal/Shell: Bash (via Claude Code) / PowerShell
  • Is this a regression?: Unknown — observed across multiple versions

Additional Information

  • Analysis was performed by reverse-engineering the bundled cli.js (deobfuscating minified variable names)
  • Multiple JSONL session logs were examined; the error pattern is consistent across sessions
  • The handleOrphanedPermissionResponse recovery mechanism exists but does not prevent the initial false denial from being recorded in the transcript
  • In one analyzed session, the error occurred 19 times within a single session (session ID: d2d2b8f7-...)
  • The error affects all tools that require permission prompts, not just Write — but Write/Edit are most commonly observed because they are the most frequent permission-required tools

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗