[BUG] createCanUseTool() Promise.race causes intermittent tool denial via orphaned permission responses
[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:
- The tool is denied despite the user never rejecting it
- An orphaned
control_responsearrives later but thependingRequestsentry is already deleted - 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_responsemessages 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
- No timeout or retry on
sendRequest()— A transient abort immediately becomes a permanent deny pendingRequests.delete()infinally— Removes the entry before a late response can match it- No linkage between
pendingRequests(keyed byrequest_id) andresolvedToolUseIds(keyed bytool_use_id) — These two data structures track the same lifecycle but have no direct connection resolvedToolUseIdshas 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
- Start a Claude Code session and work through an extended task (50+ tool calls)
- Use tools that require permission prompts (Write, Edit, Bash, etc. — not auto-allowed)
- During periods of high tool activity (e.g., multiple parallel tool calls), an abort signal or stream interruption can trigger the race
- 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
handleOrphanedPermissionResponserecovery 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
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