webapi: Event.timeStamp relative to the performance time origin

Fixes all 4 failing tests in WPT
/dom/events/Event-timestamp-high-resolution.html: event.timeStamp
returned a raw monotonic-clock value (milliseconds since boot) instead
of a DOMHighResTimeStamp sharing performance.now()'s time origin.

- Events now record their creation time with the same (already
  coarsened) clock Performance uses, and the timeStamp getter reports
  it in milliseconds relative to the time origin.
- Per spec the origin is the *event's relevant global*'s: the JS Event
  constructor captures the creating realm's origin so cross-realm reads
  (Event-timestamp-cross-realm-getter.html) stay correct; events
  without a captured origin fall back to the accessing realm's, which
  is the creating realm in all other paths.

Coverage: /dom/events/Event-timestamp-high-resolution.html 0/4 -> 4/4,
Event-timestamp-cross-realm-getter.html stays 1/1. No regressions
across /dom/events; Event-dispatch-single-activation-behavior.html now
runs (0/0 crash -> 3/132).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-10 20:36:25 +02:00
committed by Karl Seguin
parent ad9e1d8dc6
commit 40fa69ecf6
3 changed files with 30 additions and 9 deletions

View File

@@ -237,8 +237,9 @@ fn AutoPrototypeChain(comptime types: []const type) type {
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
// Same (already coarsened) clock as the performance time origin, so the
// timeStamp getter can report it relative to that origin.
const time_stamp = @import("webapi/Performance.zig").highResTimestamp();
return .{
._rc = .{},

View File

@@ -54,6 +54,9 @@ _listeners_did_throw: bool = false, // IndexedDB needs to abort on callback thro
// until one of the init*Event methods runs; dispatching one throws an
// InvalidStateError. Events created any other way start initialized.
_initialized: bool = true,
// Time origin of the event's relevant global, captured at creation when
// known; 0 means "use the accessing realm's origin" (see getTimeStamp).
_time_origin: u64 = 0,
// There's a period of time between creating an event and handing it off to v8
// where things can fail. If it does fail, we need to deinit the event. The timing
@@ -104,6 +107,15 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
return initWithTrusted(arena, str, opts_, false);
}
// The JS constructor entry point: also captures the creating realm's time
// origin so timeStamp stays relative to the event's relevant global even
// when read from another realm.
fn initFromJs(typ: []const u8, opts_: ?Options, exec: *js.Execution) !*Event {
const event = try init(typ, opts_, exec.page);
event._time_origin = exec.performance()._time_origin;
return event;
}
pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event {
const arena = try page.getArena(.tiny, "Event.trusted");
errdefer page.releaseArena(arena);
@@ -113,9 +125,9 @@ pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event {
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, comptime trusted: bool) !*Event {
const opts = opts_ orelse Options{};
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
// Same (already coarsened) clock as the performance time origin, so the
// timeStamp getter can report it relative to that origin.
const time_stamp = @import("Performance.zig").highResTimestamp();
const event = try arena.create(Event);
event.* = .{
@@ -262,8 +274,16 @@ pub fn getEventPhase(self: *const Event) u8 {
return @intFromEnum(self._event_phase);
}
pub fn getTimeStamp(self: *const Event) u64 {
return self._time_stamp;
// A DOMHighResTimeStamp in milliseconds, relative to the relevant global's
// time origin (the same clock as performance.now()). When the creating
// realm's origin wasn't captured, fall back to the accessing realm's, which
// is the same realm in all but cross-realm accesses.
pub fn getTimeStamp(self: *const Event, exec: *js.Execution) f64 {
const origin = if (self._time_origin != 0) self._time_origin else exec.performance()._time_origin;
if (self._time_stamp <= origin) {
return 0.0;
}
return @as(f64, @floatFromInt(self._time_stamp - origin)) / 1000.0;
}
pub fn setTrusted(self: *Event) void {
@@ -475,7 +495,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(Event.init, .{});
pub const constructor = bridge.constructor(Event.initFromJs, .{});
pub const @"type" = bridge.accessor(Event.getType, null, .{});
pub const bubbles = bridge.accessor(Event.getBubbles, null, .{});
pub const cancelable = bridge.accessor(Event.getCancelable, null, .{});

View File

@@ -47,7 +47,7 @@ _delivery_scheduled: bool = false,
/// Get high-resolution timestamp in microseconds, rounded to 5μs increments
/// to match browser behavior (prevents fingerprinting)
fn highResTimestamp() u64 {
pub fn highResTimestamp() u64 {
const ts = datetime.timespec();
const micros = @as(u64, @intCast(ts.sec)) * 1_000_000 + @as(u64, @intCast(@divTrunc(ts.nsec, 1_000)));
// Round to nearest 5 microseconds (like Firefox default)