idb: abort transaction on callback exception

This commit is contained in:
Karl Seguin
2026-07-03 09:22:53 +08:00
parent 22f2b15842
commit 300d783b4d
6 changed files with 134 additions and 12 deletions

View File

@@ -288,7 +288,11 @@ pub fn dispatchDirect(
if (getFunction(handler, &ls.local)) |func| {
event._current_target = target;
_ = func.callWithThis(void, target, .{event}) catch |err| {
log.warn(.event, opts.context, .{ .err = err });
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, opts.context, .{ .err = err });
}
};
}
@@ -432,24 +436,45 @@ pub const Listener = struct {
) error{OutOfMemory}!void {
switch (self.function) {
.value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| {
log.warn(.event, context, .{ .err = err });
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, context, .{ .err = err });
}
},
.string => |string| {
const str = try arena.dupeZ(u8, string.str());
local.eval(str, null) catch |err| {
log.warn(.event, context, .{ .err = err });
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, context, .{ .err = err });
}
};
},
.object => |obj_global| {
const obj = local.toLocal(obj_global);
const handle_event = obj.getFunction("handleEvent") catch |err| blk: {
log.warn(.event, context, .{ .err = err });
// Getting "handleEvent" threw (e.g. a throwing getter).
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, context, .{ .err = err });
}
break :blk null;
};
if (handle_event) |handleEvent| {
handleEvent.callWithThis(void, obj, .{event}) catch |err| {
log.warn(.event, context, .{ .err = err });
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, context, .{ .err = err });
}
};
} else {
// The listener was an object without the handleEvent function
// This is throwing a TypeError
event._listeners_did_throw = true;
}
},
}

View File

@@ -49,6 +49,7 @@ _time_stamp: u64,
_needs_retargeting: bool = false,
_is_trusted: bool = false,
_in_passive_listener: bool = false,
_listeners_did_throw: bool = false, // IndexedDB needs to abort on callback throw
// There's a period of time between creating an event and handing it off to v8
// where things can fail. If it does fail, we need to deinit the event. The timing

View File

@@ -106,7 +106,6 @@ pub const JsApi = struct {
pub const name = "DOMStringList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const length = bridge.accessor(DOMStringList.length, null, .{});

View File

@@ -23,11 +23,14 @@ const js = @import("../../../js/js.zig");
const EventTarget = @import("../../EventTarget.zig");
const idb = @import("idb.zig");
const Engine = @import("Engine.zig");
const IDBTransaction = @import("IDBTransaction.zig");
const IDBObjectStore = @import("IDBObjectStore.zig");
const DOMStringList = @import("../../collections.zig").DOMStringList;
const FunctionSetter = idb.FunctionSetter;
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
@@ -40,6 +43,7 @@ _database_id: i64,
_name: []const u8,
_version: i64,
_txn: ?*IDBTransaction = null, // only set during upgradeneeded
_on_error: ?js.Function.Global = null,
pub fn init(exec: *Execution, engine: *Engine, database_id: i64, name: []const u8, version: i64) !*IDBDatabase {
return exec._factory.eventTarget(IDBDatabase{
@@ -192,6 +196,17 @@ pub fn getObjectStoreNames(self: *IDBDatabase, exec: *Execution) !*DOMStringList
return list;
}
pub fn getOnError(self: *const IDBDatabase) ?js.Function.Global {
return self._on_error;
}
pub fn setOnError(self: *IDBDatabase, setter: ?FunctionSetter) void {
self._on_error = if (setter) |s| switch (s) {
.func => |f| f,
.anything => null,
} else null;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(IDBDatabase);
@@ -208,4 +223,5 @@ pub const JsApi = struct {
pub const deleteObjectStore = bridge.function(IDBDatabase.deleteObjectStore, .{});
pub const transaction = bridge.function(IDBDatabase.transaction, .{});
pub const close = bridge.function(IDBDatabase.close, .{});
pub const onerror = bridge.accessor(IDBDatabase.getOnError, IDBDatabase.setOnError, .{});
};

View File

@@ -36,6 +36,7 @@ const IDBObjectStore = @import("IDBObjectStore.zig");
const IDBTransaction = @import("IDBTransaction.zig");
const IDBVersionChangeEvent = @import("IDBVersionChangeEvent.zig");
const log = lp.log;
const Execution = js.Execution;
const FunctionSetter = idb.FunctionSetter;
@@ -178,14 +179,14 @@ pub fn failed(self: *const IDBRequest) bool {
pub fn deliver(self: *IDBRequest, exec: *Execution) !void {
self._ready_state = .done;
if (self._error != null) {
return self.fire(exec, comptime .wrap("error"), self._on_error);
return self.fireError(exec);
}
if (self._cursor) |cursor| {
// A cursor request re-fires on every iteration; let the cursor mark itself
// readable (got value) right before the success handler runs.
cursor.beforeDeliver();
}
return self.fire(exec, comptime .wrap("success"), self._on_success);
return self.fireSuccess(exec);
}
pub fn fireUpgradeNeeded(self: *IDBRequest, exec: *Execution, old_version: u64, new_version: u64) !void {
@@ -196,12 +197,73 @@ pub fn fireUpgradeNeeded(self: *IDBRequest, exec: *Execution, old_version: u64,
pub fn fireSuccess(self: *IDBRequest, exec: *Execution) !void {
self._ready_state = .done;
return self.fire(exec, comptime .wrap("success"), self._on_success);
const event = try Event.initTrusted(comptime .wrap("success"), null, exec.page);
event.acquireRef();
defer _ = event.releaseRef(exec.page);
try exec.dispatch(self.asEventTarget(), event, self._on_success, .{ .context = "IDBRequest.success" });
if (event._listeners_did_throw) blk: {
// if the event threw, we must abort
const txn = switch (self._txn) {
.owned => |t| t,
.none, .borrowed => break :blk,
};
if (!txn._settled and !txn._committing) {
txn.abortWith(exec, error.AbortError) catch |err| {
log.warn(.storage, "idb success-event abort", .{ .err = err });
};
}
}
}
fn fire(self: *IDBRequest, exec: *Execution, typ: lp.String, handler: ?js.Function.Global) !void {
const event = try Event.initTrusted(typ, null, exec.page);
try exec.dispatch(self.asEventTarget(), event, handler, .{ .context = "IDBRequest" });
fn fireError(self: *IDBRequest, exec: *Execution) !void {
// Requests created inside a transaction own an abortable transaction; open/
// delete requests (and the borrowed upgrade-transaction view) do not.
const txn: ?*IDBTransaction = switch (self._txn) {
.owned => |t| t,
.none, .borrowed => null,
};
const event = try Event.initTrusted(comptime .wrap("error"), .{ .bubbles = true, .cancelable = true }, exec.page);
event.acquireRef();
defer _ = event.releaseRef(exec.page);
const et = self.asEventTarget();
event._target = et;
event._dispatch_target = et;
try exec.dispatch(et, event, self._on_error, .{ .context = "IDBRequest.error", .inject_target = false });
if (txn) |tx| {
if (!event._stop_propagation) {
try exec.dispatch(tx.asEventTarget(), event, tx._on_error, .{ .context = "IDBTransaction.error", .inject_target = false });
}
if (!event._stop_propagation) {
const db = tx._db;
try exec.dispatch(db.asEventTarget(), event, db._on_error, .{ .context = "IDBDatabase.error", .inject_target = false });
}
// Don't re-abort a transaction that's already finishing — the AbortError
// events an abort itself delivers come back through here.
if (!tx._settled and !tx._committing) {
const reason: ?anyerror = if (event._listeners_did_throw)
error.AbortError
else if (!event._prevent_default)
self._error
else
null;
if (reason != null) {
// catch (rather than propagate): the abort's own event dispatch
// can re-enter here, so keeping the error set out of deliver's
// recursion is both simpler and avoids an unresolvable inferred set.
tx.abortWith(exec, reason) catch |err| {
log.warn(.storage, "idb error-event abort", .{ .err = err });
};
}
}
}
}
pub fn getReadyState(self: *const IDBRequest) ReadyState {

View File

@@ -24,6 +24,7 @@ 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");
const idb = @import("idb.zig");
const Engine = @import("Engine.zig");
@@ -80,6 +81,7 @@ _begun: bool = false,
_settled: bool = false,
_aborted: bool = false,
_committing: bool = false,
_error: ?anyerror = null,
_gate_waiter: Engine.GateWaiter,
// A transaction is only active for one execution of a Scheduler's task. We
// capture the scheduler's generation here and reject any request made in a
@@ -230,13 +232,20 @@ pub fn commit(self: *IDBTransaction, exec: *Execution) !void {
}
}
// JS-facing, no reason
pub fn abort(self: *IDBTransaction, exec: *Execution) !void {
return self.abortWith(exec, null);
}
// Internal, optional reason
pub fn abortWith(self: *IDBTransaction, exec: *Execution, reason: ?anyerror) error{InvalidStateError}!void {
if (self._settled or self._committing) {
return error.InvalidStateError;
}
self._aborted = true;
self._settled = true;
self._error = reason;
if (self._begun) {
self._engine.rollback();
@@ -417,6 +426,15 @@ pub fn getObjectStoreNames(self: *IDBTransaction, exec: *Execution) !*DOMStringL
return list;
}
pub fn getError(self: *const IDBTransaction) ?DOMException {
const err = self._error orelse return null;
const mapped: anyerror = switch (err) {
error.Constraint => error.ConstraintError,
else => err,
};
return DOMException.fromError(mapped) orelse DOMException.init(null, "UnknownError");
}
pub fn getOnComplete(self: *const IDBTransaction) ?js.Function.Global {
return self._on_complete;
}
@@ -629,6 +647,7 @@ pub const JsApi = struct {
pub const durability = bridge.accessor(IDBTransaction.getDurability, null, .{});
pub const db = bridge.accessor(IDBTransaction.getDb, null, .{});
pub const objectStoreNames = bridge.accessor(IDBTransaction.getObjectStoreNames, null, .{});
pub const @"error" = bridge.accessor(IDBTransaction.getError, null, .{ .null_as_undefined = true });
pub const objectStore = bridge.function(IDBTransaction.objectStore, .{});
pub const abort = bridge.function(IDBTransaction.abort, .{});
pub const commit = bridge.function(IDBTransaction.commit, .{});