fix(ws): guard send() with isOpen() — eliminate CLOSE-on-dying-connection race (2.0.6)#526
Conversation
… race (spec-026) send() lacked the isOpen() guard subscribe() has. A per-subscription Nostr CLOSE issued while a relay connection is breaking reached Tomcat's sendText, where doClose() fires mid-write and emits a WS CLOSE frame while the text write is still pending on the async channel — throwing "Concurrent write operations are not permitted", surfacing as a transport-error reconnect storm. Fail fast on a closed session instead. +regression test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
send() isOpen() guard (spec-026) — eliminates the CLOSE-on-dying-connection concurrent-write transport-error storm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR prepares release 2.0.6 and adds a fail-fast guard for NostrRelayClient.send() when the WebSocket session is already closed, aiming to avoid Tomcat close/write race errors during relay teardown.
Changes:
- Bumps the root and module Maven versions from 2.0.5 to 2.0.6.
- Adds an
isOpen()guard beforesend()writes to the WebSocket session. - Adds a regression test and changelog entry for the closed-session send behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pom.xml |
Updates project version to 2.0.6. |
nostr-java-core/pom.xml |
Updates parent version to 2.0.6. |
nostr-java-event/pom.xml |
Updates parent version to 2.0.6. |
nostr-java-identity/pom.xml |
Updates parent version to 2.0.6. |
nostr-java-client/pom.xml |
Updates parent version to 2.0.6. |
nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java |
Adds closed-session guard before sending relay payloads. |
nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientCloseWriteRaceTest.java |
Adds regression coverage for send-on-closed-session behavior. |
CHANGELOG.md |
Documents the 2.0.6 fix. |
| if (!clientSession.isOpen()) { | ||
| throw new IOException("WebSocket session is closed"); |
There was a problem hiding this comment.
Right — the top-of-send() placement left a TOCTOU window. Fixed in fa7b95f (cut as 2.0.7): moved the isOpen() check inside sendFrameGated(), immediately after sessionGate.readLock().lock(), so the open-check and sendMessage() are now in the same critical section and mutually exclusive with closeGated() (which holds the write lock). A concurrent close() can no longer slip between the check and the write.
2.0.6 already handled the dominant case (session already closed when send() is invoked); this closes the narrow in-flight-close window. All 24 nostr-java-client tests still pass (incl. the close-vs-write race + concurrency suites).
… (2.0.7, PR #526 review) The 2.0.6 guard checked clientSession.isOpen() at the top of send(), before sendFrameGated() took the sessionGate read lock — so a concurrent close() could close+release the session between the check and sendMessage(). Moved the check inside sendFrameGated() after the read lock, making open-check + write atomic w.r.t. closeGated() (write lock). Bump 2.0.6 -> 2.0.7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
NostrRelayClient.send()now fails fast on a closed session (if (!clientSession.isOpen()) throw IOException), mirroring the guardsubscribe()already had.IllegalStateException: Concurrent write operations are not permittedstorm: a per-subscription NostrCLOSEsent during teardown of an already-breaking connection reached Tomcat'ssendText, wheredoClose()fires mid-write and emits a WS CLOSE frame while the text write is still pending on the async channel.v2.0.6, published to reposilite).Root cause
Single-thread stack:
SubscriptionHandle.close → send(["CLOSE",subId]) → decorator → Tomcat sendText → WsSession.doClose → sendCloseMessage → AsyncChannelWrapperSecure.writethrows. Not an app-thread race, so the decorator / sessionGate / adapter sendLock can't cover it — it's Tomcat closing inside one in-flight send.Test plan
NostrRelayClientCloseWriteRaceTest.send_onClosedSession_failsFastWithoutDelegateWrite(new) — send on closed session throws IOException, never calls delegate sendMessagegrep -c 'Concurrent write'stays 0🤖 Generated with Claude Code