From 8c7706f6a141b1888d425ed05422e233956a5e93 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 31 Jul 2026 11:38:03 +0800 Subject: [PATCH] mem: improve memory efficiency of blob This figures out the size of Blob/File to chose a better arena, potentially avoiding holding a .large arena (which we have limited) for small data. This happens to circumvent an allocation bug in std.Io.Writer.Allocating that can over-reserve memory. It also removes some page: *Page parameters in favor of *js.Execution which I think should always be preferred (for no other reason that going from Execution -> Page is easy, but going from Page -> Execution is more complicated so we should just always use Execution). --- src/browser/webapi/Blob.zig | 63 ++++++++++--------- src/browser/webapi/File.zig | 20 +++--- src/browser/webapi/canvas/OffscreenCanvas.zig | 2 +- src/browser/webapi/net/FormData.zig | 29 ++++----- src/browser/webapi/net/Request.zig | 2 +- src/browser/webapi/net/Response.zig | 2 +- src/browser/webapi/net/WebSocket.zig | 2 +- 7 files changed, 63 insertions(+), 57 deletions(-) diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 4f4494a6c..16c7ca50a 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -78,37 +78,44 @@ const InitOptions = struct { /// Creates a new Blob from JS values with optional MIME validation. /// This is the JS Constructor -pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob { - const session = page.session; - const arena = try session.getPinnedArena(.large, "Blob"); - errdefer arena.release(); +pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, exec: *const Execution) !*Blob { + const blob = try buildValue(parts_, opts_ orelse .{}, exec); + errdefer blob._arena.release(); - const self = try arena.create(Blob); - self.* = try buildValue(arena, parts_, opts_ orelse .{}); - arena.report(); + const self = try blob._arena.create(Blob); + self.* = blob; + + blob._arena.report(); return self; } -// The Blob value for `parts`, with everything it references copied to -// `arena`. Callers either arena.create it (init) or embed it as the root of -// a {Blob, File} chain. -pub fn buildValue(arena: *lp.Arena, parts_: ?[]const js.Value, opts: InitOptions) !Blob { - const mime = try Mime.serialize(arena.allocator(), opts.type); - - const data = blk: { - if (parts_) |blob_parts| { - const use_native_endings = std.mem.eql(u8, opts.endings, "native"); - var w: Writer.Allocating = .init(arena.allocator()); - for (blob_parts) |js_val| { - const part = try js_val.toStringSmart(); - try writePartWithEndings(part, use_native_endings, &w.writer); - } - break :blk w.written(); +pub fn buildValue(parts_: ?[]const js.Value, opts: InitOptions, exec: *const Execution) !Blob { + const data, const arena = blk: { + const parts = parts_ orelse { + break :blk .{ "", try exec.getPinnedArena(.tiny, "Blob") }; + }; + var len: usize = 0; + const slices = try exec.call_arena.alloc([]const u8, parts.len); + for (parts, slices) |js_val, *s| { + s.* = try js_val.toStringSmart(); + len += s.len; } + // +256, ~struct overhead, mime dupe, ... + const arena = try exec.getPinnedArena(len + 256, "blob"); - break :blk ""; + const buf = try arena.alloc(u8, len); + var w: Writer = .fixed(buf); + + const use_native_endings = std.mem.eql(u8, opts.endings, "native"); + + for (slices) |part| { + try writePartWithEndings(part, use_native_endings, &w); + } + break :blk .{ w.buffered(), arena }; }; + const mime = try Mime.serialize(arena.allocator(), opts.type); + return .{ ._rc = .{}, ._arena = arena, @@ -129,8 +136,8 @@ pub fn buildValueFromBytes(arena: *lp.Arena, data: []const u8, content_type: []c } /// Creates a new Blob from raw byte slices (for internal Zig use). -pub fn initFromBytes(data: []const u8, content_type: []const u8, page: *Page) !*Blob { - const arena = try page.getPinnedArena(data.len + content_type.len + 256, "Blob"); +pub fn initFromBytes(data: []const u8, content_type: []const u8, exec: *const Execution) !*Blob { + const arena = try exec.getPinnedArena(data.len + content_type.len + 256, "Blob"); errdefer arena.release(); const self = try arena.create(Blob); @@ -318,7 +325,7 @@ pub fn slice( start_: ?i32, end_: ?i32, content_type_: ?[]const u8, - page: *Page, + exec: *const Execution, ) !*Blob { const data = self._slice; @@ -339,7 +346,7 @@ pub fn slice( break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end)))); }; - return Blob.initFromBytes(data[start..end], content_type_ orelse "", page); + return Blob.initFromBytes(data[start..end], content_type_ orelse "", exec); } /// Returns the size of the Blob in bytes. @@ -387,7 +394,7 @@ test "Blob: a pinned arena reaches the browser's account and is given back" { try testing.expectEqual(0, browser.arena_account.pending); const data = [_]u8{'x'} ** (64 * 1024); - const blob = try Blob.initFromBytes(&data, "text/plain", page); + const blob = try Blob.initFromBytes(&data, "text/plain", &frame.js.execution); try testing.expect(browser.arena_account.pending >= data.len); // The finalizer path hands every reported byte back. diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index 29d14488f..8155a807b 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -42,27 +42,25 @@ pub fn init( parts_: ?[]const js.Value, name: []const u8, opts_: ?InitOptions, - page: *Page, + exec: *js.Execution, ) !*File { const opts = opts_ orelse InitOptions{}; - const session = page.session; - const arena = try session.getPinnedArena(.large, "Blob"); - errdefer arena.release(); + const blob = try Blob.buildValue(parts_, .{ + .type = opts.type, + .endings = opts.endings, + }, exec); - const file = try Factory.chainedWithAllocator(arena.allocator(), .{ - try Blob.buildValue(arena, parts_, .{ - .type = opts.type, - .endings = opts.endings, - }), + const file = try Factory.chainedWithAllocator(blob._arena.allocator(), .{ + blob, File{ ._proto = undefined, - ._name = try arena.dupe(u8, name), + ._name = try blob._arena.dupe(u8, name), ._last_modified = opts.lastModified orelse @intCast(lp.datetime.milliTimestamp(.real)), }, }); file._proto._type = .{ .file = file }; - arena.report(); + blob._arena.report(); return file; } diff --git a/src/browser/webapi/canvas/OffscreenCanvas.zig b/src/browser/webapi/canvas/OffscreenCanvas.zig index f3e113a95..c431aa93b 100644 --- a/src/browser/webapi/canvas/OffscreenCanvas.zig +++ b/src/browser/webapi/canvas/OffscreenCanvas.zig @@ -73,7 +73,7 @@ pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, exec: *Executio /// Returns a Promise that resolves to a Blob containing the image. /// Since we have no actual rendering, this returns an empty blob. pub fn convertToBlob(_: *OffscreenCanvas, exec: *Execution) !js.Promise { - const blob = try Blob.init(null, null, exec.page); + const blob = try Blob.init(null, null, exec); return exec.js.local.?.resolvePromise(blob); } diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 892abbbce..4b4c4ccdd 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -158,7 +158,7 @@ pub fn initFromMultipart(bytes: []const u8, boundary: []const u8, exec: *const E else => {}, }; - try form_data.parseMultipart(exec.page, bytes, boundary); + try form_data.parseMultipart(bytes, boundary, exec); return form_data; } @@ -226,7 +226,7 @@ pub fn append(self: *FormData, name: []const u8, value: EntryValue, filename: ?[ if (filename) |n| { // A supplied filename means a new File over the same bytes rather // than a rename of the caller's object. - break :blk .{ .file = try fileFrom(blob, n, exec.page) }; + break :blk .{ .file = try fileFrom(blob, n, exec) }; } if (blob._type == .file) { @@ -236,7 +236,7 @@ pub fn append(self: *FormData, name: []const u8, value: EntryValue, filename: ?[ } // A Blob that is not a File becomes a File named "blob". - break :blk .{ .file = try fileFrom(blob, "blob", exec.page) }; + break :blk .{ .file = try fileFrom(blob, "blob", exec) }; }, .bytes => |b| .{ .string = try String.init(self._arena.allocator(), b, .{}) }, }; @@ -250,8 +250,9 @@ pub fn append(self: *FormData, name: []const u8, value: EntryValue, filename: ?[ // Mirrors File.init — a Blob and File sharing one reference-counted arena — // but over bytes we already hold rather than JS parts. Returned at refcount 1: // the entry owns that reference and deleteByName releases it. -fn fileFrom(source: *Blob, name: []const u8, page: *Page) !*File { - const arena = try page.getArena(source._slice.len + source._mime.len + 256, "Blob"); + +fn fileFrom(source: *Blob, name: []const u8, exec: *Execution) !*File { + const arena = try exec.getArena(source._slice.len + source._mime.len + 256, "Blob"); errdefer arena.release(); const file = try Factory.chainedWithAllocator(arena.allocator(), .{ @@ -528,7 +529,7 @@ fn urlDecode(arena: Allocator, raw: []const u8) ![]const u8 { // preamble, CRLF line breaks). Parts carrying a filename become File // entries — the FormData holds a ref on each, released in deinit — and the // rest become string entries. -fn parseMultipart(self: *FormData, page: *Page, bytes: []const u8, boundary: []const u8) !void { +fn parseMultipart(self: *FormData, bytes: []const u8, boundary: []const u8, exec: *const Execution) !void { // The body must open with the dash-boundary: "--" boundary. if (!std.mem.startsWith(u8, bytes, "--") or !std.mem.startsWith(u8, bytes[2..], boundary)) { return error.InvalidFormData; @@ -613,8 +614,8 @@ fn parseMultipart(self: *FormData, page: *Page, bytes: []const u8, boundary: []c // Got a file. if (parsed.filename) |filename| { - const blob = try Blob.initFromBytes(content, content_type, page); - errdefer blob.deinit(page); + const blob = try Blob.initFromBytes(content, content_type, exec); + errdefer blob.deinit(exec.page); const file = try blob._arena.create(File); file.* = .{ @@ -1251,7 +1252,7 @@ test "FormData: multipart parse" { ._arena = arena, ._entries = .empty, }; - try fd.parseMultipart(frame._page, "--BOUNDARY\r\n" ++ + try fd.parseMultipart("--BOUNDARY\r\n" ++ "Content-Disposition: form-data; name=\"name\"\r\n\r\n" ++ "John\r\n" ++ "--BOUNDARY\r\n" ++ @@ -1260,7 +1261,7 @@ test "FormData: multipart parse" { "--BOUNDARY\r\n" ++ "Content-Disposition: form-data; name=\"tricky\"\r\n\r\n" ++ "a\r\n--BOUNDARYx b\r\n" ++ - "--BOUNDARY--\r\n", "BOUNDARY"); + "--BOUNDARY--\r\n", "BOUNDARY", &frame.js.execution); try testing.expectEqual(3, fd._entries.items.len); try testing.expectString("John", fd.get(.wrap("name")).?); @@ -1282,14 +1283,14 @@ test "FormData: multipart parse with file" { ._arena = arena, ._entries = .empty, }; - try fd.parseMultipart(frame._page, "--B\r\n" ++ + try fd.parseMultipart("--B\r\n" ++ "Content-Disposition: form-data; name=\"upload\"; filename=\"hello.txt\"\r\n" ++ "Content-Type: text/plain\r\n\r\n" ++ "hello\r\n" ++ "--B\r\n" ++ "Content-Disposition: form-data; name=\"raw\"; filename=\"raw.bin\"\r\n\r\n" ++ "bytes\r\n" ++ - "--B--\r\n", "B"); + "--B--\r\n", "B", &frame.js.execution); defer for (fd._entries.items) |entry| switch (entry.value) { .file => |file| file.releaseRef(frame._page), else => {}, @@ -1330,7 +1331,7 @@ test "FormData: multipart parse rejects malformed bodies" { ._arena = arena, ._entries = .empty, }; - try testing.expectError(error.InvalidFormData, fd.parseMultipart(frame._page, case, "B")); + try testing.expectError(error.InvalidFormData, fd.parseMultipart(case, "B", &frame.js.execution)); } } @@ -1364,7 +1365,7 @@ test "FormData: multipart round-trip" { ._arena = arena, ._entries = .empty, }; - try fd.parseMultipart(frame._page, buf.written(), "BOUNDARY"); + try fd.parseMultipart(buf.written(), "BOUNDARY", &frame.js.execution); try testing.expectEqual(4, fd._entries.items.len); try testing.expectString("username", fd._entries.items[0].name.str()); diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 7532f1471..fa249d497 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -260,7 +260,7 @@ pub fn blob(self: *Request, exec: *const Execution) !js.Promise { const headers = try self.getHeaders(exec); const content_type = try headers.get("content-type", exec) orelse ""; - const b = try Blob.initFromBytes(body, content_type, exec.page); + const b = try Blob.initFromBytes(body, content_type, exec); return local.resolvePromise(b); } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 4e9e0f0ee..2f25bc5cf 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -448,7 +448,7 @@ pub fn blob(self: *Response, exec: *const Execution) !js.Promise { .stream => return local.rejectPromise(.{ .type_error = "Cannot read blob from stream body" }), }; const content_type = try self._headers.get("content-type", exec) orelse ""; - const b = try Blob.initFromBytes(body, content_type, exec.page); + const b = try Blob.initFromBytes(body, content_type, exec); return local.resolvePromise(b); } diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 13213054a..b9c13e62a 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -756,7 +756,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF switch (self._binary_type) { .arraybuffer => .{ .arraybuffer = .{ .values = data } }, .blob => blk: { - const blob = try Blob.initFromBytes(data, "", exec.page); + const blob = try Blob.initFromBytes(data, "", exec); blob.acquireRef(); break :blk .{ .blob = blob }; },