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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) => { | |||
| 40 | 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 | 44 | 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'); | |
| 59 | 63 | }); | |
| 60 | 64 | ||
| 61 | 65 | 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 @@ | |||
| 854 | 854 | // repository is gone, the session ended) makes the page reload. | |
| 855 | 855 | window.ermiteLive = (() => { | |
| 856 | 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 | 857 | window.addEventListener?.('htmx:sse:after:connection', (event) => { | |
| 865 | 858 | if (event.target.matches?.('.hint-live')) connection = event.detail.connection; | |
| 866 | 859 | }); | |
| @@ -918,10 +911,15 @@ window.ermiteLive = (() => { | |||
| 918 | 911 | // fitIn lays out the one-line chip lists of a fragment now in the page. | |
| 919 | 912 | const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); }; | |
| 920 | 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 | 919 | 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(); | |
| 925 | 923 | }, | |
| 926 | 924 | resumeStream(element) { | |
| 927 | 925 | // 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. | ||