ワンクリックで
audit-async-flows
Audit for race conditions, stale closures, missing cancellation, and error handling gaps
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Audit for race conditions, stale closures, missing cancellation, and error handling gaps
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Fill missing type, title, and description frontmatter on documents using structured AI output
Download a web page by URL and save it as clean markdown with images
Audit for accessibility — keyboard navigation, ARIA labels, contrast, focus indicators
Produce a full dependency health report (SBOM, vulnerabilities, staleness, upgrades, licenses)
Audit error handling UX — error boundaries, silent failures, loading states, empty states
Audit for unnecessary re-renders — Zustand subscriptions, missing memoization, inline callbacks
| name | audit-async-flows |
| description | Audit for race conditions, stale closures, missing cancellation, and error handling gaps |
| user-invocable | true |
Search the codebase for async patterns that can produce incorrect behavior. This is a research-only audit — do not modify any code.
The per-listener checks below pass each call site individually while the bus is unsound. Audit the bus, not just the cleanup:
grep -rn "\.emit(" src-tauri/src/commands and the matching listen<…>('<same-name>' sites.instanceId
while a single instance is reused across conversations/sessions (e.g. an ACP agent whose
chatSessionId is swapped, not the instance) will accept a stale stream's chunk into the
current message. Verify the gate uses the per-conversation/per-session id the payload carries
(e.g. useAcpSessionListeners.ts checking only instanceId though the payload also has a
sessionId). The correct shape is isOurEvent(conversationId) — useCopilotChat.ts.generateStructured/utility callers on shared stream names: any helper that registers its
own listen('ai-stream-chunk', …) (e.g. structured.ts) and can run concurrently with chat
is a cross-contamination source even though it looks self-contained.useEffect hooks that call async functions without checking if the effect is still current when the result arrives.Pattern to flag:
// BAD — module-level singleton, concurrent callers can both spawn
let agent: AgentHandle | null = null;
async function ensureAgent() {
if (!agent) {
agent = await spawnAgent(); // two callers can both reach here
}
return agent;
}
When an in-flight flag/ref dedups duplicate work keyed by an identity (tab id, doc id), verify the
flag is cleared when the controlling operation is aborted, not only when it completes
(.finally). A dedup keyed by tabId that survives an abort will refuse to restart the same
key on fast A→B→A re-entry — the first A run was aborted, the second A run early-returns on the
stale flag, and A is left stuck on a loading/preview state. (e.g. useEditorTabSwitch.ts clears
previewInFlightRef only in .finally, but aborts the prior pipeline without clearing the ref.)
Clear the flag at the abort site too.
Find debounce/timeout callbacks that capture values from the enclosing scope. If the captured value can change before the timer fires, the callback uses stale data.
Pattern to flag:
// BAD — commentKey captured from closure, stale if tab switches during 2s window
positionSaveTimeoutRef.current = setTimeout(() => {
saveComments(commentKey, storageRoot);
}, 2000);
Flag any useStore.getState().someField appearing inside a useCallback/useEffect/useMemo
dependency array. getState() is evaluated once at render time and does NOT subscribe — when
that field changes without an unrelated re-render, the hook keeps the stale dep and never re-runs.
The body may re-read getState() and behave correctly by accident, but the effect won't re-arm on
the setting flip (e.g. toggling "disable completions" doesn't re-register the update listener until
the next unrelated render). Correct form: subscribe with a selector
(const x = useStore((s) => s.someField);) and use x in both body and deps.
(e.g. useLocalCompletion.ts, useCopilotCompletion.ts.)
Find async operations that complete and apply their results even when they're no longer relevant:
loading=false but leaves a fire-and-forget invoke('…_stream') Rust task
reading the provider socket to completion. There must be a matching …_cancel(streamId) Tauri
command that aborts the upstream request (mirror download_cancels in transcription.rs).AbortController created and .abort()'d but
never passed into the operation it claims to cancel (Tauri invoke accepts no AbortSignal).
This is fine ONLY as a post-hoc staleness guard — but if the work itself keeps running, say so;
do not let the AbortController imply cancellation that does not happen.cancelFn is wired only after
await Promise.all([listen(...)]) resolves, a cancel issued during setup is a no-op and the
message fills in anyway. Set the cancel flag/ref BEFORE the await.Find long async pipelines that yield to the event loop mid-flight (await requestAnimationFrame,
await new Promise(setTimeout), streaming/chunked loops). For each:
AbortController get aborted on effect cleanup / unmount, or only on
the next run of the effect? An effect that creates abortController and aborts it at the top
of the next activation but has no return () => abortController.abort() never cancels on
unmount — the in-flight pipeline keeps running against a torn-down target.
(e.g. useEditorTabSwitch.ts aborts the previous run but the main effect has no cleanup return;
the worker-parse → streamingHydrate chain keeps going after the Editor unmounts.)signal? A loop that checks signal.aborted but then calls
editor.chain()…insertContent(chunk).run() with no editor.isDestroyed guard — an unmount
during the await requestAnimationFrame makes the next chunk write to a destroyed ProseMirror
view. Flag any post-yield write to an editor/view/DOM node without a freshness check.
(e.g. markdown.ts.)For any debounced request that paints a result at the cursor / latest position (inline completions, search-as-you-type, suggestions):
requestId (const thisRequest = ++requestId.current) compared after the
await (if (thisRequest !== requestId.current) return;)? Without it, a slow request N that
resolves after N+1 paints its stale result at wherever the cursor now is. (e.g.
useCopilotCompletion.requestCompletion checks only editor.isFocused/isDestroyed and re-reads
editor.state.selection — no supersede guard; useLocalCompletion.ts does it correctly.)
Cross-check sibling hooks: if one has the guard and a parallel one doesn't, flag the gap.catch {} or catch { /* ignore */ } — swallowed errors(async () => { ... })() without outer error handlingPromise.all instead of Promise.allSettled: One failure loses all resultsFind places where multiple Zustand stores are updated in sequence. If the first update triggers a re-render before the second completes, components may see inconsistent state.
Find async callbacks (in setTimeout, Promise.then, event handlers) that call React state setters or store actions without checking if the component is still mounted.
React 19 / concurrent-rendering note: React 19 may mount, unmount, and re-mount effects (Strict Mode double-invoke; concurrent interruption). An async pipeline started in an effect can therefore outlive its mount. Two consequences to check beyond "is the component mounted":
await requestAnimationFrame will resume after the
unmount that React already processed.editor.isDestroyed) because the object the effect closed over may have
been destroyed during the yield, even though the React mounted flag was the thing you checked.AbortController vs ignore-flag — two distinct mechanisms, don't conflate:
let ignore = false; … return () => { ignore = true }) — correctly discards a
late result so it isn't applied. It does NOT stop the underlying work.fetch, or a backend …_cancel(id) command) —
actually stops the work and frees the socket/tokens.
A fetch/HTTP stream or paid provider call needs true cancellation; a Tauri invoke() cannot be
aborted by an AbortSignal and needs a paired backend cancel command. Flag any code that uses an
AbortController as if it were cancelling an invoke().For each finding:
### <SEVERITY>: <Short title>
**File:** `<path>:<line>`
<Description — what can go wrong and under what conditions.>
**Fix:** <Suggested fix.>
End with a ### Confirmed Good Patterns section.
File: src/hooks/useDirectApiChat.ts + src/lib/ai/structured.ts
ai-stream-chunk / ai-stream-done / ai-tool-call are emitted from Rust as plain global
events with no stream/request id in the payload (ai_streaming.rs). Both
useDirectApiChat.sendChatMessage and generateStructured() listen on the same global names.
Any two direct-API generations that overlap (e.g. a generateStructured intent-classification
call fired mid chat-stream, or a background useAgentTaskOperations task) share one bus with no
way to tell whose chunk is whose — the structured parser accumulates the chat's prose and
JSON.parse throws, and the chat message gets the structured model's JSON appended.
A per-hook cancelled/mounted flag does NOT fix this: it only guards a stream's own teardown,
not a different stream's events landing in this listener.
Fix: Thread a streamId (UUID) from the frontend into ai_chat_stream, include it in every
emitted payload, and early-return in each listener when payload.streamId !== thisStreamId — the
isOurEvent(conversationId) pattern useCopilotChat.ts and useAgentTaskOperations.ts already
use for Copilot events. (Note: the module-level spawn singleton race is already solved in
acp-agent-state.ts via an in-flight acpSpawnPromise + post-await re-verification — verify
before reporting it.)