Skip to content

[Flight] Add fuzz test for async debug info - #37129

Open
gaearon wants to merge 3 commits into
mainfrom
gaearon-flight-debug-fuzz
Open

gaearon wants to merge 3 commits into
mainfrom
gaearon-flight-debug-fuzz

Conversation

@gaearon

@gaearon gaearon commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

I wanted to tweak some things there but I'm nervous without more coverage so I thought why not add a fuzzer.

So I asked Claude to build me one.

This adds a fuzzer for the async debug info tracking, seeded like our other fuzzers. Each seed builds a random Flight tree: components awaiting some data, cache(), waterfalls, Promise.all, promise props, elements rendered by a different component than created them (sometimes deduped across two locations), data preloaded before the request; some seeds abort, throw in a subtree, or interleave two renders; there are also a few userland thenables and Promise subclasses. The debug info is checked against what each seed's program actually did.

Details

  • Each leaf does real I/O (a timer or an fs read) because IO entries only come from real async resources. To keep this deterministic, the driver first waits for everything the render has scheduled to finish, then runs the leaf's I/O while nothing else is pending, then resolves the leaf — so how long the I/O takes can't change what order anything runs in.
  • The generator records which component created each element and invoked each data source, which real IO backs each leaf and what it resolves to, and the driver records the order it settled things in. The emitted debug info has to agree:
    • Owner chains equal the recorded creator chains, all the way up. When two renders run at once, debug info from one render never claims a component or IO from the other — that would mean the tracking mixed up which request an operation belonged to.
    • Recorded values equal what the leaf resolved to (values over the 1MB limit arrive as the omission placeholder).
    • An IO backed by a timer never carries a file-reading stack, and vice versa.
    • IO end times are ordered the way the driver settled things.
    • Every leaf a component directly fetched and settled shows up in that render's debug info. The exceptions are enumerated in the test with reasons (combinators only attribute the IO that unblocked them, promise props dedupe into the parent's await, and so on).
    • The same IO appears at most once per awaiting component, so accumulation fails immediately.
    • Time rows are monotonic, IO never ends before it starts, and IO with a stack has a name.
  • We record performance.measure and check that every span has finite bounds, non-negative width, and a track.
  • The fuzzer runs in prod too, but without the checks, to catch crashes.

Follow-ups

Found when running this:

  • In DEV, calling .then() without a rejection handler on a chunk that holds a rejected value causes an unhandled rejection that can't be caught anywhere. Debug info for caught rejections hits this on most seeds. The first commit fixes our test util's instance, but the proper fix is TODO.
  • I/O started with fs.readFile (callback form) before the request is attributed one stack frame too deep, naming the IO entry after the wrong frame. Fixed in the PR stacked on this one.
  • An element prop that throws on unexpected property access (like a ClientReference proxy) corrupts the component's data chunk on the client. TODO.

These are fixed in #37130.

getDebugInfo used a bare .then() to init the chunk holding an awaited
value. In DEV, ReactPromise.prototype.then wraps every subscription in
a native promise and .then() doesn't return a chained Promise, so when
the value is a caught server-side rejection the wrapper rejects with no
handler and no way to ever attach one. Depending on timing the rejection
either fails the surrounding test or kills the worker process outright.
Found by the generative test added in the next commit, which reads
recorded rejected values on most seeds.

Co-authored-by: Claude Fable 5 <[email protected]>
@meta-cla meta-cla Bot added the CLA Signed label Jul 26, 2026
@github-actions github-actions Bot added the React Core Team Opened by a member of the React Core Team label Jul 26, 2026
@react-sizebot

react-sizebot commented Jul 26, 2026 •

Copy link
Copy Markdown

Comparing: dcd4ec2...5a55857

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 7.19 kB 7.19 kB +0.10% 1.91 kB 1.91 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 615.77 kB 615.77 kB = 109.00 kB 109.00 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 7.19 kB 7.19 kB +0.10% 1.91 kB 1.91 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 686.96 kB 686.96 kB = 120.48 kB 120.48 kB
facebook-www/ReactDOM-prod.classic.js = 706.84 kB 706.84 kB = 123.91 kB 123.91 kB
facebook-www/ReactDOM-prod.modern.js = 697.16 kB 697.16 kB = 122.30 kB 122.30 kB

Significant size changes

Includes any change greater than 0.2%:

(No significant changes)

Generated by 🚫 dangerJS against 5a55857

@gaearon
gaearon force-pushed the gaearon-flight-debug-fuzz branch 4 times, most recently from 7a2c783 to 3db1523 Compare July 26, 2026 03:07
@gaearon
gaearon requested review from eps1lon and unstubbable July 26, 2026 03:15
@gaearon
gaearon force-pushed the gaearon-flight-debug-fuzz branch 2 times, most recently from 6054657 to 46e278c Compare July 26, 2026 14:02
daltino

This comment was marked as spam.

gaearon and others added 2 commits July 26, 2026 20:00
Each seed builds a random Flight render shaped like a real app:
components fetch their own data, share a cache()-deduped data layer,
waterfall through helpers, fan out with Promise.all, pass promises down
as props, render elements created by another component (sometimes
deduped across two locations), and await data that was preloaded before
the request. Some seeds abort mid-render, throw in a subtree, or run
two renders interleaved. A small share of the graph is hostile
(userland thenables, Promise subclasses).

The runs are deterministic. Every random decision is drawn while the
seed is built, never while it runs. Async leaves park their resolvers
with a driver; a seeded loop drains everything the render scheduled,
runs each leaf's real I/O to completion while nothing else is pending
(so its latency can't reorder anything), then settles batches of leaves
back to back in the same tick, with seeded microtask and macrotask hops
in between. Leaves do real I/O (a timer or an fs read) because io
entries only come from real async resources.

This commit only asserts that the workload doesn't crash, on every
channel including production builds. The checks on the emitted debug
info come next: they are computed from what each seed's program
actually did rather than recorded, so any seed can be verified, not
just a committed set.

To debug or explore one seed: FUZZ_TEST_SEED=<n> yarn test ReactFlightAsyncDebugInfoFuzz

Co-authored-by: Claude Fable 5 <[email protected]>
The generator and driver know the ground truth for every seed: which
component's body created each element and invoked each data source
(a marker maintained around source calls), which real io backs each
leaf, what it resolved to, and the order the driver settled things in.
The emitted debug info has to agree:

- Owner chains equal the recorded creator chains, all the way up, and
  never reach into the other interleaved render.
- An io attributed to a render was initiated by that render.
- Recorded values equal what the leaf resolved to; values over the 1MB
  limit must arrive as the omission placeholder, never raw.
- An io backed by a timer never carries a file-reading stack and vice
  versa.
- io end times are ordered like the driver's settle batches.
- Time rows are monotonic, io never ends before it starts, an io with a
  stack has a name.
- Every leaf a component directly fetched and settled appears in that
  render's debug info. Exemptions are enumerated where attribution is
  legitimately looser: combinators and parallel awaits only attribute
  the io that unblocked them, use() carries the transformed value,
  promise props dedupe into the parent's await, pass-through element
  returns don't surface the parent's io (TODO: expected, or a gap?),
  throwing subtrees never finish, aborted renders race the io.
- The same io appears at most once per awaiting component (plus forks);
  exponential accumulation fails immediately.
- performance.measure spans (recorded, not forwarded) have finite
  bounds, non-negative width, and a track.

Since every check derives from the seed's own execution, any seed
verifies, not just a committed set: FUZZ_TEST_SEED=12345 explores.

Co-authored-by: Claude Fable 5 <[email protected]>
@gaearon
gaearon force-pushed the gaearon-flight-debug-fuzz branch from 46e278c to 5a55857 Compare July 26, 2026 19:04

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants