# \[Bug\]: The server log never records why a /ws connection closed · Issue \#24280 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/issues/24280)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: backlog

Priority: 0

## Description

[Discussion and activity](https://git.cafe/pingdotgg/t3code/issues/24280)

---

### Before submitting

- [X] I searched existing issues and did not find a duplicate.
- [X] I included enough detail to reproduce or investigate the problem.

### Area

apps/server

### Steps to reproduce

The close code, with a simulated close that runs the same way every time:

1. Check out `main` and run `vp i`.
2. Save the script below as `apps/server/close-code-repro.mjs`. It imports the `effect` version the server ships (4.0.5).
3. In `apps/server`, run `node close-code-repro.mjs`.

```js
// Simulates a peer closing an RPC websocket and checks whether anything outside Effect's
// RPC server can see the close code. Save in apps/server and run `node close-code-repro.mjs`.
// 4001 and its reason are arbitrary test values; no T3 client sends them.
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Socket from "effect/socket/Socket";
import * as HttpServerRequest from "effect/http/HttpServerRequest";
import * as RpcServer from "effect/rpc/RpcServer";
import * as RpcSerialization from "effect/rpc/RpcSerialization";

// A socket whose first read fails the way a peer close does.
const closingSocket = Socket.make({
  reader: Effect.succeed({
    pull: Effect.fail(
      new Socket.SocketError({
        reason: new Socket.SocketCloseError({ code: 4001, closeReason: "client heartbeat timeout" }),
      }),
    ),
    upgrade: () => Effect.void,
  }),
  writer: Effect.succeed({ write: () => Effect.void, writeAll: () => Effect.void }),
});

// Wraps the reader to keep the close error before the RPC server catches it.
const observed = {};
const observe = (socket) =>
  Socket.make({
    reader: Effect.map(socket.reader, (reader) => ({
      ...reader,
      pull: reader.pull.pipe(
        Effect.tapError((error) =>
          Effect.sync(() => {
            if (error.reason._tag === "SocketCloseError") {
              observed.code = error.reason.code;
              observed.reason = error.reason.closeReason;
            }
          }),
        ),
      ),
    })),
    writer: socket.writer,
  });

const run = (wrap) =>
  Effect.gen(function* () {
    const { httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
    const request = { upgrade: Effect.succeed(wrap(closingSocket)), headers: {} };
    return yield* httpEffect.pipe(
      Effect.provideService(HttpServerRequest.HttpServerRequest, request),
      Effect.exit,
    );
  }).pipe(Effect.scoped, Effect.provide(RpcSerialization.layerJson));

const exitLabel = (exit) => (Exit.isSuccess(exit) ? "Success" : "Failure");

// Unwrapped, as ws.ts on main hands the request to the RPC server.
const plain = await Effect.runPromise(run((socket) => socket));
console.log(`unwrapped socket: httpEffect exit = ${exitLabel(plain)} | observed = ${JSON.stringify(observed)}`);
const wrapped = await Effect.runPromise(run(observe));
console.log(`wrapped reader: httpEffect exit = ${exitLabel(wrapped)} | observed = ${JSON.stringify(observed)}`);
```

### Expected behavior

- When a `/ws` connection ends, the server logs one line that says how it ended: a close frame with its code and reason, a dropped connection (1006), a session invalidation or a server shutdown. The line also gives how long the connection lasted, how long before the close the last frame arrived, and which client session, surface and address it was.

### Actual behavior

- The unwrapped run matches how `ws.ts` on `main` hands the connection to Effect's RPC server. It ends `httpEffect` with Success even though the reader failed with a simulated 4001 close. The RPC server catches every `SocketCloseError` its socket reader raises (`Effect.catchReason("SocketError", "SocketCloseError", ...)` in `effect/rpc/RpcServer`). The caller cannot tell a close from a clean end, and the code and reason never leave the RPC server. The wrapped run shows they are on the reader for a wrapper to keep. On the way out, the `/ws` handler in `ws.ts` calls `sessions.markDisconnected` and logs nothing.
- The trace does not have the close code either. No server code on `main` reads `SocketCloseError`, and in #10193 the server trace showed only RPC spans interrupted by `ClientAbort`. Finding out why the socket closed took Chrome remote debugging in the renderer.

### Impact

Minor bug or occasional failure

### Version or commit

main @ eeda2cb2cd

### Environment

Linux (Fedora 44), Node 24.22.0, effect 4.0.5

### Logs or stack traces

```shell
$ node close-code-repro.mjs
unwrapped socket: httpEffect exit = Success | observed = {}
wrapped reader: httpEffect exit = Success | observed = {"code":4001,"reason":"client heartbeat timeout"}
```

### Screenshots, recordings, or supporting files

_No response_

### Workaround

None on the server. For a close, the client side has to capture it, as in #10193.

I have a fix with focused tests: it wraps the socket reader to keep the close code and logs one close line per connection. I can open it as a PR linked here once this is triaged.

Written with Claude Opus 5.5 in T3 Code Nightly.

