Cap a read deadline at setTimeout's ceiling instead of firing at once - #101
Merged
portdeveloper merged 1 commit intoSep 21, 2026
Merged
Conversation
Both read deadlines accepted any finite positive timeoutMs. setTimeout does not: above 2^31-1 Node warns and fires after a millisecond, and a value below 1 is set to 1. Either way a caller asking for a long wait got the request cancelled at once — the outcome the clamp's own comment said it prevents. The rule moves into resolveDeadline, exported and pure, with three outcomes rather than two. A usable duration is floored and used, because the deadline is quoted back in the timeout message and "timed out after 1.9ms" would claim a wait no timer honoured. An oversized finite one is capped: asking for longer than setTimeout can express is asking for the longest wait available. Anything that names no usable duration takes the default. Infinity belongs in that third group, not with the capped values. Capping it would hand back a 24.9-day wait — the unbounded read that portdeveloper#92 and portdeveloper#93 exist to remove, wearing a deadline's clothes. A finite number is a duration the caller named; Infinity is the absence of one. Both fetch wrappers now resolve their own argument. Before, fetchReservoir clamped inside itself while getHistory clamped at the caller and passed a resolved number down — two shapes around one rule, which is how the next copy picks the wrong one. That asymmetry is what this change is about, so leaving it in the change itself would have been odd. Exported because the rule cannot be asserted honestly through a request. With the bug the one-millisecond timer races the loopback response and the same call rejects or succeeds run to run; with the fix the deadline is twenty-four days, so a test that waits for it to fire never finishes. The arithmetic has neither problem, and the per-path tables now say only what they observe: that the path routes through the helper rather than handing setTimeout the raw value. The NFT path carried the same construction, comment included, and had no test for the clamp at all. Both are fixed here because both came from one source.
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.
Closes #100
Both read deadlines clamped
timeoutMsthe same way —src/wallet.mjs:79for NFT reads and:292for history:A finite number above
2^31 - 1passes that check andsetTimeoutdoes not accept it: Node warnsTimeout duration was set to 1and fires after a millisecond. The meaning inverts — a caller asking for effectively no limit gets the request cancelled at once, which is what the clamp's own comment says it exists to prevent. A sub-millisecond value does the same from the other end:0.5passes> 0and rounds up to 1 ms.The failure is not deterministic, and saying so matters more than the numbers. A one-millisecond timer races the loopback response, so the same call rejects or succeeds run to run. Fifty consecutive calls with
timeoutMs = 2147483648against a fixture answering immediately, on0d2c880:getNftsgetHistoryThose ratios are one machine's, and the gap between the two paths is structural rather than noise:
getHistoryonly rejects when both requests lose the race (if (!fulfilled.length)), whilegetNftshas a single request to lose. What does not vary at all is the cause:TimeoutOverflowWarning … Timeout duration was set to 1on every call, whichever side wins.What changed
The rule moves into
resolveDeadline, exported and pure, and both reads call it. It has three outcomes rather than two: a usable duration is floored and used, an oversized finite one is capped atMAX_TIMEOUT_MS, and anything that names no usable duration takes the default. Both defaults are untouched.Infinitybelongs in the third group, not with the capped values, and that distinction is the point of the guard rather than a detail of it. Capping it would hand back a 24.9-day wait — the unbounded read #92 and #93 exist to remove, wearing a deadline's clothes. A finite number is a duration the caller named, however unreasonable;Infinityis the absence of one.Both fetch wrappers resolve their own argument now. Before this,
fetchReservoirclamped inside itself whilegetHistoryclamped at the caller and handedfetchExplorerItemsa number that was already resolved. Two shapes around one rule is exactly how the next copy picks the wrong one — the mechanism this change is about — so leaving it inside the change would have been odd. Three lines, no behaviour change:Infinity→ 10000,2147483648→ 2147483647,1.9→ 1,0.5→ 10000,NaN→ 10000,250→ 250, before and after. The timeout message quotes the resolved deadline either way, which the existing stall tests pin.The floor is there because the deadline is quoted back in the timeout message.
1.9used to pass through and be reported astimed out after 1.9ms, whilesetTimeouttruncates it: over 60 runs each,1.9fires when1does — median 1.05 ms against 1.05 ms — while2lands at 2.05 ms. Absolute numbers drift with the scheduler; the step is what holds. That is the 2^31 problem in miniature: a number told to the operator that no timer honoured. Flooring makes the number reported the number used.Exported rather than kept private because the rule cannot be asserted honestly through a request. With the bug the timer races the response, as the table above shows. With the fix the deadline is twenty-four days, so a test that waits for it to fire never finishes. Neither is a test; the arithmetic is.
An intermediate version of this change dropped the
Number.isFinitecheck, on the grounds that the cap handledInfinitytoo and mutation testing showed the check unobservable. Both halves were wrong: the cap did handleInfinity, but handling it that way was the bug above, and the check was unobservable only because the table assertingInfinitysaidCEILING. Restoring the check, the mutation is caught. Kept as a lesson about what "no test observes this" can mean — sometimes the tests are right and the behaviour is wrong; sometimes the assertion is simply wrong too.Tests
405, up from 386. 10 consecutive runs, no failure.
resolveDeadlineas a table: the ceiling itself, one past it, far past it,Number.MAX_SAFE_INTEGER,Infinity, exactly 1 ms, a fraction above one, a larger fraction, a fractional string, below 1 ms, zero, negative,NaN, a word,null,undefined, a numeric string;setTimeoutrounds them up, so taken literally they cancel everything likeNaNdoes, not wait a shorter time;must fall back to the defaulttomust not cancel the request— which is what those rows actually prove: that the path routes through the helper rather than handingsetTimeoutthe raw value. Which outcome each value produces is pinned in theresolveDeadlinetable instead.Mutation-checked, each guard reverted on its own with a no-op edit as a control:
resolveDeadlinereturns the raw requestNumber.isFiniteremoved, soInfinityis capped>= 1back to> 0One thing I corrected in review rather than shipping: the new constant and helper first landed between a comment and the constant it describes, leaving the
NFT_TIMEOUT_MSnote sitting aboveMAX_TIMEOUT_MS. Moved so nothing is orphaned. Every claim in those comments is measured, including the one above about truncation, and2147483648warns and fires at once.Provenance and scope
This is my own defect: I wrote the history clamp in #93 and #97 copied the construction — comment included — onto the NFT path. One fix belongs in both places because there is one source, and the tests showed that plainly: the history fallback was pinned with six shapes and the NFT side with exactly one.
Infinitywas among the six, but with a fixture that answers at once that row could not tell the default from the cap; the NFT table did not list it at all. Both tables are the same set now, and the comment beside the NFT one says why they are kept identical — an asymmetric pair is how the next copy loses coverage again.I had reproduced that asymmetry in this branch before noticing it: my first version widened the history table and left the NFT one a shape short.
Not reachable through the CLI:
cli.mjs:416callsgetHistory()with no arguments, and the NFT read reaches the wallet fromsrc/tools.mjs:972aswallet.getNfts(owner)— an owner, no options. What is affected istimeoutMsas a documented option of two exported functions, and a comment that promised more than the code delivered.