Skip to content

Fetch: the iterator attributes, the disturbed-body lock, and WebIDL's record order - #3247

Merged
lahma merged 4 commits into
sebastienros:mainfrom
lahma:fix/3212-fetch-object-model
Aug 22, 2026
Merged

lahma merged 4 commits into
sebastienros:mainfrom
lahma:fix/3212-fetch-object-model

Conversation

@lahma

@lahma lahma commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes items 7, 8 and 9 of #3212, and returns a verdict on item 10 rather than a fix. With #3243, #3244 and #3245 merged, this closes out the eleven — #3212 can close with this.

13 corpus rows go green; exactly 13 rows move across the whole 40,631-assertion corpus, all FAIL→PASS, verified row by row.


Item 7 — iterator prototype next — and two siblings nobody was watching

WebIDL §3.7.9.2 requires next to be { writable, enumerable, configurable }. Only enumerable was wrong; writable, configurable, length 0, name and the @@toStringTag shape were already right, and the new tests pin all of them so the next change to that object cannot quietly move one.

Headers had the failing rows, but the same defect was in FormData and URLSearchParams iterator prototypes too — and no vendored row reaches either, so it was invisible debt. Fixed all three; that is the entire change, one attribute per file, made expressible by #3238's Flags property.

Item 8 — the recorded cause was wrong in its operative half

It said the stream .body exposes "is not the one the consume path locked". The consume path locked nothing, and disturbed is not what getReader() refuses: SetUpReadableStreamDefaultReader step 1 refuses a locked stream and only a locked one, so Jint handing a reader to a disturbed-but-unlocked stream was quite correct.

What a browser does is fully read step 3 — "let reader be the result of getting a reader for body's stream" — whose read-all-bytes never releases it. So a consumed body's stream is disturbed, closed and locked for good. That also explains why the ReadableStream-source rows already passed: they go through FetchBody.FullyRead, which holds its reader.

GetOrCreateStream now cancels and acquires a default reader, building the stream empty since nothing can read a locked one.

Item 9 — the extra operation, and a pre-existing inconsistency it exposed

Measured through the WPT file's own logging proxy, the extra operation is the same on both rows: the [[Get]] of the key that ends the conversion. WebIDL §3.2.23 step 4.2 is typedKey conversion then ? Get(O, key); FillFromRecord did the Get first and converted the key inside append.

Writing step 3 as the unfiltered ? O.[[OwnPropertyKeys]]() removed a real inconsistency along the way: JsProxy.GetOwnPropertyKeys honours Jint's types filter only when there is no ownKeys trap — correctly, since the spec's internal method has no filter — so a Symbol key already reached the walk through a proxy while new Headers({ [Symbol.toStringTag]: 'x' }) on a plain object was silently accepted where a browser throws.

Measured before → after: invalid-name row 6 → 5 operations, Symbol-keys row 8 → 7; the three already-correct shapes unchanged.

Item 10 — not a defect. Row left in place.

HTML's algorithm normalizes newlines per entry and then returns "the byte sequence resulting from encoding the entry list using the rules described by RFC 7578" — it defines the escaping and delegates the framing. RFC 7578 defers to RFC 2046, whose §5.1.1 grammar ends every multipart-body with a close-delimiter that is not optional. An empty entry list has no shorter conforming encoding than the 50 bytes Jint writes.

The empirical half is decisive: on wpt.fyi across four aligned stable runs (chrome 152, edge 151, firefox 154, safari 26.6), "Consume empty FormData response body as text" is 0/1 in all four, while the file's thirteen other rows are 1/1 in all four (methodology validated against three control rows). No implementation passes it.

The row stays excluded, and its prose in WptExclusions.cs and Vendor/README.md now records that verdict instead of "wants a spec read". NeedsTriage is the wrong category for it — it wants one meaning "the test asserts what nothing requires", the WPT analogue of test262's PERMANENT EXCLUSIONS. Recommended as a separate issue rather than invented here.

Item 8's neighbours, all pinned

clone() after consumption still throws (unusable is disturbed or locked, and it was already disturbed), including with body read first; bodyUsed timing unchanged; Request covered as well as Response, with its new test watched failing against unfixed code (source reverted, run, restored); all six consumers as a theory — text/json/arrayBuffer/blob/bytes/formData, bytes() beyond the four the issue named; and the opposite direction, where getReader() + releaseLock() leaves the body usable and consuming then locks that same stream.

Exclusion accounting

13 NeedsTriage rows deleted — 3 headers-basic, 2 headers-record, 2 globs covering 8 rows of response-stream-disturbed-5. Two NeedsTriage entries remain repo-wide: the workers self = 1 row (#3224) and the empty-FormData row above. The doc comment now has exactly two live bullets.

Doc-staleness caught, as warned: three NeedsTriage bullets still described the streams defects that #3238 had fixed and whose rows were already gone. Rewritten to past tense.

Verification

Solution builds Release, 0 errors, all TFMs. Jint.Tests 10275 (net10.0) / 7147 (net472); Jint.Tests.PublicInterface 2405 / 1879; both repeated with JINT_HOST_CONTRACT_VERIFICATION=1 (2409 / 1883). WPT 420/420 after rebase.

Row-level census across all 270 files, before and after:

corpus assertions fail before fail after
fetch/api 388 88 75
every other corpus 40,243 2,819 2,819
total 40,631 2,907 2,894

Exactly 13 rows moved, all FAIL→PASS, and they are exactly the 13 intended. Zero rows changed in the other direction, none appeared or disappeared, and no other corpus moved by a single assertion. Stated because it is a finding rather than a rounding error.

Every engine change is inside Jint/WebApi/, so Jint.Tests.Test262 was not run.

Left undone

  • 188 of the 190 [JsFunction] declarations under Jint/WebApi/ are non-enumerable, where WebIDL §3.7.6 gives a regular operation { writable, enumerable, configurable }. No vendored row catches it (idlharness is not vendored). Already filed as Every web-API operation is non-enumerable, where WebIDL makes regular operations enumerable #3239; deliberately out of scope here.
  • FillFromSequence still interleaves conversion and fill — the same shape item 9 fixed on the record arm. No vendored row counts it; noted rather than changed.
  • The Vendor/README.md corpus table was stale on main for rows unrelated to this work; re-measured and corrected rather than leaving known-wrong figures beside new ones.

🤖 Generated with Claude Code

lahma and others added 4 commits August 22, 2026 19:56
…` WebIDL's attributes

https://webidl.spec.whatwg.org/#es-iterator-prototype-object: "an iterator
prototype object must have a `next` data property with attributes
`{ [[Writable]]: true, [[Enumerable]]: true, [[Configurable]]: true }`". Jint's
three were `enumerable: false`, which is ECMA-262's rule for a built-in function
property (https://tc39.es/ecma262/#sec-ecmascript-standard-built-in-objects) and
what `[JsFunction]` defaults to.

web-platform-tests' `fetch/api/headers/headers-basic.any.js` reads the
descriptor through its own `checkIteratorProperties` and asserts all three
attributes, which is what the three excluded rows ("Check keys method", "Check
values method", "Check entries method") were. Writable and configurable were
already right; enumerable was the whole of it.

This is the family, not the row. `HeadersIteratorPrototype`,
`FormDataIteratorPrototype` and `UrlSearchParamsIteratorPrototype` each declared
a bare `[JsFunction(Name = "next")]`, and no vendored corpus row reaches the
latter two — so the two siblings were the same defect with nothing to find it.
`ReadableStreamAsyncIteratorPrototype` already carries the flags from sebastienros#3238,
which is where `JsFunctionAttribute.Flags` came from.

Nothing else on those objects moves: `@@toStringTag` is a class string, which
WebIDL also gives `{ [[Writable]]: false, [[Enumerable]]: false,
[[Configurable]]: true }`, and that is what `PropertyFlag.Configurable` already
produced. `next.length` is 0 and `next.name` is "next" as before; the
declarations now say `Length = 0` explicitly rather than deriving it.

The three exclusion rows are deleted, so the driver enforces the fix.

While re-reading the `NeedsTriage` doc comment: three of its bullets described
the streams defects sebastienros#3195/sebastienros#3238 had already fixed and whose exclusion rows had
already gone. Rewritten to say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
…erely a disturbed one

`new Response('hi').text()` left `response.body.getReader()` working, where
web-platform-tests' `response-stream-disturbed-5.any.js` asserts a `TypeError` —
eight of its twelve rows, the four whose body source is a `ReadableStream`
passing.

The recorded analysis said the stream `.body` exposes for a byte-source body "is
not the one the consume path locked". Half right, and the wrong half is the
interesting one: the consume path never locked anything, and *disturbed* is not
what `getReader()` refuses. `getReader()` refuses a **locked** stream and only a
locked one — https://streams.spec.whatwg.org/#set-up-readable-stream-default-reader
step 1 — so a disturbed-but-unlocked stream is handed a reader quite correctly.

What makes a browser refuse it is that consuming a body runs *fully read*
(https://fetch.spec.whatwg.org/#concept-body-fully-read), whose step 3 is "let
reader be the result of getting a reader for body's stream"
(https://streams.spec.whatwg.org/#readablestream-get-a-reader, which locks it)
and whose read-all-bytes step never releases that reader. The stream a consumed
body exposes is therefore disturbed, closed *and locked for good*. That is
exactly why the `ReadableStream`-source rows already passed: those go through
`FetchBody.FullyRead`, which acquires the reader and holds it.

`GetOrCreateStream` defers building the stream for a bytes-source body, so the
state it has to reproduce on first ask is the state fully read would have left —
and it was reproducing only the `disturbed` half. It now cancels *and* acquires a
default reader. The stream is built empty in that case: nothing can read a locked
stream, so filling it from the source would copy a body that has already been
handed over only to throw the copy away.

Neighbours checked, and none of them moves: `bodyUsed` was already true before
this (the source-disturbed flag stands in until a stream exists, and `Cancel`
sets `disturbed` afterwards); `clone()` after a consume still throws, because
"body is unusable" is disturbed **or** locked and it was already disturbed;
reading `body` *before* consuming still leaves the body usable and a released
reader still leaves it usable; and the mixin is shared, so a `Request` behaves
the same way — pinned in `RequestTests`. All six consumers are covered by a
theory: `text`, `json`, `arrayBuffer`, `blob`, `bytes`, `formData`.

The two exclusion globs are deleted, so the driver enforces the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
…keys included

`new Headers(proxy)` performed one operation more than the specification's order
allows, on the two rows of `fetch/api/headers/headers-record.any.js` that count
them: "Correct operation ordering with two properties one of which has an
invalid name" (6 where 5 are allowed) and "Basic operation with Symbol keys"
(8 where 7 are). Measured through the file's own logging proxy, the extra
operation is the same one in both: a `[[Get]]` of the key that ends the
conversion.

https://webidl.spec.whatwg.org/#es-record step 4.2 is, per key: 4.2.1 "let
typedKey be key converted to an IDL value of type K", *then* 4.2.2 "let value be
? Get(O, key)". `FillFromRecord` did the `Get` first and converted the key
inside `append`, so a key that cannot be a `ByteString` — a Symbol, or a string
carrying a code unit above 255 — was read before it was refused.

Two things follow from writing step 3 as `? O.[[OwnPropertyKeys]]()` rather than
as Jint's string-filtered overload. It is what the specification says: a Symbol
key is a member of the walk, its descriptor is fetched, and if it is enumerable
the conversion of the key is where the `TypeError` comes from. And it removes an
inconsistency that was there before: `JsProxy.GetOwnPropertyKeys` honours the
`types` filter only when there is no `ownKeys` trap — correctly, since the
spec's internal method has no filter — so a Symbol key already reached the walk
through a proxy while `new Headers({ [Symbol.toStringTag]: 'x' })` on a plain
object was silently accepted. Both now throw, as a browser does.

The record is also converted *in full* before the fill appends anything, which
is what the two algorithms actually say: the conversion produces a
`record<ByteString, ByteString>`, and only then does
https://fetch.spec.whatwg.org/#concept-headers-fill run `append` over it. The
observable difference is that a record whose second entry has an invalid header
*name* no longer leaves the first entry appended.

`AppendChecked`/`Validate` keep the `ByteString` conversions for
`Headers.prototype.append` and `.set`, where they are WebIDL's argument
conversions; the record path calls the converted-string half directly.

The two exclusion rows are deleted, so the driver enforces the order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
… gone stale

Item 10 of sebastienros#3212 asked that HTML's multipart/form-data encoding
algorithm be read before anything was changed. Reading it settles the question
the other way, so nothing is changed and the exclusion stays — with the reason
rewritten from "wants a specification read" to what the reading found.

HTML's algorithm normalizes the newlines in each entry and then says to "return
the byte sequence resulting from encoding the entry list using the rules
described by RFC 7578": it defines the *escaping* and delegates the *framing*.
RFC 7578 defers to RFC 2046, whose §5.1.1 grammar ends every `multipart-body`
with a `close-delimiter` that is not optional. An empty entry list therefore has
no shorter conforming encoding than the 50 bytes `MultipartFormData` writes, and
nothing licenses the empty body `response-consume-empty.any.js` asks for.

Nor does any implementation produce one. Measured on wpt.fyi against the four
aligned stable runs (chrome 152, edge 151, firefox 154, safari 26.6), "Consume
empty FormData response body as text" is 0/1 in all four while the file's
thirteen other rows are 1/1 in all four.

So the row keeps its exclusion but `NeedsTriage` is the wrong category for it —
that category means "a bug or a specification detail to chase". Giving it one of
its own is left to a follow-up rather than done here.

Also re-measures the corpus-wide table in `Vendor/README.md`, which five rows
had drifted from: Fetch is this change's own (88 -> 75), and Streams (16 -> 11),
Compression (84 -> 22), User Timing (9 -> 5) and DOM (2 -> 0, 12 -> 13 files,
62 -> 76 assertions) had gone stale against sebastienros#3195/sebastienros#3238, sebastienros#3243 and sebastienros#3244, which
removed their exclusions without revisiting the table. Every per-standard
section above it was already right; only the summary and the header count
(269/40,617/2,980 -> 270/40,631/2,894) were behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
@lahma
lahma merged commit c5ad7ae into sebastienros:main Aug 22, 2026
9 of 10 checks passed
lahma added a commit that referenced this pull request Aug 22, 2026
… [Replaceable] one (#3258)

`WebApiRegistration` installed `self` once, for every global, as an ordinary
writable data property, and its own comment named the definition it was written
against: "HTML exposes `self` through a [Replaceable] accessor pair on Window"
(https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-self), which
was the only global there was at the time. `WorkerGlobalScope.self` is a plain
`readonly attribute WorkerGlobalScope self` with no [Replaceable]
(https://html.spec.whatwg.org/multipage/workers.html#dom-workerglobalscope-self),
so a worker inherited the window's semantics along with the property and
`self = 1` silently replaced the global scope.

The two globals genuinely have different IDL and an engine being built does not
know yet which one it will be, so the fix keeps Window's shape where it is and
gives the worker its own: `WebApiRegistration` records the descriptor it
installed and `WorkerGlobalScope.Install` replaces exactly that one, on the
worker's global object and on no other. Identity is the test because a
*replacement* can only be non-clobbering that way — a `self` the host installed,
before the registration through a configuration callback or after it on the
built engine, is never the recorded descriptor and is left as the host left it,
unread and so unmaterialized.

What "refused" means is then decided by the assigning code's own mode rather
than by the descriptor: silently ignored in sloppy mode, a TypeError in strict.
Both are pinned in `WorkerMechanismTests`, the module body for strict and an
indirect `eval` for the sloppy code a module worker cannot otherwise reach,
along with `self === globalThis` and `self instanceof DedicatedWorkerGlobalScope`
surviving the refusal. `GlobalErrorEventTests` pins the other half — assignment
still shadows at top level, in both modes.

`DedicatedWorkerGlobalScope.name`, which the triage note called "the same shape
and the same question", turned out to be neither: it is `[Replaceable] readonly
attribute DOMString name`, so the writable property it already had is what its
own IDL asks for. It has a pin of its own now, because the contrast is the point.

The two web-platform-tests rows this was filed from do NOT go green, and that is
the accounting rather than a shortfall. Both assert what a *sloppy-mode*
assignment does, and the worker lane runs its file as a module: `self.any.js`
used to fail with `expected object "DedicatedWorkerGlobalScope" but got 1` and
now fails with `TypeError`, which is the same refusal in its other voice. They
move from `NeedsTriage` to a new `NeedsClassicWorkerScript`, which is the lane
(module workers only, divergence 2) and not a defect. `NeedsTriage` is left
holding exactly one entry, the empty-`FormData` row that #3247 established is
not a defect either, so the category now carries no debt at all — and its doc
comment, the runner's, and `Vendor/README.md` say so.

Fixes #3224


Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Aug 22, 2026
…sTriage empty (#3265)

`WptDivergence.NeedsTriage` means "a genuine failure that is not attributable to
a feature Jint has decided not to have" -- the category that carries debt. Every
defect any corpus filed there has been paid, and what it held was one entry that
was never a defect at all: `fetch/api/response/response-consume-empty.any.js`,
"Consume empty FormData response body as text".

#3247 established the verdict rather than merely suspecting it. HTML's
multipart/form-data encoding algorithm defines the escaping and delegates the
framing to RFC 7578, which defers in turn to RFC 2046, whose section 5.1.1
grammar ends every multipart-body with a close-delimiter that is not optional --
so an empty entry list has no shorter conforming encoding than the 50 bytes Jint
writes. Nor does anything produce the empty body the row asks for: on wpt.fyi's
four aligned stable runs it is 0/1 in Chrome, Edge, Firefox and Safari, where the
file's thirteen other rows are 1/1 in all four.

Add `AssertsWhatNothingRequires` and move the row into it -- the corpus's
analogue of test262's `=== PERMANENT EXCLUSIONS ===` banner, earned the same way:
only when the standard the test claims to be about does not ask for what the test
asks for, and only with a comment recording the decision and the normative
citation that makes it defensible, never a to-do nobody intends to do. Age never
promotes an entry into it, and reversing one has to argue the decision rather
than delete the line. It is the one member not spelled `Needs...`, deliberately:
every other names something absent that would turn its rows green if it arrived,
whereas here the divergence is in the test and no change to this engine would
move it.

`NeedsTriage` stays as a named-but-empty category, because its emptiness is now
the signal -- a non-zero count there means the corpus has found something, rather
than a number a reader has to go and re-read to discover it is one permanent
entry. That is legal because nothing enumerates the type: the driver reads
`WptExclusion.Divergence` only to name a category in a staleness message, so a
member with no entries costs nothing and fails nothing. `NeedsMessageChannel` is
the older precedent for exactly this.

`NeedsClassicWorkerScript` stays where it is, and its doc now says why it is not
this new category despite also being permanent and also not debt. The line is
what the standard asks. There, nothing asks for what the test asserts and no
implementation delivers it; here HTML asks for exactly what those two `self` rows
assert -- a sloppy-mode assignment to a read-only attribute is a silent no-op --
and an implementation that runs the file as the classic `.any.worker.js` script
wpt generates satisfies them. What makes them permanent is a lane Jint chose
(module workers only), which is the family `NeedsDeclinedWorkerGlobals` and
`NeedsForbiddenHeaderNames` are in.

Both prose sites claimed that giving this row its own category "is filed rather
than done here". Nothing was ever filed until #3261, and this is that change, so
both now describe reality. Three further stale claims found while re-reading the
two doc comments the issue asks about, each corrected:

- `WptTestRunner`'s class doc said the driver has thirty-four suites. #3258
  vendored `workers/modules` as a thirty-fifth without moving the number; the
  census table in `Vendor/README.md` already said 35.
- `Vendor/README.md`'s WebCryptoAPI section still said `NeedsTriage` holds ECDH's
  mismatched-curve rows as open debt. #3183 fixed them and removed their entries
  without touching that file, so the corpus has named no debt since.
- The same section counted "one of six categories" and then gave two different
  categories the ordinal "seventh". It is five today, with the sixth and seventh
  being the two that have since emptied.

The exclusion table's coverage is untouched: all 196 entries keep their file,
name pattern and platform scope, and only one entry's category changed. Nothing
in the driver's enforcement reads the category, so the whole corpus is unchanged
-- 422/422 WPT theories green before and after, with no row moving in either
direction. Full `Jint.Tests` green on both target frameworks.

Closes #3261


Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Aug 23, 2026
…A-262's (#3272)

* WebApi: every operation carries WebIDL's property attributes, not ECMA-262's

WebIDL gives a regular operation `{ [[Writable]]: true, [[Enumerable]]: true,
[[Configurable]]: true }` (https://webidl.spec.whatwg.org/#es-operations), and
the same "define the operations" steps cover a static operation, a stringifier,
a `toJSON`, an iterable declaration's `entries`/`keys`/`values`/`forEach`, and a
namespace object's members. Enumerable is the half that matters: it is the
opposite of ECMA-262's rule for a built-in function property, which is what the
source generator defaults to because every host under `Jint/Native/` wants it.

183 `[JsFunction]` declarations across 46 files now opt in. Five already did —
the `next`/`return` of the four iterator prototype objects, fixed by #3238 and
#3247 when a corpus row happened to assert them — so 188 of 188 operations are
now right. Two `[JsAccessor]` declarations join them, `Crypto.prototype.subtle`
and `Performance.prototype.timeOrigin`, the last two attributes in the subtree
that were not already `Configurable | Enumerable`.

This removes an incoherence rather than creating one. The attribute and constant
rules were already implemented deliberately — 184 of 187 accessors and all 72
constants were correct — so `Object.keys(Response.prototype)` answered 9 while
`getOwnPropertyNames` answered 17, and a `for...in` over a `Response` yielded
nine attribute names and not one operation. Node 24 is the oracle throughout and
agrees member for member: `SubtleCrypto.prototype.digest`,
`TextDecoder.prototype.decode`, `Performance.prototype.now`, `Response.json`,
`URL.canParse`, `ReadableStream.from` and `Storage.prototype`'s six are all
enumerable there.

`navigator` and `scheduler` are deliberately untouched; the next commit says why.
`Jint/Native/` is untouched, and test262 is unmoved at 102,495 passed / 0 failed.

Cost is zero, measured rather than assumed: `new Engine()` allocates 9,256.0 B,
`new Engine(o => o.UseWebApis())` 24,280.0 B, and the same engine with fifteen
web-API objects' property tables materialized 60,912.0 B — bit-identical before
and after, three rounds each. The flags are one enum argument on the same call in
both storage paths, and the one enumerability-gated fast path
(`BuiltinShape.FixedSlotsAllNonEnumerable` via `HasNoEnumerableOwnStringKeysCore`)
is unreachable here: `HasNoEnumerableOwnStringKeys` is overridden only by
`ArrayInstance`, `JsObject` and `ObjectPrototype`.

The twenty "one documented simplification against WebIDL: the operations are
non-enumerable" paragraphs those host files carried are removed, because they no
longer describe anything. What survives is the part of each that is still true —
`console`'s and `crypto`'s and `performance`'s data-property install where WebIDL
asks for a `[Replaceable]` accessor pair, and `Storage.prototype.length` being a
real accessor pair.

Closes #3239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd

* WebApi: navigator and scheduler keep ECMAScript's attributes, and the reason is in the code

The one part of the sweep that must not be automated. `NavigatorInstance` and
`SchedulerInstance` have no interface object and no interface prototype object,
so `userAgent`, `postTask` and `yield` are own properties of the singleton rather
than of a prototype. WebIDL's enumerability rule assumes the member is where the
specification puts it, and there the enumerability is invisible from the
instance: Node 24 reports `userAgent` as an enumerable accessor on
`Navigator.prototype` and `Object.keys(navigator)` as `[]`, because the instance
has no own properties at all; a browser answers `Object.keys(scheduler)` the same
way for the same reason.

So flipping these three would make `Object.keys(navigator)` report
`["userAgent"]` and `Object.keys(scheduler)` report `["postTask", "yield"]`,
which no implementation does. Non-enumerable here is what reproduces the browser
answer, and what fixes them properly is the interface object — as #3250 did for
`Crypto`, `SubtleCrypto` and `Performance` — not a flag. That is a separate
change, and this is exactly why the sweep is a reviewed per-declaration opt-in
rather than a namespace-scoped generator default: a blanket flip would move these
two objects further from a browser rather than closer.

`console` is the counter-example that proves the rule rather than a precedent
against it: it is a WebIDL namespace, whose members genuinely are own properties
of the namespace object, so its eighteen operations are enumerable here exactly
as they are in Node, where `Object.keys(console).length` is 25.

Both exemptions are recorded in `WebIdlPropertyAttributeTests.Divergences`, which
fails on an entry that has stopped diverging — so whoever gives these two an
interface object has to delete the entry rather than inherit a stale exemption.

The stale "the same pair console, crypto and performance carry" sentence both
files opened with goes too: console got a private prototype in #3266, and crypto
and performance got real interface objects in #3250.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd

* Tests: a structural WebIDL attribute matrix, and the nine pins it moves

The artefact that keeps the sweep true. `WebIdlPropertyAttributeTests` builds an
engine with every web-API feature on, walks every object reachable from the
principal realm's global — 131 objects, 816 members — classifies each member by
the WebIDL member kind it is, and holds it to that kind's property attributes.
Nothing is enumerated by hand, so a member added tomorrow is checked tomorrow;
against the previous commits reverted it reports exactly the 185 members that
changed, 183 operations and 2 attributes.

Four assertions, three of which exist to stop the matrix rotting:

* the sweep itself;
* every `[JsObject]` host under `Jint/WebApi/` must be visited — found by
  reflecting for the method the generator emits on one, so the list cannot go
  stale, with the two worker global scopes recorded as out of reach from the
  principal realm and re-checked in both directions;
* every recorded divergence must still diverge, the WPT exclusion table's
  discipline: a fix cannot leave an exemption behind;
* `Object.keys(navigator)` and `Object.keys(scheduler)` are `[]` while
  `Object.keys(console)` is not — the property the two divergences rest on,
  stated where a reader will find it.

The class doc is the table to read when adding a member: WebIDL member kind,
attributes, and the declaration that produces them, with the spec anchor for
each. A green matrix is not a conformance certificate and says so — it checks
that a member carries its kind's attributes, never that the member is on the
right object, which is the very distinction the two divergences turn on.

Nine existing pins move to the browser-correct expectation. Six asserted an
operation or an attribute was non-enumerable and now assert the opposite —
`CryptoTests.GivesItsMembersTheAttributesWebIdlGivesAnOperation`,
`SubtleCryptoTests.IsAReadOnlyEnumerableAttributeOnTheCryptoPrototype`,
`SubtleCryptoTests.ExposesAllTwelveOperations`,
`SubtleCryptoAesWrapTests.ExposesAllTwelveOperationsWithTheirIdlNameAndArity`,
`PerformanceTests.ExposesTimeOriginAsAReadOnlyAccessor`,
`EncodingTests.ExposesAttributesAndOperationsWithTheirWebIdlPropertyAttributes` —
three of them renamed, because the old names asserted the old answer. Each keeps
its `Object.keys(instance)` is `[]` half, which is what shows the enumerability
lands on the prototype and not on the object a script holds.

`StorageTests.ForInVisitsTheStoredKeysAndTheEnumerableAttribute` now sees the
five operations beside `length`, which is what Node 24 sees; the two visit the
same six names in a different order, because Node builds its prototype in IDL
declaration order where Jint's generator sorts a host's members by JS name.

In `Jint.Tests.PublicInterface`, which has no `InternalsVisibleTo` and therefore
proves the surface from a third party's side,
`WebApiEventTests.GivesEveryMemberTheAttributesWebIdlAsksFor` had pinned the old
answer with "changing it is a deliberate act". This is that act — and it gains
the `Object.keys(EventTarget.prototype)` reading plus the `constructor`
back-reference, which stays non-enumerable and is why that list is three names
and not four.

One comment correction rides along: `ReadableStreamAsyncIterationTests` said
`ReadableStream.prototype[@@asynciterator]` is non-enumerable "because it is an
interface member, and those stay non-enumerable". Interface members no longer
stay non-enumerable; that symbol is non-enumerable because WebIDL's iterable
declarations say so, which is now the whole of the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd

* Wpt: correct the reason @@asynciterator is non-enumerable in the #3195 triage record

The record explaining why the async-iterator corpus row moved ended by noting
that `ReadableStream.prototype[@@asynciterator]` was already right, "non-enumerable
because it is an interface member rather than an iterator-prototype method". The
first half is still true and the reason is not: interface members are enumerable
now, and that symbol is non-enumerable because WebIDL's iterable declarations give
it `{ [[Writable]]: true, [[Enumerable]]: false, [[Configurable]]: true }`.

The corpus itself is untouched and unmoved — 422/422 before and after the sweep,
no row in either direction, so no exclusion-table entry changes either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd

* AGENTS.md: WebIDL's property attributes are the fourth Web APIs convention

The subtree already had three conventions written down — the whole-file
`#if NET8_0_OR_GREATER`, the install from `Options.Apply` as lazy globals, and the
principal realm only. The fourth is now true of every member and worth stating
where the next web-API PR will read it: WebIDL's attributes rather than
ECMA-262's, the four flag spellings that produce them, the matrix test that
enforces them, and — the part that is not mechanical — why this is deliberately
not a namespace-scoped generator default, with `navigator` and `scheduler` named
as the two objects a blanket flip would get wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant