Commit f06f7b1
authored
fix(web): pull a streamed result behind a demand gate (#3124)
* fix(web): pull a streamed result behind a demand gate
The stream was built with no `pull` and no queuing strategy, and every
codec node is enqueued as soon as it is parsed, so the producer ran as
fast as it could resolve whether or not anyone read. One slow consumer
buffered the entire result in server memory — unbounded, and invisible to
application code.
The gate sits in the iterator wrapper the runtime already installs, which
its own comment calls "the only seam where a dropped consumer can stop
the producer": the same seam lets a SLOW consumer slow it. A read drives
`pull`, `pull` releases one source pull. Teardown releases a parked pull
too, or an aborted stream would hang on it.
Measured with an async generator and no reads for 200 event-loop turns:
the producer advanced by 1, where it previously tracked the turn count
(and reached ~17,000 items, +19 MiB, in the wall-clock shape the issue
reports). Reading 20 chunks advances it by 20, and cancelling stops it
within the pull in flight.
The #3112 marker for this gap comes off, per the convention the other two
followed when they closed.
* fix(web): release a parked pull when the stream ends, and say what the gate covers
Review of the first shape found a regression I introduced. `onDone` and
`onError` set `closed` directly rather than through `teardown()`, so a
pull parked on the demand gate was stranded: `desiredSize` is 0 after
close and null after error, both failing the `> 0` check, so the gate
never reopened and the source's `finally` never ran — leaking generator,
cursor and file-handle cleanup once per failed request. Every path that
ends the stream now runs `finishSource()`, which closes the source and
releases the parked pull.
Also from review:
- The gate rests on the default high-water mark of 1, and `releaseDemand`
holds a single resolver, safe only because the pump is sequential.
Both were load-bearing and unstated; both are now in the comment.
- `!streamController` in `wantsMore` was unreachable — `start()` assigns
the controller before the iterator is ever opened.
- The spec header still called #3118 open, and the changeset claimed the
producer "advanced by one step" where the test asserts it stays near
the queue size.
- The liveness assertion was `> 1` under a comment claiming it tracked
the reads; a gate that resumed twice in twenty reads would have passed.
Scope is now stated rather than implied: only the result itself is
gated. A nested `{ items: rows() }` is pumped by the codec directly and
still runs ahead — measured at 200 items over 200 idle turns against 1
for the top-level shape.
* test(web): make the teardown guard actually discriminate
The first version of this test passed with finishSource() removed from
both codec-end paths — it never reached the defect. Two reasons, and the
second is the one worth writing down:
- the source was NESTED (`{ rows: gen(), … }`), and a nested iterable
never enters the wrapper, so nothing parked;
- a top-level source needs no sibling branch to carry the failure. The
deferred failure rides INSIDE a yielded chunk: a pending promise in the
first value resolving to an object whose getter throws. The pump
enqueues the chunk, asks for the second, parks because the consumer
stopped reading, and only then does the promise settle and onError fire
against a parked pull.
`produced` is asserted alongside the cleanup so the nested-shape mistake
cannot quietly recur: if the source never parks, the test says so instead
of passing for the wrong reason.
Verified both ways — with finishSource() in the codec-end paths the
cleanup runs, without it the assertion fails.
* fix(web): check finished before the gate, and make a test that parks
Third review round, both findings real.
The gate was checked before `finished`, so teardown landing while a pull
was in flight stranded the codec's pump: the release it fires finds
nothing parked, the in-flight pull then resolves, the pump asks for the
next item, `wantsMore()` is false — 0 after close, null after error — and
it parks on a resolver nobody will ever call. `push()` never returns.
Impossible before this PR, where `finished` was checked first. One token,
and it mirrors the defect the previous round fixed: that one stranded the
source, this one stranded the pump.
Worth stating plainly: this half is not guarded by a test. The failure is
a leaked pending promise inside seroval's pump with no outward symptom —
the source still runs its `finally`, the response still ends, nothing
observable differs. It was found with instrumented park/release counters,
and that is the evidence it rests on.
The tests also did not park at all. Both read in a tight loop, so a read
request is always pending, `desiredSize` never drops and the producer
never reaches the gate — deleting `pull()` outright left all eight green,
including the one whose comment claimed it would catch a deadlock. The
new test pauses between reads, which is what puts the producer on the
gate, and reads to completion with a deadline so a gate that never
reopens fails fast instead of hanging. It counts arrivals by its own key
rather than by the codec's node shapes.
* test(web): say what the resume test actually pins
Its comment claimed it was the case that would catch a deadlock. It is
not: it reads in a tight loop, so a read request is always pending,
`desiredSize` never drops and nothing parks — the same blindness review
found in the original pair. What it pins is the cancel half. The pausing
test is the liveness one.1 parent 40af4d6 commit f06f7b1
3 files changed
Lines changed: 189 additions & 13 deletions
File tree
- .changeset
- packages/web
- server-functions/src
- test/server
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1681 | 1681 | | |
1682 | 1682 | | |
1683 | 1683 | | |
| 1684 | + | |
| 1685 | + | |
| 1686 | + | |
| 1687 | + | |
| 1688 | + | |
| 1689 | + | |
| 1690 | + | |
| 1691 | + | |
| 1692 | + | |
| 1693 | + | |
| 1694 | + | |
| 1695 | + | |
| 1696 | + | |
| 1697 | + | |
| 1698 | + | |
| 1699 | + | |
| 1700 | + | |
| 1701 | + | |
| 1702 | + | |
| 1703 | + | |
| 1704 | + | |
| 1705 | + | |
| 1706 | + | |
1684 | 1707 | | |
1685 | 1708 | | |
| 1709 | + | |
| 1710 | + | |
| 1711 | + | |
| 1712 | + | |
| 1713 | + | |
| 1714 | + | |
| 1715 | + | |
| 1716 | + | |
1686 | 1717 | | |
1687 | 1718 | | |
1688 | 1719 | | |
1689 | 1720 | | |
1690 | 1721 | | |
1691 | | - | |
| 1722 | + | |
1692 | 1723 | | |
1693 | 1724 | | |
1694 | 1725 | | |
| |||
1716 | 1747 | | |
1717 | 1748 | | |
1718 | 1749 | | |
| 1750 | + | |
1719 | 1751 | | |
1720 | | - | |
| 1752 | + | |
| 1753 | + | |
| 1754 | + | |
| 1755 | + | |
| 1756 | + | |
| 1757 | + | |
| 1758 | + | |
| 1759 | + | |
| 1760 | + | |
1721 | 1761 | | |
1722 | 1762 | | |
1723 | 1763 | | |
| |||
1727 | 1767 | | |
1728 | 1768 | | |
1729 | 1769 | | |
| 1770 | + | |
1730 | 1771 | | |
1731 | 1772 | | |
1732 | 1773 | | |
| |||
1766 | 1807 | | |
1767 | 1808 | | |
1768 | 1809 | | |
| 1810 | + | |
1769 | 1811 | | |
1770 | 1812 | | |
1771 | 1813 | | |
1772 | 1814 | | |
1773 | 1815 | | |
1774 | 1816 | | |
| 1817 | + | |
1775 | 1818 | | |
1776 | 1819 | | |
1777 | 1820 | | |
| |||
1798 | 1841 | | |
1799 | 1842 | | |
1800 | 1843 | | |
| 1844 | + | |
| 1845 | + | |
| 1846 | + | |
1801 | 1847 | | |
1802 | 1848 | | |
1803 | 1849 | | |
| |||
Lines changed: 121 additions & 11 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
161 | 161 | | |
162 | 162 | | |
163 | 163 | | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
168 | | - | |
169 | | - | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
170 | 171 | | |
171 | | - | |
172 | | - | |
173 | | - | |
174 | | - | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
175 | 176 | | |
176 | 177 | | |
177 | 178 | | |
| |||
191 | 192 | | |
192 | 193 | | |
193 | 194 | | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
194 | 304 | | |
195 | 305 | | |
196 | 306 | | |
| |||
0 commit comments