Allow event listener to remove itself or other pending listeners

This commit is contained in:
Karl Seguin
2025-12-10 17:56:49 +08:00
parent 9c8299f13f
commit 159165490d
3 changed files with 195 additions and 10 deletions

View File

@@ -39,6 +39,7 @@ page: *Page,
arena: Allocator,
listener_pool: std.heap.MemoryPool(Listener),
lookup: std.AutoHashMapUnmanaged(usize, std.DoublyLinkedList),
dispatch_depth: u32 = 0,
pub fn init(page: *Page) EventManager {
return .{
@@ -46,6 +47,7 @@ pub fn init(page: *Page) EventManager {
.lookup = .{},
.arena = page.arena,
.listener_pool = std.heap.MemoryPool(Listener).init(page.arena),
.dispatch_depth = 0,
};
}
@@ -247,12 +249,27 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
const page = self.page;
const typ = event._type_string;
// Track that we're dispatching to prevent immediate removal
self.dispatch_depth += 1;
defer {
self.dispatch_depth -= 1;
// Clean up any marked listeners in this target's list after this phase
// We do this regardless of depth to handle cross-target removals correctly
self.cleanupMarkedListeners(list);
}
var node = list.first;
while (node) |n| {
// do this now, in case we need to remove n (once: true or aborted signal)
node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
// Skip listeners that were marked for removal
if (listener.marked_for_removal) {
continue;
}
if (!listener.typ.eql(typ)) {
continue;
}
@@ -310,8 +327,27 @@ fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target:
}
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
list.remove(&listener.node);
self.listener_pool.destroy(listener);
if (self.dispatch_depth > 0) {
// We're in the middle of dispatching, just mark for removal
// This prevents invalidating the linked list during iteration
listener.marked_for_removal = true;
} else {
// Safe to remove immediately
list.remove(&listener.node);
self.listener_pool.destroy(listener);
}
}
fn cleanupMarkedListeners(self: *EventManager, list: *std.DoublyLinkedList) void {
var node = list.first;
while (node) |n| {
node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
if (listener.marked_for_removal) {
list.remove(&listener.node);
self.listener_pool.destroy(listener);
}
}
}
fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, function: js.Function, capture: bool) ?*Listener {
@@ -341,6 +377,7 @@ const Listener = struct {
function: Function,
signal: ?*@import("webapi/AbortSignal.zig") = null,
node: std.DoublyLinkedList.Node,
marked_for_removal: bool = false,
};
const Function = union(enum) {

View File

@@ -133,12 +133,10 @@ pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []con
// The values context and groupId are not used here
const js_val = unwrapped.value;
if (js_val.isObject() == false) {
std.debug.print("XX-0\n", .{});
return error.ObjectIdIsNotANode;
}
const Node = @import("../webapi/Node.zig");
return Context.typeTaggedAnyOpaque(*Node, js_val.castTo(v8.Object)) catch {
std.debug.print("XX-1\n", .{});
return error.ObjectIdIsNotANode;
};
}

View File

@@ -319,14 +319,20 @@
</script>
<script id=objectWithoutHandleEvent>
// Test object without handleEvent (should be ignored)
// Test that registration succeeds even with invalid handlers
// (Dispatch behavior differs: spec-compliant browsers throw, some ignore)
const badHandler = { foo: 'bar' };
let badHandlerTestPassed = false;
let registrationSucceeded = false;
window.addEventListener('testbad', badHandler);
// Event should not be triggered since there's no valid handler
window.dispatchEvent(new Event('testbad'));
badHandlerTestPassed = true;
testing.expectEqual(true, badHandlerTestPassed);
registrationSucceeded = true;
testing.expectEqual(true, registrationSucceeded);
// Test object with handleEvent that's not a function
const badHandler2 = { handleEvent: 'not a function' };
let registrationSucceeded2 = false;
window.addEventListener('testbad2', badHandler2);
registrationSucceeded2 = true;
testing.expectEqual(true, registrationSucceeded2);
</script>
<script id=passiveDetection>
@@ -347,3 +353,147 @@
testing.expectEqual(true, passiveSupported);
</script>
<div id=reentrancy_test></div>
<script id=removeOtherListenerDuringDispatch>
// Test that removing another listener during dispatch doesn't crash
// This reproduces the segfault bug where n.next becomes invalid
const reentrancy_el = $('#reentrancy_test');
let reentrancy_calls = [];
const listener1 = () => {
reentrancy_calls.push(1);
};
const listener2 = () => {
reentrancy_calls.push(2);
// Remove listener3 while we're still iterating the listener list
reentrancy_el.removeEventListener('reentrancy', listener3);
};
const listener3 = () => {
reentrancy_calls.push(3);
};
const listener4 = () => {
reentrancy_calls.push(4);
};
reentrancy_el.addEventListener('reentrancy', listener1);
reentrancy_el.addEventListener('reentrancy', listener2);
reentrancy_el.addEventListener('reentrancy', listener3);
reentrancy_el.addEventListener('reentrancy', listener4);
reentrancy_el.dispatchEvent(new Event('reentrancy'));
// listener3 was removed during dispatch by listener2, so it should not fire
// But listener4 should still fire
testing.expectEqual(1, reentrancy_calls[0]);
testing.expectEqual(2, reentrancy_calls[1]);
testing.expectEqual(4, reentrancy_calls[2]);
testing.expectEqual(3, reentrancy_calls.length);
</script>
<div id=self_remove_test></div>
<script id=removeSelfDuringDispatch>
// Test that a listener can remove itself during dispatch
const self_remove_el = $('#self_remove_test');
let self_remove_calls = [];
const selfRemovingListener = () => {
self_remove_calls.push('self');
self_remove_el.removeEventListener('selfremove', selfRemovingListener);
};
const otherListener = () => {
self_remove_calls.push('other');
};
self_remove_el.addEventListener('selfremove', selfRemovingListener);
self_remove_el.addEventListener('selfremove', otherListener);
// First dispatch - selfRemovingListener should fire and remove itself
self_remove_el.dispatchEvent(new Event('selfremove'));
testing.expectEqual('self', self_remove_calls[0]);
testing.expectEqual('other', self_remove_calls[1]);
testing.expectEqual(2, self_remove_calls.length);
// Second dispatch - only otherListener should fire
self_remove_el.dispatchEvent(new Event('selfremove'));
testing.expectEqual('other', self_remove_calls[2]);
testing.expectEqual(3, self_remove_calls.length);
</script>
<div id=multi_remove_test></div>
<script id=removeMultipleListenersDuringDispatch>
// Test removing multiple listeners during dispatch (stress test)
const multi_el = $('#multi_remove_test');
let multi_calls = [];
const listeners = [];
for (let i = 0; i < 10; i++) {
const listener = () => {
multi_calls.push(i);
// Each even-numbered listener removes the next two listeners
if (i % 2 === 0 && listeners[i + 1] && listeners[i + 2]) {
multi_el.removeEventListener('multiremove', listeners[i + 1]);
multi_el.removeEventListener('multiremove', listeners[i + 2]);
}
};
listeners.push(listener);
multi_el.addEventListener('multiremove', listener);
}
multi_el.dispatchEvent(new Event('multiremove'));
// Should see: 0 (removes 1,2), 3 (but 1,2 already removed), 4 (removes 5,6), 7 (but 5,6 already removed), 8 (doesn't remove because listeners[10] doesn't exist), 9 (not removed)
// Expected: 0, 3, 4, 7, 8, 9
testing.expectEqual(0, multi_calls[0]);
testing.expectEqual(3, multi_calls[1]);
testing.expectEqual(4, multi_calls[2]);
testing.expectEqual(7, multi_calls[3]);
testing.expectEqual(8, multi_calls[4]);
testing.expectEqual(9, multi_calls[5]);
testing.expectEqual(6, multi_calls.length);
</script>
<div id=nested_dispatch_test></div>
<script id=nestedDispatchWithRemoval>
// Test nested event dispatch with listener removal
const nested_el = $('#nested_dispatch_test');
let nested_calls = [];
const inner1 = () => {
nested_calls.push('inner1');
};
const inner2 = () => {
nested_calls.push('inner2');
};
const outer = () => {
nested_calls.push('outer-start');
// Dispatch another event in the middle of handling this one
nested_el.dispatchEvent(new Event('inner'));
nested_calls.push('outer-end');
// Remove a listener after the nested dispatch
nested_el.removeEventListener('inner', inner2);
};
nested_el.addEventListener('outer', outer);
nested_el.addEventListener('inner', inner1);
nested_el.addEventListener('inner', inner2);
nested_el.dispatchEvent(new Event('outer'));
// Should see outer-start, then both inner listeners, then outer-end
testing.expectEqual('outer-start', nested_calls[0]);
testing.expectEqual('inner1', nested_calls[1]);
testing.expectEqual('inner2', nested_calls[2]);
testing.expectEqual('outer-end', nested_calls[3]);
// Dispatch inner again - inner2 should be gone
nested_el.dispatchEvent(new Event('inner'));
testing.expectEqual('inner1', nested_calls[4]);
testing.expectEqual(5, nested_calls.length);
</script>