Skip to content

fix: ensure stable WebSocket object identity across lifecycle hooks#1729

Closed
raunak-rpm wants to merge 1 commit intoelysiajs:mainfrom
raunak-rpm:fix/ws-object-identity-1716
Closed

fix: ensure stable WebSocket object identity across lifecycle hooks#1729
raunak-rpm wants to merge 1 commit intoelysiajs:mainfrom
raunak-rpm:fix/ws-object-identity-1716

Conversation

@raunak-rpm
Copy link

@raunak-rpm raunak-rpm commented Feb 9, 2026

Summary

Fixes #1716

The ws object passed to WebSocket lifecycle hooks (open, message, drain, close) was a new ElysiaWS wrapper each time, breaking JavaScript identity (===). This made common patterns unreliable:

  • Tracking active sockets in a Map / Set
  • Cleaning up connections on close
  • Per-socket state storage
  • Matching sockets by reference

Root Cause

In the Bun adapter (src/adapter/bun/index.ts), every lifecycle hook called new ElysiaWS(ws, context), creating a fresh wrapper despite the underlying Bun ServerWebSocket being the same native object across all hooks.

Fix

Introduced a per-connection WeakMap<ServerWebSocket, ElysiaWS> cache with a getElysiaWS() helper. The first hook to fire (typically open) creates and caches the wrapper; subsequent hooks (message, drain, close) reuse the same instance.

  • WeakMap ensures automatic garbage collection when the raw socket is collected — no memory leaks
  • message hook updates ws.body on each call to reflect the current message payload
  • No API changes — fully backward compatible

Tests

Added 8 targeted tests in test/ws/identity.test.ts:

  1. Same instance across open and message
  2. Same instance across open and close
  3. Same instance across open, message, and close
  4. Custom properties set in open persist to message and close
  5. Separate connections get separate wrapper instances
  6. Set-based connection tracking with cleanup on close
  7. Map-based per-socket state accumulation
  8. body updates per message while preserving identity

All 1508 tests pass (1500 existing + 8 new), verified stable across multiple runs.

Summary by CodeRabbit

  • Bug Fixes
    • Improved WebSocket reliability by ensuring consistent connection object identity across open/message/drain/close events, enabling stable per-connection state and proper cleanup.
  • Tests
    • Added comprehensive WebSocket identity and lifecycle tests covering per-connection state isolation, message body updates, active-connection tracking, and cleanup after close.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 9, 2026

Walkthrough

Introduces a per-WebSocket WeakMap cache and helper to ensure a single ElysiaWS instance is reused per connection across open/message/drain/close hooks, and adds tests validating identity, per-connection state, and per-message body updates.

Changes

Cohort / File(s) Summary
WebSocket Identity Caching
src/adapter/bun/index.ts
Adds elysiaWSCache (WeakMap) and getElysiaWS(ws, body?) to create/return a cached ElysiaWS per native socket; replaces direct wrapper instantiation in open, message, drain, and close handlers and updates cached body on message.
Identity Test Suite
test/ws/identity.test.ts
Adds tests covering ElysiaWS identity consistency across open/message/close, per-connection isolation, preservation of properties, per-socket state tracking and cleanup, concurrent connections, and per-message body updates.
Metadata
manifest_file, package.json
Test-related additions/metadata updates included in the diff.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant BunWS as "Bun WebSocket"
    participant Cache as "getElysiaWS / elysiaWSCache"
    participant ElysiaWS

    Client->>BunWS: Upgrade -> open(ws)
    BunWS->>Cache: getElysiaWS(ws)
    Cache-->>ElysiaWS: create/store ElysiaWS(ws, context)
    Cache-->>BunWS: return ElysiaWS
    BunWS->>ElysiaWS: open handler(ElysiaWS)

    Client->>BunWS: send message (body)
    BunWS->>Cache: getElysiaWS(ws, body)
    Cache-->>ElysiaWS: return existing ElysiaWS (update body)
    BunWS->>ElysiaWS: message handler(ElysiaWS, body)

    Client->>BunWS: close
    BunWS->>Cache: getElysiaWS(ws)
    Cache-->>ElysiaWS: return existing ElysiaWS
    BunWS->>ElysiaWS: close handler(ElysiaWS)
    BunWS->>Cache: optional cleanup (WeakMap allows GC)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A hop for a socket, one wrapper to keep,

