webapi: don't fire error event when a worker script fetch is aborted

terminate() can cancel a still-inflight script fetch. That's a cancellation, not
a failure: a terminated worker must fire no further events, and Chrome is silent
here. We logged an error and fired an ErrorEvent — observable by Cloudflare
challenge scripts, which create a blob-URL worker and terminate it in the same
tick.

On error.TransferCanceled, skip the log and the error event; same log skip in
SharedWorkerGlobalScope.

This was tried in https://github.com/lightpanda-io/browser/pull/3189, but is
now ambiguous thanks to https://github.com/lightpanda-io/browser/pull/3368
which introduced a distinct `error.TransferCanceled`.
This commit is contained in:
Karl Seguin committed 2026-09-05 19:47:08 +08:00
1 parent 77594b7ce4
commit 042df66646
3 files changed
+61 -10

No files matched your search

+39
View File
@@ -553,3 +553,42 @@
});
}
</script>
<script id="worker_terminate_during_fetch_fires_no_error" type=module>
// terminate() cancels the still-inflight script fetch. That's a
// cancellation, not a load failure: a terminated worker fires no further
// events, error included. Challenge scripts create a blob worker and
// terminate it in the same tick, and probe for exactly this.
{
const state = await testing.async();
const blobUrl = URL.createObjectURL(new Blob(["postMessage('loaded');"], { type: 'text/javascript' }));
const worker = new Worker(blobUrl);
let fired = null;
worker.onerror = () => { fired = 'error'; };
worker.onmessage = () => { fired = 'message'; };
worker.terminate();
setTimeout(() => state.resolve(fired), 100);
await state.done((fired) => {
testing.expectEqual(null, fired);
URL.revokeObjectURL(blobUrl);
});
}
</script>
<script id="worker_missing_script_fires_error" type=module>
// A non-2xx script response is a load failure, and must keep firing the
// error event: it travels the same error callback as a cancellation.
{
const state = await testing.async();
const worker = new Worker('./does-not-exist.js');
worker.onerror = (e) => {
e.preventDefault();
state.resolve(e);
};
await state.done((e) => {
testing.expectTrue(e instanceof ErrorEvent);
});
}
</script>
@@ -250,10 +250,14 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self._http_transfer = null;
self.releaseScriptArena();
log.err(.browser, "shared worker fetch error", .{
.url = self._url,
.err = err,
});
// TransferCanceled is teardown cancelling a still-inflight script fetch,
// not a load failure.
if (err != error.TransferCanceled) {
log.err(.browser, "shared worker fetch error", .{
.url = self._url,
.err = err,
});
}
// The worker will never load and onconnect will never be registered.
// Drain the buffered connects so they get dispatched (and dropped at the
+14 -6
View File
@@ -268,10 +268,16 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self._http_transfer = null;
self.releaseScriptArena();
log.err(.browser, "worker fetch error", .{
.url = self._url,
.err = err,
});
// TransferCanceled is not a load failure: terminate() (or worker teardown)
// cancelled a still-inflight script fetch. We shouldn't fireErrorEvent
// (or bother logging)
const canceled = err == error.TransferCanceled;
if (!canceled) {
log.err(.browser, "worker fetch error", .{
.url = self._url,
.err = err,
});
}
// The worker will never load and onmessage will never be registered.
// Drain any buffered messages so they get dispatched (and silently
@@ -280,7 +286,9 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self._script_loaded = true;
self._worker_scope.drainPendingMessages();
self.fireErrorEvent(@errorName(err), null);
if (!canceled) {
self.fireErrorEvent(@errorName(err), null);
}
}
fn releaseScriptArena(self: *Worker) void {
@@ -481,7 +489,7 @@ pub const JsApi = struct {
const testing = @import("../../testing.zig");
test "WebApi: Worker" {
testing.silenceLog(&.{.http});
testing.silenceLog(&.{.http, .browser, .browser});
// Worker tests chain a worker-script fetch with a dynamic-import fetch
// and a cross-context postMessage. The default 2 s assertion budget can