Skip to content

Attach an error listener to the upgrade socket in NodeHttpServer - #6927

Merged
tim-smart merged 3 commits into
Effect-TS:mainfrom
longtngo:fix/upgrade-socket-error-listener
Aug 4, 2026
Merged

Attach an error listener to the upgrade socket in NodeHttpServer#6927
tim-smart merged 3 commits into
Effect-TS:mainfrom
longtngo:fix/upgrade-socket-error-listener

Conversation

@longtngo

@longtngo longtngo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

A peer that resets an HTTP upgrade connection takes the whole Node process down.

Mechanism

Node removes its own listeners from the socket when it emits upgrade and hands the raw socket to the application, and ws only attaches its own once handleUpgrade runs. In makeUpgradeHandler the socket gets a close listener but never an error one, so in that window (and for the entire life of any upgrade request that never completes the handshake) the socket has no error listener at all. Node turns an 'error' event with no listener into an uncaught exception, which exits the process.

const fiber = Fiber.runIn(Effect.runForkWith(context)(handledApp), options.scope)
socket.on("close", () => { ... })   // <- no socket.on("error", ...)

Reproduction

Twenty lines, no application code involved. Server:

import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
import * as Effect from "effect/Effect"
import * as HttpServer from "effect/unstable/http/HttpServer"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import * as Http from "node:http"

const program = Effect.gen(function*() {
  yield* HttpServer.serveEffect(Effect.succeed(HttpServerResponse.text("ok")))
  yield* Effect.never
}).pipe(
  Effect.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 43111 })),
  Effect.scoped
)

Effect.runFork(program)

Client: open a TCP socket, send an upgrade request, then reset it instead of closing it.

import * as net from "node:net"

const socket = net.connect({ port: 43111, host: "127.0.0.1" }, () => {
  socket.write(
    "GET /ws HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
      "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n"
  )
  setTimeout(() => socket.resetAndDestroy(), 250)
})
socket.on("error", () => {})

The server process is gone:

node:events:487
      throw er; // Unhandled 'error' event
      ^
Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
Emitted 'error' event on Socket instance at:
    ...
  errno: -54, code: 'ECONNRESET', syscall: 'read'

This is not a synthetic edge case: it took down a deployed server roughly every one to three minutes (10 process restarts in 30 minutes) as ordinary clients dropped their websockets, a browser tab closing or a phone leaving the network. Instrumenting net.Socket.prototype.emit in the live process named the socket outright: listenerCount('error') === 0 on a GET /ws request with resUpgrade: true.

Fix

Attach a no-op error listener alongside the existing close listener. The connection is already gone by the time this fires, so there is nothing to do but keep the event from going unhandled. The close handler still runs and still interrupts the fiber.

Confirmation that this is the mechanism rather than a correlation: with the listener attached, the same sockets emit the same ECONNRESET at the same rate with unhandled: false, and the process survives. The only variable changed is whether a listener existed.

Test

an upgrade connection reset by the peer does not crash the process drives a real socket against a real port and asserts nothing reached uncaughtException. Without the source change it fails with expected [ Error: read ECONNRESET ] to deeply equal []; with it the file passes 27/27.

BunHttpServer is not affected, since upgrades there go through bunServer.upgrade() rather than a raw Node socket.

Node removes its own socket listeners when it emits `upgrade`, and `ws`
only attaches its own once `handleUpgrade` runs. In between, and for any
upgrade that never completes the handshake, the socket had no 'error'
listener, so a peer resetting the connection became an unhandled 'error'
event and ended the process.
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0ab2e83

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
@effect/platform-node Patch
effect Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Add error listener to upgrade socket — a no-op socket.on("error", () => {}) is added in makeUpgradeHandler alongside the existing close listener, closing the window where a peer reset would turn into an unhandled 'error' event and crash the process.
  • Integration test for upgrade connection reset — drives a raw socket with an upgrade request, resets it with resetAndDestroy(), and asserts no uncaughtException reaches the process.
  • Patch changeset — correctly scoped to @effect/platform-node.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) (Kimi K2 not used — the program covers this model; add its provider key to run your pick) | 𝕏

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 7.06 KB 7.06 KB 0.00 KB (0.00%)
batching.ts 9.86 KB 9.86 KB 0.00 KB (0.00%)
brand.ts 6.34 KB 6.34 KB 0.00 KB (0.00%)
cache.ts 10.62 KB 10.62 KB 0.00 KB (0.00%)
config.ts 20.60 KB 20.60 KB 0.00 KB (0.00%)
differ.ts 20.20 KB 20.20 KB 0.00 KB (0.00%)
http-client.ts 21.49 KB 21.49 KB 0.00 KB (0.00%)
logger.ts 10.76 KB 10.76 KB 0.00 KB (0.00%)
metric.ts 8.98 KB 8.98 KB 0.00 KB (0.00%)
optic.ts 7.18 KB 7.18 KB 0.00 KB (0.00%)
pubsub.ts 14.90 KB 14.90 KB 0.00 KB (0.00%)
queue.ts 11.58 KB 11.58 KB 0.00 KB (0.00%)
schedule.ts 10.74 KB 10.74 KB 0.00 KB (0.00%)
schema-class.ts 19.14 KB 19.14 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 28.96 KB 28.96 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.29 KB 25.29 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.30 KB 13.30 KB 0.00 KB (0.00%)
schema-string.ts 10.94 KB 10.94 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.17 KB 15.17 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 21.94 KB 21.94 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.34 KB 24.34 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.18 KB 19.18 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.01 KB 19.01 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.87 KB 18.87 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.60 KB 22.60 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.52 KB 19.52 KB 0.00 KB (0.00%)
schema.ts 18.41 KB 18.41 KB 0.00 KB (0.00%)
stm.ts 12.54 KB 12.54 KB 0.00 KB (0.00%)
stream.ts 9.80 KB 9.80 KB 0.00 KB (0.00%)

tim-smart
tim-smart approved these changes Aug 4, 2026
Comment thread .changeset/upgrade-socket-error-listener.md Outdated
Comment thread packages/platform-node/src/NodeHttpServer.ts
Comment thread packages/platform-node/test/NodeHttpServer.test.ts
Co-authored-by: Tim <hello@timsmart.co>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The latest commit fixes the source indentation, but the incremental diff removed the uncaught array declaration from the new test, so the test currently fails at runtime with ReferenceError: uncaught is not defined. That needs to be restored before merge.

Reviewed changes

  • Fixed indentation in NodeHttpServer.tsconst fiber and the socket.on("close", ...) lines are now correctly indented after the code-review suggestion.
  • Removed explanatory comments and uncaught declaration in the test — the incremental edit trimmed the comments around the uncaughtException listener and deleted const uncaught: Array<unknown> = [], leaving uncaught.push(error) and the assertion expect(uncaught).toEqual([]) referencing an undefined variable.
  • Changeset wording tightened — now reads "Stop a reset upgrade connection from crashing the process in NodeHttpServer".

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Comment thread packages/platform-node/test/NodeHttpServer.test.ts
@github-project-automation github-project-automation Bot moved this from Discussion Ongoing to Waiting on Author in PR Backlog Aug 4, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Restored the missing uncaught array declaration in the new upgrade-socket test; the uncaughtException listener and final assertion now reference a defined variable.
  • Confirmed the test passesan upgrade connection reset by the peer does not crash the process runs successfully.
  • No source-code changes in this delta beyond the already-reviewed socket.on("error", () => {}) addition.

Pullfrog  | View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

@tim-smart
tim-smart merged commit 721b9f0 into Effect-TS:main Aug 4, 2026
27 of 29 checks passed
@github-project-automation github-project-automation Bot moved this from Waiting on Author to Done in PR Backlog Aug 4, 2026
longtngo added a commit to longtngo/t3code that referenced this pull request Aug 4, 2026
…103)

Upstream's beta.103 bump lands squarely on both dependency patches the fork
carries, in opposite directions.

effect: upstream fixed the aggregateWithin idle leak themselves, by hoisting
the recursive loop out of the `Effect.never` wrapper - the same correction our
hunk made. Dropped. Measured rather than assumed, with one probe run against
both builds: 3.83 MB of heap growth over a 4s idle window on beta.102 against
0.03 MB on beta.103.

@effect/platform-node: upstream deleted the whole patch file, because their
permessage-deflate hunk now ships natively as `websocket: { perMessageDeflate }`
(server.ts passes it on both the Node and Bun paths, so wire compression is
unaffected). That deletion would have taken our upgrade-socket error listener
with it. Effect-TS/effect#6927 merged 95 minutes after beta.103 was published,
so the listener is re-derived as a beta.103-only patch and can go at the next
bump.

`pnpm check:deps` caught both, which is what it is for. It was also wrong about
one of them: beta.103's refactor left the aggregateWithin source marker intact
while fixing the behavior it stood for, so a string match reported "reverted"
on code that was fine. That invariant is now the measurement itself, run in its
own process because forced GC needs --expose-gc.

Conflicts: http.ts and ClaudeAdapter.ts were import-line only (upstream dropped
the hand-rolled gzip middleware for effect's HttpMiddleware.compression, and
renamed Schema.UnknownFromJsonString).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aHKjwZuipaALhwguxeRJe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants