From 0bb6291ad0ecf2cd3377af9cfb9995c081460b82 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 2 Jul 2026 18:30:47 +0800 Subject: [PATCH] idb: improve indexeddb memory model Rather than having everything tied to the page's memory (arena, factory), most things are now tied to an IDBTransaction and its arena. The IDBTransaction is reference counted and finalized with v8. The lifecycle is relatively complicated compared to anything else we have, which is a concern, but using the page arena seems like a dealbreaker to me. Any "child" created for the transaction (e.g. IDBRequest) has its v8 acquireRef and releaseRef forwarded to the Transaction. This should make v8's usage safe. However, in addition to this, Zig itself must take a RC whenever a drain is scheduled AND whenever the transaction is parked in the engine. And these things, especially on cleanup, can be a little messy. The _awful_ cursor allocations have been improved by a local re-used ArrayLists for the key/primary key/value. So rather than accumulating _every_ allocation we now only capture the peak. One final memory-related area this commit addresses is the lifetime difference between Transactions (Page) and Engine (Session). This is problematic because the Engine can reference the Transaction. On js.Context deinit, the engine is notified and all related Transactions are canceled/removed. This is...unusual in our design. We have other similar cases that use a list on the frame/GWS to track resources to cleanup. But, the Engine already _has_ to have this list so rather than book-keeping in Engine AND Frame AND WGS, only Engine has a list at the cost of js.Context having to notify it on teardown. --- src/browser/js/Context.zig | 4 + src/browser/js/Value.zig | 12 + src/browser/tests/indexeddb.html | 36 +++ .../webapi/DedicatedWorkerGlobalScope.zig | 3 +- src/browser/webapi/Window.zig | 1 + src/browser/webapi/storage/idb/Engine.zig | 109 +++++++- src/browser/webapi/storage/idb/IDBCursor.zig | 105 ++++--- .../webapi/storage/idb/IDBCursorWithValue.zig | 6 +- .../webapi/storage/idb/IDBDatabase.zig | 52 ++-- src/browser/webapi/storage/idb/IDBFactory.zig | 60 +++- src/browser/webapi/storage/idb/IDBIndex.zig | 43 +-- .../webapi/storage/idb/IDBObjectStore.zig | 124 +++++---- src/browser/webapi/storage/idb/IDBRecord.zig | 38 +-- src/browser/webapi/storage/idb/IDBRequest.zig | 114 +++++++- .../webapi/storage/idb/IDBTransaction.zig | 257 +++++++++++++++--- src/browser/webapi/storage/idb/Manager.zig | 10 + 16 files changed, 748 insertions(+), 226 deletions(-) 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);