diff --git a/src/bun.js/api/html_rewriter.zig b/src/bun.js/api/html_rewriter.zig index 2ee193d213..c78c02727b 100644 --- a/src/bun.js/api/html_rewriter.zig +++ b/src/bun.js/api/html_rewriter.zig @@ -60,12 +60,19 @@ pub const HTMLRewriter = struct { listener: JSValue, ) bun.JSError!JSValue { const selector_slice = std.fmt.allocPrint(bun.default_allocator, "{}", .{selector_name}) catch bun.outOfMemory(); + defer bun.default_allocator.free(selector_slice); var selector = LOLHTML.HTMLSelector.parse(selector_slice) catch - return createLOLHTMLError(global); + return global.throwValue(createLOLHTMLError(global)); + errdefer selector.deinit(); + const handler_ = try ElementHandler.init(global, listener); const handler = bun.default_allocator.create(ElementHandler) catch bun.outOfMemory(); handler.* = handler_; + errdefer { + handler.deinit(); + bun.default_allocator.destroy(handler); + } this.builder.addElementContentHandlers( selector, @@ -91,8 +98,7 @@ pub const HTMLRewriter = struct { else null, ) catch { - selector.deinit(); - return createLOLHTMLError(global); + return global.throwValue(createLOLHTMLError(global)); }; this.context.selectors.append(bun.default_allocator, selector) catch bun.outOfMemory(); @@ -110,6 +116,10 @@ pub const HTMLRewriter = struct { const handler = bun.default_allocator.create(DocumentHandler) catch bun.outOfMemory(); handler.* = handler_; + errdefer { + handler.deinit(); + bun.default_allocator.destroy(handler); + } // If this fails, subsequent calls to write or end should throw this.builder.addDocumentContentHandlers( @@ -883,6 +893,11 @@ fn HandlerCallback( wrapper.deref(); } + // Use a CatchScope to properly handle exceptions from the JavaScript callback + var scope: bun.jsc.CatchScope = undefined; + scope.init(this.global, @src()); + defer scope.deinit(); + const result = @field(this, callback_name).?.call( this.global, if (comptime @hasField(HandlerType, "thisObject")) @@ -891,10 +906,36 @@ fn HandlerCallback( JSValue.zero, &.{wrapper.toJS(this.global)}, ) catch { - // If there's an error, we'll propagate it to the caller. + // If there's an exception in the scope, capture it for later retrieval + if (scope.exception()) |exc| { + const exc_value = JSValue.fromCell(exc); + // Store the exception in the VM's unhandled rejection capture mechanism + // if it's available (this is the same mechanism used by BufferOutputSink) + if (this.global.bunVM().unhandled_pending_rejection_to_capture) |err_ptr| { + err_ptr.* = exc_value; + exc_value.protect(); + } + } + // Clear the exception from the scope to prevent assertion failures + scope.clearException(); + // Return true to indicate failure to LOLHTML, which will cause the write + // operation to fail and the error handling logic to take over. return true; }; + // Check if there's an exception that was thrown but not caught by the error union + if (scope.exception()) |exc| { + const exc_value = JSValue.fromCell(exc); + // Store the exception in the VM's unhandled rejection capture mechanism + if (this.global.bunVM().unhandled_pending_rejection_to_capture) |err_ptr| { + err_ptr.* = exc_value; + exc_value.protect(); + } + // Clear the exception to prevent assertion failures + scope.clearException(); + return true; + } + if (!result.isUndefinedOrNull()) { if (result.isError() or result.isAggregateError(this.global)) { return true; diff --git a/test/regression/issue/21680.test.ts b/test/regression/issue/21680.test.ts new file mode 100644 index 0000000000..e5be963a56 --- /dev/null +++ b/test/regression/issue/21680.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { tempDirWithFiles } from "harness"; + +test("HTMLRewriter should not crash when element handler throws an exception - issue #21680", () => { + // The most important test: ensure the original crashing case from the GitHub issue doesn't crash + // This was the exact case from the issue that caused "ASSERTION FAILED: Unexpected exception observed" + + // Create a minimal HTML file for testing + const dir = tempDirWithFiles("htmlrewriter-crash-test", { + "min.html": "", + }); + + // Original failing case: this should not crash the process + expect(() => { + const rewriter = new HTMLRewriter().on("script", { + element(a) { + throw new Error("abc"); + }, + }); + rewriter.transform(new Response(Bun.file(`${dir}/min.html`))); + }).not.toThrow(); // The important thing is it doesn't crash, we're ok with it silently failing + + // Test with Response containing string content + expect(() => { + const rewriter = new HTMLRewriter().on("script", { + element(a) { + throw new Error("response test"); + }, + }); + rewriter.transform(new Response("")); + }).toThrow("response test"); +}); + +test("HTMLRewriter exception handling should not break normal operation", () => { + // Ensure that after an exception occurs, the rewriter still works normally + let normalCallCount = 0; + + // First, trigger an exception + try { + const rewriter = new HTMLRewriter().on("div", { + element(element) { + throw new Error("test error"); + }, + }); + rewriter.transform(new Response("