Skip to content

Event and PerformanceMark: the user-timing and DOM defects the corpus found - #3244

Merged
lahma merged 4 commits into
sebastienros:mainfrom
lahma:fix/3212-timing-events
Aug 22, 2026
Merged

lahma merged 4 commits into
sebastienros:mainfrom
lahma:fix/3212-timing-events

Conversation

@lahma

@lahma lahma commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes items 1, 2 and 3 of #3212 — the user-timing and DOM Event defects. With #3243 (items 4–6) that leaves four of the eleven; #3212 stays open.

Two of the three recorded analyses were wrong, and both corrections are in the docs rather than quietly worked around.


Item 1 — PerformanceMark options

The issue says performance.mark(name, 123) "refuses it correctly", so the two halves of mark-errors.any.js disagree. They did not disagree. mark-errors.any.js calls the method unbound (testInfo.testFunction(self.performance.mark)), so the TypeError its passing half observed came from the brand check on a this of undefined — not from any conversion. performance.mark(name, 123) did not refuse a non-object either.

Both halves already shared one conversion, UserTiming.ReadMarkOptions (reached through PerformanceMarkConstructor.ReadArguments, which performance.mark runs as its own step 1), and it treated every non-object as the empty dictionary. Fixed in that shared path per WebIDL §3.2.19 step 1 — "If jsDict is not an Object and jsDict is neither undefined nor null, then throw a TypeError" — which is exactly what EventConstructor.ReadEventInit already did.

Item 2 — isTrusted, and its allocation cost

DOM declares it [LegacyUnforgeable]; WebIDL §3.4.10 makes that "non-configurable and … an own property on the object itself rather than on its prototype", and §3.7.6 both gives it {[[Set]]: undefined, [[Enumerable]]: true, [[Configurable]]: false} and removes it from the prototype. Implemented as a virtual own property — the shape Function already uses for length/name and JsError for message.

The per-instance cost is zero, which is not the obvious outcome and was measured rather than asserted (GC.GetAllocatedBytesForCurrentThread, 20,000 iterations, best of 7, net10.0, engine reverted to main for the before column):

workload before after
new Event('x') 112 B 112 B
new CustomEvent('x') 120 B 120 B
new Event('x'); e.isTrusted 112 B 112 B
t.dispatchEvent(new Event('x')) 192 B 192 B
Object.getOwnPropertyDescriptor(e,'isTrusted') 112 B 616 B

Nothing per instance and nothing per read. The only row that moved is the descriptor query, which previously returned undefined; the 504 B is ECMAScript's own FromPropertyDescriptor result — what any accessor costs.

Worth stating because the naive implementation is not free: one stored own property on an event measures 296 B against 112 B — +184 B, +164%. Two things buy the zero: the descriptor is one shared instance per realm (an accessor is unwrapped by calling its getter with the receiver), and it is never stored. Sharing is safe by the argument the engine already makes process-wide for AllForbiddenDescriptor — a non-configurable, setter-less descriptor is unreachable by every mutating branch of ValidateAndApplyPropertyDescriptor.

Behaviour that changed for every event, and is the point: Object.keys(new Event('x')) is now ["isTrusted"] and JSON.stringify gives {"isTrusted":false}, as a browser answers. One existing test asserted the old count and was updated.

Item 3 — srcElement and returnValue

The recorded analysis called srcElement [LegacyUnforgeable]. Re-deriving from the interface IDL shows it carries no extended attribute — only isTrusted does — so it is an ordinary prototype accessor returning this's target.

returnValue is implemented as the algorithm, not a field: the getter is !canceledFlag and the setter runs set the canceled flag, so it is preventDefault() under another name — ignored by a non-cancelable event and inside a passive listener, and = true never clears a flag already set. Its IDL type is boolean, so = 0 cancels and = 1 does not.

Event-constructors.any.js — vendored, but it needed a third member

Items 2 and 3 did not make it vendorable, and the recorded reason was incomplete: each of its two unnamed failures asserts the whole initial state of a new event, assert_true("initEvent" in ev) included, so fixing srcElement/returnValue left it failing one assertion later.

initEvent() and initCustomEvent() are implemented — initialize an event step for step behind the dispatch-flag guard, deliberately not touching the composed flag — and the file is vendored. Its "set the initialized flag" step has nothing to set here: that flag is unset only by document.createEvent() and read only by dispatchEvent's InvalidStateError guard, and there is no document.

Byte-verified: git hash-object of the vendored copy is faa623ea92991b72742477a18471449f5382f1a8, the blob id GitHub reports for that path at the pin. Minimum-test floor 14 — the file has 14 tests, not the 15 the old prose claimed. All 14 pass.

This is beyond the three items and is its own commit, so it can be dropped without touching the rest.

Exclusion accounting

NeedsTriage 29 → 23 rows: 4 × mark-errors.any.js, Event-isTrusted.any.js, AddEventListenerOptions-passive.any.js. _notVendored loses its Event-constructors.any.js row. The NeedsTriage doc comment and three Vendor/README.md sections were rewritten to match.

Verification

Solution builds Release, 0 errors. All production changes are under Jint/WebApi/.

suite net472 net10.0
Jint.Tests 7147 10234
Jint.Tests + JINT_HOST_CONTRACT_VERIFICATION=1 7147 10234
Jint.Tests.PublicInterface 1879 2405
Jint.Tests.PublicInterface + verification 1883 2409

WPT 417/417 files. Per-row before/after with only the engine reverted — exactly 8 rows moved, all FAIL→PASS, all intended: dom/events 37/4 → 41/0, user-timing 69/9 → 73/5, and every other corpus (dom/abort, hr-time, html/*, fetch/api/*, streams, workers, WebCryptoAPI, encoding, url, urlpattern, FileAPI, compression) byte-identical.

test262 not run: nothing reaches non-web-API engine code — the ObjectInstance virtuals are overridden only on JsEvent, which test262 never creates.

Left undone

cancelBubble is still absent — the last legacy Event member, not in the issue and needed by no vendored file.

🤖 Generated with Claude Code

lahma and others added 4 commits August 22, 2026 16:34
… object

`new PerformanceMark("m", 123)` built a mark. So did `performance.mark("m", 123)`
— which the wpt corpus could not see, because `user-timing/mark-errors.any.js`
calls the method **unbound**, as `testInfo.testFunction(self.performance.mark)`,
so the `TypeError` its `[performance.mark]` half observed came from the brand
check on a `this` of `undefined` rather than from any conversion. The two halves
were never disagreeing about the dictionary; only one of them was ever asking.

Both run one conversion — `UserTiming.ReadMarkOptions`, reached through
`PerformanceMarkConstructor.ReadArguments`, which `performance.mark`'s step 1
runs — and it treated every non-object as the empty dictionary. Step 1 of
https://webidl.spec.whatwg.org/#es-dictionary is the whole rule: "If jsDict is
not an Object and jsDict is neither undefined nor null, then throw a TypeError."
`EventConstructor.ReadEventInit` already had it; this is the same three lines in
the sibling conversion, so the fix is in the shared path and not in either
caller.

`undefined` and `null` stay the empty dictionary, so `performance.mark('x').detail`
is still `null`, and a boxed `new Number(5)` is still an Object and so a
dictionary with no members.

The four `NeedsTriage` rows of `mark-errors.any.js` go with it, and
`PerformanceTimelineTests` pins the *bound* call the corpus cannot make.

Item 1 of sebastienros#3212.

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

`isTrusted` sat on `Event.prototype` beside `type` and `bubbles`, an ordinary
configurable accessor a script could shadow. The DOM Standard declares it
`[LegacyUnforgeable]` (https://dom.spec.whatwg.org/#dom-event-istrusted), which
WebIDL defines as "non-configurable and … exist[ing] as an own property on the
object itself rather than on its prototype"
(https://webidl.spec.whatwg.org/#LegacyUnforgeable). §3.7.6 says both halves of
that: "Remove from attributes all the attributes that are unforgeable" before
defining the prototype's, and `configurable` is false for an unforgeable one — so
the property is `{ [[Get]]: getter, [[Set]]: undefined, [[Enumerable]]: true,
[[Configurable]]: false }` on every instance and absent from the prototype.

The getter is the *interface's*, one per realm, which is what
`dom/events/Event-isTrusted.any.js` asserts: it reads the descriptor off two
separate events and requires the same function object in both.

It costs nothing. `JsEvent.GetOwnProperty` answers the name from the realm's one
shared descriptor and `GetInitialOwnStringPropertyKeys` lists it ahead of
anything script adds — the shape `Function` already uses for
`length`/`name`/`prototype` and `JsError` for `message`. Measured with
`GC.GetAllocatedBytesForCurrentThread` over 20,000 iterations, before and after:
`new Event('x')` is 112 bytes either way, and reading `.isTrusted` is 112 either
way. The obvious implementation is not free — one *stored* own property on an
event measures +184 bytes, more than doubling it, on an object the engine
creates per `dispatchEvent` and throughout the abort, message, worker and fetch
paths.

Sharing one descriptor across every event of a realm is safe for the reason the
engine already shares `AllForbiddenDescriptor` process-wide for every function's
`length`: a non-configurable descriptor with no setter is out of reach of every
mutating branch of ValidateAndApplyPropertyDescriptor. The one redefinition the
spec permits — an identical `{ get: theSameGetter, set: undefined }` — is applied
to a copy, which the ordinary machinery stores; both lookups prefer that copy so
the key is never reported twice.

Because the property is non-configurable and setter-less it is a constant of the
type — always present, never removable — which is what keeps the interpreter's
version-gated prototype cache sound for an event receiver.

What changed for every event is what enumeration reports, and it is the point:
`Object.keys(new Event('x'))` is `["isTrusted"]` and `JSON.stringify` gives
`{"isTrusted":false}`, as a browser answers.

Item 2 of sebastienros#3212.

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

Both are in https://dom.spec.whatwg.org/#interface-event — marked `// legacy`,
normative all the same — and neither existed here.

`srcElement` is the easy half: a plain `readonly attribute EventTarget?` whose
"getter steps are to return this's target", so it is an ordinary configurable
prototype accessor like the rest. It carries no extended attribute, unlike
`isTrusted`, so nothing about where it lives is special.

`returnValue` is not a property over a field, and implementing it as one would
have been wrong. Its getter is "false if this's canceled flag is set; otherwise
true", and its setter is "set the canceled flag with this if the given value is
false; otherwise do nothing" — the *algorithm*
(https://dom.spec.whatwg.org/#set-the-canceled-flag), which is gated on
`cancelable` and on the in-passive-listener flag. So it is `preventDefault()`
under another name: `e.returnValue = false` inside a passive listener is ignored
exactly as `e.preventDefault()` is, and assigning `true` can never clear a flag
already set. The IDL type is `boolean`, so the assigned value goes through
ToBoolean rather than being refused — `e.returnValue = 0` cancels, `= 1` does not.

`AddEventListenerOptions-passive.any.js`'s "returnValue should be ignored
if-and-only-if the passive option is true" was the only one of that file's five
rows to fail, and its exclusion goes with the fix.

`dom/events/Event-constructors.any.js` stays out of the corpus, and the reason
recorded for it was incomplete: each of its two unnamed failures also asserts
`"initEvent" in ev`, a third legacy member of the same interface. The row now
says so.

Item 3 of sebastienros#3212.

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

`dom/events/Event-constructors.any.js` sat in `_notVendored` because all
fourteen of its tests are registered without a name, so the driver reports every
one of them under the same one and no per-test exclusion can single out the two
that fail. The reason recorded for those two was `srcElement` and `returnValue`,
and that reading was incomplete: each of them asserts the whole initial state of
a new event, `assert_true("initEvent" in ev)` included. Implementing the previous
commit's two members left the file exactly as red as it was, failing one
assertion later — which is why the file is the check and the prose was not.

So the third legacy member of the same interface:

  https://dom.spec.whatwg.org/#dom-event-initevent
  https://dom.spec.whatwg.org/#dom-customevent-initcustomevent

Both are "If this's dispatch flag is set, then return. Initialize this with
type, bubbles, and cancelable", with `initCustomEvent` adding "set this's detail
attribute to detail". `JsEvent.InitializeEvent` is *initialize an event*
(https://dom.spec.whatwg.org/#concept-event-initialize) step for step: unset the
stop-propagation, stop-immediate-propagation and canceled flags, clear
`isTrusted`, clear `target`, then set type, bubbles and cancelable. It
deliberately leaves the composed flag alone, which is the limitation the
specification notes ("incapable of setting composed").

Step 1's "set event's initialized flag" has nothing to set. The flag is unset by
exactly one algorithm in the standard, `document.createEvent()`, and read by
exactly one, `dispatchEvent`'s `InvalidStateError` guard. There is no `document`
here, so every event Jint can build has it set from birth and no observation can
tell a stored flag from an assumed one.

The dispatch-flag guard is what stops a listener re-initializing the very event
it is handed: a silent no-op there, not an error. `EventType`, `TypeName`,
`Bubbles`, `Cancelable` and `CustomEvent`'s `Detail` become settable for this
and nothing else — private setters, one caller.

The file is vendored byte-identical to upstream at the pin: `git hash-object`
gives faa623e, the blob id GitHub reports for
that path at 6c7127bdd9. All fourteen of its tests pass, and it gets a
minimum-test floor of 14 like every other vendored file.

Beyond the three items of sebastienros#3212 — it is what made the file vendorable, which
that issue's item 3 asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
@lahma
lahma merged commit 87828f2 into sebastienros:main Aug 22, 2026
5 checks passed
@lahma
lahma deleted the fix/3212-timing-events branch August 22, 2026 16:03
lahma added a commit that referenced this pull request Aug 22, 2026
… record order (#3247)

* Fetch/FormData/URLSearchParams: give every iterator prototype's `next` 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 #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 #3195/#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

* Fetch: a consumed bytes-source body hands back a locked stream, not merely 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

* Fetch: convert a `record<ByteString, ByteString>` in WebIDL's order, 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

* Wpt: the empty-FormData row is not a defect, and the corpus table had gone stale

Item 10 of #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 #3195/#3238, #3243 and #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

---------

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