Skip to content

fix(ws): guard send() with isOpen() — eliminate CLOSE-on-dying-connection race (2.0.6)#526

Merged
tcheeric merged 3 commits into
mainfrom
spec-026-us3-close-vs-write
May 27, 2026
Merged

fix(ws): guard send() with isOpen() — eliminate CLOSE-on-dying-connection race (2.0.6)#526
tcheeric merged 3 commits into
mainfrom
spec-026-us3-close-vs-write

Conversation

@tcheeric
Copy link
Copy Markdown
Owner

Summary

  • NostrRelayClient.send() now fails fast on a closed session (if (!clientSession.isOpen()) throw IOException), mirroring the guard subscribe() already had.
  • Eliminates the spec-026 residual IllegalStateException: Concurrent write operations are not permitted storm: a per-subscription Nostr CLOSE sent during teardown of an already-breaking connection 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.
  • Release 2.0.6 (tag v2.0.6, published to reposilite).

Root cause

Single-thread stack: SubscriptionHandle.close → send(["CLOSE",subId]) → decorator → Tomcat sendText → WsSession.doClose → sendCloseMessage → AsyncChannelWrapperSecure.write throws. 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 sendMessage
  • all 24 nostr-java-client tests pass
  • 24h staging soak: grep -c 'Concurrent write' stays 0

🤖 Generated with Claude Code

tcheeric and others added 2 commits May 27, 2026 07:34
… 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 before send() 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.

Comment on lines +490 to +491
if (!clientSession.isOpen()) {
throw new IOException("WebSocket session is closed");
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@tcheeric tcheeric merged commit 48dd4ec into main May 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants