quarkusio/quarkus · error · IllegalStateException

Already closed

Error message

Already closed

What it means

SseEventSinkImpl.send() throws IllegalStateException when the sink is already closed — either explicitly via close() or because the underlying server response connection was closed by the client. No further events can be written to that sink.

Source

Thrown at independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/jaxrs/SseEventSinkImpl.java:34

    public static final byte[] EMPTY_BUFFER = new byte[0];
    private ResteasyReactiveRequestContext context;
    private SseBroadcasterImpl broadcaster;
    private AtomicBoolean closed = new AtomicBoolean();

    public SseEventSinkImpl(ResteasyReactiveRequestContext context) {
        this.context = context;
    }

    @Override
    public boolean isClosed() {
        return context.serverResponse().closed() || closed.get();
    }

    @Override
    public CompletionStage<?> send(OutboundSseEvent event) {
        if (isClosed())
            throw new IllegalStateException("Already closed");
        // NOTE: we can't cast event to OutboundSseEventImpl because the TCK sends us its own subclass
        return SseUtil.send(context, event, Collections.emptyList());
    }

    @Override
    public void close() {
        if (!closed.compareAndSet(false, true))
            return;

        ServerHttpResponse response = context.serverResponse();
        if (!response.closed()) {
            if (!response.headWritten()) {
                // make sure we send the headers if we're closing this sink before the
                // endpoint method is over
                SseUtil.setHeaders(context, response);
            }
            response.end();
            context.close();

View on GitHub (pinned to e1c734241f)

Solutions

  1. Check sink.isClosed() before each send, and prune closed sinks from your registry.
  2. Catch IllegalStateException around send() and remove the sink from active tracking.
  3. Register an onClose handler on the broadcaster to drop dead sinks promptly.
  4. Use SseBroadcaster (which handles closed sinks internally) instead of manual per-sink sends.

Example fix

// before
sink.send(event); // IllegalStateException if client gone
// after
if (!sink.isClosed()) {
    sink.send(event);
} else {
    sinks.remove(sink);
}
Defensive patterns

Strategy: type-guard

Validate before calling

if (sink.isClosed()) {
    sinks.remove(sink);
    return;
}

Type guard

boolean canSend(SseEventSink s) {
    return s instanceof SseEventSinkImpl impl && !impl.isClosed();
}

Try / catch

try {
    sink.send(event);
} catch (IllegalStateException e) {
    sinks.remove(sink); // client disconnected
}

Prevention

When it happens

Trigger: Calling sink.send(event) after sink.close(), after the client disconnected (context.serverResponse().closed()), or in a scheduled/async task that outlives the connection.

Common situations: Periodic jobs pushing to a stale sink; broadcasting to a list of sinks where some clients left; client browser tab closed while server keeps streaming; long GC pause causing connection termination.

Related errors


AI-assisted analysis of quarkusio/quarkus@e1c734241f (2026-09-05). Data as JSON: /api/errors/77f7ae4ef1c0b3cb. Report an issue: GitHub.