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); +}