No swapping of objects when messages leap.
Identity steady from open through close,
Per-socket small secrets the cache safely knows.
I nibble a carrot and watch tests all green—cheers! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: ensuring stable WebSocket object identity across lifecycle hooks, which is the core fix addressed in the PR.
Linked Issues check ✅ Passed The PR directly addresses issue #1716 by introducing a per-connection WeakMap cache and getElysiaWS helper to ensure the same ElysiaWS instance is reused across open/message/close hooks, fixing identity comparison failures.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing WebSocket object identity: the implementation change in src/adapter/bun/index.ts and comprehensive test coverage in test/ws/identity.test.ts remain focused on the stated objective.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/adapter/bun/index.ts`:
- Around line 505-527: The WeakMap and cached wrapper use ElysiaWS with its
default generic parameters which narrows body to never; change the type to a
widened generic (e.g., ElysiaWS<any, any>) so the cache and returned value from
getElysiaWS are assignable to the open/drain/close hooks. Update the declaration
of elysiaWSCache (WeakMap<ServerWebSocket<any>, ElysiaWS>) and any related
types/usages in getElysiaWS to use ElysiaWS<any, any>, ensuring the cached.body
assignment and passing cached into options.open/options.drain/options.close
compile cleanly.
🧹 Nitpick comments (1)
src/adapter/bun/index.ts (1)

505-527: Consider simplifying: the WeakMap only ever holds one entry per closure

The elysiaWSCache WeakMap and getElysiaWS are created inside the per-upgrade route handler. Since each upgrade creates a fresh closure, the WeakMap will only ever cache one ServerWebSocket → ElysiaWS mapping. A plain let variable would achieve the same result with less overhead:

let cachedElysiaWS: ElysiaWS<any, any> | undefined

const getElysiaWS = (ws: ServerWebSocket<any>, body?: unknown) => {
  if (!cachedElysiaWS) {
    cachedElysiaWS = new ElysiaWS(ws, context as any, body)
  } else if (body !== undefined) {
    cachedElysiaWS.body = body
  }
  return cachedElysiaWS
}

That said, the current approach is correct and the WeakMap makes the "per-socket cache" intent very clear, so this is entirely optional.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 9, 2026

Open in StackBlitz

npm i https://pkg.pr.new/elysiajs/elysia@1729

