Repository navigation
Description
Summary
When a WebSocket client disconnects, Harper signals close on the incomingMessages queue without closing it. A connect() generator waiting in for await (const message of incomingMessages) therefore never resumes: its finally never runs, and whatever it opened stays open.
This affects the echo pattern that reference/rest/websockets.md documents:
export class Echo extends Resource {
async *connect(incomingMessages) {
for await (let message of incomingMessages) yield message;
}
}Where
server/REST.ts on main @ c4e8a3493, in the WebSocket close handler:
incomingMessages.emit('close');
if (iterator) iterator.return();emit('close')runs thecloselisteners but leavesIterableEventQueue.closedfalse. It also does not settle the pendingnext()(resources/IterableEventQueue.ts).iterator.return()on an async generator that is suspended at anawaitis queued until that await settles. Here that never happens, because no further message arrives after the socket closed.
Reproduction
Script run against the built dist/resources/IterableEventQueue.js. It mirrors the two close handler lines above:
const incomingMessages = new IterableEventQueue();
async function* connect(messages) {
try {
for await (const message of messages) yield message;
} finally {
console.log('generator finally ran');
}
}
const iterator = connect(incomingMessages)[Symbol.asyncIterator]();
iterator.next();
incomingMessages.emit('close');
iterator.return(); // never settles; 'finally' never prints
// after 200 ms: return settled = false, queue closed = falseImpact
- No external hold: a generator like the bare echo is unreachable once the socket is gone, so it is garbage-collected. Only its cleanup is skipped.
- External hold: a generator that also holds something external stays alive for the life of the process, once per closed connection. Examples are a table subscription, a timer, or an upstream connection. Nothing is logged.
Proposed fix
Call incomingMessages.close() instead of emit('close'). close() still emits close, so existing listeners keep working. It also ends the pending next(), so the generator's loop exits and the queued return() runs.
A generator that waits on anything other than incomingMessages still needs request.signal, which aborts on disconnect (harper#513). The SSE form of the parked-iterator teardown is harper#1399.
Found while answering a review question on harper#3257.
Related validation
Also track a real-transport oracle for the outgoing drain-close repair in PR #3199: producer cleanup does not directly observe outer REST handler completion. The follow-up comment describes the negative control and the required completion check. Use real modules and sockets under the repository testing rules.
Review-Deferral: 451aea71a9a77983 severity=minor category=test-coverage
🤖 Generated by Claude Code and OpenAI Codex; posted via @kriszyp.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
kriszyp commentedon Oct 20, 2026
PR #3199 repairs a separate disconnect path: it settles pending socket-drain waits and returns subscriptions whose connect resolves after close. It still uses
incomingMessages.emit('close'), so it does not repair the pending incoming-message iterator described here.Please retain a real-transport handler-completion regression when addressing this issue. The paused-reader test in #3199 observes producer cleanup; that producer can finish while the outer REST handler is still awaiting drain. A direct completion/listener-removal oracle remains a coverage gap. The proposed fake-scope/socket test would require stubbing, so a real transport fixture or an appropriate test seam is needed under the repository's test guidance.
🤖 Generated by OpenAI Codex; posted via @kriszyp.