Skip to content

KTOR-9361 Fix JsWebSocketSession._closeReason completed twice#5457

Open
osipxd wants to merge 1 commit intorelease/3.xfrom
osipxd/close-reason-completed-twice
Open

KTOR-9361 Fix JsWebSocketSession._closeReason completed twice#5457
osipxd wants to merge 1 commit intorelease/3.xfrom
osipxd/close-reason-completed-twice

Conversation

@osipxd
Copy link
Member

@osipxd osipxd commented Mar 19, 2026

Subsystem
Client Js

Motivation
KTOR-9361 WebSockets: JsWebSocketSession._closeReason is completed twice

Solution
Two bugs caused the issue:

  1. In JsWebSocketSession.onClose, _closeReason.complete() was called unconditionally even when onError already called completeExceptionally(). Fixed by checking the return value: Frame.Close is only forwarded to _incoming when _closeReason was newly completed.

  2. In awaitConnection(), the combined open/error event listener was never removed after the open event fired. When an error occurred on an established connection, the stale listener tried to resume the already- completed continuation, logging "Already resumed, but proposed with update". Fixed by using lateinit var so the listener can remove itself on first fire.

Both fixes apply to js and wasmJs targets.

Two bugs caused the issue:

1. In JsWebSocketSession.onClose, _closeReason.complete() was called
   unconditionally even when onError already called completeExceptionally().
   Fixed by checking the return value: Frame.Close is only forwarded to
   _incoming when _closeReason was newly completed.

2. In awaitConnection(), the combined open/error event listener was never
   removed after the open event fired. When an error occurred on an
   established connection, the stale listener tried to resume the already-
   completed continuation, logging "Already resumed, but proposed with update".
   Fixed by using lateinit var so the listener can remove itself on first fire.

Both fixes apply to js and wasmJs targets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@osipxd osipxd self-assigned this Mar 19, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ddaecc51-9a2b-4871-a349-4b05d213fbbb

📥 Commits

Reviewing files that changed from the base of the PR and between d56335c and d5f766f.

📒 Files selected for processing (4)
  • ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.kt
  • ktor-client/ktor-client-core/js/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt
  • ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/engine/js/WasmJsClientEngine.kt
  • ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt

📝 Walkthrough

Walkthrough

WebSocket event handling in JavaScript and WASM client engines is improved by storing event callbacks in named variables for consistent identity, enabling proper listener removal. Close frame emission is now conditional on successful completion state completion, preventing redundant sends across both JavaScript and WASM implementations.

Changes

Cohort / File(s) Summary
Client Engine – Event Listener Management
ktor-client/ktor-client-core/js/src/.../JsClientEngine.kt, ktor-client/ktor-client-core/wasmJs/src/.../WasmJsClientEngine.kt
Refactored WebSocket.awaitConnection() to use lateinit var for event callbacks, enabling consistent reference removal. Handlers now proactively remove both "open" and "error" listeners before resuming the coroutine.
WebSocket Session – Conditional Close Frame Emission
ktor-client/ktor-client-core/js/src/.../JsWebSocketSession.kt, ktor-client/ktor-client-core/wasmJs/src/.../JsWebSocketSession.kt
Modified "close" event handlers to only emit Frame.Close when _closeReason completion succeeds, preventing unconditional frame sends.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

bug

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'KTOR-9361 Fix JsWebSocketSession._closeReason completed twice' directly and specifically describes the main issue being fixed, which aligns with the changeset's primary objective of resolving the duplicate completion bug.
Description check ✅ Passed The description follows the template structure with Subsystem, Motivation, and Solution sections. It comprehensively explains both bugs fixed (duplicate _closeReason completion and unremoved event listener) and their fixes across both js and wasmJs targets.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch osipxd/close-reason-completed-twice
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@osipxd
Copy link
Member Author

osipxd commented Mar 19, 2026

@coderabbitai review
@codex review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 19, 2026

@osipxd Sure, I'll review the changes in this PR now!


🧠 Learnings used
Learnt from: bjhham
Repo: ktorio/ktor PR: 4887
File: ktor-server/ktor-server-jetty-jakarta/jvm/src/io/ktor/server/jetty/jakarta/JettyWebsocketConnection.kt:35-52
Timestamp: 2025-09-05T12:47:49.016Z
Learning: JettyWebsocketConnection in ktor-server-jetty-jakarta implements Closeable interface (has close() method), so connection.use { } is valid Kotlin syntax and will compile correctly.

Learnt from: zibet27
Repo: ktorio/ktor PR: 5421
File: ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http2/NettyHttp2Handler.kt:83-88
Timestamp: 2026-03-16T15:51:45.670Z
Learning: In `ktor-server/ktor-server-netty/jvm/src/io/ktor/server/netty/http2/NettyHttp2Handler.kt`, `channelInactive` (and `onStreamClose`) and the `context.executor().execute { ... }` dispatch in `startHttp2` both run on the same single-threaded Netty event loop for a given channel. Because the coroutine inside the execute block is launched with `CoroutineStart.UNDISPATCHED`, `CallSetup` (which registers `HttpRequestCloseHandlerKey`) completes synchronously before the event loop can process a subsequent `channelInactive` event. Therefore there is no race between `HttpRequestCloseHandlerKey` registration and `onStreamClose` invocation — the single-threaded event loop provides the necessary ordering guarantee.

Learnt from: osipxd
Repo: ktorio/ktor PR: 5396
File: ktor-network/web/src/io/ktor/network/sockets/nodejs/node.net.kt:28-29
Timestamp: 2026-03-11T22:52:05.024Z
Learning: In the ktorio/ktor repository, the `ktor-network` module's `web` source set intentionally includes `JsModule("node:net")` in `ktor-network/web/src/io/ktor/network/sockets/nodejs/node.net.kt` because Kotlin Multiplatform does not currently support separate source sets for Node.js vs Browser within the same JS target. Do not flag this as a browser-bundle issue; it is a known architectural constraint. Browser consumers are responsible for adding their own webpack externals/stubs for `node:net`.

Learnt from: bjhham
Repo: ktorio/ktor PR: 4887
File: ktor-server/ktor-server-jetty-jakarta/jvm/src/io/ktor/server/jetty/jakarta/JettyWebsocketConnection.kt:35-52
Timestamp: 2025-09-05T12:47:49.016Z
Learning: Jetty's AbstractConnection class implements Closeable interface, so any class extending AbstractConnection (like JettyWebsocketConnection) can be used with Kotlin's use { } extension function for resource management.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. Delightful!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@osipxd osipxd marked this pull request as ready for review March 19, 2026 13:21
@osipxd osipxd requested a review from zibet27 March 19, 2026 13:21
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d5f766f2c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +83 to +85
if (_closeReason.complete(reason)) {
_incoming.trySend(Frame.Close(reason))
}

Choose a reason for hiding this comment

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

P2 Badge Preserve peer close frame after local close

Conditioning _incoming.trySend(Frame.Close(reason)) on _closeReason.complete(reason) drops the peer's close frame whenever the client initiated shutdown first, because FrameType.CLOSE handling already completes _closeReason before websocket.close(...). In that common path, onClose now closes incoming without ever emitting Frame.Close, so callers that read incoming to observe the peer close reply (and its code/message) lose that signal; this regression also appears in the wasmJs counterpart.

Useful? React with 👍 / 👎.

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