Skip to content

WebSocket disconnect never ends a connect() generator waiting on incomingMessages #3261

Open

Description

@kriszyp

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 the close listeners but leaves IterableEventQueue.closed false. It also does not settle the pending next() (resources/IterableEventQueue.ts).
  • iterator.return() on an async generator that is suspended at an await is 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 = false

Impact

  • 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.

Activity

  1. added theissue type on Oct 18, 2026
  2. kriszyp commented on Oct 20, 2026

    @kriszyp
    MemberAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions