Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 5d32587

Browse files
committed
Fix memory-leak in HTMLRewriter with non-ArrayBuffer(View) chunks
1 parent b265c37 commit 5d32587

File tree

2 files changed

+86
-60
lines changed

2 files changed

+86
-60
lines changed

packages/html-rewriter/src/rewriter.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,15 @@ export class HTMLRewriter {
7272
rewriter.free();
7373
throw e;
7474
}
75-
} else if (typeof chunk === "string") {
76-
throw new TypeError(
77-
"This TransformStream is being used as a byte stream, " +
78-
"but received a string on its writable side. " +
79-
"If you wish to write a string, you'll probably want to " +
80-
"explicitly UTF-8-encode it with TextEncoder."
81-
);
8275
} else {
76+
rewriter.free();
77+
const isString = typeof chunk === "string";
8378
throw new TypeError(
84-
"This TransformStream is being used as a byte stream, " +
85-
"but received an object of non-ArrayBuffer/ArrayBufferView " +
86-
"type on its writable side."
79+
"This TransformStream is being used as a byte stream, but received " +
80+
(isString
81+
? "a string on its writable side. If you wish to write a string, " +
82+
"you'll probably want to explicitly UTF-8-encode it with TextEncoder."
83+
: "an object of non-ArrayBuffer/ArrayBufferView type on its writable side.")
8784
);
8885
}
8986
},

packages/html-rewriter/test/rewriter.spec.ts

Lines changed: 79 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import {
88
getObjectProperties,
99
useMiniflareWithHandler,
1010
} from "@miniflare/shared-test";
11-
import test, { Macro } from "ava";
11+
import test, { ExecutionContext, Macro } from "ava";
1212
import {
13+
HTMLRewriter as BaseHTMLRewriter,
1314
Comment,
1415
Doctype,
1516
DocumentEnd,
@@ -21,6 +22,20 @@ import {
2122

2223
const encoder = new TextEncoder();
2324

25+
// Must be run in serial tests
26+
function recordFree(t: ExecutionContext): { freed: boolean } {
27+
const result = { freed: false };
28+
const originalFree = BaseHTMLRewriter.prototype.free;
29+
BaseHTMLRewriter.prototype.free = function () {
30+
result.freed = true;
31+
originalFree.bind(this)();
32+
};
33+
t.teardown(() => {
34+
BaseHTMLRewriter.prototype.free = originalFree;
35+
});
36+
return result;
37+
}
38+
2439
// region: ELEMENT HANDLERS
2540

2641
const mutationsMacro: Macro<
@@ -584,39 +599,45 @@ test("HTMLRewriter: handles streaming responses", async (t) => {
584599
t.true(chunks.length >= 2);
585600
t.is(chunks.join(""), '<html lang="en"><body><p>test</p></body></html>');
586601
});
587-
test("HTMLRewriter: handles ArrayBuffer and ArrayBufferView chunks", async (t) => {
588-
t.plan(2);
589-
const inputStream = new ReadableStream({
590-
start(controller) {
591-
const buffer = encoder.encode("<p>").buffer;
592-
let array = encoder.encode("test");
593-
const view1 = new Uint16Array(
594-
array.buffer,
595-
array.byteOffset,
596-
array.byteLength / Uint16Array.BYTES_PER_ELEMENT
597-
);
598-
array = encoder.encode("</p>");
599-
const view2 = new DataView(
600-
array.buffer,
601-
array.byteOffset,
602-
array.byteLength
603-
);
604-
controller.enqueue(buffer);
605-
controller.enqueue(view1);
606-
controller.enqueue(view2);
607-
controller.close();
608-
},
609-
});
610-
const res = new HTMLRewriter()
611-
.on("p", {
612-
text(text) {
613-
if (text.text) t.is(text.text, "test");
602+
test.serial(
603+
"HTMLRewriter: handles ArrayBuffer and ArrayBufferView chunks",
604+
async (t) => {
605+
t.plan(3);
606+
const inputStream = new ReadableStream({
607+
start(controller) {
608+
const buffer = encoder.encode("<p>").buffer;
609+
let array = encoder.encode("test");
610+
const view1 = new Uint16Array(
611+
array.buffer,
612+
array.byteOffset,
613+
array.byteLength / Uint16Array.BYTES_PER_ELEMENT
614+
);
615+
array = encoder.encode("</p>");
616+
const view2 = new DataView(
617+
array.buffer,
618+
array.byteOffset,
619+
array.byteLength
620+
);
621+
controller.enqueue(buffer);
622+
controller.enqueue(view1);
623+
controller.enqueue(view2);
624+
controller.close();
614625
},
615-
})
616-
.transform(new Response(inputStream));
617-
t.is(await res.text(), "<p>test</p>");
618-
});
619-
test("HTMLRewriter: throws on string chunks", async (t) => {
626+
});
627+
const freed = recordFree(t);
628+
const res = new HTMLRewriter()
629+
.on("p", {
630+
text(text) {
631+
if (text.text) t.is(text.text, "test");
632+
},
633+
})
634+
.transform(new Response(inputStream));
635+
t.is(await res.text(), "<p>test</p>");
636+
t.true(freed.freed);
637+
}
638+
);
639+
test.serial("HTMLRewriter: throws on string chunks", async (t) => {
640+
const freed = recordFree(t);
620641
const inputStream = new ReadableStream({
621642
start(controller) {
622643
controller.enqueue("I'm a string");
@@ -632,23 +653,29 @@ test("HTMLRewriter: throws on string chunks", async (t) => {
632653
"If you wish to write a string, you'll probably want to " +
633654
"explicitly UTF-8-encode it with TextEncoder.",
634655
});
656+
t.true(freed.freed);
635657
});
636-
test("HTMLRewriter: throws on non-ArrayBuffer/ArrayBufferView chunks", async (t) => {
637-
const inputStream = new ReadableStream({
638-
start(controller) {
639-
controller.enqueue(42);
640-
controller.close();
641-
},
642-
});
643-
const res = new HTMLRewriter().transform(new Response(inputStream));
644-
await t.throwsAsync(res.text(), {
645-
instanceOf: TypeError,
646-
message:
647-
"This TransformStream is being used as a byte stream, " +
648-
"but received an object of non-ArrayBuffer/ArrayBufferView " +
649-
"type on its writable side.",
650-
});
651-
});
658+
test.serial(
659+
"HTMLRewriter: throws on non-ArrayBuffer/ArrayBufferView chunks",
660+
async (t) => {
661+
const freed = recordFree(t);
662+
const inputStream = new ReadableStream({
663+
start(controller) {
664+
controller.enqueue(42);
665+
controller.close();
666+
},
667+
});
668+
const res = new HTMLRewriter().transform(new Response(inputStream));
669+
await t.throwsAsync(res.text(), {
670+
instanceOf: TypeError,
671+
message:
672+
"This TransformStream is being used as a byte stream, " +
673+
"but received an object of non-ArrayBuffer/ArrayBufferView " +
674+
"type on its writable side.",
675+
});
676+
t.true(freed.freed);
677+
}
678+
);
652679
test("HTMLRewriter: handles empty response", async (t) => {
653680
// Shouldn't call BaseHTMLRewriter.write, just BaseHTMLRewriter.end
654681
const res = new HTMLRewriter()
@@ -698,7 +725,8 @@ test("HTMLRewriter: copies response status and headers", async (t) => {
698725
});
699726
// endregion: responses
700727

701-
test("HTMLRewriter: rethrows error thrown in handler", async (t) => {
728+
test.serial("HTMLRewriter: rethrows error thrown in handler", async (t) => {
729+
const freed = recordFree(t);
702730
const res = new HTMLRewriter()
703731
.on("p", {
704732
element() {
@@ -707,6 +735,7 @@ test("HTMLRewriter: rethrows error thrown in handler", async (t) => {
707735
})
708736
.transform(new Response("<p>test</p>"));
709737
await t.throwsAsync(res.text(), { message: "Whoops!" });
738+
t.true(freed.freed);
710739
});
711740

712741
test("HTMLRewriter: can use same rewriter multiple times", async (t) => {

0 commit comments

Comments
 (0)