Skip to content

Bound Reservoir NFT reads with a deadline covering headers and body - #97

Merged
portdeveloper merged 2 commits into
portdeveloper:mainfrom
ColinkaMir:feat/reservoir-deadline
Sep 17, 2026
Merged

portdeveloper merged 2 commits into
portdeveloper:mainfrom
ColinkaMir:feat/reservoir-deadline

Conversation

@ColinkaMir

Copy link
Copy Markdown
Contributor

Closes #94.

fetchReservoir called fetch and res.json() with no deadline, so a stalled indexer kept get_nfts waiting indefinitely. The deadline #93 added for the explorer reads covers /history, not this path.

What changed

One AbortController now covers the request and the body read, because a response whose headers arrive and whose JSON then stalls hangs just as completely as one that never answers. The timer is cleared in finally, so a normal answer leaves nothing pending on the event loop. An expiry is reported as an NFT-read error naming the path and the deadline, rather than as a bare AbortError about a signal. A non-finite deadline falls back to the default, since setTimeout treats NaN as fire-now and that would fail every read rather than none.

The no-indexer and no-key refusals, the malformed-row handling and the truncation notice from #68 are untouched. The change stays inside the NFT indexer read path; tools.mjs calls getNfts(owner) exactly as before.

Tests

Four cases in test/nft-deadline.test.mjs, against a loopback server rather than a fetch double, mirroring how the history deadline is tested: a double that ignores signal looks exactly like one that honours it, so only a real connection can show the request was cancelled rather than merely abandoned.

  • stalled headers: rejects with the deadline message, and the socket is destroyed
  • stalled body after good headers: same, which a header-only timeout would miss
  • ordinary answer: tokens come back and no timer outlives the call
  • nonsense deadline (NaN): falls back to the default instead of failing instantly

Verified all four fail on main before the fix and pass after.

What I ran

npm test — 348 pass, 0 fail. npm run build — clean. npm run smoke stops earlier than this path, at the ERC-20 balance step, on a public RPC returning requests limited to 15/sec; that call is untouched by this diff, so I could not get a SMOKE_OK from a shared endpoint today. No indexer credentials and no writes are needed for any of the new tests.

@portdeveloper portdeveloper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The production deadline covers headers and the body correctly. The cancellation observer in test/nft-deadline.test.mjs:86-91 repeats the race tracked in #95: it only subscribes to close, so a socket that closed before the check is reported as uncancelled. I ran this exact helper against a real socket after its close event and got false.

Please handle the already-closed state and add a regression for it, as #96 does for history. Also assert the stalled request reached the fixture before indexing stalledSockets[0], and give connection setup a reasonable test budget; the current 120 ms can expire before a loaded runner opens the socket. Keep the independent outer timeout and real cancellation assertions, and rerun both OS jobs.

Closes portdeveloper#94.

fetchReservoir called fetch and res.json() with no deadline, so a stalled
indexer kept get_nfts waiting indefinitely. The deadline portdeveloper#93 added for the
explorer reads does not cover this path.

One AbortController now covers the request and the body read, because a
response whose headers arrive and whose JSON then stalls hangs just as
completely as one that never answers. The timer is cleared in finally so a
normal answer leaves nothing pending, and an expiry is reported as an NFT-read
error naming the path and the deadline rather than as a bare AbortError. A
non-finite deadline falls back to the default: setTimeout treats NaN as fire-now,
which would fail every read rather than none.

The no-indexer and no-key refusals, the malformed-row handling and the portdeveloper#68
truncation notice are untouched.

Tests run against a loopback server rather than a fetch double, the same way the
history deadline is tested: a double that ignores signal looks exactly like one
that honours it, so only a real connection shows the request was cancelled.
Stalled headers, stalled body, an ordinary answer with no timer left behind, and
a nonsense deadline.
Review on portdeveloper#97: the cancellation observer repeated the race portdeveloper#95 tracks. It only
subscribed to close, so a socket already destroyed when the check ran was
reported as uncancelled, and only after the full wait.

Same three fixes portdeveloper#96 made for history, applied here:

- wasCancelled reads the settled state before it waits, so an already-closed
  socket answers true on the microtask queue.
- A regression covers exactly that: destroy a socket, wait for its close, then
  assert the observer answers true and answers without waiting. Removing the
  settled-state check turns this test red.
- The stall deadline moves from 120ms to 500ms and the outer bound to an absolute
  4000ms. 120ms could expire while a loaded runner was still opening the
  connection, which leaves no socket to assert on; the independent outer timeout
  still turns a missing production deadline into a prompt failure.
- Both stall cases assert the request reached the fixture before indexing the
  socket, so an unreached fixture reads as itself instead of as a TypeError.
@ColinkaMir
ColinkaMir force-pushed the feat/reservoir-deadline branch from 3c0e052 to 6e7e869 Compare September 17, 2026 06:20
@ColinkaMir

Copy link
Copy Markdown
Contributor Author

Fixed, and thank you — the observer did repeat the #95 race. I reproduced it here before changing anything: destroying a socket, waiting for its close, then calling the old helper returned false after the full wait.

Three changes, mirroring #96 rather than inventing a second shape:

  • wasCancelled reads the settled state first, so an already-closed socket answers true on the microtask queue.
  • A regression covers exactly that case, and it is a real red test: removing the settled-state check turns it red while the other four stay green.
  • The stall deadline moves from 120ms to STALL_DEADLINE_MS = 500 and the outer bound to an absolute SETTLE_BOUND_MS = 4000, so connection setup has room on a loaded runner while a missing production deadline still fails promptly rather than hanging.
  • Both stall cases now assert the request reached the fixture before indexing stalledSockets[0], so an unreached fixture reads as itself instead of as a TypeError.

The independent outer timeout and the real cancellation assertions are unchanged. Full suite: 350 pass, 0 fail; both OS jobs are rerunning on this push.

@portdeveloper portdeveloper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

looks good, thanks

@portdeveloper
portdeveloper merged commit 3e77d03 into portdeveloper:main Sep 17, 2026
3 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.

Bound Reservoir NFT reads through the response body

2 participants