Skip to content

Decide read timeout arming on the nano deadline - #2391

Open
pavel-ptashyts wants to merge 6 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/read-timeout-monotonic-deadline
Open

pavel-ptashyts wants to merge 6 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/read-timeout-monotonic-deadline

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Problem

TimeoutsHolder.startReadTimeout(task, delay) decides whether a read timeout needs a timer entry of its own by checking whether it would be due before the request deadline. It still made that check on the wall clock:

delay < requestTimeoutMillisTime - millisClock.getAsLong()

requestTimeoutMillisTime was DateUtils.unpreciseMillisTime() plus the timeout, taken in the constructor. A step of the wall clock between the two readings changed the answer:

  • Step back: a read timeout was armed even though the request deadline comes first.
  • Step forward: the read timeout was skipped while the request deadline was still far off. The exchange then had only the request timeout to end it.

#2382 already moved every other check of the request deadline onto nanoClock (requestDeadlineNanos, compared by subtraction). This line kept the wall clock only because #2377 had rewritten it in parallel.

Change

  • startReadTimeout compares against untilRequestDeadline() on nanoClock, through a new dueBeforeRequestDeadline(delay).
  • The default path (read timeout no shorter than the request timeout) reads no clock at all. A delay no shorter than the whole request timeout can never be due first, so the check returns false before it reads anything. Serve the read timeout from the request timeout's timer entry #2382 keeps the deadline off nanoClock on this path on purpose, and this change keeps it that way.
  • Below that threshold the deadline is always on nanoClock:
    • An absolute deadline is set in the constructor, as before.
    • A per-attempt deadline is now also set in the constructor, under the same condition start() uses to arm the entry early: the read timeout is the shorter. It used to be set in start(). But NettyResponseFuture.setTimeoutsHolder makes the holder reachable through the future before it calls start(), so a startReadTimeout in that window would have read an unset deadline. The clock is read the same number of times as before.
    • start() still sets the deadline for an event-loop entry that is not early, which rehomeOn may move.
    • A delay shorter than the read timeout only comes from a read timeout that has already passed this check in full.
  • The wall-clock deadline requestTimeoutMillisTime had no readers left, so it is gone, together with its clock read in the constructor and the test-only accessor. TimeoutsHolderTest now checks the deadline through untilRequestDeadline().

Overflow: a request timeout of Long.MAX_VALUE makes millis + timeout overflow. That did not break the old check, because the subtraction cancels the overflow again; a test written for it passes on main. The new code adds the saturated toNanos(Long.MAX_VALUE) to a nano reading, which wraps, and survives for the same reason. The test is kept as a guard for that.

No public API change. Revapi is clean.

Tests

In RequestTimeoutTimerTaskTest:

Test On main
aWallClockStepForwardDoesNotKeepAReadTimeoutFromStarting fails
aWallClockStepForwardDoesNotKeepAReadTimeoutFromStartingUnderAnAbsoluteDeadline fails
aWallClockStepBackDoesNotStartAReadTimeoutThatTheRequestDeadlineBeats fails
aWallClockStepBackDoesNotStartAReadTimeoutNoShorterThanTheRequestTimeout (also asserts no nano read) fails
aRequestTimeoutPastWhatTheClocksCanHoldStillLetsAReadTimeoutStart passes (guard for the new code)
aReadTimeoutStartedBeforeTheHolderIsStartedStillGetsAnEntry passes (guards the constructor anchor)

Other test changes:

  • ReadTimeoutTimerTaskTest.aReadTimeoutStillDueBeforeTheRequestTimeoutIsReArmed called startReadTimeout() on a holder that was never started. It ran on the real nano clock, so on the new code it would no longer test what it claims. It now reaches the read timeout's own entry the way an exchange does: the early entry fires before the request is written. It runs on fake clocks.
  • The two Long.MAX_VALUE tests build their holder after moving the fake monotonic clock to a positive origin. A real monotonic clock never steps back across a constructor.

Benchmark

RequestTimeoutsArmBenchmark.armAndCancel, JDK 17.0.2, Windows 11, -f 2 -wi 5 -w 2s -i 8 -r 2s -t 1 -prof gc, -Xms256m -Xmx256m -XX:+UseParallelGC. main and the branch were run alternately.

readTimeoutMs main ops/us branch ops/us main B/op branch B/op
60000 (default path) 4.061 +- 0.016 6.557 +- 0.080 223.9 216.0
10000 (early entry) 3.423 +- 0.060, 3.451 +- 0.016 3.384 +- 0.035, 3.384 +- 0.078 223.9 215.9
  • Default path: no clock read is left in the holder, where main read the wall clock in the constructor. The holder is 8 bytes smaller.
  • Early entry: level with main.
  • An intermediate version set the per-attempt deadline in start() and measured 5.40 / 5.31 ops/us on the early path. The number of clock reads was the same. With -XX:+PrintInlining the difference shows up as a different inlining tree: the 224-byte constructor is "already compiled into a big method" where the 180-byte one was inlined. It was given up so that a holder reachable before start() reads a set deadline.
  • -t 8 was too noisy on this shared machine to read (+-100%), so it is not shown.

Second-agent review

Both reviewers ran read-only in fresh CLI sessions.

Round Reviewer Agent and model Effort Commit reviewed Result
1 Guided (against AGENTS.md, on git diff upstream/main...HEAD) Codex gpt-6-astra medium e5ed931 No findings
1 Independent (no checklist, reads the files) Claude Code claude-opus-5-5 medium e5ed931 2 risks, 1 nit
2 Guided Codex gpt-6-astra medium 79391ae No findings
2 Independent Claude Code claude-opus-5-5 medium 79391ae 1 risk, 1 nit
3 Guided Codex gpt-6-astra medium 2fdb3aa No findings
3 Independent Claude Code claude-opus-5-5 medium 2fdb3aa 1 nit
4 Guided Codex gpt-6-astra medium 8f5db61 No findings
4 Independent Claude Code claude-opus-5-5 medium 8f5db61 3 nits
5 Guided Codex gpt-6-astra medium 128b91c No findings
5 Independent Claude Code claude-opus-5-5 medium 128b91c 2 nits
6 Guided Codex gpt-6-astra medium beadd55 No findings
6 Independent Claude Code claude-opus-5-5 medium beadd55 No findings

Findings and what was done:

  • The no-clock-read shortcut silently relied on start() setting the deadline whenever the read timeout is the shorter (round 1, risk). Fixed: the comments say so where the deadline is set and read (79391ae). Round 2's fix then made this structural.
  • A holder is reachable through its future before start() (rounds 1 and 2, risk). startReadTimeout there read an unset deadline, and on main the constructor set it. Fixed in 2fdb3aa: the per-attempt deadline is set in the constructor. aReadTimeoutStartedBeforeTheHolderIsStartedStillGetsAnEntry covers it.
  • No wall-clock test under an absolute deadline (round 1, nit). Added aWallClockStepForwardDoesNotKeepAReadTimeoutFromStartingUnderAnAbsoluteDeadline.
  • nanoOrigin = ONE_HOUR_MS is a millisecond constant used as a nano origin (round 2, nit). Not changed: it mirrors the neighbouring test from Serve the read timeout from the request timeout's timer entry #2382, and the value only needs to be positive.
  • "The deadline is not on nanoClock" on the default path (round 3, nit). Not true for event-loop timeouts. Reworded to "need not be" (8f5db61).
  • Ambiguous "it" in the javadoc (round 4, nit). Fixed (128b91c).
  • The test helper drops a holder without saying why (round 4, nit). Commented (128b91c). The reason was then corrected in round 5 (beadd55).
  • aPerAttemptTimeoutGivesTheSecondHopItsOwnBudget would pass with an unset deadline (round 4, nit). Not changed: aPerAttemptExchangeIsNotBoundedAsAWhole next to it asserts the exact budget, as the reviewer noted.
  • aReadTimeoutStartedBeforeTheHolderIsStartedStillGetsAnEntry also passes on main (round 5, nit). Not changed: it guards the constructor anchor, not the wall-clock fix, as the table above says.

Claude Code on behalf of Pavel Ptashyts

🤖 Generated with Claude Code

pavel-ptashyts and others added 6 commits November 10, 2026 07:45
startReadTimeout still decided whether a read timeout needs an entry
of its own by comparing against the request deadline on the wall
clock. A step of that clock moved the answer: a step back armed a read
timeout due after the request deadline, a step forward skipped one
while the request deadline was still far off. AsyncHttpClient#2382 already keeps the
request deadline on nanoClock for every other check, so compare
against that, by subtraction.

The default path, a read timeout no shorter than the request timeout,
never puts the deadline on nanoClock, and no read timeout there can
be due first, so a delay no shorter than the request timeout is
answered without a clock read. With the wall-clock deadline unused,
it goes, together with its clock read in the constructor.

A request timeout of Long.MAX_VALUE did not break the old comparison:
the subtraction cancels the overflow. A test now guards that case.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nano deadline that startReadTimeout now consults is set lazily, so
whether it is there depends on start() having put it there whenever
the read timeout is the shorter. Say so where it is set and read, so
that narrowing when start() sets it does not silently break the check.
Also cover a wall-clock step under an absolute deadline.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setTimeoutsHolder makes a holder reachable through its future before
it calls start(), so startReadTimeout can run on a holder whose
per-attempt deadline start() has not yet put on nanoClock, and read
an unset one. Set it in the constructor instead, under the same
condition start() arms the entry early: the read timeout is the
shorter. That reads the clock no more often, and leaves start() to
set it only for an event-loop entry rehomeOn may move.

The overflow tests now build their holder after moving the fake
monotonic clock, which a real one never steps back across.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On an event loop start() puts the deadline on nanoClock even when the
read timeout is no shorter, so the default path is one where it need
not be there, not one where it never is.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name the deadline in the read check's javadoc, where "it" could be
read as the delay, and say why the longest-timeout test helper drops
the holder holder() builds.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dropped holder read the clock at the future's real start, which
is not known to be positive, so its deadline need not overflow; the
helper rebuilds one at an origin that does.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant