Skip to content

fix(agent): time out hung service heartbeats - #3990

Merged
kwakayama merged 4 commits into
mainfrom
fix/issue-728-heartbeat-timeout
Aug 22, 2026
Merged

fix(agent): time out hung service heartbeats#3990
kwakayama merged 4 commits into
mainfrom
fix/issue-728-heartbeat-timeout

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • give every push-service heartbeat attempt an interval-sized timeout
  • keep the existing retry schedule and in-flight overlap guard intact
  • make a permanently hung transport count as a failed tick after retries exhaust
  • retry a heartbeat whose response body read fails after the headers arrive
  • regenerate agent API reference source links

Why one interval

The deadline is heartbeatIntervalMs, so an attempt is cut off only once the
next heartbeat is already due. A request that slow is gone, not slow. The
deadline is not a separate setting: it moves with
VERYFRONT_AGENT_SERVICE_HEARTBEAT_INTERVAL_MS, so raising the interval for a
slow link raises the deadline with it.

Worst case, a tick is three attempts plus backoff, about 3.25 intervals, and
escalation needs three failed ticks. At the 30s default a permanent hang now
escalates in roughly five minutes. Before, it never escalated at all.

The body-read failure

A body read can fail after the headers land: the deadline fires while the JSON
is still arriving, or the connection resets mid-body. That error came out of the
read, which sat outside the transport-error wrapper in sendHeartbeatRequest,
so it reached isRetryableHeartbeatFailure as a raw AbortError and was
classified as permanent: the tick stopped after one attempt instead of using its
remaining two. The response read now shares the fetch call's NETWORK_ERROR mapping. An
error that is already ours is rethrown untouched, so a non-ok response keeps its
httpStatus and a 4xx still fails on the first attempt with no retry.

Red and green

Every new test was proven capable of failing:

Test Mutation Result
hung heartbeat escalates drop timeoutMs red, no escalation in 1.5s
hung heartbeat escalates timeoutMs: 3_600_000 red
failed body read is retried restore the pre-fix read placement red, 1 request instead of 3
failed body read is retried wrap every error, losing httpStatus red on the 4xx test
healthy and slow heartbeats are left alone timeoutMs: interval / 4 red, retry notice on a healthy beat

The hang test holds every request open on a promise that never settles until its
signal aborts, so it exercises a real hang rather than a rejection. A rejecting
stub already advanced the counter before this change and would have proven
nothing.

Verification

  • deno test --preload=src/testing/preload.ts --no-check --allow-all src/agent/service/registration.test.ts
  • deno fmt --check
  • deno task lint:ci
  • deno task typecheck
  • deno task docs
  • deno task docs:api-reference:check

Refs veryfront/veryfront-issue-inbox#728

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@kwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: daa1acf6-3408-42e5-a86d-17cfac70e2c9

📥 Commits

Reviewing files that changed from the base of the PR and between 1a3cda1 and 1fe922d.

📒 Files selected for processing (3)
  • docs/api-reference/veryfront/agent.md
  • src/agent/service/registration.test.ts
  • src/agent/service/registration.ts

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.

@github-actions

Copy link
Copy Markdown

📦 Client bundle boundary

Entrypoint Modules Source size Server leaks
src/index.client.ts 327 1961 KiB ✅ 0

A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in scripts/lint/client-bundle-baseline.json to burn down.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d794d8dbb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agent/service/registration.ts
The per-attempt deadline can fire after the response headers arrive,
while the JSON body is still being read. That abort surfaced from the
body read, outside the transport-error wrapper in sendHeartbeatRequest,
so it reached isRetryableHeartbeatFailure as a raw AbortError and was
classified as permanent. The tick stopped after one attempt instead of
using its remaining two, so a transient body stall counted as a full
heartbeat failure.

Wrap the response read in the same NETWORK_ERROR mapping as the fetch
call. An error that is already ours is rethrown untouched, so a non-ok
response keeps its httpStatus and a 4xx still fails on the first attempt
with no retry.

Also add the false-positive guard the deadline needs: a heartbeat that
answers inside its own interval, fast or slow, must never retry, skip,
or escalate. Its double honours the abort signal the way a real fetch
does, so an over-eager deadline shows up as a failure instead of being
answered late anyway.
The stalled-body-read test timed out on a CI coverage shard. Its 20ms
interval left a full escalation, nine attempts across three ticks, to
finish inside 1.5 seconds, and a contended runner stretches timers of
that size well past their nominal values. What the test pins is the
classification of a body-read abort, not the clock, so it now runs on a
50ms interval and waits with the same 10 second budget the other
lifecycle tests here use. The bounded-time property stays pinned by the
hung-heartbeat test, which is where it belongs.

The false-positive test had the same fragility in the other direction: a
140ms answer against a 200ms deadline needed only a 60ms overshoot to
report a false timeout. The slow answer now sits at 200ms against a
500ms deadline, still above the quarter-interval mark, so an over-eager
deadline is still caught.
The previous version of this test built a Response over a ReadableStream
that only errored once the per-attempt deadline aborted its signal. That
passed locally and failed on the CI coverage shard, where escalation
never arrived inside a ten second budget, so the double was not portable
to the instrumented runner.

The classification it pins does not need any of that. A body read that
fails after the headers arrive is transient whatever caused it, so the
double now just rejects the read directly with the raw DOMException an
aborted or reset read throws. The test drives one heartbeat, asserts all
three attempts are used, and asserts both retry notices are logged. No
timers, no streams, and it fails in milliseconds against the old code
instead of waiting out a budget.
@kwakayama
kwakayama added this pull request to the merge queue Aug 22, 2026
@kwakayama

Copy link
Copy Markdown
Contributor

Review — score: 86/100. fix-then-merge, one line.

Reviewed against veryfront/veryfront-issue-inbox#728. This genuinely closes that issue and I
would not want it reverted. One measured false-escalation case is worth closing first.

It closes #728 — measured, not read

interval 200ms, transport hangs:  requests=15  ESCALATIONS=2
                                  first at consecutiveFailures=3, within ~4.2s

Before this change, the same probe gave 1 request and 0 escalations, ever — that measurement is
what filed #728. Removing timeoutMs reddens times out a permanently hung heartbeat and escalates in bounded time. Pinned.

No regression on the slow-but-healthy case, which was the risk in picking a timeout: the real
duration_ms=2704 from #709 under the 30s default gives 1 request, succeeded, no throw.

4xx still fails immediately — 400/401/404 each take one attempt. And the refactor moving
readAgentPushRuntimeServiceResponse inside the try is safe: removing
if (isVeryfrontError(cause)) throw cause; reddens fails a client error immediately without retrying. Without that rethrow a 4xx would be rewrapped as a status-less NETWORK_ERROR and become
retryable — the guard is real and tested.

The #3961 in-flight guard still holds — re-measured at interval 200ms / latency 100ms:
MAX CONCURRENT = 1, no leaked timer, sanitizers on.

Retrying a heartbeat whose response body read fails after headers arrive is a case neither the
issue nor its prior analysis named. Good addition, and tested.

Finding — CONFIRMED, medium. A healthy service can declare itself failing.

timeoutMs: input.heartbeatIntervalMs makes the per-attempt deadline hostage to a config value with
no necessary relationship to control-plane latency, and there is no floor. Measured against a
perfectly healthy 200ms control plane with a 100ms configured interval:

requests=18  succeeded=0  ESCALATIONS=4  first at consecutiveFailures=3

A healthy service raises the exact alarm this subsystem exists for. That is worse than the silence
#728 fixed, because a false page trains people to ignore the real one.

Reachability, stated fairly: the default is 30s against an observed worst latency of 2704ms — a
10× margin, so nobody hits this today. But nothing prevents a short interval, the schema only
requires .positive(), and this repo's own tests already use 40ms intervals. The configuration is
not absurd, just unattended.

One line:

timeoutMs: Math.max(input.heartbeatIntervalMs, HEARTBEAT_MIN_ATTEMPT_TIMEOUT_MS)

with a floor of a few seconds. That keeps the property worth having — raise the interval for a slow
link and the deadline moves with it — while refusing to go below what a heartbeat can physically
take.

Secondary, non-blocking and correctly documented

With timeoutMs = interval and 3 attempts, a hung tick occupies roughly three intervals, so ticks
are skipped while it drains. The comment was updated to say so honestly — "a complete retry
sequence can still outlive one interval"
— and the in-flight guard makes it safe. Documented
behaviour, not a defect.

A methodology note worth recording

The reviewer's first run reported the hung heartbeat still not escalating — the fix appearing not to
work at all. That was the harness: a fake fetch ignoring the abort signal, which real fetch does
not. Signal-honouring transports gave the opposite answer. Only the corrected numbers are above.
Fifth time today a discipline check has stopped a false finding, and the first where the false
result would have condemned working code rather than blessed broken code.

CI

32 pass, 6 skipping, zero failing, zero pending. Fully green.

Disposition

This is in the merge queue. The floor is cheaper to add now than to diagnose later from a false
page — but the default config is nowhere near the cliff, so landing it and following up is
defensible. Flagging it deliberately rather than by omission, and leaving the call to the author.

@kwakayama

Copy link
Copy Markdown
Contributor

#4004 fixed the same defect (veryfront/veryfront-issue-inbox#728) in the same file and is now closed as a duplicate of this PR. I compared them by running each one's tests against the other's code, with the reasoning and numbers in the closing comment on #4004.

Short version, since it bears on this PR:

  • Both pass a per-attempt timeoutMs into the same retryWithBackoff. Same mechanism. The difference is the value: one full interval here, versus about 22% to 30% of the interval there.
  • fix(agent): give each heartbeat attempt a deadline so a hang escalates #4004's tests on this code: the hang test and the slow-success test both pass. Only its schedule-arithmetic test fails, and only because it reads a schedule.attemptTimeoutMs field that exists only on its branch, asserting a property this PR deliberately does not hold. Not a defect.
  • This PR's tests on fix(agent): give each heartbeat attempt a deadline so a hang escalates #4004's code: two fail. Its shorter deadline cuts off a heartbeat answering in 40% of its interval, and it does not carry the body-read fix, so a read that fails after the headers arrive still stops the tick after one attempt instead of three.

Nothing to change here. One test from #4004 covers a case this PR's guard does not: a deadline pinned to a fixed number of milliseconds rather than a fraction of the interval. It is carried over in #4007 rather than lost, stacked on this branch so it collapses to a single test once this merges. This branch is untouched.

Merged via the queue into main with commit 9497625 Aug 22, 2026
38 of 39 checks passed
@kwakayama
kwakayama deleted the fix/issue-728-heartbeat-timeout branch August 22, 2026 23:19
@kwakayama

Copy link
Copy Markdown
Contributor

One finding against this PR, raised while comparing it with #4004. Recording it here so it is not lost, whichever way it goes. It does not touch the hang fix, which is sound.

A schema failure is now retried

Moving readAgentPushRuntimeServiceResponse(response) inside the transport-error try also moved agentPushRuntimeServiceResponseSchema.parse(...) inside it. That parse throws a raw ZodError, so isVeryfrontError(cause) is false, so the new catch wraps it as NETWORK_ERROR with no httpStatus, and isRetryableHeartbeatFailure returns true for httpStatus === undefined.

An HTTP 200 with a malformed body is a permanent protocol mismatch, and it now gets all three attempts on every tick.

Probed directly, same probe on both trees, driving lifecycle.heartbeat() against a fetch answering 200 with valid JSON of the wrong shape:

origin/main   requests=1  thrown=ZodError        isVeryfrontError=false  warnings=0
this branch   requests=3  thrown=VeryfrontError  isVeryfrontError=true   warnings=2

Codex reached the same conclusion independently on #4007, which is stacked on this branch.

How much it matters

Not much, which is why this is a note rather than a request to pull the PR from the queue:

  • Escalation is unaffected. Three failed ticks still escalate, at the same moment.
  • The cost is 3x heartbeat requests and two spurious retry warnings per tick, and only while the control plane is serving malformed 200s.
  • It is bounded, self-limiting, and on a path that should not occur in normal operation.

It is still a behaviour change from main that no test here covers and the PR body does not mention.

The fix

Split the two concerns. Wrap the fetch call and the await response.json() read as transport failures, and leave .parse outside the wrapper. That keeps the body-read retry fix this PR added and drops the schema widening that came with it.

I have not touched this branch. Happy to make the change here or as a follow-up, whichever the merge owner prefers.

kwakayama added a commit that referenced this pull request Aug 22, 2026
Follow-up to #3990, which is merged. This is not a competing fix: it keeps
both behaviours #3990 added and narrows only the classification that came
with them.

Four mutations of src/agent/service/registration.ts, each run against the
full registration test file:

  revert to main's shape       schema test RED (3 requests, not 1)
  drop the body-read mapping   #3990's body-read test RED (1, not 3)
  remove timeoutMs entirely    #3990's hang test RED (no escalation in 1500ms)
  timeoutMs: 3_600_000         #3990's hang test RED (same)

The first two show each half of this change is load-bearing and that the
narrowing did not detach #3990's body-read retry. The last two show the
hang-escalation deadline is untouched: both still produce the diagnostic
"hung heartbeat attempts never reached persistent-failure escalation".

Unmutated: 2 passed (16 steps), 0 failed.

Refs veryfront/veryfront-issue-inbox#728
Refs veryfront/veryfront-issue-inbox#764
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