detached 18da841

fix(web): handle the live stream reader cancellation at its source

hx-sse cancels the stream reader without catching its AbortError. Wrap
that one reader before the delete swap instead of filtering unhandled
rejections during a timer window, which Chromium reports after.
commit
18da84177479932e36c46f4b828a1c198d4b70a0
author
brice · · 2026-10-20 06:18 UTC
parent
e688ec5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +27 -25
M internal/adapters/web/jstest/live.test.mjs +19 -15
@@ -40,22 +40,26 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => {
4040 assert.equal(hint.dataset.state, 'live');
4141 });
4242
43-test('controlled stream deletion suppresses only teardown AbortError rejections', async (t) => {
43+test('stream teardown handles the reader cancellation that hx-sse leaves uncaught', async (t) => {
4444 const { live, listeners } = setup(t);
45- const rejection = (name) => {
46- const event = { reason: { name }, prevented: false, preventDefault() { this.prevented = true; } };
47- listeners.unhandledrejection(event);
48- return event.prevented;
49- };
50- assert.equal(rejection('AbortError'), false);
51- await live.teardown(async () => {
52- assert.equal(rejection('AbortError'), true, 'no AbortError escapes the delete swap');
53- assert.equal(rejection('TypeError'), false);
54- await Promise.resolve();
55- });
56- assert.equal(rejection('AbortError'), true, 'async cancellation reporting is covered');
57- t.mock.timers.tick(1);
58- assert.equal(rejection('AbortError'), false, 'unrelated later aborts remain visible');
45+ assert.equal(listeners.unhandledrejection, undefined, 'no global rejection filter');
46+ const abort = Object.assign(new Error('signal is aborted without reason'), { name: 'AbortError' });
47+ let cancelled = 0;
48+ const connection = { reader: { cancel: () => { cancelled++; return Promise.reject(abort); } } };
49+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
50+ const escaped = [];
51+ const onRejection = (reason) => escaped.push(reason);
52+ process.on('unhandledRejection', onRejection);
53+ try {
54+ // remove stands for hx-sse's cleanup: r.reader?.cancel?.(), result ignored.
55+ await live.teardown(() => { connection.reader?.cancel?.(); });
56+ await new Promise((resolve) => setImmediate(resolve));
57+ await new Promise((resolve) => setImmediate(resolve));
58+ } finally {
59+ process.off('unhandledRejection', onRejection);
60+ }
61+ assert.equal(cancelled, 1, 'the real reader is still cancelled');
62+ assert.deepEqual(escaped, [], 'no AbortError escapes the teardown');
5963 });
6064
6165 test('replacement stream URL carries the last connection id, including id-only events', (t) => {
@@ -40,22 +40,26 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => {
40 assert.equal(hint.dataset.state, 'live');40 assert.equal(hint.dataset.state, 'live');
41 });41 });
42 42
43-test('controlled stream deletion suppresses only teardown AbortError rejections', async (t) => {43+test('stream teardown handles the reader cancellation that hx-sse leaves uncaught', async (t) => {
44 const { live, listeners } = setup(t);44 const { live, listeners } = setup(t);
45- const rejection = (name) => {45+ assert.equal(listeners.unhandledrejection, undefined, 'no global rejection filter');
46- const event = { reason: { name }, prevented: false, preventDefault() { this.prevented = true; } };46+ const abort = Object.assign(new Error('signal is aborted without reason'), { name: 'AbortError' });
47- listeners.unhandledrejection(event);47+ let cancelled = 0;
48- return event.prevented;48+ const connection = { reader: { cancel: () => { cancelled++; return Promise.reject(abort); } } };
49- };49+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
50- assert.equal(rejection('AbortError'), false);50+ const escaped = [];
51- await live.teardown(async () => {51+ const onRejection = (reason) => escaped.push(reason);
52- assert.equal(rejection('AbortError'), true, 'no AbortError escapes the delete swap');52+ process.on('unhandledRejection', onRejection);
53- assert.equal(rejection('TypeError'), false);53+ try {
54- await Promise.resolve();54+ // remove stands for hx-sse's cleanup: r.reader?.cancel?.(), result ignored.
55- });55+ await live.teardown(() => { connection.reader?.cancel?.(); });
56- assert.equal(rejection('AbortError'), true, 'async cancellation reporting is covered');56+ await new Promise((resolve) => setImmediate(resolve));
57- t.mock.timers.tick(1);57+ await new Promise((resolve) => setImmediate(resolve));
58- assert.equal(rejection('AbortError'), false, 'unrelated later aborts remain visible');58+ } finally {
59+ process.off('unhandledRejection', onRejection);
60+ }
61+ assert.equal(cancelled, 1, 'the real reader is still cancelled');
62+ assert.deepEqual(escaped, [], 'no AbortError escapes the teardown');
59 });63 });
60 64
61 test('replacement stream URL carries the last connection id, including id-only events', (t) => {65 test('replacement stream URL carries the last connection id, including id-only events', (t) => {
M internal/adapters/web/static/js/ermite.js +8 -10
@@ -854,13 +854,6 @@
854854 // repository is gone, the session ended) makes the page reload.
855855 window.ermiteLive = (() => {
856856 let connection = null;
857- let tearingDown = 0;
858- // hx-sse has no public close API. Its cleanup aborts the fetch and leaves
859- // reader.cancel() uncaught. Suppress only AbortError during our delete swap
860- // (through the rejection-reporting task), not ordinary stream/app failures.
861- window.addEventListener?.('unhandledrejection', (event) => {
862- if (tearingDown && event.reason?.name === 'AbortError') event.preventDefault();
863- });
864857 window.addEventListener?.('htmx:sse:after:connection', (event) => {
865858 if (event.target.matches?.('.hint-live')) connection = event.detail.connection;
866859 });
@@ -918,10 +911,15 @@ window.ermiteLive = (() => {
918911 // fitIn lays out the one-line chip lists of a fragment now in the page.
919912 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
920913 return {
914+ // teardown runs remove, which deletes the stream element. hx-sse has no
915+ // public close API: its cleanup aborts the fetch, then calls
916+ // connection.reader.cancel() without catching the AbortError it rejects
917+ // with. Wrapping that one reader handles exactly this rejection, with no
918+ // timing window and no global filter.
921919 async teardown(remove) {
922- ++tearingDown;
923- try { return await remove(); }
924- finally { setTimeout(() => { --tearingDown; }, 0); }
920+ const reader = connection?.reader;
921+ if (reader) connection.reader = { cancel: () => reader.cancel().catch(() => {}) };
922+ return remove();
925923 },
926924 resumeStream(element) {
927925 // The public connection includes id-only messages as well as named ones.
@@ -854,13 +854,6 @@
854 // repository is gone, the session ended) makes the page reload.854 // repository is gone, the session ended) makes the page reload.
855 window.ermiteLive = (() => {855 window.ermiteLive = (() => {
856 let connection = null;856 let connection = null;
857- let tearingDown = 0;
858- // hx-sse has no public close API. Its cleanup aborts the fetch and leaves
859- // reader.cancel() uncaught. Suppress only AbortError during our delete swap
860- // (through the rejection-reporting task), not ordinary stream/app failures.
861- window.addEventListener?.('unhandledrejection', (event) => {
862- if (tearingDown && event.reason?.name === 'AbortError') event.preventDefault();
863- });
864 window.addEventListener?.('htmx:sse:after:connection', (event) => {857 window.addEventListener?.('htmx:sse:after:connection', (event) => {
865 if (event.target.matches?.('.hint-live')) connection = event.detail.connection;858 if (event.target.matches?.('.hint-live')) connection = event.detail.connection;
866 });859 });
@@ -918,10 +911,15 @@ window.ermiteLive = (() => {
918 // fitIn lays out the one-line chip lists of a fragment now in the page.911 // fitIn lays out the one-line chip lists of a fragment now in the page.
919 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };912 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
920 return {913 return {
914+ // teardown runs remove, which deletes the stream element. hx-sse has no
915+ // public close API: its cleanup aborts the fetch, then calls
916+ // connection.reader.cancel() without catching the AbortError it rejects
917+ // with. Wrapping that one reader handles exactly this rejection, with no
918+ // timing window and no global filter.
921 async teardown(remove) {919 async teardown(remove) {
922- ++tearingDown;920+ const reader = connection?.reader;
923- try { return await remove(); }921+ if (reader) connection.reader = { cancel: () => reader.cancel().catch(() => {}) };
924- finally { setTimeout(() => { --tearingDown; }, 0); }922+ return remove();
925 },923 },
926 resumeStream(element) {924 resumeStream(element) {
927 // The public connection includes id-only messages as well as named ones.925 // The public connection includes id-only messages as well as named ones.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close