From ec149a3cce8b488d6602f21a5683e1c30abdf11e Mon Sep 17 00:00:00 2001 From: Priya Raman Date: Fri, 4 Sep 2026 00:43:41 +0000 Subject: [PATCH] feat(server): add a process-level guard for the postgres driver null-socket write crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The postgres driver can crash the whole server process. A database backend can close a connection while a transaction still holds it. A later query on that connection reaches a deferred write to a socket the driver already set to null. The write throws inside a timer callback that no request handler can reach. Register one uncaughtException listener that recognizes this exact fault by error class, message, and stack frame, logs one structured line, and lets the process continue. Every other uncaught exception still ends the process, matching today's default behavior. An operator can disable the guard with POSTGRES_NULL_SOCKET_GUARD_ENABLED=false. A fake wire-protocol server reproduces the crash deterministically and proves: the guard neutralizes the known fault and lets an unrelated fault still end the process; the caller's transaction promise rejects; and a pool with more than one connection keeps serving queries afterward. A single-connection pool does not recover the exact connection that crashed — the driver never resets that connection's own write-scheduling state on this throw path, so it can never send another byte. This is a separate, pre-existing driver defect the guard cannot reach; the test documents it rather than hiding it. Co-authored-by: Paperclip --- pnpm-lock.yaml | 96 ++++--- server/package.json | 1 + ...postgres-null-socket-guard.test-fixture.ts | 239 ++++++++++++++++ .../postgres-null-socket-guard.test.ts | 266 ++++++++++++++++++ server/src/index.ts | 6 + server/src/postgres-null-socket-guard.ts | 105 +++++++ 6 files changed, 680 insertions(+), 33 deletions(-) create mode 100644 server/src/__tests__/postgres-null-socket-guard.test-fixture.ts create mode 100644 server/src/__tests__/postgres-null-socket-guard.test.ts create mode 100644 server/src/postgres-null-socket-guard.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad9de12489d..cbc14b4d37a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) cli: dependencies: @@ -281,7 +281,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/adapters/hermes-gateway: dependencies: @@ -297,7 +297,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/adapters/kimi-local: dependencies: @@ -398,7 +398,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/google-sheets-mcp-server: dependencies: @@ -420,7 +420,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/kv-demo-mcp-server: dependencies: @@ -439,7 +439,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/mcp-server: dependencies: @@ -461,7 +461,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/paperclip-eval-kernel: devDependencies: @@ -537,7 +537,7 @@ importers: version: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)) packages/plugins/create-paperclip-plugin: dependencies: @@ -584,7 +584,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/plugins/examples/plugin-file-browser-example: dependencies: @@ -702,7 +702,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/plugins/plugin-llm-wiki: devDependencies: @@ -741,7 +741,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/plugins/plugin-workspace-diff: dependencies: @@ -775,7 +775,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/plugins/sdk: dependencies: @@ -829,7 +829,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages/teams-catalog: devDependencies: @@ -895,7 +895,7 @@ importers: version: link:../packages/skills-catalog '@vercel/connect': specifier: 0.6.1 - version: 0.6.1(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12))) + version: 0.6.1(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)))) acpx: specifier: 0.13.1 version: 0.13.1(patch_hash=lzpwjtiaybzoijy455dfycwavu) @@ -907,7 +907,7 @@ importers: version: 3.0.1(ajv@8.20.0) better-auth: specifier: 1.7.2 - version: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12)) + version: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))) chokidar: specifier: ^5.0.0 version: 5.0.0 @@ -990,6 +990,9 @@ importers: cross-env: specifier: ^10.1.0 version: 10.1.0 + postgres: + specifier: ^3.4.9 + version: 3.4.9 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -1004,7 +1007,7 @@ importers: version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) ui: dependencies: @@ -1179,7 +1182,7 @@ importers: version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) packages: @@ -8236,6 +8239,7 @@ packages: '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -12229,11 +12233,11 @@ snapshots: dependencies: execa: 5.1.1 - '@vercel/connect@0.6.1(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12)))': + '@vercel/connect@0.6.1(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))))': dependencies: '@vercel/oidc': 3.8.2 optionalDependencies: - better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12)) + better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))) '@vercel/oidc@3.8.2': dependencies: @@ -12268,6 +12272,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 + '@vitest/mocker@4.1.11(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.11 @@ -12481,7 +12493,7 @@ snapshots: dependencies: tweetnacl: 0.14.5 - better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12)): + better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))): dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9)) @@ -12506,7 +12518,7 @@ snapshots: pg: 8.18.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -15819,7 +15831,36 @@ snapshots: jiti: 2.7.0 tsx: 4.23.12 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(tsx@4.23.12): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.3 + jsdom: 30.0.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - msw + + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -15846,18 +15887,7 @@ snapshots: '@types/node': 24.13.3 jsdom: 30.0.1(@noble/hashes@2.4.0) transitivePeerDependencies: - - '@vitejs/devtools' - - esbuild - - jiti - - less - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml vscode-jsonrpc@9.0.1: {} diff --git a/server/package.json b/server/package.json index fdeaf13f131..65689781824 100644 --- a/server/package.json +++ b/server/package.json @@ -99,6 +99,7 @@ "@types/supertest": "^7.2.1", "@types/ws": "^8.18.1", "cross-env": "^10.1.0", + "postgres": "^3.4.9", "supertest": "^7.0.0", "tsx": "^4.23.12", "typescript": "^7.0.2", diff --git a/server/src/__tests__/postgres-null-socket-guard.test-fixture.ts b/server/src/__tests__/postgres-null-socket-guard.test-fixture.ts new file mode 100644 index 00000000000..4763862b4be --- /dev/null +++ b/server/src/__tests__/postgres-null-socket-guard.test-fixture.ts @@ -0,0 +1,239 @@ +// Child-process fixture for the postgres driver null-socket write crash. +// +// A single process cannot prove its own survival — a test in the same +// process as an unguarded crash would take the whole test run down with +// it. This script runs in its own forked process. It reproduces the crash +// sequence against a fake wire-protocol server, then reports each +// checkpoint to the parent test over IPC. The parent asserts on those +// messages and on this process's own exit code. +// +// This is not a real PostgreSQL server. It speaks only the small subset of +// the wire protocol the driver needs for a startup handshake and a simple +// query: no SSL negotiation, no authentication challenge, no extended +// query protocol. `fetch_types: false` on the client skips the driver's +// own startup catalog query, and every query used here carries no bind +// parameters, so the driver always uses the simple query protocol. +// +// A note on "the client recovers": the driver's own write-scheduling state +// (a buffered chunk and a scheduled-write flag, both private to one +// connection's closure) is never reset on the throw path this guard +// neutralizes — the two statements that would reset them sit after the +// line that throws. A later query on that exact same connection can +// therefore never send another byte; it silently times out instead of +// failing fast. A pool with more than one connection is not stuck: the +// pool's other connections are separate closures the crash never touches, +// so a query on any of them succeeds normally. This fixture proves both +// outcomes — see the "same connection" and "pool" scenarios below. +import net from "node:net"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { registerPostgresNullSocketGuard } from "../postgres-null-socket-guard.js"; + +const scenario = process.argv[2]; + +function send(message: Record): void { + process.send?.(message); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function beMessage(type: string, payload: Buffer): Buffer { + const header = Buffer.alloc(5); + header.write(type, 0, "ascii"); + header.writeUInt32BE(payload.length + 4, 1); + return Buffer.concat([header, payload]); +} + +function authenticationOkMessage(): Buffer { + return beMessage("R", Buffer.alloc(4)); +} + +function readyForQueryMessage(status: "I" | "T"): Buffer { + return beMessage("Z", Buffer.from(status, "ascii")); +} + +function commandCompleteMessage(tag: string): Buffer { + return beMessage("C", Buffer.from(`${tag}\0`, "ascii")); +} + +/** + * Speaks just enough of the wire protocol for one connection: an untyped + * startup packet, then a stream of typed messages. The only typed message + * this test ever sends is a simple-protocol query ('Q'). A `begin` query + * gets a `BEGIN` response, followed at once by the server closing the + * socket — standing in for a database backend that drops a connection + * while a transaction still holds it. Every other query gets a generic + * success response and the socket stays open. + */ +function attachFakeBackend(socket: net.Socket): void { + let buffer = Buffer.alloc(0); + let startupDone = false; + + socket.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + for (;;) { + if (!startupDone) { + if (buffer.length < 4) return; + const length = buffer.readUInt32BE(0); + if (buffer.length < length) return; + buffer = buffer.subarray(length); + startupDone = true; + socket.write(authenticationOkMessage()); + socket.write(readyForQueryMessage("I")); + continue; + } + + if (buffer.length < 5) return; + const type = String.fromCharCode(buffer[0] ?? 0); + const length = buffer.readUInt32BE(1); + const total = 1 + length; + if (buffer.length < total) return; + const payload = buffer.subarray(5, total); + buffer = buffer.subarray(total); + + if (type === "Q") { + const text = payload + .toString("utf8", 0, Math.max(payload.length - 1, 0)) + .trim() + .toLowerCase(); + if (text.startsWith("begin")) { + socket.write(commandCompleteMessage("BEGIN")); + socket.write(readyForQueryMessage("T")); + socket.end(); + } else { + socket.write(commandCompleteMessage("SELECT 0")); + socket.write(readyForQueryMessage("I")); + } + } + } + }); +} + +async function startFakeServer(): Promise<{ port: number; close: () => Promise }> { + const server = net.createServer((socket) => attachFakeBackend(socket)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("the fake wire server did not bind a TCP port"); + } + return { + port: address.port, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function runDriverCrashScenario(withGuard: boolean, poolMax: number): Promise { + if (withGuard) registerPostgresNullSocketGuard(); + + const { port, close } = await startFakeServer(); + + let resolveClosed = () => {}; + const closedPromise = new Promise((resolve) => { + resolveClosed = resolve; + }); + + const client = postgres({ + host: "127.0.0.1", + port, + user: "test", + password: "test", + database: "test", + max: poolMax, + fetch_types: false, + idle_timeout: null, + max_lifetime: null, + keep_alive: false, + connect_timeout: 3, + // Fires once the driver has fully processed the closed connection — + // after the transaction's own rejection, per the ordering documented + // in the module this fixture exercises. Gates the next query so the + // crash reproduces deterministically instead of racing a real close. + onclose: () => resolveClosed(), + }); + + const db = drizzle(client); + + const transactionPromise = db.transaction(async (tx) => { + await closedPromise; + // Triggers the defect: the driver defers this write to a timer + // callback, which throws once it runs because the socket is already + // null. That promise never settles, so it is deliberately not awaited. + void tx.execute(sql`select 1`).catch(() => {}); + // Keeps this transaction scope from following up with a commit + // attempt on the same dead connection, which would throw a second + // time and add noise this test does not need. + return await new Promise(() => {}); + }); + + transactionPromise.then( + () => send({ type: "transaction-resolved-unexpectedly" }), + (error: unknown) => { + send({ + type: "transaction-rejected", + message: error instanceof Error ? error.message : String(error), + }); + }, + ); + + // Gives the deferred write time to fire — and, with the guard registered, + // time to be neutralized — before this process checks whether it is + // still the one running. + await delay(300); + send({ type: "still-alive" }); + + try { + const recovery = await db.execute(sql`select 1`); + send({ type: "recovery-ok", rowCount: Array.isArray(recovery) ? recovery.length : null }); + } catch (error) { + // See the module comment above `runDriverCrashScenario`: with a + // single-connection pool, the crashed connection's own write-scheduling + // state is never reset on the throw path, so it can never send another + // byte. This is expected and reported, not swallowed. + send({ + type: "recovery-failed", + message: error instanceof Error ? error.message : String(error), + }); + } + + await client.end({ timeout: 1 }); + await close(); + send({ type: "survived" }); + process.exit(0); +} + +async function runUnrelatedCrashScenario(): Promise { + registerPostgresNullSocketGuard(); + setImmediate(() => { + throw new TypeError("an unrelated failure the guard must not swallow"); + }); + // If the guard wrongly neutralized this unrelated error, this process + // would still be alive to send this message. Give the throw time to + // reach the guard and end the process first. + await delay(500); + send({ type: "unexpected-survival" }); +} + +async function main(): Promise { + switch (scenario) { + case "guarded-crash-same-connection": + await runDriverCrashScenario(true, 1); + return; + case "guarded-crash-pool": + await runDriverCrashScenario(true, 2); + return; + case "unguarded-crash": + await runDriverCrashScenario(false, 1); + return; + case "unrelated-crash": + await runUnrelatedCrashScenario(); + return; + default: + throw new Error(`Unknown fixture scenario: ${scenario}`); + } +} + +void main(); diff --git a/server/src/__tests__/postgres-null-socket-guard.test.ts b/server/src/__tests__/postgres-null-socket-guard.test.ts new file mode 100644 index 00000000000..d2e7fcbec65 --- /dev/null +++ b/server/src/__tests__/postgres-null-socket-guard.test.ts @@ -0,0 +1,266 @@ +import { fork } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockLoggerError = vi.hoisted(() => vi.fn()); +const mockLoggerFatal = vi.hoisted(() => vi.fn()); + +vi.mock("../middleware/logger.js", () => ({ + logger: { + error: mockLoggerError, + fatal: mockLoggerFatal, + }, +})); + +const { + handlePostgresNullSocketGuardException, + isGuardEnabled, + isPostgresNullSocketWriteCrash, + registerPostgresNullSocketGuard, +} = await import("../postgres-null-socket-guard.js"); + +const FIXTURE_PATH = fileURLToPath(new URL("./postgres-null-socket-guard.test-fixture.ts", import.meta.url)); + +interface ChildRunResult { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + messages: Array>; +} + +function runFixture(scenario: string): Promise { + return new Promise((resolve, reject) => { + const child = fork(FIXTURE_PATH, [scenario], { + // `tsx` is a devDependency of the server package only, so the child's + // module resolution for `--import tsx` needs a cwd under server/ to + // find it — the repo root has no top-level `tsx` package. + cwd: dirname(dirname(dirname(FIXTURE_PATH))), + execArgv: ["--import", "tsx"], + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + + let stdout = ""; + let stderr = ""; + const messages: Array> = []; + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("message", (message) => { + messages.push(message as Record); + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + resolve({ code, signal, stdout, stderr, messages }); + }); + }); +} + +function messageTypes(result: ChildRunResult): string[] { + return result.messages.map((message) => String(message.type)); +} + +/** + * Builds a `TypeError` whose stack carries a `nextWrite` frame in + * `postgres/src/connection.js`, matching the real crash signature. The path + * prefix before `postgres/src/connection.js` stands in for any install + * layout (a package-store path in this repository, a plain + * `node_modules/postgres` path elsewhere). + */ +function makeDriverCrashError(message: string): Error { + const error = new TypeError(message); + error.stack = + `TypeError: ${message}\n` + + " at Immediate.nextWrite (/app/node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:255:20)\n" + + " at processImmediate (node:internal/timers:483:21)"; + return error; +} + +describe("isPostgresNullSocketWriteCrash", () => { + it("matches the older V8 message spelling with the driver frame", () => { + const error = makeDriverCrashError("Cannot read property 'write' of null"); + expect(isPostgresNullSocketWriteCrash(error)).toBe(true); + }); + + it("matches the newer V8 message spelling with the driver frame", () => { + const error = makeDriverCrashError("Cannot read properties of null (reading 'write')"); + expect(isPostgresNullSocketWriteCrash(error)).toBe(true); + }); + + it("does not match an unrelated TypeError with a different message", () => { + const error = makeDriverCrashError("Cannot read properties of null (reading 'foo')"); + expect(isPostgresNullSocketWriteCrash(error)).toBe(false); + }); + + it("does not match the right message from an unrelated frame", () => { + const error = new TypeError("Cannot read properties of null (reading 'write')"); + error.stack = + "TypeError: Cannot read properties of null (reading 'write')\n" + + " at Object. (/app/server/src/services/unrelated.js:12:5)"; + expect(isPostgresNullSocketWriteCrash(error)).toBe(false); + }); + + it("does not match a non-TypeError with the same message and frame", () => { + const error = new Error("Cannot read properties of null (reading 'write')"); + error.stack = + "Error: Cannot read properties of null (reading 'write')\n" + + " at Immediate.nextWrite (/app/node_modules/postgres/src/connection.js:255:20)"; + expect(isPostgresNullSocketWriteCrash(error)).toBe(false); + }); +}); + +describe("isGuardEnabled", () => { + it("defaults to enabled when the variable is unset", () => { + expect(isGuardEnabled({})).toBe(true); + }); + + it("stays enabled for \"true\" and \"1\"", () => { + expect(isGuardEnabled({ POSTGRES_NULL_SOCKET_GUARD_ENABLED: "true" })).toBe(true); + expect(isGuardEnabled({ POSTGRES_NULL_SOCKET_GUARD_ENABLED: "1" })).toBe(true); + }); + + it("disables for \"false\" and \"0\"", () => { + expect(isGuardEnabled({ POSTGRES_NULL_SOCKET_GUARD_ENABLED: "false" })).toBe(false); + expect(isGuardEnabled({ POSTGRES_NULL_SOCKET_GUARD_ENABLED: "0" })).toBe(false); + }); + + it("throws on an unrecognized value", () => { + expect(() => isGuardEnabled({ POSTGRES_NULL_SOCKET_GUARD_ENABLED: "maybe" })).toThrow( + /must be "true" or "false"/, + ); + }); +}); + +describe("registerPostgresNullSocketGuard", () => { + afterEach(() => { + process.off("uncaughtException", handlePostgresNullSocketGuardException); + }); + + it("registers a listener by default", () => { + const before = process.listenerCount("uncaughtException"); + registerPostgresNullSocketGuard({}); + expect(process.listenerCount("uncaughtException")).toBe(before + 1); + }); + + it("registers no listener when an operator disables it", () => { + const before = process.listenerCount("uncaughtException"); + registerPostgresNullSocketGuard({ POSTGRES_NULL_SOCKET_GUARD_ENABLED: "false" }); + expect(process.listenerCount("uncaughtException")).toBe(before); + }); +}); + +describe("handlePostgresNullSocketGuardException", () => { + let exitSpy: ReturnType; + + beforeEach(() => { + mockLoggerError.mockReset(); + mockLoggerFatal.mockReset(); + exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as unknown as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it("logs a stable marker and the error name, and does not exit, on a matching crash", () => { + const error = makeDriverCrashError("Cannot read properties of null (reading 'write')"); + + handlePostgresNullSocketGuardException(error); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(mockLoggerError).toHaveBeenCalledTimes(1); + const [fields] = mockLoggerError.mock.calls[0] as [Record]; + expect(fields).toMatchObject({ + marker: "postgres_null_socket_write_guard_neutralized", + errorName: "TypeError", + }); + }); + + it("never logs the query text, the message, or the stack for a matching crash", () => { + const secret = "select * from accounts where token = 'hunter2-secret-token'"; + const error = makeDriverCrashError(`Cannot read properties of null (reading 'write') — ${secret}`); + // The classifier only matches the exact V8 message, so force a match by + // constructing the error the classifier itself would accept, then + // verify no field of the log call carries anything beyond the marker + // and the fixed error name. + error.message = "Cannot read properties of null (reading 'write')"; + + handlePostgresNullSocketGuardException(error); + + const [fields] = mockLoggerError.mock.calls[0] as [Record]; + expect(Object.keys(fields).sort()).toEqual(["errorName", "marker"]); + for (const value of Object.values(fields)) { + expect(String(value)).not.toContain(secret); + } + }); + + it("logs and exits non-zero for a non-matching error", () => { + const error = new RangeError("some unrelated fatal condition"); + + handlePostgresNullSocketGuardException(error); + + expect(mockLoggerError).not.toHaveBeenCalled(); + expect(mockLoggerFatal).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("exits non-zero for a non-Error thrown value", () => { + handlePostgresNullSocketGuardException("a thrown string"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("the driver crash sequence, reproduced against a fake wire server", () => { + it("without the guard: the process ends and never reaches recovery", async () => { + const result = await runFixture("unguarded-crash"); + + expect(messageTypes(result)).toContain("transaction-rejected"); + expect(messageTypes(result)).not.toContain("still-alive"); + expect(messageTypes(result)).not.toContain("survived"); + expect(result.code).not.toBe(0); + }, 20_000); + + // Finding, not a defect in this guard: the driver never resets the + // crashed connection's own write-scheduling state on the throw path this + // guard neutralizes (see the module comment in + // postgres-null-socket-guard.test-fixture.ts). A later query on that + // exact same connection can never send another byte and times out + // instead of failing fast. A single-connection pool (`max: 1`) has no + // other connection to fall back to, so this is the worst case. + it("with the guard: the transaction rejects, the process survives, but the same crashed connection does not recover", async () => { + const result = await runFixture("guarded-crash-same-connection"); + + expect(messageTypes(result)).toContain("transaction-rejected"); + expect(messageTypes(result)).toContain("still-alive"); + expect(messageTypes(result)).not.toContain("recovery-ok"); + expect(messageTypes(result)).toContain("recovery-failed"); + expect(messageTypes(result)).toContain("survived"); + expect(result.code).toBe(0); + expect(result.stdout + result.stderr).toContain("postgres_null_socket_write_guard_neutralized"); + }, 20_000); + + // The realistic production shape: a pool with more than one connection. + // The crash never touches the pool's other connections — each is its own + // closure — so a later query routed to a different connection succeeds. + it("with the guard: a pool with another connection keeps serving queries", async () => { + const result = await runFixture("guarded-crash-pool"); + + expect(messageTypes(result)).toContain("transaction-rejected"); + expect(messageTypes(result)).toContain("recovery-ok"); + expect(messageTypes(result)).toContain("survived"); + expect(result.code).toBe(0); + }, 20_000); + + it("a non-matching uncaught exception still ends the process", async () => { + const result = await runFixture("unrelated-crash"); + + expect(messageTypes(result)).not.toContain("unexpected-survival"); + expect(result.code).toBe(1); + }, 20_000); +}); diff --git a/server/src/index.ts b/server/src/index.ts index d35563665a1..415ef0b39e5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ // HTTP server, so trace coverage does not depend on incidental timing. import { instrumentationReady, shutdownInstrumentation } from "./instrumentation.js"; import { sentryReady, shutdownSentry, captureException } from "./sentry.js"; +import { registerPostgresNullSocketGuard } from "./postgres-null-socket-guard.js"; import { existsSync, readFileSync, rmSync } from "node:fs"; import { createServer } from "node:http"; import { resolve } from "node:path"; @@ -155,6 +156,11 @@ export interface StartedServer { } export async function startServer(): Promise { + // Registered before every other startup step, including Sentry, so this + // listener runs first on any later uncaught exception — see + // postgres-null-socket-guard.ts for why that order matters. + registerPostgresNullSocketGuard(); + warnIfUnsupportedNodeVersion(process.versions.node, (message) => logger.warn(message)); // Tracing must be active (or have failed and logged) before the first DB diff --git a/server/src/postgres-null-socket-guard.ts b/server/src/postgres-null-socket-guard.ts new file mode 100644 index 00000000000..57616df9b33 --- /dev/null +++ b/server/src/postgres-null-socket-guard.ts @@ -0,0 +1,105 @@ +// Process-level guard for a known defect in the `postgres` driver: when a +// database backend closes a connection that a transaction still holds, the +// driver's own reconnect bookkeeping still lets a later query on that same +// connection object proceed. That query defers its wire write to a timer +// callback. By the time the callback runs, the driver has already set its +// socket reference to null, and the write throws a `TypeError`. No +// application `try`/`catch` sits between that timer callback and the +// process, so Node ends the process by default. +// +// This module does not patch the driver and does not change the driver +// version. It adds one `uncaughtException` listener. Registering a listener +// cancels Node's default crash-on-uncaught-exception behavior, so the +// listener must end the process itself for every uncaught exception except +// the one known, neutralized fault below — otherwise an unrelated bug would +// leave the process running in a state nobody chose. +// +// This module reports no telemetry event and calls no external +// error-reporting client. It logs one structured line, with a fixed marker +// and the error name only — never a query, a query parameter, a connection +// URL, a host name, or a database name. +// +// Interaction with Sentry: `./sentry.js` registers no `uncaughtException` +// listener today. `@sentry/node` is an optional peer dependency this +// repository does not install (`bootstrapSentry` fails its own version +// check and returns before it calls `Sentry.init`), so Sentry never adds a +// listener of its own. If an operator later installs `@sentry/node` and +// sets a Sentry DSN, the default `OnUncaughtException` integration is not +// among the integrations `sentry.ts` removes, so it would register its own +// listener too. This module registers first (from the server entry point, +// before `sentryReady`), so it runs first on every uncaught exception. + +import { logger } from "./middleware/logger.js"; + +const GUARD_ENABLED_ENV_VAR = "POSTGRES_NULL_SOCKET_GUARD_ENABLED"; +const GUARD_MARKER = "postgres_null_socket_write_guard_neutralized"; + +// Both V8 message spellings for a read of a property on `null`. The wording +// changed between an older V8 (`Cannot read property 'write' of null`) and +// a newer V8 (`Cannot read properties of null (reading 'write')`). +const NULL_WRITE_MESSAGE_PATTERN = + /Cannot read propert(?:y 'write' of null|ies of null \(reading 'write'\))/; + +// Matches the frame, not an absolute path, so the check survives any +// install-path prefix (a package-store path, a different path separator). +const NEXT_WRITE_FRAME_PATTERN = /\bnextWrite\b.*postgres[\\/]src[\\/]connection\.js/; + +/** + * Matches the known driver defect: a `TypeError` from a deferred write to a + * socket the driver already set to null, after a database backend closed + * the connection. Matches three facts together — the error class, both V8 + * message spellings, and a `nextWrite` frame in the driver's connection + * module — so an unrelated `TypeError` never counts. + */ +export function isPostgresNullSocketWriteCrash(error: unknown): boolean { + if (!(error instanceof Error) || error.name !== "TypeError") return false; + if (!NULL_WRITE_MESSAGE_PATTERN.test(error.message)) return false; + const stack = error.stack ?? ""; + return stack.split("\n").some((line) => NEXT_WRITE_FRAME_PATTERN.test(line)); +} + +/** + * Reads the runtime opt-out. An operator sets `false` or `0` to disable the + * guard; every other value, including an unset variable, keeps it enabled. + * Follows the same boolean convention as `envBoolean` in + * `packages/db/src/client.ts`: a value other than a recognized true/false + * spelling is a configuration mistake, so this throws instead of guessing. + */ +export function isGuardEnabled(env: NodeJS.ProcessEnv): boolean { + const value = env[GUARD_ENABLED_ENV_VAR]?.trim().toLowerCase(); + if (value === undefined || value === "") return true; + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + throw new Error(`${GUARD_ENABLED_ENV_VAR} must be "true" or "false", got: ${env[GUARD_ENABLED_ENV_VAR]}`); +} + +/** + * Handles one uncaught exception. The known driver fault logs one + * structured, searchable line and returns — the process survives. Every + * other error keeps today's default behavior: log, then end the process. + * Exported so a test can drive it directly instead of dispatching a real + * process event. + */ +export function handlePostgresNullSocketGuardException(error: unknown): void { + if (isPostgresNullSocketWriteCrash(error)) { + logger.error( + { marker: GUARD_MARKER, errorName: (error as Error).name }, + "neutralized a known postgres driver crash: a deferred write reached a connection socket the driver had already closed", + ); + return; + } + + const rootError = error instanceof Error ? error : new Error(String(error)); + logger.fatal({ err: rootError }, "uncaught exception; process exiting"); + process.exit(1); +} + +/** + * Registers the guard's `uncaughtException` listener, unless an operator + * disables it with `POSTGRES_NULL_SOCKET_GUARD_ENABLED=false` (or `0`). + * Call this once, early, from the server entry point. + */ +export function registerPostgresNullSocketGuard(env: NodeJS.ProcessEnv = process.env): void { + if (!isGuardEnabled(env)) return; + process.on("uncaughtException", handlePostgresNullSocketGuardException); +}