Skip to content

Web API: give WinterTC §5.1 its interface objects, on the principal realm and in workers - #3250

Merged
lahma merged 7 commits into
sebastienros:mainfrom
lahma:feat/3195-interface-globals
Aug 22, 2026
Merged

lahma merged 7 commits into
sebastienros:mainfrom
lahma:feat/3195-interface-globals

Conversation

@lahma

@lahma lahma commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes #3195 — item 4, the §5.1 interface-globals exposure decision. Items 1–3 landed in #3238.

The decision taken was widen fully: expose what browsers expose. This does that with real interface objects and real prototype chains — no Symbol.hasInstance shim anywhere.

What is exposed

Eight Streams interfaces (flag Streams), matching the issue's list exactly:

interface new
ReadableStreamDefaultReader, ReadableStreamBYOBReader, WritableStreamDefaultWriter constructible
the four controllers, and ReadableStreamBYOBRequest TypeError (whatwg/streams#870 removed the last one's constructor)

Three singletons — Crypto, SubtleCrypto (flag Crypto), Performance (flag Performance), none constructible. These had no interface object at all: their members were own properties of the one instance. Each became the usual triple — a non-constructible XConstructor, an XPrototype holding the members verbatim (the brand check now names the instance type), and a JsX instance whose [[Prototype]] is that prototype. Performance's entry buffer and its four buffer operations moved to JsPerformance, which is the split WebIDL draws.

Two worker-scope interfaces, worker global only per [Exposed=Worker] / [Exposed=DedicatedWorker]: WorkerGlobalScope and DedicatedWorkerGlobalScope. The parent engine carries neither name.

Two links deliberately not claimed

Performance does not inherit EventTarget, and WorkerGlobalScope.prototype inherits %Object.prototype% rather than EventTarget.prototype. Jint's global is genuinely not an EventTarget, so claiming either would make instanceof true while the brand check failed — precisely the half-truth this exercise exists to remove. Both are documented and asserted.

Tests assert Object.prototype.hasOwnProperty.call(C, Symbol.hasInstance) === false on all thirteen, so every instanceof answer comes from the chain.

The cost is not "near zero", and the report should say so

The issue predicted lazy non-clobbering installs would cost nothing. Measured, they do not — GC.GetAllocatedBytesForCurrentThread over 2,000 constructions against a git archive export of main, allocation figures bit-identical on every repeat:

baseline after delta
new Engine() 9,216 B 9,256 B +40 B (five reference fields on Intrinsics)
new Engine(o => o.UseWebApis()) 20,896 B / 24.2 µs 24,264 B / ~28.7 µs +3,368 B (+16.1%) / +4.5 µs
UseWebApis() + Execute("1+1") 23,080 B 26,448 B same +3,368 B

A default engine is untouched, and a script that never names one of these pays the slots and nothing more. But the cost is proportional to the number of names: a Default engine installs ~69 globals for 22.4 µs, ~0.32 µs each, and 11 more cost 11 × that.

An isolation experiment confirms it is the names, not this implementation: adding 11 arbitrary lazy globals to the baseline costs +6,848 B, nearly twice this change's +3,368, because AddLazyGlobal allocates a closure and a configuration action per name where WebApiRegistration uses cached statics. Most of the step is one growth of the global's property storage rather than per-name cost.

Nothing is materialized — all 11 descriptors stay LazyPropertyDescriptor<Engine> with _value == null, pinned per name by CostsNothingUntilItIsRead.

An unrelated allocation waste found while measuring, and fixed: the non-clobbering probe called JsString.Create(name) per install, allocating a throwaway string object for every global — ~69 per web-API engine. Interned: −2,208 B exactly, every run. The +3,368 B above is after that credit; the raw exposure was +5,576 B. Committed as an allocation change and described as one, since the time difference was not resolvable against a ~2 µs spread.

The worker lane

self instanceof DedicatedWorkerGlobalScope and self instanceof WorkerGlobalScope are both true inside a worker, by setting the global's [[Prototype]]. Non-clobbering in both respects: a provider whose Host.CreateGlobalObject gave the global a prototype of its own keeps it, and instanceof then honestly answers false.

The worker global's own names (postMessage, close, importScripts, name, the event-handler attributes) deliberately stay own properties of the global rather than moving to the prototypes: they are per connection — they close over the WorkerLink — while an interface prototype is a realm intrinsic. Documented in three places.

Vendored: 13 blobs, every one byte-verified at the pin

  • streams/readable-byte-streams/construct-byob-request.any.js — floor 16, all 16 pass. This is the file that read ReadableByteStreamController.prototype at file scope and so could not be vendored at all.
  • workers/modules/dedicated-worker-import.any.js + 11 fixtures — floor 9, 7 pass. The two failures need a wpt server (a cross-origin .sub.js fixture, and one importing through redirect.py); they reach the file's own onerror reject path rather than stalling, so they are per-test exclusions under NeedsWptServer rather than a not-vendored row.

Driver changes this required: a workers/ file with no // META: global= key now runs in the top-level lane with a WptWorkerProvider; the provider grew a second shape serving vendored corpus modules; and NextDue moved from the internal clocks-only TimeUntilNextPumpScheduledWork() to public Advanced.TimeUntilNextScheduledWork — without which a worker→parent message enqueues with no due time and the loop declares "nothing left to pump" while the answer sits in the parent's queue.

One row regresses, and it is a real consequence of the decision

WebCryptoAPI/historical.any.js's SubtleCrypto row used to pass only because there was no such interface object. Exposing it makes the test fail, because it asserts the [SecureContext] model Jint does not have. Excluded under NeedsSecureContextModel with that reasoning recorded — worth stating plainly, since it is the one place "expose what browsers expose" costs something rather than buying something.

Accounting

NeedsStreamInterfaceGlobals and its 7 rows deleted; the self instanceof WorkerGlobalScope row deleted; Worker-replace-self.any.js moved to NeedsTriage, where #3224 is now the only thing left. _notVendored loses the construct-byob-request and blanket workers/modules/* rows, replaced by narrower ones for what genuinely needs a server, a browsing context or a SharedWorker.

README: 11 rows move in the §5.1 table (the issue comment's figure was right), the "five entries need more than a flag name" list is now three, and the Streams reduction paragraph is gone.

Vendor/README.md's inventory was stale — it read 269 files / 40,617 assertions / 2,980 not-passing where the true pre-change figures were 270 / 40,631 / 2,907, with Streams, Compression, User Timing and DOM each carrying a number a later fix had already improved. Re-censused whole to 272 / 40,656 / 2,902, with a note on method.

Verification

Release solution build clean on all five Jint TFMs. Jint.Tests 10,344 (net10.0) / 7,147 (net472), 0 failed, plain and verifying. Jint.Tests.PublicInterface 2,463 / 1,879 (2,467 / 1,883 verifying). CommonScripts 28+28. test262 102,495 passed, 0 failed.

WPT before → after: 270 → 272 files, 40,631 → 40,656 assertions, 2,907 → 2,902 not passing. Only intended rows moved, and the arithmetic closes exactly: +16 (byob) +7 (default-reader) +1 (worker self) +7 (worker modules) −1 (SubtleCrypto) = +30 passes; −7 −1 +2 +1 = −5 not-passing. Every one of the twenty other corpora is unchanged.

Sequencing note for #3224

This touches WorkerGlobalScope.Install, which is where #3224's fix goes, but they do not overlap textually — this adds a prototype assignment and two installs at the top; #3224 redefines self non-writable further down. What has changed is the accounting: #3224 is now the only thing standing between two exclusion rows and green, and one of them has already been re-pointed at it. Sequence #3224 after this and its PR deletes two rows instead of one.

🤖 Generated with Claude Code

lahma and others added 7 commits August 22, 2026 21:08
… Crypto/SubtleCrypto/Performance

Item 4 of sebastienros#3195, widened as the owner ruled: expose as real globals every §5.1 interface
object that was reachable only through an instance's prototype, or not reachable at all.

Eight Streams interfaces — ReadableStreamDefaultReader, ReadableStreamBYOBReader, the four
controllers, ReadableStreamBYOBRequest and WritableStreamDefaultWriter — already existed as
genuine interface objects; they were simply not installed. The Streams Standard declares
every one of them [Exposed=*], and the reduction cost seven excluded WPT rows plus a file
that could not be vendored at all. All thirteen are globals now.

Crypto, SubtleCrypto and Performance had no interface object at all: their members were own
properties of the one instance, so `crypto instanceof Crypto` could not be written and
Crypto.prototype did not exist. Each is now the usual triple — a non-constructible
Constructor, a Prototype carrying the members, and a JsX instance whose [[Prototype]] is
that prototype — so instanceof is answered by a real prototype chain rather than by a brand
shim. No Symbol.hasInstance anywhere. The members' bodies are unchanged; what moved is where
they live and what the brand check names. Performance's entry buffer moved with the instance,
which is the split WebIDL draws.

Deliberately not claimed: Performance does not inherit from EventTarget. The specification
says it does, but nothing here fires an event at it, and asserting the inheritance would be
exactly the half-truth this change exists to remove.

WPT accounting: the seven NeedsStreamInterfaceGlobals rows of default-reader.any.js now pass,
so the category and its rows are gone; construct-byob-request.any.js is vendored (16 tests,
byte-identical to the pinned SHA) and leaves the not-vendored table. Going the other way,
WebCryptoAPI/historical.any.js's SubtleCrypto row asserts what a *non-secure* context sees
and is now excluded beside its two siblings under NeedsSecureContextModel.

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

The second half of sebastienros#3195 item 4. A worker's global object now inherits from
DedicatedWorkerGlobalScope.prototype, which inherits from WorkerGlobalScope.prototype, and
both interface objects are installed on the worker global — [Exposed=Worker] and
[Exposed=DedicatedWorker], so the engine that *created* the worker carries neither. So the
canonical worker feature-detect,

    if ('DedicatedWorkerGlobalScope' in self && self instanceof DedicatedWorkerGlobalScope)

takes the branch it takes in a browser, and it does so because the prototype chain says so:
neither interface object owns a Symbol.hasInstance, which is pinned.

The chain stops one link early, deliberately. HTML has WorkerGlobalScope : EventTarget, and
Jint's global is not one — addEventListener is an ordinary function bound to the synthetic
listener list, the "suitable alternative mechanism" WinterTC §6 blesses — so
WorkerGlobalScope.prototype inherits from %Object.prototype% and `self instanceof
EventTarget` stays false rather than becoming an instanceof that lies. The per-connection
members (postMessage, close, importScripts, name, the event-handler attributes) stay own
properties of the global, because they close over this worker's link while an interface
prototype is a realm intrinsic; what the pair adds is the brand, which is what a script asks
for.

That unlocks workers/modules/dedicated-worker-import.any.js, which could not be vendored at
all: every fixture registers its onmessage inside that sniff, so the file used to stall
rather than fail. Twelve files vendored, each byte-verified against the pinned SHA. Seven of
its nine cases pass — static, nested static, dynamic, nested dynamic, both orders of the
mixed pair, and eval(import()). The two that need a wpt server (a cross-origin .sub.js
fixture and a redirect.py handler) fail as tests rather than stalling, because the worker's
module loader refuses the specifier and the parent hears the startup failure as an error
event; they are named individually under NeedsWptServer.

Driver changes this needed, all of them in Jint.Tests/Wpt:

* A workers/ file with no `// META: global=` key is a file about a page creating workers, so
  the top-level lane gets a WptWorkerProvider for it. _topLevelWorkersFiles names which files
  are meant to do that, so the lane-selection invariant still pins both directions.
* WptWorkerProvider grew a second shape: with a null module source it serves vendored corpus
  modules, resolving specifiers through WptCorpus.ResolveReference exactly as the shim's fetch
  does.
* The drive loop pumps live workers, and NextDue now reads Advanced.TimeUntilNextScheduledWork
  rather than the internal clocks-only TimeUntilNextPumpScheduledWork. That is what makes a
  two-engine loop terminate for the right reason: a message crossing from a worker to its
  parent enqueues with no due time, so the old question answered "nothing is left to pump"
  while the answer sat in the parent's queue.

Also: `self instanceof WorkerGlobalScope` in workers/interfaces/WorkerGlobalScope/self.any.js
now passes and leaves the table. Worker-replace-self.any.js's row moves from
NeedsDeclinedWorkerGlobals to NeedsTriage — what is left failing there is only sebastienros#3224, `self`
installed writable on a worker global.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
Found while measuring the exposure decision above rather than looked for. The probe that keeps
an install non-clobbering needs a JsValue for the name, and JsString.Create allocates a fresh
object for anything longer than one character — so every engine that enabled the web APIs was
building one throwaway string object per installed global, whether or not the name was already
taken. There are about seventy on a WebApiFeatures.Default engine.

Measured with GC.GetAllocatedBytesForCurrentThread over 2,000 constructions: 26,472 -> 24,264
bytes for `new Engine(o => o.UseWebApis())`, exactly 2,208 bytes and identical on every run.
The time difference is not resolvable — 28.8/29.6 and 28.4/28.6 us on interleaved pairs,
against a run-to-run spread of about 2 us on a machine that was not idle — so this is an
allocation change and is reported as one.

The keys are the string literals at the call sites, so the table is bounded by the number of
globals the file can install and is complete after the first engine of a given feature set. It
is static because a JsString is immutable and realm-independent; the value factories, which
produce realm-owned objects, stay per engine as they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
README.md: the WinterTC §5.1 table loses all eleven of its "not a global" / "absent" rows —
the eight Streams interfaces and Crypto, SubtleCrypto and Performance — and the two bullets
that explained them are gone with them, leaving three where there were five. The Streams
section's "one deliberate reduction" paragraph is replaced by what is true now: all thirteen
interface objects are globals, each is what its instances inherit from, three are
constructible and the rest refuse `new`. The crypto and performance sections each gain a
sentence on their interface object, including the one link Performance does not claim.

The workers section gains a paragraph on the worker global's prototype chain: what is
claimed, that instanceof is answered by walking it rather than by a Symbol.hasInstance, why
the chain stops short of EventTarget, and why the per-connection members stay own properties
of the global.

Vendor/README.md: the two not-vendored rows this unlocks are gone, replaced by narrower rows
for what a wpt server would have produced; the streams, WebCryptoAPI and workers sections are
rewritten around what actually moved; and the two-lanes section documents the third shape, a
workers/ file that runs in the top-level lane and creates real workers there.

The inventory table was re-censused whole rather than adjusted, because four of its rows had
gone stale between the changes that moved them: it read 269 files / 40,617 assertions / 2,980
not passing where the true figures before this change were 270 / 40,631 / 2,907, with Streams
(16 against 11), Compression (84 against 22), User Timing (9 against 5) and DOM (2 against 0)
each carrying a number a later fix had already improved. It now reads 272 / 40,656 / 2,902,
and says how to take the census again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
The eleven new interface objects and the worker global's prototype chain are a public-surface
commitment, so they are pinned in the project without InternalsVisibleTo, where an assertion
proves a third party can actually reach the thing.

WebApiInterfaceObjectTests is new: each of the eleven arrives with its own flag and never on
its own, never reaches a ShadowRealm, and leaves a host's own lazy global unmaterialized; each
is the object its instances inherit from and owns no Symbol.hasInstance, so the instanceof is
the chain's answer; and each is constructible exactly where the standard gives the interface a
constructor operation.

WebApiWorkerTests gains the worker half, written through the shipped WorkerProvider extension
point: the sniff every worker library opens with takes the dedicated branch, the chain is real
in both links, neither interface object is planted with a Symbol.hasInstance, the parent
carries neither name, and the chain's end at %Object.prototype% is asserted rather than only
documented.

Two existing pins moved with the decision: WebApiStreamsTests' "the helper interfaces are not
globals" becomes "each helper global is what its instances inherit from", and
WebApiSubtleCryptoTests reads the `subtle` accessor off Crypto.prototype, where a browser's is.

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

WorkerGlobalScope.Install treats the global object's [[Prototype]] the way it treats every
name it puts on that object: it inserts the chain only where the object still has the
[[Prototype]] an ordinary global starts with. A provider that built its worker engine on a
Host.CreateGlobalObject of its own, and gave that object a chain, keeps the chain it chose —
and `self instanceof DedicatedWorkerGlobalScope` then answers false, which is the truth about
that object rather than a claim planted on it. The two names are still installed, because they
are non-clobbering against the global's own properties and such a host has taken neither.

Pinned by WorkerMechanismTests.AHostThatGaveItsGlobalAPrototypeKeepsIt, through
Options.UseHostFactory and a Host subclass — the same door a provider would use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
TimestampOfMark moved to JsPerformance with the entry buffer, and it was the only member
reaching for a DOMException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
@lahma
lahma force-pushed the feat/3195-interface-globals branch from 7ea4a8a to 16b4490 Compare August 22, 2026 18:13
@lahma
lahma merged commit 61142c5 into sebastienros:main Aug 22, 2026
5 checks passed
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>
lahma added a commit that referenced this pull request Aug 23, 2026
…type (#3270) (#3275)

Both were interface *instances* built without either, so their members were
own properties of the singleton — the last two [JsObject] hosts under
Jint/WebApi/ that were neither an interface prototype object, an interface
object, nor a WebIDL namespace. Three consequences closed at once.

The WebIDL attribute exemption. #3239 had to leave navigator.userAgent,
scheduler.postTask and scheduler.yield with an ECMAScript built-in's
attributes, because WebIDL's enumerability rule assumes the member sits on
the interface prototype object, where the enumerability is invisible from
the instance. Flipping the flags where they were would have made
Object.keys(navigator) answer ["userAgent"], which no implementation does.
Moving the members is what fixes it, so the three entries in
WebIdlPropertyAttributeTests.Divergences are deleted and that list is now
empty; EveryRecordedDivergenceIsReal is what would have caught a stale one.

The containment hazard #3266 identified for these two and #3257 left open.
Measured before the change, in the REPL:

  navigator.__proto__.poisoned = 42   ({}).poisoned   42     Object.prototype own property: true
  scheduler.__proto__.poisoned = 43   ({}).poisoned   43     Object.prototype own property: true

and after: undefined and false for both, with the value still reachable
through the singleton, which is what a patching library is after.

The own-key lists. Reflect.ownKeys(navigator) was ["userAgent",
Symbol(Symbol.toStringTag)]; it is [] now, as it is in Node 24 and a
browser. Same for scheduler.

Node 24 is the oracle for Navigator and agrees line for line — typeof
Navigator "function", name/length Navigator/0, the prototype chain, the
constructor property's attributes, userAgent an enumerable accessor with no
setter on Navigator.prototype, and "TypeError: Illegal constructor" from
new Navigator(). Two lines differ, both deliberately: Jint's
Navigator.prototype carries only userAgent, everything else being absent
rather than faked; and Object.prototype.toString.call(navigator) stays
[object Navigator] as WebIDL's class-string rule and a browser require,
where Node answers [object Object] because its Navigator is an ordinary
class. Node has no scheduler at all, so for that one the oracle is the WICG
draft's IDL plus Chrome.

Constructibility, checked per spec rather than assumed. Navigator is
[Exposed=Window] and declares no constructor operation
(https://html.spec.whatwg.org/multipage/system-state.html#the-navigator-object);
Scheduler is [Exposed=(Window, Worker)] and declares none either
(https://wicg.github.io/scheduling-apis/#sec-scheduler), unlike
TaskController and TaskPriorityChangeEvent beside it. Neither constructs.

Both interface objects are exposed as globals, following #3250's pattern
exactly: a lazy, non-clobbering descriptor on the principal realm's global
object, WebIDL's interface-object attributes, never in a ShadowRealm.
Navigator's [Exposed=Window] is not a reason to withhold it — the instance
carries the same declaration and is a Jint global anyway because WinterTC
asks for globalThis.navigator.userAgent, and Node's global is not a Window
either yet exposes both. Scheduler joins the three interface objects its own
feature already installs.

Cost, measured with GC.GetAllocatedBytesForCurrentThread over 2,000
constructions, bit-identical on every repeat:

  new Engine()                                 9,256 ->  9,272 B   +16
  new Engine(o => o.UseWebApis())             24,280 -> 24,392 B  +112
  UseWebApis() + Execute("1+1")               26,464 -> 26,576 B  +112
  UseWebApis(Navigator)                       11,176 -> 11,288 B  +112
  UseWebApis(Scheduler)                       12,032 -> 12,144 B  +112
  UseWebApis() + read navigator.userAgent     27,440 -> 27,912 B  +472
  UseWebApis() + read scheduler.postTask      27,584 -> 28,056 B  +472

The +16 B on a default engine is two reference fields on Intrinsics. The
+112 B is the two extra lazy global descriptors and the storage they land
in; nothing materializes until a script names one, which
InterfaceObjectExposureTests.CostsNothingUntilItIsRead now pins for both
names and NavigatorTests pins for the singleton's own descriptor. The +472 B
is what a script that actually reads a member pays for the interface object
and prototype it did not have before.

WPT: 422/422 before, 422/422 after, corpus untouched. No row moved, and none
could have moved silently — the exclusion table refuses an entry that has
started passing, so a green run is the assertion.


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.

Streams: three defects the WPT corpus found, and the interface-globals exposure decision

1 participant