diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index cf11b3a0e..085649ba3 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -207,6 +207,10 @@ pub fn deinit(self: *Context) void { const env = self.env; defer env.app.arena_pool.release(self.arena); + // Unlink any IndexedDB gate participants first: the session-scoped engine + // must never wake a waiter into this scheduler once it's torn down. + self.page.session.idb.detachContext(self); + var hs: js.HandleScope = undefined; const entered = self.enter(&hs); defer entered.exit(); diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index f4cb06f2e..3ac76aa43 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -629,6 +629,16 @@ pub fn temp(self: Value) !Temp { return self._persist(false); } +// Like persist(), but not tracked on the context: the caller owns the handle +// and must deinit (Reset) it. A reset is only idempotent through the same +// instance — copies alias one v8 slot, so keep a single canonical instance and +// reset through it. +pub fn bare(self: Value) BareGlobal { + var global: v8.Global = undefined; + v8.v8__Global__New(self.local.ctx.isolate.handle, self.handle, &global); + return .{ .handle = global, .temps = {} }; +} + fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Global else Temp) { var ctx = self.local.ctx; @@ -688,10 +698,12 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void { pub const Temp = G(.temp); pub const Global = G(.global); +pub const BareGlobal = G(.bare); const GlobalType = enum(u8) { temp, global, + bare, }; fn G(comptime global_type: GlobalType) type { diff --git a/src/browser/tests/indexeddb.html b/src/browser/tests/indexeddb.html index aee2f92ea..973ebfd32 100644 --- a/src/browser/tests/indexeddb.html +++ b/src/browser/tests/indexeddb.html @@ -1153,4 +1153,40 @@ await state.done((got) => testing.expectEqual("v", got)); } + + + diff --git a/src/browser/webapi/DedicatedWorkerGlobalScope.zig b/src/browser/webapi/DedicatedWorkerGlobalScope.zig index 479f7c2bc..311306d94 100644 --- a/src/browser/webapi/DedicatedWorkerGlobalScope.zig +++ b/src/browser/webapi/DedicatedWorkerGlobalScope.zig @@ -82,7 +82,8 @@ pub fn postMessage(self: *DedicatedWorkerGlobalScope, data: js.Value) !void { } pub fn close(self: *DedicatedWorkerGlobalScope) void { - // TOOD: we should also stop new tasks from being scheduled + // TODO: we should also stop new tasks from being scheduled + self._proto._session.idb.detachContext(self._proto.js); self._proto.js.scheduler.reset(); self._closed = true; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 5e22b270b..bfc7dd851 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -682,6 +682,7 @@ pub fn close(self: *Window) void { } } + page.session.idb.detachContext(frame.js); frame.js.scheduler.reset(); frame.abortTransfers(); diff --git a/src/browser/webapi/storage/idb/Engine.zig b/src/browser/webapi/storage/idb/Engine.zig index 8b8d73b0c..6d80c9621 100644 --- a/src/browser/webapi/storage/idb/Engine.zig +++ b/src/browser/webapi/storage/idb/Engine.zig @@ -34,22 +34,32 @@ conn: Sqlite.Conn, // (intrusive, caller-owned) node on `_gate_waiters` and are handed ownership in // FIFO order as it's released. // -// LIFETIME GAP (to resolve in the IDB memory rework): the Engine is -// session-scoped but the waiters (IDBTransaction / Open+DeleteContext) are -// page-scoped, so these are the only session->page pointers in IDB. A waiter -// freed on navigation while still owning or parked here leaves a dangling -// pointer/node that a later same-origin page can deref or deadlock on; a parked -// waiter has no scheduler task and so no finalizer to unlink it on teardown. The -// rework must session-scope these objects, add a page-teardown unlink, or drop -// the wait-list for a bool gate. +// The Engine is session-scoped but the waiters (IDBTransaction / +// Open+DeleteContext) are context-scoped, so these are the only session->page +// pointers in IDB. Every participant tags its waiter with its js Context; +// detach() must be called before that context's scheduler is reset or torn +// down, otherwise a parked waiter dangles here and a later wake would schedule +// onto a dead scheduler. _gate_owner: ?*GateWaiter = null, _gate_waiters: std.DoublyLinkedList = .{}, pub const GateWaiter = struct { node: std.DoublyLinkedList.Node = .{}, + // A bit unusual. Normally, if we need to tear something down when the frame + // is destroyed, we track that on the frame (see Frame._file_lists). And + // we _could_ do that here too, but the Engine still needs the list, so that + // would require book-keeping in 2 lists. Instead, Engine owns the list and + // we track the *js.Context (because it needs to work for both WGS and + // frames). On context teardown, we can iterate the list and remove any + // waiters associated with that context. + ctx: *anyopaque, + // Called when this waiter is handed the gate; typically reschedules the // owner's task so it re-runs and finds itself the owner. wake: *const fn (waiter: *GateWaiter) void, + + // Called when detach() removes this waiter because its context is going away. + cancel: *const fn (waiter: *GateWaiter) void, }; pub fn acquireGate(self: *Engine, waiter: *GateWaiter) bool { @@ -68,18 +78,39 @@ pub fn acquireGate(self: *Engine, waiter: *GateWaiter) bool { // Release the gate held by `waiter`, handing it directly to the next parked // waiter (no window where an unrelated contender can grab it) and waking it. -// No-op if `waiter` isn't the current owner. -pub fn releaseGate(self: *Engine, waiter: *GateWaiter) void { +pub fn releaseGate(self: *Engine, waiter: *GateWaiter) bool { if (self._gate_owner != waiter) { - return; + return false; } if (self._gate_waiters.popFirst()) |node| { const next: *GateWaiter = @fieldParentPtr("node", node); self._gate_owner = next; next.wake(next); - return; + return true; } self._gate_owner = null; + return true; +} + +// A js Context is being torn down: remove every waiter associated with it. +pub fn detach(self: *Engine, ctx: *anyopaque) void { + var node = self._gate_waiters.first; + while (node) |n| { + const next = n.next; + const waiter: *GateWaiter = @fieldParentPtr("node", n); + if (waiter.ctx == ctx) { + self._gate_waiters.remove(n); + waiter.cancel(waiter); + } + node = next; + } + + const owner = self._gate_owner orelse return; + if (owner.ctx != ctx) { + return; + } + owner.cancel(owner); + _ = self.releaseGate(owner); } pub fn open(path: [:0]const u8) !Engine { @@ -807,6 +838,60 @@ test "IDB - Engine: getRange/getKeyRange first-in-range and deleteRange" { try testing.expectEqual(3, try engine.countRange(store_id, Engine.Bounds.unbounded())); } +test "IDB - Engine: detach unlinks parked waiters and hands off an owned gate" { + var engine = try Engine.open(":memory:"); + defer engine.close(); + + const TestWaiter = struct { + waiter: Engine.GateWaiter, + woken: u32 = 0, + cancelled: u32 = 0, + + fn init(ctx: *anyopaque) @This() { + return .{ .waiter = .{ .ctx = ctx, .wake = wake, .cancel = cancel } }; + } + fn wake(w: *Engine.GateWaiter) void { + const self: *@This() = @fieldParentPtr("waiter", w); + self.woken += 1; + } + fn cancel(w: *Engine.GateWaiter) void { + const self: *@This() = @fieldParentPtr("waiter", w); + self.cancelled += 1; + } + }; + + var ctx_a: u8 = 0; + var ctx_b: u8 = 0; + + // a1 owns; a2 and b1 park behind it. + var a1 = TestWaiter.init(&ctx_a); + var a2 = TestWaiter.init(&ctx_a); + var b1 = TestWaiter.init(&ctx_b); + try testing.expectEqual(true, engine.acquireGate(&a1.waiter)); + try testing.expectEqual(false, engine.acquireGate(&a2.waiter)); + try testing.expectEqual(false, engine.acquireGate(&b1.waiter)); + + // Detaching context A cancels both its waiters (owner + parked, no wakes + // for either) and hands the gate straight to b1. + engine.detach(&ctx_a); + try testing.expectEqual(1, a1.cancelled); + try testing.expectEqual(0, a1.woken); + try testing.expectEqual(1, a2.cancelled); + try testing.expectEqual(0, a2.woken); + try testing.expectEqual(0, b1.cancelled); + try testing.expectEqual(1, b1.woken); + try testing.expect(engine._gate_owner == &b1.waiter); + + // Detaching a context with no participants is a no-op. + engine.detach(&ctx_a); + try testing.expect(engine._gate_owner == &b1.waiter); + + // releaseGate reports whether the caller actually owned the gate. + try testing.expectEqual(false, engine.releaseGate(&a1.waiter)); + try testing.expectEqual(true, engine.releaseGate(&b1.waiter)); + try testing.expectEqual(null, engine._gate_owner); +} + test "IDB - Engine: open creates schema" { var engine = try Engine.open(":memory:"); defer engine.close(); diff --git a/src/browser/webapi/storage/idb/IDBCursor.zig b/src/browser/webapi/storage/idb/IDBCursor.zig index 260414fee..ab3a71e3c 100644 --- a/src/browser/webapi/storage/idb/IDBCursor.zig +++ b/src/browser/webapi/storage/idb/IDBCursor.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); const Key = @import("Key.zig"); const Engine = @import("Engine.zig"); @@ -49,7 +50,7 @@ _index_id: ?i64 = null, // the JS value of this Cursor, pre-converted and cached as an optimization // since this cursor will be the request value on every iteration. -_js: js.Value.Global, +_js: *js.Value.BareGlobal, // Encoded current key; null before iteration and at the end. For an index cursor // this is the index key; for an object store it equals the primary key. @@ -60,6 +61,14 @@ _primary_key: ?[]const u8 = null, // Current record's serialized value bytes (null when key-only or exhausted). _value: ?[]const u8 = null, +// Backing storage for _key/_primary_key/_value, reused across positions so a +// long scan holds one record's worth of memory, not the whole traversal. +// Anything that must survive a reposition (update/delete/continue snapshots) +// is duped onto the transaction's arena instead. +_key_buf: std.ArrayList(u8) = .empty, +_pk_buf: std.ArrayList(u8) = .empty, +_val_buf: std.ArrayList(u8) = .empty, + pub const Direction = enum { next, nextunique, @@ -96,23 +105,22 @@ fn startSentinel(reverse: bool) []const u8 { } // Cursor over an object store. -pub fn open(store: *IDBObjectStore, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest { - const txn = store._txn orelse return error.TransactionInactiveError; - return create(store, txn, null, .{ .store = store }, bounds, direction, key_only, exec); +pub fn init(store: *IDBObjectStore, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest { + return _init(store, store._txn, null, .{ .store = store }, bounds, direction, key_only, exec); } // Cursor over an index (key = index key, primaryKey = store key). -pub fn openIndex(index: *IDBIndex, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest { +pub fn initIndex(index: *IDBIndex, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest { const store = index._store; - const txn = store._txn orelse return error.TransactionInactiveError; - return create(store, txn, index._index_id, .{ .index = index }, bounds, direction, key_only, exec); + return _init(store, store._txn, index._index_id, .{ .index = index }, bounds, direction, key_only, exec); } -fn create(store: *IDBObjectStore, txn: *IDBTransaction, index_id: ?i64, source: Source, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest { +fn _init(store: *IDBObjectStore, txn: *IDBTransaction, index_id: ?i64, source: Source, bounds: Engine.Bounds, direction: Direction, key_only: bool, exec: *Execution) !*IDBRequest { try txn.assertActive(); const request = try txn.newRequest(); - const self = try exec._factory.create(IDBCursor{ + const self = try txn._arena.create(IDBCursor); + self.* = .{ ._engine = store._engine, ._store = store, ._txn = txn, @@ -123,29 +131,30 @@ fn create(store: *IDBObjectStore, txn: *IDBTransaction, index_id: ?i64, source: ._index_id = index_id, ._js = undefined, ._source = source, - }); + }; request._cursor = self; const local = exec.js.local.?; const public: js.Value = if (key_only) try local.zigValueToJs(self, .{}) else - try local.zigValueToJs(try IDBCursorWithValue.init(self, exec), .{}); + try local.zigValueToJs(try IDBCursorWithValue.init(self), .{}); // Pre-converted and cached because it's the request value on every iteration, // avoiding the bridge's pointer -> js.Value lookup each time. - self._js = try public.persist(); + self._js = try txn.persist(public); // The first seek runs in the drain (or immediately, for a versionchange txn), // not here — so the connection is only touched while the txn owns it. return request.submit(.{ .cursor_iterate = .{ .cursor = self, .seek = .first, .offset = 0 } }, exec); } -// Re-arm the cursor's existing request with its next seek and requeue it. Called by -// continue/advance from within a success handler; the drain loop picks the requeued -// request up in the same pass (a versionchange cursor runs it now). -fn reiterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void { - _ = try self._request.submit(.{ .cursor_iterate = .{ .cursor = self, .seek = seek, .offset = offset } }, exec); +pub fn acquireRef(self: *IDBCursor) void { + self._txn.acquireRef(); +} + +pub fn releaseRef(self: *IDBCursor, page: *Page) void { + self._txn.releaseRef(page); } // Run the deferred seek, staging the positioned cursor (or null) on the request. @@ -165,7 +174,7 @@ pub fn @"continue"(self: *IDBCursor, key_arg: ?js.Value, exec: *Execution) !void try self.prepareIterate(); if (key_arg) |k| { - const encoded = try Key.encodeValue(exec.arena, k); + const encoded = try Key.encodeValue(self._txn._arena, k); // The target must move past the current key in the iteration direction. const order = std.mem.order(u8, encoded, self._key.?); if (if (self._direction.reverse()) order != .lt else order != .gt) { @@ -185,8 +194,8 @@ pub fn continuePrimaryKey(self: *IDBCursor, key_arg: js.Value, primary_key_arg: try self.prepareIterate(); const reverse = self._direction.reverse(); - const key = try Key.encodeValue(exec.arena, key_arg); - const primary_key = try Key.encodeValue(exec.arena, primary_key_arg); + const key = try Key.encodeValue(self._txn._arena, key_arg); + const primary_key = try Key.encodeValue(self._txn._arena, primary_key_arg); // The (key, primaryKey) pair must move past the current position. const ok = switch (std.mem.order(u8, key, self._key.?)) { @@ -226,25 +235,28 @@ pub fn update(self: *IDBCursor, value: js.Value, exec: *Execution) !*IDBRequest } // The record sits at the primary (store) key, even for an index cursor. - const key = self._primary_key orelse return error.InvalidStateError; + const current_key = self._primary_key orelse return error.InvalidStateError; // For an in-line store, the value's own key must match the record's key. if (self._store._key_path) |kp| { const extracted = Key.evaluatePath(value, kp) orelse return error.DataError; const encoded = try Key.encodeValue(exec.call_arena, extracted); - if (!std.mem.eql(u8, encoded, key)) { + if (!std.mem.eql(u8, encoded, current_key)) { return error.DataError; } } // Snapshot the record key: the write runs in the drain, by which point a - // `continue` could have moved the cursor's live position on. + // `continue` could have moved the cursor's live position (and reused its + // key buffer). + const key = try self._txn._arena.dupe(u8, current_key); const request = try self._txn.newRequest(); - const value_global = try value.persist(); + const value_global = try self._txn.persist(value); return request.submit(.{ .cursor_update = .{ .cursor = self, .key = key, .value = value_global } }, exec); } -pub fn runUpdate(self: *IDBCursor, request: *IDBRequest, key: []const u8, value_global: js.Value.Global, exec: *Execution) !void { +pub fn runUpdate(self: *IDBCursor, request: *IDBRequest, key: []const u8, value_global: *js.Value.BareGlobal, exec: *Execution) !void { + defer value_global.deinit(); const local = exec.js.local.?; const value = value_global.local(local); @@ -276,7 +288,8 @@ pub fn delete(self: *IDBCursor, exec: *Execution) !*IDBRequest { } // Snapshot the record key (see update): the delete runs later, in the drain. - const key = self._primary_key orelse return error.InvalidStateError; + const current_key = self._primary_key orelse return error.InvalidStateError; + const key = try self._txn._arena.dupe(u8, current_key); const request = try self._txn.newRequest(); return request.submit(.{ .cursor_delete = .{ .cursor = self, .key = key } }, exec); } @@ -307,11 +320,12 @@ pub fn getSource(self: *const IDBCursor) Source { return self._source; } -// Seek the next record and stage it as the request result. The keys/value live on -// the page arena (they must survive across event-loop turns within the txn). +// Seek the next record and stage it as the request result. The engine dupes +// the row onto the per-call scratch arena; position() then copies it into the +// cursor's reused buffers (which must survive across event-loop turns). fn iterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void { const reverse = self._direction.reverse(); - const arena = exec.arena; + const arena = exec.call_arena; const store_id = self._store._store_id; if (self._index_id) |index_id| { @@ -322,7 +336,7 @@ fn iterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void { .to_primary => |tp| .{ tp.key, tp.primary_key, true }, }; const rec = try self._engine.indexCursorSeek(arena, store_id, index_id, self._bounds, reverse, from_key, from_pk, pk_inclusive, !self._key_only, offset); - if (rec) |r| self.position(r.key, r.primary_key, r.value) else self.exhaust(); + if (rec) |r| try self.position(r.key, r.primary_key, r.value) else self.exhaust(); } else { const from_op, const from_key = switch (seek) { .first => .{ if (reverse) "<= " else ">= ", startSentinel(reverse) }, @@ -332,14 +346,35 @@ fn iterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void { }; const rec = try self._engine.cursorSeek(arena, store_id, self._bounds, reverse, from_op, from_key, offset, !self._key_only); // For an object store the key is the primary key. - if (rec) |r| self.position(r.key, r.key, r.value) else self.exhaust(); + if (rec) |r| try self.position(r.key, r.key, r.value) else self.exhaust(); } } -fn position(self: *IDBCursor, key: []const u8, primary_key: []const u8, value: ?[]const u8) void { - self._key = key; - self._primary_key = primary_key; - self._value = value; +// Re-arm the cursor's existing request with its next seek and requeue it. Called by +// continue/advance from within a success handler; the drain loop picks the requeued +// request up in the same pass (a versionchange cursor runs it now). +fn reiterate(self: *IDBCursor, seek: Seek, offset: u32, exec: *Execution) !void { + _ = try self._request.submit(.{ .cursor_iterate = .{ .cursor = self, .seek = seek, .offset = offset } }, exec); +} + +fn position(self: *IDBCursor, key: []const u8, primary_key: []const u8, value: ?[]const u8) !void { + const arena = self._txn._arena; + + self._key_buf.clearRetainingCapacity(); + try self._key_buf.appendSlice(arena, key); + self._key = self._key_buf.items; + + self._pk_buf.clearRetainingCapacity(); + try self._pk_buf.appendSlice(arena, primary_key); + self._primary_key = self._pk_buf.items; + + if (value) |v| { + self._val_buf.clearRetainingCapacity(); + try self._val_buf.appendSlice(arena, v); + self._value = self._val_buf.items; + } else { + self._value = null; + } self._request.setValueGlobal(self._js); } diff --git a/src/browser/webapi/storage/idb/IDBCursorWithValue.zig b/src/browser/webapi/storage/idb/IDBCursorWithValue.zig index e12758681..a3ef220fd 100644 --- a/src/browser/webapi/storage/idb/IDBCursorWithValue.zig +++ b/src/browser/webapi/storage/idb/IDBCursorWithValue.zig @@ -26,8 +26,10 @@ const IDBCursorWithValue = @This(); _proto: *IDBCursor, -pub fn init(cursor: *IDBCursor, exec: *Execution) !*IDBCursorWithValue { - return exec._factory.create(IDBCursorWithValue{ ._proto = cursor }); +pub fn init(cursor: *IDBCursor) !*IDBCursorWithValue { + const self = try cursor._txn._arena.create(IDBCursorWithValue); + self.* = .{ ._proto = cursor }; + return self; } pub fn getValue(self: *const IDBCursorWithValue, exec: *Execution) !?js.Value { diff --git a/src/browser/webapi/storage/idb/IDBDatabase.zig b/src/browser/webapi/storage/idb/IDBDatabase.zig index 2a601f48f..81afd31da 100644 --- a/src/browser/webapi/storage/idb/IDBDatabase.zig +++ b/src/browser/webapi/storage/idb/IDBDatabase.zig @@ -66,7 +66,6 @@ pub fn createObjectStore( self: *IDBDatabase, name: []const u8, options: ?CreateObjectStoreOptions, - exec: *Execution, ) !*IDBObjectStore { const txn = self._txn orelse return error.InvalidStateError; @@ -81,17 +80,18 @@ pub fn createObjectStore( else => return err, }; - const owned_name = try exec.dupeString(name); - const key_path = if (opts.keyPath) |kp| try exec.dupeString(kp) else null; - return IDBObjectStore.init(self._engine, txn, store_id, owned_name, key_path, opts.autoIncrement, exec); + const owned_name = try txn.dupe(name); + const key_path = if (opts.keyPath) |kp| try txn.dupe(kp) else null; + const store = try IDBObjectStore.init(txn, store_id, owned_name, key_path, opts.autoIncrement); + try txn.cacheStore(store); + return store; } // Only callable during upgradeneeded, hence the _txn check pub fn deleteObjectStore(self: *IDBDatabase, name: []const u8, _: *Execution) !void { - if (self._txn == null) { - return error.InvalidStateError; - } - return self._engine.deleteObjectStore(self._database_id, name); + const txn = self._txn orelse return error.InvalidStateError; + try self._engine.deleteObjectStore(self._database_id, name); + txn.uncacheStore(name); } const TransactionMode = enum { @@ -116,32 +116,44 @@ pub fn transaction( options: ?TransactionOptions, exec: *Execution, ) !*IDBTransaction { - const scope = try normalizeStoreNames(store_names, exec.arena); const opts = options orelse TransactionOptions{}; - return IDBTransaction.init(exec, self, switch (mode orelse .readonly) { + const txn = try IDBTransaction.init(self, switch (mode orelse .readonly) { .readonly => .readonly, .readwrite => .readwrite, - }, opts.durability, scope); + }, opts.durability, exec); + txn._scope = try normalizeStoreNames(txn._arena, store_names); + return txn; } // The transaction's scope: the requested store names, sorted with duplicates // removed (per the IndexedDB spec's "transaction scope" steps). -fn normalizeStoreNames(store_names: StoreNames, arena: Allocator) ![]const []const u8 { +fn normalizeStoreNames(arena: Allocator, store_names: StoreNames) ![]const []const u8 { var list: std.ArrayList([]const u8) = .empty; switch (store_names) { .name => |name| try list.append(arena, try arena.dupe(u8, name)), .names => |names| { try list.ensureUnusedCapacity(arena, names.len); - for (names) |name| list.appendAssumeCapacity(try arena.dupe(u8, name)); + for (names) |name| { + list.appendAssumeCapacity(try arena.dupe(u8, name)); + } }, } - std.mem.sort([]const u8, list.items, {}, lessThan); + std.mem.sort([]const u8, list.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); - // Drop duplicates now that equal names are adjacent. - var write: usize = 0; - for (list.items, 0..) |name, read| { - if (read == 0 or !std.mem.eql(u8, name, list.items[write - 1])) { + // Drop duplicates now that equal names are adjacent. The first name always + // survives, so only the rest need comparing. + if (list.items.len <= 1) { + return list.items; + } + + var write: usize = 1; + for (list.items[1..]) |name| { + if (!std.mem.eql(u8, name, list.items[write - 1])) { list.items[write] = name; write += 1; } @@ -149,10 +161,6 @@ fn normalizeStoreNames(store_names: StoreNames, arena: Allocator) ![]const []con return list.items[0..write]; } -fn lessThan(_: void, a: []const u8, b: []const u8) bool { - return std.mem.lessThan(u8, a, b); -} - pub fn close(_: *IDBDatabase) void { // Connections are pooled on the Manager and shared across handles, so a // single handle's close() is a no-op for the bare slice. diff --git a/src/browser/webapi/storage/idb/IDBFactory.zig b/src/browser/webapi/storage/idb/IDBFactory.zig index 5e70083d9..fb26838f4 100644 --- a/src/browser/webapi/storage/idb/IDBFactory.zig +++ b/src/browser/webapi/storage/idb/IDBFactory.zig @@ -52,7 +52,7 @@ pub fn open(_: *IDBFactory, name: []const u8, version: ?u64, exec: *Execution) ! .name = try exec.dupeString(name), .version = version, .exec = exec, - ._gate_waiter = .{ .wake = OpenContext.wakeUp }, + ._gate_waiter = .{ .ctx = exec.js, .wake = OpenContext.wakeUp, .cancel = OpenContext.cancelParked }, }); try exec.js.scheduler.add(ctx, OpenContext.run, 0, .{ @@ -69,14 +69,30 @@ const OpenContext = struct { exec: *Execution, // Our node in the engine's connection gate wait-list. See Engine.acquireGate. _gate_waiter: Engine.GateWaiter, + // Whether a scheduler task currently points at us; its finalizer owns our + // destruction then. When parked on the gate instead, cancelParked owns it. + _scheduled: bool = true, fn cancelled(ctx: *anyopaque) void { + // What if we're gated? Well, A scheduled task is only canceled on + // teardown, which would have already called Engine.detach(js_ctx). const self: *OpenContext = @ptrCast(@alignCast(ctx)); self.exec._factory.destroy(self); } + // Engine.detach cancel: our context is going away while we sit on the + // gate. When parked there's no scheduler task, so we own our destruction; + // in the wake->run window the task finalizer does. + fn cancelParked(waiter: *Engine.GateWaiter) void { + const self: *OpenContext = @fieldParentPtr("_gate_waiter", waiter); + if (!self._scheduled) { + self.exec._factory.destroy(self); + } + } + fn run(ctx: *anyopaque) !?u32 { const self: *OpenContext = @ptrCast(@alignCast(ctx)); + self._scheduled = false; const engine = self.resolveEngine() catch |err| { self.exec._factory.destroy(self); @@ -92,7 +108,7 @@ const OpenContext = struct { return null; // parked; not destroyed } defer self.exec._factory.destroy(self); - defer engine.releaseGate(&self._gate_waiter); + defer _ = engine.releaseGate(&self._gate_waiter); self.runOpen(engine) catch |err| { log.warn(.storage, "idb open", .{ .err = err, .name = self.name }); @@ -111,9 +127,11 @@ const OpenContext = struct { }) catch |err| { // We were handed the gate; if we can't reschedule, hand it off so the // waiters behind us aren't stranded. - if (self.resolveEngine()) |engine| engine.releaseGate(&self._gate_waiter) else |_| {} log.warn(.storage, "idb resume open", .{ .err = err }); + if (self.resolveEngine()) |engine| _ = engine.releaseGate(&self._gate_waiter) else |_| {} + self.exec._factory.destroy(self); }; + self._scheduled = true; } fn resolveEngine(self: *OpenContext) !*Engine { @@ -161,8 +179,15 @@ const OpenContext = struct { const db = try IDBDatabase.init(exec, engine, database_id, self.name, requested); self.request.setDatabaseResult(db); - const txn = try IDBTransaction.initVersionChange(exec, db); - self.request._txn = txn; + const txn = try IDBTransaction.initVersionChange(db, exec); + txn.acquireRef(); + defer txn.releaseRef(exec.page); + + self.request._txn = .{ .borrowed = txn }; + // The request is page-scoped and must never outlive this pointer; the + // success/abort paths below null it before delivering, this covers the + // error paths. + defer self.request._txn = .none; { db._txn = txn; @@ -175,14 +200,14 @@ const OpenContext = struct { // updateneeded handler called abort() (what a jerk!) — abort() already // rolled back. closed = true; - self.request._txn = null; + self.request._txn = .none; self.request.setError(error.AbortError); return self.request.deliver(exec); } txn.settle(exec); closed = true; - self.request._txn = null; + self.request._txn = .none; return self.request.fireSuccess(exec); } }; @@ -199,7 +224,7 @@ pub fn deleteDatabase(_: *IDBFactory, name: []const u8, exec: *Execution) !*IDBR .request = request, .name = try exec.dupeString(name), .exec = exec, - ._gate_waiter = .{ .wake = DeleteContext.wakeUp }, + ._gate_waiter = .{ .ctx = exec.js, .wake = DeleteContext.wakeUp, .cancel = DeleteContext.cancelParked }, }); try exec.js.scheduler.add(ctx, DeleteContext.run, 0, .{ @@ -214,14 +239,27 @@ const DeleteContext = struct { name: []const u8, exec: *Execution, _gate_waiter: Engine.GateWaiter, + // See OpenContext._scheduled. + _scheduled: bool = true, fn cancelled(ctx: *anyopaque) void { + // What if we're gated? Well, A scheduled task is only canceled on + // teardown, which would have already called Engine.detach(js_ctx). const self: *DeleteContext = @ptrCast(@alignCast(ctx)); self.exec._factory.destroy(self); } + // See OpenContext.cancelParked. + fn cancelParked(waiter: *Engine.GateWaiter) void { + const self: *DeleteContext = @fieldParentPtr("_gate_waiter", waiter); + if (!self._scheduled) { + self.exec._factory.destroy(self); + } + } + fn run(ctx: *anyopaque) !?u32 { const self: *DeleteContext = @ptrCast(@alignCast(ctx)); + self._scheduled = false; const engine = self.resolveEngine() catch |err| { self.exec._factory.destroy(self); @@ -234,7 +272,7 @@ const DeleteContext = struct { return null; // parked; not destroyed } defer self.exec._factory.destroy(self); - defer engine.releaseGate(&self._gate_waiter); + defer _ = engine.releaseGate(&self._gate_waiter); self.runDelete(engine) catch |err| { log.warn(.storage, "idb deleteDatabase", .{ .err = err, .name = self.name }); @@ -253,9 +291,11 @@ const DeleteContext = struct { }) catch |err| { // We were handed the gate; if we can't reschedule, hand it off so the // waiters behind us aren't stranded. - if (self.resolveEngine()) |engine| engine.releaseGate(&self._gate_waiter) else |_| {} log.warn(.storage, "idb resume delete", .{ .err = err }); + if (self.resolveEngine()) |engine| _ = engine.releaseGate(&self._gate_waiter) else |_| {} + self.exec._factory.destroy(self); }; + self._scheduled = true; } fn resolveEngine(self: *DeleteContext) !*Engine { diff --git a/src/browser/webapi/storage/idb/IDBIndex.zig b/src/browser/webapi/storage/idb/IDBIndex.zig index 5fb92b9d0..03c532342 100644 --- a/src/browser/webapi/storage/idb/IDBIndex.zig +++ b/src/browser/webapi/storage/idb/IDBIndex.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); const Key = @import("Key.zig"); const Engine = @import("Engine.zig"); const IDBCursor = @import("IDBCursor.zig"); @@ -43,8 +44,9 @@ _key_path: []const u8, _unique: bool, _multi_entry: bool, -pub fn init(obj_store: *IDBObjectStore, info: Engine.IndexInfo, name: []const u8, exec: *Execution) !*IDBIndex { - return exec._factory.create(IDBIndex{ +pub fn init(obj_store: *IDBObjectStore, info: Engine.IndexInfo, name: []const u8) !*IDBIndex { + const self = try obj_store._txn._arena.create(IDBIndex); + self.* = .{ ._store = obj_store, ._engine = obj_store._engine, ._index_id = info.id, @@ -52,18 +54,27 @@ pub fn init(obj_store: *IDBObjectStore, info: Engine.IndexInfo, name: []const u8 ._key_path = info.key_path, ._unique = info.unique, ._multi_entry = info.multi_entry, - }); + }; + return self; +} + +pub fn acquireRef(self: *IDBIndex) void { + self._store._txn.acquireRef(); +} + +pub fn releaseRef(self: *IDBIndex, page: *Page) void { + self._store._txn.releaseRef(page); } fn txn(self: *IDBIndex) !*IDBTransaction { - const t = self._store._txn orelse return error.TransactionInactiveError; + const t = self._store._txn; try t.assertActive(); return t; } pub fn get(self: *IDBIndex, query: js.Value, exec: *Execution) !*IDBRequest { const t = try self.txn(); - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); + const bounds = try IDBKeyRange.resolveQuery(t._arena, query, exec); const request = try t.newRequest(); return request.submit(.{ .index_get = .{ .index = self, .bounds = bounds } }, exec); } @@ -81,7 +92,7 @@ pub fn runGet(self: *IDBIndex, request: *IDBRequest, bounds: Engine.Bounds, exec pub fn getKey(self: *IDBIndex, query: js.Value, exec: *Execution) !*IDBRequest { const t = try self.txn(); - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); + const bounds = try IDBKeyRange.resolveQuery(t._arena, query, exec); const request = try t.newRequest(); return request.submit(.{ .index_get_key = .{ .index = self, .bounds = bounds } }, exec); } @@ -107,14 +118,14 @@ pub fn getAllKeys(self: *IDBIndex, query_or_options: ?js.Value, count_: ?u32, ex pub fn getAllRecords(self: *IDBIndex, options: ?js.Value, exec: *Execution) !*IDBRequest { const t = try self.txn(); - const args = try IDBKeyRange.resolveGetAllOptions(exec.arena, options, exec); + const args = try IDBKeyRange.resolveGetAllOptions(t._arena, options, exec); const request = try t.newRequest(); return request.submit(.{ .index_get_all = .{ .index = self, .args = args, .mode = .record } }, exec); } fn _getAll(self: *IDBIndex, query_or_options: ?js.Value, count_: ?u32, mode: IDBObjectStore.GetAllMode, exec: *Execution) !*IDBRequest { const t = try self.txn(); - const args = try IDBKeyRange.resolveGetAll(exec.arena, query_or_options, count_, exec); + const args = try IDBKeyRange.resolveGetAll(t._arena, query_or_options, count_, exec); const request = try t.newRequest(); return request.submit(.{ .index_get_all = .{ .index = self, .args = args, .mode = mode } }, exec); } @@ -141,24 +152,24 @@ fn collectAll(self: *IDBIndex, args: IDBKeyRange.GetAllArgs, mode: IDBObjectStor const arr = local.newArray(0); var i: u32 = 0; while (try rows.next()) |row| { - _ = try arr.set(i, try rowToValue(exec, mode, row.get([]const u8, 0), row.get([]const u8, 1), row.get([]const u8, 2)), .{}); + _ = try arr.set(i, try self.rowToValue(mode, row.get([]const u8, 0), row.get([]const u8, 1), row.get([]const u8, 2), exec), .{}); i += 1; } return arr.toValue(); } -fn rowToValue(exec: *Execution, mode: IDBObjectStore.GetAllMode, key: []const u8, primary_key: []const u8, value: []const u8) !js.Value { +fn rowToValue(self: *IDBIndex, mode: IDBObjectStore.GetAllMode, key: []const u8, primary_key: []const u8, value: []const u8, exec: *Execution) !js.Value { const local = exec.js.local.?; return switch (mode) { .value => js.Value.deserialize(local, value), .key => Key.decodeToJs(exec.call_arena, local, primary_key), - .record => IDBRecord.initValue(exec, local, key, primary_key, value), + .record => IDBRecord.initValue(self._store._txn, local, key, primary_key, value), }; } pub fn count(self: *IDBIndex, query: ?js.Value, exec: *Execution) !*IDBRequest { const t = try self.txn(); - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); + const bounds = try IDBKeyRange.resolveQuery(t._arena, query, exec); const request = try t.newRequest(); return request.submit(.{ .index_count = .{ .index = self, .bounds = bounds } }, exec); } @@ -173,13 +184,13 @@ pub fn runCount(self: *IDBIndex, request: *IDBRequest, bounds: Engine.Bounds, ex } pub fn openCursor(self: *IDBIndex, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest { - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); - return IDBCursor.openIndex(self, bounds, direction orelse .next, false, exec); + const bounds = try IDBKeyRange.resolveQuery(self._store._txn._arena, query, exec); + return IDBCursor.initIndex(self, bounds, direction orelse .next, false, exec); } pub fn openKeyCursor(self: *IDBIndex, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest { - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); - return IDBCursor.openIndex(self, bounds, direction orelse .next, true, exec); + const bounds = try IDBKeyRange.resolveQuery(self._store._txn._arena, query, exec); + return IDBCursor.initIndex(self, bounds, direction orelse .next, true, exec); } pub fn getName(self: *const IDBIndex) []const u8 { diff --git a/src/browser/webapi/storage/idb/IDBObjectStore.zig b/src/browser/webapi/storage/idb/IDBObjectStore.zig index 74cd93f31..833badb43 100644 --- a/src/browser/webapi/storage/idb/IDBObjectStore.zig +++ b/src/browser/webapi/storage/idb/IDBObjectStore.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); const Key = @import("Key.zig"); const Engine = @import("Engine.zig"); const IDBIndex = @import("IDBIndex.zig"); @@ -42,26 +43,35 @@ _store_id: i64, _name: []const u8, _key_path: ?[]const u8, _auto_increment: bool, -// only null during an upgradeneeded -_txn: ?*IDBTransaction, +_txn: *IDBTransaction, +// identity map, store.indexes('a') === store.index('a') +_indexes: std.ArrayList(*IDBIndex) = .empty, pub fn init( - engine: *Engine, - txn: ?*IDBTransaction, + txn: *IDBTransaction, store_id: i64, name: []const u8, key_path: ?[]const u8, auto_increment: bool, - exec: *Execution, ) !*IDBObjectStore { - return exec._factory.create(IDBObjectStore{ - ._engine = engine, + const self = try txn._arena.create(IDBObjectStore); + self.* = .{ ._txn = txn, - ._store_id = store_id, ._name = name, + ._engine = txn._engine, + ._store_id = store_id, ._key_path = key_path, ._auto_increment = auto_increment, - }); + }; + return self; +} + +pub fn acquireRef(self: *IDBObjectStore) void { + self._txn.acquireRef(); +} + +pub fn releaseRef(self: *IDBObjectStore, page: *Page) void { + self._txn.releaseRef(page); } pub fn add(self: *IDBObjectStore, value: js.Value, key: ?js.Value, exec: *Execution) !*IDBRequest { @@ -73,18 +83,14 @@ pub fn put(self: *IDBObjectStore, value: js.Value, key: ?js.Value, exec: *Execut } pub fn get(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; try txn.assertActive(); - // Bounds are captured for deferred execution, so they must outlive this call — - // the page arena, not the per-call scratch arena. - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); + const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec); const request = try txn.newRequest(); return request.submit(.{ .store_get = .{ .store = self, .bounds = bounds } }, exec); } pub fn runGet(self: *IDBObjectStore, request: *IDBRequest, bounds: Engine.Bounds, exec: *Execution) !void { - // The fetched bytes are deserialized here and then discarded, so the per-call - // scratch arena suffices for them. const arena = exec.call_arena; const bytes = self._engine.getRange(arena, self._store_id, bounds) catch |err| { log.warn(.storage, "idb get", .{ .err = err }); @@ -96,12 +102,12 @@ pub fn runGet(self: *IDBObjectStore, request: *IDBRequest, bounds: Engine.Bounds } pub fn delete(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; if (txn._mode == .readonly) { return error.ReadOnlyError; } try txn.assertActive(); - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); + const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec); const request = try txn.newRequest(); return request.submit(.{ .store_delete = .{ .store = self, .bounds = bounds } }, exec); } @@ -119,7 +125,7 @@ fn deleteBounds(self: *IDBObjectStore, bounds: Engine.Bounds) !void { } pub fn clear(self: *IDBObjectStore, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; if (txn._mode == .readonly) { return error.ReadOnlyError; } @@ -141,9 +147,9 @@ fn clearAll(self: *IDBObjectStore) !void { } pub fn count(self: *IDBObjectStore, query: ?js.Value, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; try txn.assertActive(); - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); + const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec); const request = try txn.newRequest(); return request.submit(.{ .store_count = .{ .store = self, .bounds = bounds } }, exec); } @@ -169,17 +175,17 @@ pub fn getAllKeys(self: *IDBObjectStore, query_or_options: ?js.Value, count_: ?u } pub fn getAllRecords(self: *IDBObjectStore, options: ?js.Value, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; try txn.assertActive(); - const args = try IDBKeyRange.resolveGetAllOptions(exec.arena, options, exec); + const args = try IDBKeyRange.resolveGetAllOptions(txn._arena, options, exec); const request = try txn.newRequest(); return request.submit(.{ .store_get_all = .{ .store = self, .args = args, .mode = .record } }, exec); } fn _getAll(self: *IDBObjectStore, query_or_options: ?js.Value, count_: ?u32, mode: GetAllMode, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; try txn.assertActive(); - const args = try IDBKeyRange.resolveGetAll(exec.arena, query_or_options, count_, exec); + const args = try IDBKeyRange.resolveGetAll(txn._arena, query_or_options, count_, exec); const request = try txn.newRequest(); return request.submit(.{ .store_get_all = .{ .store = self, .args = args, .mode = mode } }, exec); } @@ -210,7 +216,7 @@ fn collectAll(self: *IDBObjectStore, exec: *Execution, args: IDBKeyRange.GetAllA const out: js.Value = switch (mode) { .value => try js.Value.deserialize(local, row.get([]const u8, 1)), .key => try Key.decodeToJs(exec.call_arena, local, key), - .record => try IDBRecord.initValue(exec, local, key, key, row.get([]const u8, 1)), + .record => try IDBRecord.initValue(self._txn, local, key, key, row.get([]const u8, 1)), }; _ = try arr.set(i, out, .{}); i += 1; @@ -219,9 +225,9 @@ fn collectAll(self: *IDBObjectStore, exec: *Execution, args: IDBKeyRange.GetAllA } pub fn getKey(self: *IDBObjectStore, query: js.Value, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; try txn.assertActive(); - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); + const bounds = try IDBKeyRange.resolveQuery(txn._arena, query, exec); const request = try txn.newRequest(); return request.submit(.{ .store_get_key = .{ .store = self, .bounds = bounds } }, exec); } @@ -238,13 +244,13 @@ pub fn runGetKey(self: *IDBObjectStore, request: *IDBRequest, bounds: Engine.Bou } pub fn openCursor(self: *IDBObjectStore, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest { - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); - return IDBCursor.open(self, bounds, direction orelse .next, false, exec); + const bounds = try IDBKeyRange.resolveQuery(self._txn._arena, query, exec); + return IDBCursor.init(self, bounds, direction orelse .next, false, exec); } pub fn openKeyCursor(self: *IDBObjectStore, query: ?js.Value, direction: ?IDBCursor.Direction, exec: *Execution) !*IDBRequest { - const bounds = try IDBKeyRange.resolveQuery(exec.arena, query, exec); - return IDBCursor.open(self, bounds, direction orelse .next, true, exec); + const bounds = try IDBKeyRange.resolveQuery(self._txn._arena, query, exec); + return IDBCursor.init(self, bounds, direction orelse .next, true, exec); } pub fn getName(self: *const IDBObjectStore) []const u8 { @@ -259,7 +265,7 @@ pub fn getAutoIncrement(self: *const IDBObjectStore) bool { return self._auto_increment; } -pub fn getTransaction(self: *IDBObjectStore) ?*IDBTransaction { +pub fn getTransaction(self: *IDBObjectStore) *IDBTransaction { return self._txn; } @@ -279,15 +285,15 @@ pub const PreparedKey = union(enum) { }; fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: WriteKind, exec: *Execution) !*IDBRequest { - const txn = self._txn orelse return error.TransactionInactiveError; + const txn = self._txn; if (txn._mode == .readonly) { return error.ReadOnlyError; } try txn.assertActive(); // Resolve (and validate) the key now, so DataError / a throwing key getter - // throws synchronously. Encoded key bytes are captured on the page arena to - // outlive this call. The record write itself is deferred to runWrite. + // throws synchronously. Encoded key bytes are captured on the transaction's + // arena to outlive this call. The record write itself is deferred to runWrite. const prepared: PreparedKey = blk: { if (self._key_path) |kp| { if (key_arg != null) { @@ -296,7 +302,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write } if (Key.evaluatePath(value, kp)) |extracted| { break :blk .{ .explicit = .{ - .encoded = try Key.encodeValue(exec.arena, extracted), + .encoded = try Key.encodeValue(txn._arena, extracted), .bump = if (self._auto_increment and extracted.isNumber()) try extracted.toF64() else null, } }; } @@ -313,7 +319,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write // Out-of-line keys. if (key_arg) |k| { break :blk .{ .explicit = .{ - .encoded = try Key.encodeValue(exec.arena, k), + .encoded = try Key.encodeValue(txn._arena, k), .bump = if (self._auto_increment and k.isNumber()) try k.toF64() else null, } }; } @@ -324,7 +330,7 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write }; const request = try txn.newRequest(); - const value_global = try value.persist(); + const value_global = try txn.persist(value); return request.submit(.{ .store_write = .{ .store = self, .kind = kind, @@ -333,7 +339,10 @@ fn write(self: *IDBObjectStore, value: js.Value, key_arg: ?js.Value, kind: Write } }, exec); } -pub fn runWrite(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: js.Value.Global, prepared: PreparedKey, exec: *Execution) !void { +pub fn runWrite(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: *js.Value.BareGlobal, prepared: PreparedKey, exec: *Execution) !void { + // Written (or failed) is written: the pinned value is dead once this op + // ran, so release its handle now instead of at transaction teardown. + defer value_global.deinit(); self.writeInner(request, kind, value_global, prepared, exec) catch |err| { if (err != error.Constraint) { log.warn(.storage, "idb write", .{ .err = err, .kind = kind }); @@ -342,7 +351,7 @@ pub fn runWrite(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, va }; } -fn writeInner(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: js.Value.Global, prepared: PreparedKey, exec: *Execution) !void { +fn writeInner(self: *IDBObjectStore, request: *IDBRequest, kind: WriteKind, value_global: *js.Value.BareGlobal, prepared: PreparedKey, exec: *Execution) !void { const local = exec.js.local.?; const value = value_global.local(local); @@ -454,7 +463,7 @@ const CreateIndexOptions = struct { // Only callable during an upgrade (versionchange transaction). pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: []const u8, options: ?CreateIndexOptions, exec: *Execution) !*IDBIndex { - const txn = self._txn orelse return error.InvalidStateError; + const txn = self._txn; if (txn._mode != .versionchange) { return error.InvalidStateError; } @@ -484,21 +493,22 @@ pub fn createIndex(self: *IDBObjectStore, name: []const u8, key_path: []const u8 } } - const owned_name = try exec.dupeString(name); - const owned_key_path = try exec.dupeString(key_path); + const owned_name = try txn.dupe(name); + const owned_key_path = try txn.dupe(key_path); const idb_index = try IDBIndex.init(self, .{ .id = index_id, .key_path = owned_key_path, .unique = opts.unique, .multi_entry = opts.multiEntry, - }, owned_name, exec); + }, owned_name); try self._engine.releaseSavepoint(); + try self._indexes.append(txn._arena, idb_index); return idb_index; } // Only callable during an upgrade (versionchange transaction). pub fn deleteIndex(self: *IDBObjectStore, name: []const u8, _: *Execution) !void { - const txn = self._txn orelse return error.InvalidStateError; + const txn = self._txn; if (txn._mode != .versionchange) { return error.InvalidStateError; } @@ -506,15 +516,27 @@ pub fn deleteIndex(self: *IDBObjectStore, name: []const u8, _: *Execution) !void error.NotFound => return error.NotFoundError, else => return err, }; + for (self._indexes.items, 0..) |idx, i| { + if (std.mem.eql(u8, idx._name, name)) { + _ = self._indexes.swapRemove(i); + break; + } + } } -pub fn index(self: *IDBObjectStore, name: []const u8, exec: *Execution) !*IDBIndex { - if (self._txn == null) { - return error.InvalidStateError; +pub fn index(self: *IDBObjectStore, name: []const u8, _: *Execution) !*IDBIndex { + for (self._indexes.items) |idx| { + if (std.mem.eql(u8, idx._name, name)) { + return idx; + } } - const info = (try self._engine.indexInfo(exec.arena, self._store_id, name)) orelse return error.NotFound; - const owned_name = try exec.dupeString(name); - return IDBIndex.init(self, info, owned_name, exec); + + const txn = self._txn; + const info = (try self._engine.indexInfo(txn._arena, self._store_id, name)) orelse return error.NotFound; + const owned_name = try txn.dupe(name); + const idx = try IDBIndex.init(self, info, owned_name); + try self._indexes.append(txn._arena, idx); + return idx; } pub fn getIndexNames(self: *IDBObjectStore, exec: *Execution) !*DOMStringList { diff --git a/src/browser/webapi/storage/idb/IDBRecord.zig b/src/browser/webapi/storage/idb/IDBRecord.zig index 35a1e04dc..6afda07f7 100644 --- a/src/browser/webapi/storage/idb/IDBRecord.zig +++ b/src/browser/webapi/storage/idb/IDBRecord.zig @@ -18,7 +18,9 @@ const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); const Key = @import("Key.zig"); +const IDBTransaction = @import("IDBTransaction.zig"); const Execution = js.Execution; @@ -26,29 +28,31 @@ const IDBRecord = @This(); // A single result of getAllRecords: the (index or store) key, the primary/store // key, and the value. Stored encoded/serialized and decoded lazily on access -// (mirroring IDBKeyRange). The bytes are page-arena lived — the record is handed -// back inside a JS array the page may retain past the request. +// (mirroring IDBKeyRange). Record and bytes live on the transaction's arena; +_txn: *IDBTransaction, _key: []const u8, _primary_key: []const u8, _value: []const u8, -pub fn init(exec: *Execution, key: []const u8, primary_key: []const u8, value: []const u8) !*IDBRecord { - return exec._factory.create(IDBRecord{ - ._key = key, - ._primary_key = primary_key, - ._value = value, - }); +pub fn acquireRef(self: *IDBRecord) void { + self._txn.acquireRef(); } -// Build a record from borrowed bytes — duped onto the page arena so the record -// outlives this call — and return its JS value. -pub fn initValue(exec: *Execution, local: *const js.Local, key: []const u8, primary_key: []const u8, value: []const u8) !js.Value { - const record = try init( - exec, - try exec.arena.dupe(u8, key), - try exec.arena.dupe(u8, primary_key), - try exec.arena.dupe(u8, value), - ); +pub fn releaseRef(self: *IDBRecord, page: *Page) void { + self._txn.releaseRef(page); +} + +// Build a record from borrowed bytes — duped onto the transaction's arena so +// the record outlives this call — and return its JS value. +pub fn initValue(txn: *IDBTransaction, local: *const js.Local, key: []const u8, primary_key: []const u8, value: []const u8) !js.Value { + const arena = txn._arena; + const record = try arena.create(IDBRecord); + record.* = .{ + ._txn = txn, + ._key = try arena.dupe(u8, key), + ._primary_key = try arena.dupe(u8, primary_key), + ._value = try arena.dupe(u8, value), + }; return local.zigValueToJs(record, .{}); } diff --git a/src/browser/webapi/storage/idb/IDBRequest.zig b/src/browser/webapi/storage/idb/IDBRequest.zig index b6ba7d788..b2dbd0cd7 100644 --- a/src/browser/webapi/storage/idb/IDBRequest.zig +++ b/src/browser/webapi/storage/idb/IDBRequest.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); const Event = @import("../../Event.zig"); const EventTarget = @import("../../EventTarget.zig"); const DOMException = @import("../../DOMException.zig"); @@ -43,13 +44,16 @@ const IDBRequest = @This(); _proto: *EventTarget, _op: Operation = .none, _error: ?anyerror = null, -_txn: ?*IDBTransaction = null, +_txn: Txn = .none, // Same request can show up multiple times in txn._requests, but it should only // be executed/fired in its last append (to preserve ordering). _txn_index: usize = 0, _cursor: ?*IDBCursor = null, _ready_state: ReadyState = .pending, _result: Result = .{ .none = js.Undefined{} }, +// whether or not we own th result value, if we do, we can free it once we know +// it cannot be used +_result_owned: bool = false, _on_success: ?js.Function.Global = null, _on_error: ?js.Function.Global = null, @@ -66,10 +70,26 @@ const ReadyState = enum { const Result = union(enum) { none: ?js.Undefined, // null or undefined (different APIs return different values) - value: js.Value.Global, // the result of a get/add/put, or a positioned cursor + value: *js.Value.BareGlobal, // the result of a get/add/put, or a positioned cursor database: *IDBDatabase, // the result of an open }; +const Txn = union(enum) { + // a page-scoped open/deleteDatabase request, outside an upgrade + none, + + // The transaction this request belongs to — and whose arena it lives on. + // Set once by IDBTransaction.newRequest, before the request is ever + // wrapped, and never reassigned: the FC acquire and release must resolve + // the same target. Wrapper pins forward to it. + owned: *IDBTransaction, + + // An open request's exposure of the versionchange transaction while the + // upgrade runs — spec visibility only, not ownership: the request stays + // page-scoped and pins nothing. + borrowed: *IDBTransaction, +}; + pub fn init(exec: *Execution) !*IDBRequest { return exec._factory.eventTarget(IDBRequest{ ._proto = undefined }); } @@ -78,18 +98,66 @@ pub fn asEventTarget(self: *IDBRequest) *EventTarget { return self._proto; } -// Not exposed to JS, called internally -pub fn setValue(self: *IDBRequest, value: js.Value) !void { - return self.setValueGlobal(try value.persist()); +// The FC machinery calls these when a JS wrapper is created/collected. A +// request created through a transaction lives on that transaction's arena and +// forwards its wrapper pins there; open/delete requests are page-scoped and +// pin nothing. +pub fn acquireRef(self: *IDBRequest) void { + switch (self._txn) { + .owned => |txn| txn.acquireRef(), + .none, .borrowed => {}, + } } -// Not exposed to JS, called internally -pub fn setValueGlobal(self: *IDBRequest, global: js.Value.Global) void { +pub fn releaseRef(self: *IDBRequest, page: *Page) void { + if (self._op == .none and self._ready_state == .done) { + // v8 is done with this request and we're done with it. Eagerly release + // our value + self.clearOwnedResult(); + } + switch (self._txn) { + .owned => |txn| txn.releaseRef(page), + .none, .borrowed => {}, + } +} + +// Release the result handle if this request owns one. +fn clearOwnedResult(self: *IDBRequest) void { + if (!self._result_owned) { + return; + } + + self._result_owned = false; + switch (self._result) { + .value => |global| { + // It's ok to keep this in txn._globals, deinit can be called multiple times + global.deinit(); + self._result = .{ .none = js.Undefined{} }; + }, + .none, .database => {}, + } +} + +// Not exposed to JS, called internally. Only requests created through a +// transaction (newRequest) carry value results; open/delete requests use +// setDatabaseResult or errors. +pub fn setValue(self: *IDBRequest, value: js.Value) !void { + const global = try self._txn.owned.persist(value); + self.clearOwnedResult(); + self._result = .{ .value = global }; + self._result_owned = true; +} + +// Not exposed to JS, called internally. The handle is borrowed (a cursor's +// transaction-owned _js), not owned by this request. +pub fn setValueGlobal(self: *IDBRequest, global: *js.Value.BareGlobal) void { + self.clearOwnedResult(); self._result = .{ .value = global }; } // Not exposed to JS, called internally. Result becomes JS `null` (not undefined). pub fn setNull(self: *IDBRequest) void { + self.clearOwnedResult(); self._result = .{ .none = null }; } @@ -140,12 +208,27 @@ pub fn getReadyState(self: *const IDBRequest) ReadyState { return self._ready_state; } -pub fn getResult(self: *const IDBRequest) Result { - return self._result; +// What the `result` accessor hands the bridge: the stored handle resolved to a +// local value. +const JsResult = union(enum) { + value: js.Value, + none: ?js.Undefined, + database: *IDBDatabase, +}; + +pub fn getResult(self: *const IDBRequest, exec: *Execution) JsResult { + return switch (self._result) { + .none => |n| .{ .none = n }, + .value => |global| .{ .value = global.local(exec.js.local.?) }, + .database => |db| .{ .database = db }, + }; } pub fn getTransaction(self: *const IDBRequest) ?*IDBTransaction { - return self._txn; + return switch (self._txn) { + .none => null, + .owned, .borrowed => |txn| txn, + }; } // Return this as a DOMException directly. If we return an error, the bridge @@ -212,11 +295,11 @@ pub const Operation = union(enum) { const StoreQuery = struct { store: *IDBObjectStore, bounds: Engine.Bounds }; const StoreGetAll = struct { store: *IDBObjectStore, args: IDBKeyRange.GetAllArgs, mode: IDBObjectStore.GetAllMode }; - const StoreWrite = struct { store: *IDBObjectStore, kind: IDBObjectStore.WriteKind, value: js.Value.Global, key: IDBObjectStore.PreparedKey }; + const StoreWrite = struct { store: *IDBObjectStore, kind: IDBObjectStore.WriteKind, value: *js.Value.BareGlobal, key: IDBObjectStore.PreparedKey }; const IndexQuery = struct { index: *IDBIndex, bounds: Engine.Bounds }; const IndexGetAll = struct { index: *IDBIndex, args: IDBKeyRange.GetAllArgs, mode: IDBObjectStore.GetAllMode }; const CursorIterate = struct { cursor: *IDBCursor, seek: IDBCursor.Seek, offset: u32 }; - const CursorUpdate = struct { cursor: *IDBCursor, key: []const u8, value: js.Value.Global }; + const CursorUpdate = struct { cursor: *IDBCursor, key: []const u8, value: *js.Value.BareGlobal }; const CursorDelete = struct { cursor: *IDBCursor, key: []const u8 }; }; @@ -224,7 +307,7 @@ pub const Operation = union(enum) { // transaction's drain. pub fn submit(self: *IDBRequest, op: Operation, exec: *Execution) !*IDBRequest { self._op = op; - const txn = self._txn.?; + const txn = self._txn.owned; try txn.enqueue(self); if (txn.getMode() == .versionchange) { @@ -245,8 +328,9 @@ pub fn execute(self: *IDBRequest, exec: *Execution) !void { } self._op = .none; - if (self._txn) |txn| { - try txn.ensureBegun(); + switch (self._txn) { + .owned => |txn| try txn.ensureBegun(), + .none, .borrowed => {}, } switch (op) { .none => unreachable, diff --git a/src/browser/webapi/storage/idb/IDBTransaction.zig b/src/browser/webapi/storage/idb/IDBTransaction.zig index 87a2802a8..4e33b4ea5 100644 --- a/src/browser/webapi/storage/idb/IDBTransaction.zig +++ b/src/browser/webapi/storage/idb/IDBTransaction.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); const Event = @import("../../Event.zig"); const EventTarget = @import("../../EventTarget.zig"); @@ -33,6 +34,7 @@ const DOMStringList = @import("../../collections.zig").DOMStringList; const log = lp.log; const Execution = js.Execution; +const Allocator = std.mem.Allocator; const FunctionSetter = idb.FunctionSetter; const IS_DEBUG = @import("builtin").mode == .Debug; @@ -50,6 +52,24 @@ _scope: []const []const u8 = &.{}, // Advisory only: we always commit through sqlite. Stored to expose the property. _durability: Durability = .default, +// The transaction owns everything created under it — requests, stores, +// indexes, cursors, encoded keys, pending write values — on a pooled arena, +// released when `_rc` drops to zero. Refs: one per live JS wrapper of any +// owned object, one while a drain task is scheduled, one while parked on the +// engine's connection gate (gate ownership needs no ref of its own: it's only +// ever held while a drain task exists). +_rc: lp.RC(u32) = .{}, +_arena: Allocator, + +// v8 handles owned by the transaction, swept (reset) in deinit. Slots are +// arena-allocated so an early release and the sweep hit the same instance — +// a v8 Global reset is only idempotent through a single instance. +_globals: std.ArrayList(*js.Value.BareGlobal) = .empty, + +// objectStore() must return the same object for a given name within one +// transaction (per spec); this also keeps repeated lookups off sqlite. +_stores: std.ArrayList(*IDBObjectStore) = .empty, + // request queue, swaps between &_queue_a and &_queue_b so that, as we drain, new // requests are queued in the new queue and will be processed on the next drain _queue: *std.ArrayList(*IDBRequest), @@ -66,6 +86,13 @@ _gate_waiter: Engine.GateWaiter, // later generation (see assertActive). _active_turn: u64 = 0, +// The transaction can be freed when v8 doesn't reference it (or any child, +// e.g. an IDBRequest), when we have no scheduled drain AND when we aren't +// parked in the Engine's wait gate. This last one we track explicitly so +// that, when Engine.detach cancels us, we know whether the registration held +// a pin (parked) or the drain task does (owner). +_parked: bool = false, + _on_complete: ?js.Function.Global = null, _on_error: ?js.Function.Global = null, _on_abort: ?js.Function.Global = null, @@ -92,38 +119,46 @@ pub const Durability = enum { } }; -pub fn init(exec: *Execution, db: *IDBDatabase, mode: Mode, durability: Durability, scope: []const []const u8) !*IDBTransaction { - const self = try exec._factory.eventTarget(IDBTransaction{ - ._proto = undefined, - ._exec = exec, - ._db = db, - ._engine = db._engine, - ._mode = mode, - ._scope = scope, - ._durability = durability, - ._active_turn = exec.js.scheduler.generation, - ._queue = undefined, - ._gate_waiter = undefined, - }); - self._queue = &self._queue_a; - self._gate_waiter = .{ .wake = resumeDrain }; +pub fn init(db: *IDBDatabase, mode: Mode, durability: Durability, exec: *Execution) !*IDBTransaction { + const arena = try exec.getArena(.small, "IDBTransaction"); - // Schedule the drain even for an empty transaction so it still `complete`s. - try exec.js.scheduler.add(self, drain, 0, .{ - .name = "IDBTransaction.drain", - .finalizer = finalize, - }); + const self = blk: { + errdefer exec.releaseArena(arena); + const s = try exec._factory.eventTargetWithAllocator(arena, IDBTransaction{ + ._proto = undefined, + ._exec = exec, + ._db = db, + ._engine = db._engine, + ._mode = mode, + ._arena = arena, + ._durability = durability, + ._active_turn = exec.js.scheduler.generation, + ._queue = undefined, + ._gate_waiter = undefined, + }); + s._queue = &s._queue_a; + s._gate_waiter = .{ .ctx = exec.js, .wake = resumeDrain, .cancel = cancelGate }; + break :blk s; + }; + + try self.scheduleDrain(); return self; } -// We need a "special" transaction for upgradeneeded -pub fn initVersionChange(exec: *Execution, db: *IDBDatabase) !*IDBTransaction { - const self = try exec._factory.eventTarget(IDBTransaction{ +// We need a "special" transaction for upgradeneeded. It has no drain task and +// no gate registration, so it starts with zero refs: the caller (the open +// path) must pin it for the duration of the upgrade. +pub fn initVersionChange(db: *IDBDatabase, exec: *Execution) !*IDBTransaction { + const arena = try exec.getArena(.small, "IDBTransaction"); + errdefer exec.releaseArena(arena); + + const self = try exec._factory.eventTargetWithAllocator(arena, IDBTransaction{ ._proto = undefined, ._exec = exec, ._db = db, ._engine = db._engine, ._mode = .versionchange, + ._arena = arena, ._begun = true, ._queue = undefined, ._gate_waiter = undefined, @@ -131,10 +166,47 @@ pub fn initVersionChange(exec: *Execution, db: *IDBDatabase) !*IDBTransaction { self._queue = &self._queue_a; // A versionchange transaction never contends for the gate (the open path // holds it); keep the node well-formed so releaseGate's owner check no-ops. - self._gate_waiter = .{ .wake = resumeDrain }; + self._gate_waiter = .{ .ctx = exec.js, .wake = resumeDrain, .cancel = cancelGate }; return self; } +pub fn deinit(self: *IDBTransaction, page: *Page) void { + if (comptime IS_DEBUG) { + // Pins hold refs, so the last release can't happen while parked (nor + // while a drain task is scheduled). + std.debug.assert(self._parked == false); + } + for (self._globals.items) |slot| { + slot.deinit(); + } + page.releaseArena(self._arena); +} + +pub fn acquireRef(self: *IDBTransaction) void { + self._rc.acquire(); +} + +pub fn releaseRef(self: *IDBTransaction, page: *Page) void { + self._rc.release(self, page); +} + +// Persist a JS value with the transaction's lifetime: the handle is reset when the +// transaction's memory is released — or earlier, by calling deinit() on the +// returned slot (the sweep's second reset is then a no-op). +pub fn persist(self: *IDBTransaction, value: js.Value) !*js.Value.BareGlobal { + const slot = try self._arena.create(js.Value.BareGlobal); + try self._globals.append(self._arena, slot); + slot.* = value.bare(); + return slot; +} + +pub fn dupe(self: *IDBTransaction, value: []const u8) ![]const u8 { + if (lp.String.intern(value)) |v| { + return v; + } + return self._arena.dupe(u8, value); +} + pub fn asEventTarget(self: *IDBTransaction) *EventTarget { return self._proto; } @@ -167,8 +239,11 @@ pub fn abort(self: *IDBTransaction, exec: *Execution) !void { if (self._begun) { self._engine.rollback(); + self._begun = false; } - self._engine.releaseGate(&self._gate_waiter); + // No-op if we're parked rather than owner; the park pin is then released + // by the eventual wake (drain's settled path) or a detach cancel. + _ = self._engine.releaseGate(&self._gate_waiter); for ([_]*std.ArrayList(*IDBRequest){ &self._queue_a, &self._queue_b }) |queue| { for (queue.items, 0..) |request, i| { @@ -212,12 +287,14 @@ fn commitAndComplete(self: *IDBTransaction, exec: *Execution) void { self._engine.commit() catch |err| { log.warn(.storage, "idb commit", .{ .err = err, .sqlite = self._engine.conn.lastError() }); self._engine.rollback(); - self._engine.releaseGate(&self._gate_waiter); + self._begun = false; + _ = self._engine.releaseGate(&self._gate_waiter); self.fire(exec, comptime .wrap("abort"), self._on_abort); return; }; + self._begun = false; } - self._engine.releaseGate(&self._gate_waiter); + _ = self._engine.releaseGate(&self._gate_waiter); self.fire(exec, comptime .wrap("complete"), self._on_complete); } @@ -247,24 +324,48 @@ pub fn ensureBegun(self: *IDBTransaction) !void { } pub fn newRequest(self: *IDBTransaction) !*IDBRequest { - const request = try IDBRequest.init(self._exec); - request._txn = self; + const request = try self._exec._factory.eventTargetWithAllocator(self._arena, IDBRequest{ ._proto = undefined }); + request._txn = .{ .owned = self }; return request; } pub fn enqueue(self: *IDBTransaction, request: *IDBRequest) !void { request._txn_index = self._queue.items.len; - try self._queue.append(self._exec.arena, request); + try self._queue.append(self._arena, request); } -pub fn objectStore(self: *IDBTransaction, name: []const u8, exec: *Execution) !*IDBObjectStore { +pub fn objectStore(self: *IDBTransaction, name: []const u8) !*IDBObjectStore { + for (self._stores.items) |store| { + if (std.mem.eql(u8, store._name, name)) { + return store; + } + } + const database_id = self._db._database_id; - const info = (try self._engine.objectStoreInfo(exec.arena, database_id, name)) orelse { + const info = (try self._engine.objectStoreInfo(self._arena, database_id, name)) orelse { return error.NotFound; }; - const owned_name = try exec.dupeString(name); - return IDBObjectStore.init(self._engine, self, info.id, owned_name, info.key_path, info.auto_increment, exec); + const owned_name = try self.dupe(name); + const store = try IDBObjectStore.init(self, info.id, owned_name, info.key_path, info.auto_increment); + try self._stores.append(self._arena, store); + return store; +} + +// Register a store created during an upgrade so a later objectStore() returns +// the same object. +pub fn cacheStore(self: *IDBTransaction, store: *IDBObjectStore) !void { + try self._stores.append(self._arena, store); +} + +// A store was deleted during an upgrade; a later objectStore() must miss. +pub fn uncacheStore(self: *IDBTransaction, name: []const u8) void { + for (self._stores.items, 0..) |store, i| { + if (std.mem.eql(u8, store._name, name)) { + _ = self._stores.swapRemove(i); + return; + } + } } pub fn getMode(self: *const IDBTransaction) Mode { @@ -285,10 +386,17 @@ pub fn getObjectStoreNames(self: *IDBTransaction, exec: *Execution) !*DOMStringL // A versionchange transaction spans every store; its set changes as the // upgrade creates/deletes stores, so resolve it live rather than caching. + // The list is refcounted and can outlive the transaction, so the scope + // names are copied onto the list's own arena. const names = if (self._mode == .versionchange) try self._engine.objectStoreNames(arena, self._db._database_id) - else - self._scope; + else blk: { + const copy = try arena.alloc([]const u8, self._scope.len); + for (self._scope, 0..) |name, i| { + copy[i] = try arena.dupe(u8, name); + } + break :blk copy; + }; const list = try arena.create(DOMStringList); list.* = .{ ._items = names, ._arena = arena }; @@ -338,11 +446,33 @@ fn fire(self: *IDBTransaction, exec: *Execution, typ: lp.String, handler: ?js.Fu }; } +// Schedule the drain task, which pins the transaction until it runs to completion +// (or its scheduler finalizer runs). +fn scheduleDrain(self: *IDBTransaction) !void { + self.acquireRef(); + errdefer self.releaseRef(self._exec.page); + try self._exec.js.scheduler.add(self, drain, 0, .{ + .name = "IDBTransaction.drain", + .finalizer = finalize, + }); +} + fn drain(ctx: *anyopaque) !?u32 { const self: *IDBTransaction = @ptrCast(@alignCast(ctx)); + const repeat = self.drainInner(); + if (repeat == null) { + // The drain task is done; drop its pin. May free the transaction — + // must be the last touch. + self.releaseRef(self._exec.page); + } + return repeat; +} + +fn drainInner(self: *IDBTransaction) ?u32 { if (self._settled) { - // Already settled (e.g. via an abort). - self._engine.releaseGate(&self._gate_waiter); + // Already settled (e.g. via an abort, which released the gate; the + // release here is a defensive no-op in that case). + _ = self._engine.releaseGate(&self._gate_waiter); return null; } @@ -350,7 +480,12 @@ fn drain(ctx: *anyopaque) !?u32 { if (self._queue.items.len > 0) { if (self._engine.acquireGate(&self._gate_waiter) == false) { - return null; // parked; resumeDrain reschedules us + // Parked: no drain task exists while we wait for the gate, and our + // wrappers may all be collected, so the registration itself must + // pin us until resumeDrain (or a detach cancel) unparks. + self._parked = true; + self.acquireRef(); + return null; } self.deliverBatch(exec); @@ -369,6 +504,14 @@ fn drain(ctx: *anyopaque) !?u32 { return null; } +fn unpark(self: *IDBTransaction) void { + if (comptime IS_DEBUG) { + std.debug.assert(self._parked); + } + self._parked = false; + self.releaseRef(self._exec.page); +} + // Scheduler wake-up: the gate was handed to us, so run the drain again. fn resumeDrain(waiter: *Engine.GateWaiter) void { const self: *IDBTransaction = @fieldParentPtr("_gate_waiter", waiter); @@ -376,21 +519,45 @@ fn resumeDrain(waiter: *Engine.GateWaiter) void { std.debug.assert(self._mode != .versionchange); } - self._exec.js.scheduler.add(self, drain, 0, .{ - .name = "IDBTransaction.drain", - .finalizer = finalize, - }) catch |err| { - self._engine.releaseGate(&self._gate_waiter); + defer self.unpark(); + self.scheduleDrain() catch |err| { + // We were handed the gate; if we can't reschedule, hand it off so the + // waiters behind us aren't stranded. Unpark may free self — last touch. log.warn(.storage, "idb resume drain", .{ .err = err }); + _ = self._engine.releaseGate(&self._gate_waiter); }; } +// Scheduler task finalizer: our context's scheduler is being torn down. +// Engine.detach normally ran first and already unlinked us from the gate; the +// gate handling here is a backstop for a scheduler reset without a detach. +// Never parked here — a parked transaction has no task to finalize. fn finalize(ctx: *anyopaque) void { const self: *IDBTransaction = @ptrCast(@alignCast(ctx)); if (self._begun and !self._settled) { self._engine.rollback(); + self._begun = false; + } + self._settled = true; + _ = self._engine.releaseGate(&self._gate_waiter); + // The task pin; may free the transaction — must be the last touch. + self.releaseRef(self._exec.page); +} + +// Engine.detach cancel: our context is going away; the unlink/hand-off is +// detach's job. The one site that runs in either gate state: parked, the +// registration held our pin; as owner, a drain task exists and its finalizer +// releases that pin instead. +fn cancelGate(waiter: *Engine.GateWaiter) void { + const self: *IDBTransaction = @fieldParentPtr("_gate_waiter", waiter); + if (self._begun) { + self._engine.rollback(); + self._begun = false; + } + self._settled = true; + if (self._parked == true) { + self.unpark(); } - self._engine.releaseGate(&self._gate_waiter); } // Deliver the requests queued as of now, one queue's worth. Requests enqueued diff --git a/src/browser/webapi/storage/idb/Manager.zig b/src/browser/webapi/storage/idb/Manager.zig index 0803dfe26..0eaecb3b3 100644 --- a/src/browser/webapi/storage/idb/Manager.zig +++ b/src/browser/webapi/storage/idb/Manager.zig @@ -43,6 +43,16 @@ pub fn deinit(self: *Manager) void { self.engines.deinit(self.allocator); } +// A js Context is being torn down (navigation, popup close, worker close): +// every engine must drop any gate participant whose callbacks would run in it. +// Must be called before that context's scheduler is reset or deinit'd. +pub fn detachContext(self: *Manager, ctx: *anyopaque) void { + var it = self.engines.valueIterator(); + while (it.next()) |engine| { + engine.*.detach(ctx); + } +} + // Gets or creates the engine for the given origin. pub fn engineForOrigin(self: *Manager, origin: []const u8) !*Engine { const gop = try self.engines.getOrPut(self.allocator, origin);