Skip to content

fix(tasks): do not register a waiter for an already-aborted signal - #354

Open
mcollina wants to merge 1 commit into
mainfrom
fix/333-task-waiter-aborted-signal
Open

mcollina wants to merge 1 commit into
mainfrom
fix/333-task-waiter-aborted-signal

Conversation

@mcollina

Copy link
Copy Markdown
Member

Summary

A tasks/result request whose client had already disconnected left an entry in TaskWaiters that was never removed. handleTasksResult aborts its controller before waiting when the response is already destroyed. TaskWaiters.wait then still registered the waiter and attached an abort listener to a signal that had already fired, so the listener never ran. Only a local notify() could remove the entry, and that never happens for a task that finishes on another instance or expires. A caller with one long-running task could grow server memory this way.

Fix

  • TaskWaiters.wait (src/stores/task-store.ts): when signal.aborted is already true, it rejects at once and registers nothing. It also removes its abort listener when a notify() resolves it, so a long-lived signal does not keep listeners from settled waits.
  • awaitTaskTerminal (src/handlers.ts): calls signal.throwIfAborted() before waiting, so a client that is already gone causes no waiter, poll or store read. The existing catch in handleTasksResult returns the usual "Client disconnected" or "Server shutting down" error.

Both changes are needed. The first makes TaskWaiters safe for every caller. The second makes the handler skip the wait for a client that is already gone.

Tests (test/tasks-result-lifecycle.test.ts)

  • Unit: wait() with an already-aborted signal rejects and leaves the waiter map empty. Before the fix it never settled and left one entry. Another unit test checks that an abort after registration still removes the entry.
  • Integration, memory and Redis: the store's get is patched to destroy the server-side socket right after handleTasksResult reads the task, which reproduces the window where the response is already destroyed. The test then checks that no waiter is left while the task is still running. It failed before the fix with 1 waiter left on each backend.
  • npm run lint, npm run typecheck and the full npm run test all pass (770/770).

Fixes #333

🤖 Generated with Claude Code

https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

handleTasksResult aborts its controller up front when the client has
already disconnected, but TaskWaiters.wait still registered the waiter
and attached an abort listener that could never fire. Only a local
notify() removed the entry, which never happens for tasks finishing on
another instance or expiring, so the waiters leaked.

TaskWaiters.wait now rejects at once and registers nothing when the
signal is already aborted, and drops its abort listener when notified.
awaitTaskTerminal also bails out before waiting when the signal has
already aborted.

Fixes #333

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tasks/result leaves a waiter behind forever when its client has already disconnected

1 participant