Higher performance.now() precision (closer to FFs behavior)

Much better v8 object debugging/printing in debug mode

Window.requestIdleCallback and cancelIdleCallback

Don't prematurely close stream on empty read - queue promises.
This commit is contained in:
Karl Seguin
2025-12-05 16:09:00 +08:00
parent ff9f9bae1d
commit dd3781a1ea
12 changed files with 182 additions and 102 deletions

View File

@@ -475,23 +475,15 @@ fn logFunctionCallError(self: *Caller, type_name: []const u8, func: []const u8,
}
fn serializeFunctionArgs(self: *Caller, info: v8.FunctionCallbackInfo) ![]const u8 {
const separator = log.separator();
const js_parameter_count = info.length();
const context = self.context;
var arr: std.ArrayListUnmanaged(u8) = .{};
for (0..js_parameter_count) |i| {
const js_value = info.getArg(@intCast(i));
const value_string = try context.valueToDetailString(js_value);
const value_type = try context.jsStringToZig(try js_value.typeOf(self.isolate), .{});
try std.fmt.format(arr.writer(context.call_arena), "{s}{d}: {s} ({s})", .{
separator,
i + 1,
value_string,
value_type,
});
var buf = std.Io.Writer.Allocating.init(context.call_arena);
const separator = log.separator();
for (0..info.length()) |i| {
try buf.writer.print("{s}{d} - ", .{ separator, i + 1 });
try context.debugValue(info.getArg(@intCast(i)), &buf.writer);
}
return arr.items;
return buf.written();
}
// Takes a function, and returns a tuple for its argument. Used when we

View File

@@ -1062,67 +1062,84 @@ pub fn jsStringToZigZ(self: *const Context, str: v8.String, opts: JsStringToZigO
return buf;
}
pub fn valueToDetailString(self: *const Context, value: v8.Value) ![]u8 {
var str: ?v8.String = null;
const v8_context = self.v8_context;
if (value.isObject() and !value.isFunction()) blk: {
str = v8.Json.stringify(v8_context, value, null) catch break :blk;
if (str.?.lenUtf8(self.isolate) == 2) {
// {} isn't useful, null this so that we can get the toDetailString
// (which might also be useless, but maybe not)
str = null;
}
}
if (str == null) {
str = try value.toDetailString(v8_context);
}
const s = try self.jsStringToZig(str.?, .{});
if (comptime builtin.mode == .Debug) {
if (std.mem.eql(u8, s, "[object Object]")) {
if (self.debugValueToString(value.castTo(v8.Object))) |ds| {
return ds;
} else |err| {
log.err(.js, "debug serialize value", .{ .err = err });
}
}
}
return s;
pub fn debugValue(self: *const Context, js_val: v8.Value, writer: *std.Io.Writer) !void {
var seen: std.AutoHashMapUnmanaged(u32, void) = .empty;
return _debugValue(self, js_val, &seen, 0, writer) catch error.WriteFailed;
}
fn debugValueToString(self: *const Context, js_obj: v8.Object) ![]u8 {
if (comptime builtin.mode != .Debug) {
@compileError("debugValue can only be called in debug mode");
fn _debugValue(self: *const Context, js_val: v8.Value, seen: *std.AutoHashMapUnmanaged(u32, void), depth: usize, writer: *std.Io.Writer) !void {
if (js_val.isNull()) {
// I think null can sometimes appear as an object, so check this and
// handle it first.
return writer.writeAll("null");
}
const v8_context = self.v8_context;
if (!js_val.isObject()) {
// handle these explicitly, so we don't include the type (we only want to include
// it when there's some ambiguity, e.g. the string "true")
if (js_val.isUndefined()) {
return writer.writeAll("undefined");
}
if (js_val.isTrue()) {
return writer.writeAll("true");
}
if (js_val.isFalse()) {
return writer.writeAll("false");
}
// TODO: KARL wait for v8 build to work again, this works with
// the latest version of zig-v8-fork, I just can't build it right now
// APPLY THIS change to valueToString and valueToStringz
// if (js_val.isSymbol()) {
// const js_sym = v8.Symbol{.handle = js_val.handle};
// const js_sym_desc = js_sym.getDescription(self.isolate);
// const js_sym_str = try self.valueToString(js_sym_desc, .{});
// return writer.print("{s} (symbol)", .{js_sym_str});
// }
const js_type = try self.jsStringToZig(try js_val.typeOf(self.isolate), .{});
const js_val_str = try self.valueToString(js_val, .{});
if (js_val_str.len > 2000) {
try writer.writeAll(js_val_str[0..2000]);
try writer.writeAll(" ... (truncated)");
} else {
try writer.writeAll(js_val_str);
}
return writer.print(" ({s})", .{js_type});
}
const js_obj = js_val.castTo(v8.Object);
{
// explicit scope because gop will become invalid in recursive call
const gop = try seen.getOrPut(self.call_arena, js_obj.getIdentityHash());
if (gop.found_existing) {
return writer.writeAll("<circular>\n");
}
gop.value_ptr.* = {};
}
const v8_context = self.v8_context;
const names_arr = js_obj.getOwnPropertyNames(v8_context);
const names_obj = names_arr.castTo(v8.Object);
const len = names_arr.length();
var arr: std.ArrayListUnmanaged(u8) = .empty;
var writer = arr.writer(self.call_arena);
try writer.writeAll("(JSON.stringify failed, dumping top-level fields)\n");
if (depth > 20) {
return writer.writeAll("...deeply nested object...");
}
try writer.print("({d}/{d})", .{ js_obj.getOwnPropertyNames(v8_context).length(), js_obj.getPropertyNames(v8_context).length() });
for (0..len) |i| {
if (i == 0) {
try writer.writeByte('\n');
}
const field_name = try names_obj.getAtIndex(v8_context, @intCast(i));
const field_value = try js_obj.getValue(v8_context, field_name);
const name = try self.valueToString(field_name, .{});
const value = try self.valueToString(field_value, .{});
try writer.splatByteAll(' ', depth);
try writer.writeAll(name);
try writer.writeAll(": ");
if (std.mem.indexOfAny(u8, value, &std.ascii.whitespace) == null) {
try writer.writeAll(value);
} else {
try writer.writeByte('"');
try writer.writeAll(value);
try writer.writeByte('"');
try self._debugValue(try js_obj.getValue(v8_context, field_name), seen, depth + 1, writer);
if (i != len - 1) {
try writer.writeByte('\n');
}
try writer.writeByte(' ');
}
return arr.items;
}
pub fn stackTrace(self: *const Context) !?[]const u8 {

View File

@@ -20,6 +20,8 @@ const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const PersistentObject = v8.Persistent(v8.Object);
@@ -74,12 +76,10 @@ pub fn toString(self: Object) ![]const u8 {
return self.context.valueToString(js_value, .{});
}
pub fn toDetailString(self: Object) ![]const u8 {
const js_value = self.js_obj.toValue();
return self.context.valueToDetailString(js_value);
}
pub fn format(self: Object, writer: *std.Io.Writer) !void {
if (comptime IS_DEBUG) {
return self.context.debugValue(self.js_obj.toValue(), writer);
}
const str = self.toString() catch return error.WriteFailed;
return writer.writeAll(str);
}

View File

@@ -71,7 +71,12 @@ pub const PromiseResolver = struct {
return self.resolver.getPromise();
}
pub fn resolve(self: PromiseResolver, value: anytype) !void {
pub fn resolve(self: PromiseResolver, comptime source: []const u8, value: anytype) void {
self._resolve(value) catch |err| {
log.err(.bug, "resolve", .{ .source = source, .err = err, .persistent = false });
};
}
fn _resolve(self: PromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value);
@@ -81,7 +86,12 @@ pub const PromiseResolver = struct {
self.runMicrotasks();
}
pub fn reject(self: PromiseResolver, value: anytype) !void {
pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype) void {
self._reject(value) catch |err| {
log.err(.bug, "reject", .{ .source = source, .err = err, .persistent = false });
};
}
fn _reject(self: PromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value);
@@ -104,7 +114,12 @@ pub const PersistentPromiseResolver = struct {
return self.resolver.castToPromiseResolver().getPromise();
}
pub fn resolve(self: PersistentPromiseResolver, value: anytype) !void {
pub fn resolve(self: PersistentPromiseResolver, comptime source: []const u8, value: anytype) void {
self._resolve(value) catch |err| {
log.err(.bug, "resolve", .{ .source = source, .err = err, .persistent = true });
};
}
fn _resolve(self: PersistentPromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value, .{});
defer context.runMicrotasks();
@@ -114,7 +129,13 @@ pub const PersistentPromiseResolver = struct {
}
}
pub fn reject(self: PersistentPromiseResolver, value: anytype) !void {
pub fn reject(self: PersistentPromiseResolver, comptime source: []const u8, value: anytype) void {
self._reject(value) catch |err| {
log.err(.bug, "reject", .{ .source = source, .err = err, .persistent = true });
};
}
fn _reject(self: PersistentPromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value, .{});
defer context.runMicrotasks();

View File

@@ -21,18 +21,6 @@
}
</script>
<script id=readable_stream_read_empty>
(async function() {
const stream = new ReadableStream();
const reader = stream.getReader();
// Reading from empty stream should return done:true
const result = await reader.read();
testing.expectEqual('object', typeof result);
testing.expectEqual(true, result.done);
})();
</script>
<script id=response_body>
(async function() {
const response = new Response('hello world');

View File

@@ -106,7 +106,7 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu
}
if (self._when_defined.fetchRemove(name)) |entry| {
try entry.value.resolve(constructor);
entry.value.resolve("whenDefined", constructor);
}
}

View File

@@ -13,21 +13,33 @@ const Performance = @This();
_time_origin: u64,
_entries: std.ArrayListUnmanaged(*Entry) = .{},
/// Get high-resolution timestamp in microseconds, rounded to 5μs increments
/// to match browser behavior (prevents fingerprinting)
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)
const rounded = @divTrunc(micros + 2, 5) * 5;
return rounded;
}
pub fn init() Performance {
return .{
._time_origin = datetime.milliTimestamp(.monotonic),
._time_origin = highResTimestamp(),
._entries = .{},
};
}
pub fn now(self: *const Performance) f64 {
const current = datetime.milliTimestamp(.monotonic);
const current = highResTimestamp();
const elapsed = current - self._time_origin;
return @floatFromInt(elapsed);
// Return as milliseconds with microsecond precision
return @as(f64, @floatFromInt(elapsed)) / 1000.0;
}
pub fn getTimeOrigin(self: *const Performance) f64 {
return @floatFromInt(self._time_origin);
// Return as milliseconds
return @as(f64, @floatFromInt(self._time_origin)) / 1000.0;
}
pub fn mark(self: *Performance, name: []const u8, _options: ?Mark.Options, page: *Page) !*Mark {

View File

@@ -222,6 +222,24 @@ pub fn cancelAnimationFrame(self: *Window, id: u32) void {
sc.removed = true;
}
const RequestIdleCallbackOpts = struct {
timeout: ?u32 = null,
};
pub fn requestIdleCallback(self: *Window, cb: js.Function, opts_: ?RequestIdleCallbackOpts, page: *Page) !u32 {
const opts = opts_ orelse RequestIdleCallbackOpts{};
return self.scheduleCallback(cb, opts.timeout orelse 50, .{
.repeat = false,
.params = &.{},
.low_priority = true,
.name = "window.requestIdleCallback",
}, page);
}
pub fn cancelIdleCallback(self: *Window, id: u32) void {
var sc = self._timers.get(id) orelse return;
sc.removed = true;
}
pub fn reportError(self: *Window, err: js.Object, page: *Page) !void {
const error_event = try ErrorEvent.init("error", .{
.@"error" = err,
@@ -485,6 +503,8 @@ pub const JsApi = struct {
pub const clearImmediate = bridge.function(Window.clearImmediate, .{});
pub const requestAnimationFrame = bridge.function(Window.requestAnimationFrame, .{});
pub const cancelAnimationFrame = bridge.function(Window.cancelAnimationFrame, .{});
pub const requestIdleCallback = bridge.function(Window.requestIdleCallback, .{});
pub const cancelIdleCallback = bridge.function(Window.cancelIdleCallback, .{});
pub const matchMedia = bridge.function(Window.matchMedia, .{});
pub const postMessage = bridge.function(Window.postMessage, .{});
pub const btoa = bridge.function(Window.btoa, .{});

View File

@@ -112,12 +112,10 @@ fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
fn httpDoneCallback(ctx: *anyopaque) !void {
const self: *Fetch = @ptrCast(@alignCast(ctx));
self._response._body = self._buf.items;
return self._resolver.resolve(self._response);
return self._resolver.resolve("fetch done", self._response);
}
fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *Fetch = @ptrCast(@alignCast(ctx));
self._resolver.reject(@errorName(err)) catch |inner| {
log.err(.bug, "failed to reject", .{ .source = "fetch", .err = inner, .reject = err });
};
self._resolver.reject("fetch error", @errorName(err));
}

View File

@@ -21,6 +21,7 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const ReadableStream = @import("ReadableStream.zig");
const ReadableStreamDefaultReader = @import("ReadableStreamDefaultReader.zig");
const ReadableStreamDefaultController = @This();
@@ -28,24 +29,42 @@ _page: *Page,
_stream: *ReadableStream,
_arena: std.mem.Allocator,
_queue: std.ArrayList([]const u8),
_pending_reads: std.ArrayList(js.PersistentPromiseResolver),
pub fn init(stream: *ReadableStream, page: *Page) !*ReadableStreamDefaultController {
return page._factory.create(ReadableStreamDefaultController{
._page = page,
._queue = .empty,
._stream = stream,
._arena = page.arena,
._queue = std.ArrayList([]const u8){},
._pending_reads = .empty,
});
}
pub fn addPendingRead(self: *ReadableStreamDefaultController, page: *Page) !js.Promise {
const resolver = try page.js.createPromiseResolver(.page);
try self._pending_reads.append(self._arena, resolver);
return resolver.promise();
}
pub fn enqueue(self: *ReadableStreamDefaultController, chunk: []const u8) !void {
if (self._stream._state != .readable) {
return error.StreamNotReadable;
}
// Store a copy of the chunk in the page arena
const chunk_copy = try self._page.arena.dupe(u8, chunk);
try self._queue.append(self._arena, chunk_copy);
if (self._pending_reads.items.len == 0) {
const chunk_copy = try self._page.arena.dupe(u8, chunk);
return self._queue.append(self._arena, chunk_copy);
}
// I know, this is ouch! But we expect to have very few (if any)
// pending reads.
const resolver = self._pending_reads.orderedRemove(0);
const result = ReadableStreamDefaultReader.ReadResult{
.done = false,
.value = .{ .values = chunk },
};
resolver.resolve("stream enqueue", result);
}
pub fn close(self: *ReadableStreamDefaultController) !void {
@@ -54,6 +73,16 @@ pub fn close(self: *ReadableStreamDefaultController) !void {
}
self._stream._state = .closed;
// Resolve all pending reads with done=true
const result = ReadableStreamDefaultReader.ReadResult{
.done = true,
.value = null,
};
for (self._pending_reads.items) |resolver| {
resolver.resolve("stream close", result);
}
self._pending_reads.clearRetainingCapacity();
}
pub fn doError(self: *ReadableStreamDefaultController, err: []const u8) !void {
@@ -63,6 +92,12 @@ pub fn doError(self: *ReadableStreamDefaultController, err: []const u8) !void {
self._stream._state = .errored;
self._stream._stored_error = try self._page.arena.dupe(u8, err);
// Reject all pending reads
for (self._pending_reads.items) |resolver| {
resolver.reject("stream errror", err);
}
self._pending_reads.clearRetainingCapacity();
}
pub fn dequeue(self: *ReadableStreamDefaultController) ?[]const u8 {

View File

@@ -59,17 +59,14 @@ pub fn read(self: *ReadableStreamDefaultReader, page: *Page) !js.Promise {
if (stream._state == .closed) {
const result = ReadResult{
.value = null,
.done = true,
.value = null,
};
return page.js.resolvePromise(result);
}
const result = ReadResult{
.done = true,
.value = null,
};
return page.js.resolvePromise(result);
// No data, but not closed. We need to queue the read for any future data
return stream._controller.addPendingRead(page);
}
pub fn releaseLock(self: *ReadableStreamDefaultReader) void {

View File

@@ -548,7 +548,7 @@ pub fn milliTimestamp(comptime mode: TimestampMode) u64 {
return @as(u64, @intCast(ts.sec)) * 1000 + @as(u64, @intCast(@divTrunc(ts.nsec, 1_000_000)));
}
fn timespec() posix.timespec {
pub fn timespec() posix.timespec {
if (comptime is_posix == false) {
@compileError("`timespec` should not be called when `is_posix` is false");
}