Repository navigation
Decide read timeout arming on the nano deadline - #2391
Open
pavel-ptashyts wants to merge 6 commits into
Open
pavel-ptashyts wants to merge 6 commits into
pavel-ptashyts wants to merge 6 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:requestTimeoutMillisTimewasDateUtils.unpreciseMillisTime()plus the timeout, taken in the constructor. A step of the wall clock between the two readings changed the answer:#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
startReadTimeoutcompares againstuntilRequestDeadline()onnanoClock, through a newdueBeforeRequestDeadline(delay).falsebefore it reads anything. Serve the read timeout from the request timeout's timer entry #2382 keeps the deadline offnanoClockon this path on purpose, and this change keeps it that way.nanoClock:start()uses to arm the entry early: the read timeout is the shorter. It used to be set instart(). ButNettyResponseFuture.setTimeoutsHoldermakes the holder reachable through the future before it callsstart(), so astartReadTimeoutin 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, whichrehomeOnmay move.requestTimeoutMillisTimehad no readers left, so it is gone, together with its clock read in the constructor and the test-only accessor.TimeoutsHolderTestnow checks the deadline throughuntilRequestDeadline().Overflow: a request timeout of
Long.MAX_VALUEmakesmillis + timeoutoverflow. That did not break the old check, because the subtraction cancels the overflow again; a test written for it passes onmain. The new code adds the saturatedtoNanos(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:mainaWallClockStepForwardDoesNotKeepAReadTimeoutFromStartingaWallClockStepForwardDoesNotKeepAReadTimeoutFromStartingUnderAnAbsoluteDeadlineaWallClockStepBackDoesNotStartAReadTimeoutThatTheRequestDeadlineBeatsaWallClockStepBackDoesNotStartAReadTimeoutNoShorterThanTheRequestTimeout(also asserts no nano read)aRequestTimeoutPastWhatTheClocksCanHoldStillLetsAReadTimeoutStartaReadTimeoutStartedBeforeTheHolderIsStartedStillGetsAnEntryOther test changes:
ReadTimeoutTimerTaskTest.aReadTimeoutStillDueBeforeTheRequestTimeoutIsReArmedcalledstartReadTimeout()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.Long.MAX_VALUEtests 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.mainand the branch were run alternately.readTimeoutMsmainops/usmainB/opmainread the wall clock in the constructor. The holder is 8 bytes smaller.main.start()and measured 5.40 / 5.31 ops/us on the early path. The number of clock reads was the same. With-XX:+PrintInliningthe 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 beforestart()reads a set deadline.-t 8was 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.
AGENTS.md, ongit diff upstream/main...HEAD)gpt-6-astraclaude-opus-5-5gpt-6-astraclaude-opus-5-5gpt-6-astraclaude-opus-5-5gpt-6-astraclaude-opus-5-5gpt-6-astraclaude-opus-5-5gpt-6-astraclaude-opus-5-5Findings and what was done:
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.start()(rounds 1 and 2, risk).startReadTimeoutthere read an unset deadline, and onmainthe constructor set it. Fixed in 2fdb3aa: the per-attempt deadline is set in the constructor.aReadTimeoutStartedBeforeTheHolderIsStartedStillGetsAnEntrycovers it.aWallClockStepForwardDoesNotKeepAReadTimeoutFromStartingUnderAnAbsoluteDeadline.nanoOrigin = ONE_HOUR_MSis 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.aPerAttemptTimeoutGivesTheSecondHopItsOwnBudgetwould pass with an unset deadline (round 4, nit). Not changed:aPerAttemptExchangeIsNotBoundedAsAWholenext to it asserts the exact budget, as the reviewer noted.aReadTimeoutStartedBeforeTheHolderIsStartedStillGetsAnEntryalso passes onmain(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