commit: cb24b9b

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/adapter/bun/index.ts`:
- Around line 522-524: The current logic lets cached.body persist across hooks
because the guard "else if (body !== undefined) { cached.body = body }" skips
resetting on non-message events; to restore previous behavior, ensure
cached.body is explicitly cleared when building a fresh ElysiaWS for hooks that
shouldn't inherit the last message (e.g., in the drain/close paths or when
creating a new ElysiaWS instance). Update the code that constructs
ElysiaWS/cached (referencing ElysiaWS and cached.body) to set cached.body =
undefined for non-message hooks or always initialize a new cached.body when
creating the hook-specific ElysiaWS so message values do not leak into
subsequent drain/close handlers.
🧹 Nitpick comments (3)
src/adapter/bun/index.ts (1)

505-527: The WeakMap will only ever hold one entry per connection — a plain variable is simpler.

The elysiaWSCache and getElysiaWS are created inside the per-upgrade route handler. Each invocation (i.e. each connection) gets its own WeakMap instance, which will only ever store a single key-value pair. A simple captured variable achieves the same result without the overhead of a WeakMap lookup:

♻️ Suggested simplification
-				const elysiaWSCache = new WeakMap<
-					ServerWebSocket<any>,
-					ElysiaWS<any, any>
-				>()
-
-				const getElysiaWS = (
-					ws: ServerWebSocket<any>,
-					body?: unknown
-				): ElysiaWS<any, any> => {
-					let cached = elysiaWSCache.get(ws)
-
-					if (!cached) {
-						cached = new ElysiaWS(ws, context as any, body)
-						elysiaWSCache.set(ws, cached)
-					} else if (body !== undefined) {
-						cached.body = body
-					}
-
-					return cached
-				}
+				let cachedElysiaWS: ElysiaWS<any, any> | undefined
+
+				const getElysiaWS = (
+					ws: ServerWebSocket<any>,
+					body?: unknown
+				): ElysiaWS<any, any> => {
+					if (!cachedElysiaWS) {
+						cachedElysiaWS = new ElysiaWS(ws, context as any, body)
+					} else if (body !== undefined) {
+						cachedElysiaWS.body = body
+					}
+
+					return cachedElysiaWS
+				}

If future changes move the cache to a broader scope (e.g. shared across routes), the WeakMap would make sense, but in the current per-connection closure it's unnecessary indirection.

test/ws/identity.test.ts (2)

35-37: app.stop() is not awaited in any test — may cause port/resource leaks between tests.

BunAdapter.stop is async. Not awaiting it means the server may not be fully shut down before the next test starts. While .listen(0) mitigates port conflicts, unawaited cleanup can cause flaky failures under parallel execution or resource pressure. This applies to all 8 tests in this file.

♻️ Suggested fix (apply to all tests)
-		await wsClosed(ws)
-		app.stop()
+		await wsClosed(ws)
+		await app.stop()

262-295: Assertion inside the WebSocket handler may silently swallow failures.

Line 273 (expect(ws).toBe(stableWs)) throws inside the server-side message handler if it fails. The adapter's try/catch would catch it and route it to handleErrors, but since there's no custom error handler in this test, the failure won't propagate to the test runner. Consider moving identity assertions outside the handler (as other tests do) for reliable failure reporting.

♻️ Suggested approach
 			.ws('/ws', {
 				open(ws) {
 					stableWs = ws
 				},
 				message(ws, message) {
 					bodies.push(ws.body)
-					expect(ws).toBe(stableWs)
+					// capture for external assertion
 					ws.send('ok')
 				}
 			})
 			.listen(0)
 		...
 		expect(bodies[0]).toBe('first')
 		expect(bodies[1]).toBe('second')
+		expect(stableWs).toBeDefined()
+		// Identity already guaranteed by other tests; body update is the focus here

Comment on lines +522 to +524
} else if (body !== undefined) {
cached.body = body
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Subtle behavioral change: body now persists across hooks.

Previously, each hook created a fresh ElysiaWS with body = undefined (except message which passed the parsed message). Now, after a message hook fires, body retains the last message value in subsequent drain/close calls since the body !== undefined guard skips the reset.

This is unlikely to cause issues in practice (close/drain handlers rarely inspect body), but it's a change from the prior behavior worth being aware of.

🤖 Prompt for AI Agents
In `@src/adapter/bun/index.ts` around lines 522 - 524, The current logic lets
cached.body persist across hooks because the guard "else if (body !== undefined)
{ cached.body = body }" skips resetting on non-message events; to restore
previous behavior, ensure cached.body is explicitly cleared when building a
fresh ElysiaWS for hooks that shouldn't inherit the last message (e.g., in the
drain/close paths or when creating a new ElysiaWS instance). Update the code
that constructs ElysiaWS/cached (referencing ElysiaWS and cached.body) to set
cached.body = undefined for non-message hooks or always initialize a new
cached.body when creating the hook-specific ElysiaWS so message values do not
leak into subsequent drain/close handlers.

@SaltyAom
Copy link
Member

This user has mass usage of fully automatic AI-generated pull requests without human interaction (also known as "AI slop”) to abuse issues and pull requests for multiple times for "karma farming" purpose

For example:

This is against CONTRIBUTING.md as stated

  • AI generated pull request without human interaction, review and supervision may result in close without further notice or ban from future contribution to Elysia

We have blocked this users to prevent them abusing the system in the future

Thank you for your understanding

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.

Websockets object identity is not stable across open/message/close hooks

3 participants