Skip to content

fix(fetch): a hard deadline that holds even when undici drops the abort (PHARN-09) - #203

Merged
PrzemekGalarowicz merged 1 commit into
mainfrom
claude/bold-archimedes-5czyyn
Sep 24, 2026
Merged

PrzemekGalarowicz merged 1 commit into
mainfrom
claude/bold-archimedes-5czyyn

Conversation

@PrzemekGalarowicz

Copy link
Copy Markdown
Contributor

What this changes

Every network request used the same timeout: a setTimeout that calls controller.abort(). On Node 20 and 22, the bundled HTTP client (undici 6) keeps only a weak reference to that abort signal. After a full garbage collection, the abort no longer reaches the response body. Reproduced on Node 22.22.2 and 20.20.2 with a server that sends one byte every 250 ms and a 3 s cap: after one forced gc(), the read was still running at 10 s. Node 24 is not affected, which is why CI (Node 24 only) never showed it. undici's own bodyTimeout only measures the gap between chunks, so a slow trickle never triggers it. As a result init, add, update and status could hang indefinitely, and add/update would hold the project lock the whole time.

The fix:

  • New helper src/lib/deadline.ts (withDeadline): runs the whole request, including reading the body, against a timer. When the timer fires it aborts the request and fails the call itself, so control comes back at the deadline whether or not the abort reaches the body stream.
  • Where it is used: downloadArchive, which downloads the pharn-oss tarball (60 s limit); fetchCommitSha, which looks up the upstream commit (8 s limit, and still returns null on failure); and fetchRemoteSkillsVersion, which fetches SKILLS_VERSION (8 s limit). Timeout errors keep the existing "Could not reach " wording.
  • Connection cleanup: each body reader is cancelled from our own abort listener, so the connection is closed even when undici ignores the abort.

Evidence, using the review's original repro script (one gc() after 1 s):

Node Old code This PR
22.22.2 still running after 10 s fails with a timeout at 3.0 s
20.20.2 still running after 10 s fails with a timeout at 3.0 s

Built with /pharn-dev-ship; stage artifacts are in .dev/features/fetch-hard-deadline/. Results:

  • validate: exit 0
  • regress: no-regressions
  • verify: PASS

Type of change

  • feat — new stack option, wizard step, or command capability
  • fix — bug fix
  • docs — docs-only change
  • chore / refactor — tooling or internal restructure, no behavior change

Area(s) touched

lib/deadline (new) | lib/repo | lib/skills-version

Checklist

  • Read the existing file(s) before editing; followed the ESM .js-extension import convention.
  • Tests: tests/deadline.test.ts (4 cases), plus one test per request type in which the body never responds to the abort. All three of those hang on the old code.
  • Updated test fakes in repo.test.ts and repo-signals.test.ts: the fake download body is now a real web ReadableStream, the shape fetch actually returns.
  • Preserved the network rules: redirect: 'error', the byte caps and the timeout values are unchanged.

Quality gates

  • npm run check passes locally (1345/1345; non-root user, node 22).
  • npm run build / npm run test:coverage (left to CI).

🤖 Generated with Claude Code

https://claude.ai/code/session_01TvcuVhk8hTeDskp5pAJhnc


Generated by Claude Code

…rt (PHARN-09)

Every fetch used an abort-only timer: setTimeout -> controller.abort(). On
Node 20/22 (undici 6) the caller's signal is held through a WeakRef, and after
one full GC the abort no longer reaches the response body: with a server
dripping a byte every 250 ms, a 3 s-capped read was still running at 10 s
(reproduced on 22.22.2 and 20.20.2; Node 24 unaffected). undici's bodyTimeout
measures the gap between chunks, so init/add/update/status could hang
indefinitely — add/update while holding the project lock.

New lib/deadline.ts `withDeadline` races the whole fetch-and-read against a
timer that aborts AND rejects on its own; downloadArchive, fetchCommitSha and
fetchRemoteSkillsVersion use it, and each body reader is cancelled from our
own abort listener so the socket is released too. With the same GC repro the
new shape rejects at 3.0 s.

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

coderabbitai Bot commented Sep 24, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6c4551b5-a681-4d33-b6a7-336548de9ec2


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@PrzemekGalarowicz
PrzemekGalarowicz merged commit c3e3473 into main Sep 24, 2026
14 checks passed
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.

2 participants