From 560ee4c16ee97aedbf932796cf335e6f5a1679b0 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sat, 18 Apr 2026 22:20:45 -0700 Subject: [PATCH] Fix memory leak --- apps/backend/package.json | 2 +- claude/CLAUDE-KNOWLEDGE.md | 6 + package.json | 1 + pnpm-lock.yaml | 179 +++++++++++++--- ...ostinstall-patch-next-async-debug-info.mjs | 199 ++++++++++++++++++ 5 files changed, 356 insertions(+), 31 deletions(-) create mode 100644 scripts/postinstall-patch-next-async-debug-info.mjs diff --git a/apps/backend/package.json b/apps/backend/package.json index c6b189ada..9adf7529f 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,7 @@ "with-env:dev": "dotenv -c development --", "with-env:prod": "dotenv -c production --", "with-env:test": "dotenv -c test --", - "dev": "BACKEND_PORT=${STACK_DEV_FALLBACK_BACKEND:+${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}10} && BACKEND_PORT=${BACKEND_PORT:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02} && concurrently -n \"dev,codegen,prisma-studio,email-queue,cron-jobs,bulldozer-studio\" -k \"next dev --port $BACKEND_PORT ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\" \"pnpm run run-bulldozer-studio\"", + "dev": "BACKEND_PORT=${STACK_DEV_FALLBACK_BACKEND:+${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}10} && BACKEND_PORT=${BACKEND_PORT:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02} && concurrently -n \"dev,codegen,prisma-studio,email-queue,cron-jobs,bulldozer-studio\" -k \"STACK_DISABLE_REACT_ASYNC_DEBUG_INFO=${STACK_DISABLE_REACT_ASYNC_DEBUG_INFO:-true} next dev --port $BACKEND_PORT ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\" \"pnpm run run-bulldozer-studio\"", "dev:inspect": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev", "dev:profile": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev", "build": "pnpm run codegen && next build", diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index b32a96f21..970595b27 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -377,3 +377,9 @@ A: Any trigger callbacks written as `(changesTable) => ...` can fail against the Q: How should `x-stack-override-error-status` behave in backend smart responses? A: In `apps/backend/src/route-handlers/smart-response.tsx`, only override `4xx` responses to `200` with `x-stack-actual-status`. Do not override `5xx`, so infrastructure/runtime failures still surface as real server errors. + +Q: Why can `email-queue-step` heap growth still point at `app-page-turbo.runtime.dev.js` after disabling React async debug info in `react-server-dom-*` bundles? +A: Next.js dev app-page runtimes (`app-page*.runtime.dev.js`) include their own inlined async debug hook (`async_hooks.createHook`) with `pendingOperations` nodes plus stack-frame arrays from `collectStackTracePrivate`. Patching only `react-server-dom-*` is not enough. Gate the app-page runtime hook with `STACK_DISABLE_REACT_ASYNC_DEBUG_INFO` too; once gated, inspector allocation samples stop showing `init`/`collectStackTracePrivate` in `app-page-turbo.runtime.dev.js` and per-burst retained deltas drop from multi-MB to near-baseline noise. + +Q: How can we replace the huge `next@16.1.7` patch file with a resilient install-time rewrite? +A: Use a strict root `postinstall` script that rewrites only Next `>=16` app-page dev runtime bundles (`app-page*.runtime.dev.js`) from `doNotLimit=new WeakSet;async_hooks.createHook(` to the guarded `STACK_DISABLE_REACT_ASYNC_DEBUG_INFO` form. Guardrails should fail loud on marker mismatches, mixed guarded/unguarded states, replacement counts not equal to one, or missing runtime fingerprints; the script should also be idempotent (`patched=0, alreadyPatched>0` on second run). diff --git a/package.json b/package.json index 295ca0593..7828d0154 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "pre-preinstall": "pnpx only-allow@1.2.2 pnpm && node -e \"if(process.env.STACK_SKIP_TEMPLATE_GENERATION !== 'true') require('child_process').execSync('pnpm exec tsx ./scripts/generate-sdks.ts', {stdio: 'inherit'})\"", "pre": "pnpm pre-preinstall", "preinstall": "pnpm pre-preinstall", + "postinstall": "node ./scripts/postinstall-patch-next-async-debug-info.mjs", "typecheck": "pnpm pre && turbo typecheck --", "build:dev": "pnpm pre && NODE_ENV=development pnpm run build", "build": "pnpm pre && turbo build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ab99f01..57c63c24b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1020,7 +1020,7 @@ importers: devDependencies: mint: specifier: ^4.2.487 - version: 4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0) + version: 4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0) examples/cjs-test: dependencies: @@ -22672,6 +22672,16 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/checkbox@4.3.2(@types/node@24.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/confirm@5.1.21(@types/node@20.17.6)': dependencies: '@inquirer/core': 10.3.2(@types/node@20.17.6) @@ -22679,6 +22689,13 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/confirm@5.1.21(@types/node@24.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/core@10.3.2(@types/node@20.17.6)': dependencies: '@inquirer/ansi': 1.0.2 @@ -22692,6 +22709,19 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/core@10.3.2(@types/node@24.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.9.2) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/editor@4.2.23(@types/node@20.17.6)': dependencies: '@inquirer/core': 10.3.2(@types/node@20.17.6) @@ -22700,6 +22730,14 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/editor@4.2.23(@types/node@24.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/external-editor': 1.0.3(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/expand@4.0.23(@types/node@20.17.6)': dependencies: '@inquirer/core': 10.3.2(@types/node@20.17.6) @@ -22708,6 +22746,14 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/expand@4.0.23(@types/node@24.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/external-editor@1.0.3(@types/node@20.17.6)': dependencies: chardet: 2.1.1 @@ -22715,6 +22761,13 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/external-editor@1.0.3(@types/node@24.9.2)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/figures@1.0.15': {} '@inquirer/figures@1.0.3': {} @@ -22726,6 +22779,13 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/input@4.3.1(@types/node@24.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/number@3.0.23(@types/node@20.17.6)': dependencies: '@inquirer/core': 10.3.2(@types/node@20.17.6) @@ -22733,6 +22793,13 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/number@3.0.23(@types/node@24.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/password@4.0.23(@types/node@20.17.6)': dependencies: '@inquirer/ansi': 1.0.2 @@ -22741,6 +22808,14 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/password@4.0.23(@types/node@24.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/prompts@7.10.1(@types/node@20.17.6)': dependencies: '@inquirer/checkbox': 4.3.2(@types/node@20.17.6) @@ -22756,20 +22831,35 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 - '@inquirer/prompts@7.9.0(@types/node@20.17.6)': + '@inquirer/prompts@7.10.1(@types/node@24.9.2)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@20.17.6) - '@inquirer/confirm': 5.1.21(@types/node@20.17.6) - '@inquirer/editor': 4.2.23(@types/node@20.17.6) - '@inquirer/expand': 4.0.23(@types/node@20.17.6) - '@inquirer/input': 4.3.1(@types/node@20.17.6) - '@inquirer/number': 3.0.23(@types/node@20.17.6) - '@inquirer/password': 4.0.23(@types/node@20.17.6) - '@inquirer/rawlist': 4.1.11(@types/node@20.17.6) - '@inquirer/search': 3.2.2(@types/node@20.17.6) - '@inquirer/select': 4.4.2(@types/node@20.17.6) + '@inquirer/checkbox': 4.3.2(@types/node@24.9.2) + '@inquirer/confirm': 5.1.21(@types/node@24.9.2) + '@inquirer/editor': 4.2.23(@types/node@24.9.2) + '@inquirer/expand': 4.0.23(@types/node@24.9.2) + '@inquirer/input': 4.3.1(@types/node@24.9.2) + '@inquirer/number': 3.0.23(@types/node@24.9.2) + '@inquirer/password': 4.0.23(@types/node@24.9.2) + '@inquirer/rawlist': 4.1.11(@types/node@24.9.2) + '@inquirer/search': 3.2.2(@types/node@24.9.2) + '@inquirer/select': 4.4.2(@types/node@24.9.2) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 24.9.2 + + '@inquirer/prompts@7.9.0(@types/node@24.9.2)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.9.2) + '@inquirer/confirm': 5.1.21(@types/node@24.9.2) + '@inquirer/editor': 4.2.23(@types/node@24.9.2) + '@inquirer/expand': 4.0.23(@types/node@24.9.2) + '@inquirer/input': 4.3.1(@types/node@24.9.2) + '@inquirer/number': 3.0.23(@types/node@24.9.2) + '@inquirer/password': 4.0.23(@types/node@24.9.2) + '@inquirer/rawlist': 4.1.11(@types/node@24.9.2) + '@inquirer/search': 3.2.2(@types/node@24.9.2) + '@inquirer/select': 4.4.2(@types/node@24.9.2) + optionalDependencies: + '@types/node': 24.9.2 '@inquirer/rawlist@4.1.11(@types/node@20.17.6)': dependencies: @@ -22779,6 +22869,14 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/rawlist@4.1.11(@types/node@24.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/search@3.2.2(@types/node@20.17.6)': dependencies: '@inquirer/core': 10.3.2(@types/node@20.17.6) @@ -22788,6 +22886,15 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/search@3.2.2(@types/node@24.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/select@4.4.2(@types/node@20.17.6)': dependencies: '@inquirer/ansi': 1.0.2 @@ -22798,10 +22905,24 @@ snapshots: optionalDependencies: '@types/node': 20.17.6 + '@inquirer/select@4.4.2(@types/node@24.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.2 + '@inquirer/type@3.0.10(@types/node@20.17.6)': optionalDependencies: '@types/node': 20.17.6 + '@inquirer/type@3.0.10(@types/node@24.9.2)': + optionalDependencies: + '@types/node': 24.9.2 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -22939,9 +23060,9 @@ snapshots: dependencies: langium: 3.3.1 - '@mintlify/cli@4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)': + '@mintlify/cli@4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@20.17.6) + '@inquirer/prompts': 7.9.0(@types/node@24.9.2) '@mintlify/common': 1.0.835(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0) '@mintlify/link-rot': 3.0.1010(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0) '@mintlify/prebuild': 1.0.977(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0) @@ -22954,7 +23075,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@18.3.12)(react@19.2.3) - inquirer: 12.3.0(@types/node@20.17.6) + inquirer: 12.3.0(@types/node@24.9.2) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 open: 8.4.2 @@ -30122,7 +30243,6 @@ snapshots: '@types/node@24.9.2': dependencies: undici-types: 7.16.0 - optional: true '@types/nodemailer@6.4.15': dependencies: @@ -33200,7 +33320,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.2(eslint@8.57.1) eslint-plugin-react-hooks: 5.1.0(eslint@8.57.1) @@ -33242,7 +33362,7 @@ snapshots: debug: 4.4.3 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) fast-glob: 3.3.3 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -33285,7 +33405,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -33363,7 +33483,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -35269,12 +35389,12 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - inquirer@12.3.0(@types/node@20.17.6): + inquirer@12.3.0(@types/node@24.9.2): dependencies: - '@inquirer/core': 10.3.2(@types/node@20.17.6) - '@inquirer/prompts': 7.10.1(@types/node@20.17.6) - '@inquirer/type': 3.0.10(@types/node@20.17.6) - '@types/node': 20.17.6 + '@inquirer/core': 10.3.2(@types/node@24.9.2) + '@inquirer/prompts': 7.10.1(@types/node@24.9.2) + '@inquirer/type': 3.0.10(@types/node@24.9.2) + '@types/node': 24.9.2 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -36757,9 +36877,9 @@ snapshots: dependencies: minipass: 7.1.2 - mint@4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0): + mint@4.2.487(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0): dependencies: - '@mintlify/cli': 4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@20.17.6)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0) + '@mintlify/cli': 4.0.1090(@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/node@24.9.2)(@types/react@18.3.12)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.0) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -40923,8 +41043,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: - optional: true + undici-types@7.16.0: {} undici@6.19.8: {} diff --git a/scripts/postinstall-patch-next-async-debug-info.mjs b/scripts/postinstall-patch-next-async-debug-info.mjs new file mode 100644 index 000000000..0ec9dda9f --- /dev/null +++ b/scripts/postinstall-patch-next-async-debug-info.mjs @@ -0,0 +1,199 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Why this script exists: + * - Next.js dev app-page runtimes (app-page*.runtime.dev.js) include an async debug hook + * (`async_hooks.createHook`) that captures async stack-trace metadata in hot paths. + * - In our backend dev workload (notably repeated email-queue-step requests), this hook + * causes measurable heap growth/retention in dev mode. + * - We disable that hook when STACK_DISABLE_REACT_ASYNC_DEBUG_INFO=true. + * + * Why we do this in postinstall: + * - The equivalent pnpm patch touched minified one-line bundles and produced a multi-MB + * patch file that is hard to review and noisy in diffs. + * - A strict install-time rewrite keeps the repo clean while still being deterministic: + * if assumptions no longer hold, we fail loudly instead of silently continuing. + */ +const LOG_PREFIX = "[patch-next-async-debug-info]"; +const MIN_TARGET_NEXT_MAJOR = 16; + +// We only patch app-page dev runtimes where this hook is present and relevant. +const APP_PAGE_RUNTIME_FILE_REGEX = /^app-page(?:-turbo)?(?:-experimental)?\.runtime\.dev\.js$/; +const HOOK_NEEDLE = "doNotLimit=new WeakSet;async_hooks.createHook("; +const GUARDED_HOOK = + "doNotLimit=new WeakSet,shouldEnableAsyncDebugInfo=\"true\"!==process.env.STACK_DISABLE_REACT_ASYNC_DEBUG_INFO;shouldEnableAsyncDebugInfo&&async_hooks.createHook("; +// Extra fingerprints reduce the chance of accidentally patching unrelated files. +const RUNTIME_FINGERPRINTS = [ + "collectStackTracePrivate(", + "pendingOperations", +]; + +function fail(message) { + throw new Error(`${LOG_PREFIX} ${message}`); +} + +function hasAllRuntimeFingerprints(content) { + return RUNTIME_FINGERPRINTS.every((fingerprint) => content.includes(fingerprint)); +} + +function patchRuntimeFile(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + const hasNeedle = content.includes(HOOK_NEEDLE); + const hasGuard = content.includes(GUARDED_HOOK); + + if (!hasAllRuntimeFingerprints(content)) { + return { status: "ignored" }; + } + + if (hasNeedle && hasGuard) { + fail(`File ${filePath} contains both guarded and unguarded markers; refusing to continue.`); + } + + if (!hasNeedle && !hasGuard) { + fail(`File ${filePath} no longer contains the expected async debug marker. Next.js internals likely changed.`); + } + + // Already guarded => idempotent no-op. + if (hasGuard) { + return { status: "already" }; + } + + const needleCount = content.split(HOOK_NEEDLE).length - 1; + if (needleCount !== 1) { + fail(`File ${filePath} matched ${needleCount} unguarded markers (expected exactly 1).`); + } + + const patchedContent = content.replace(HOOK_NEEDLE, GUARDED_HOOK); + + if (patchedContent === content) { + fail(`File ${filePath} did not change after replacement.`); + } + + if (patchedContent.includes(HOOK_NEEDLE)) { + fail(`File ${filePath} still contains unguarded marker after patch.`); + } + + if (!patchedContent.includes(GUARDED_HOOK)) { + fail(`File ${filePath} is missing guarded marker after patch.`); + } + + fs.writeFileSync(filePath, patchedContent); + + return { status: "patched" }; +} + +function listInstalledNextServerDirs(repoRoot) { + const pnpmVirtualStoreDir = path.join(repoRoot, "node_modules", ".pnpm"); + if (!fs.existsSync(pnpmVirtualStoreDir)) { + fail(`Missing ${pnpmVirtualStoreDir}. Run pnpm install before applying this patch.`); + } + + const dirEntries = fs.readdirSync(pnpmVirtualStoreDir, { withFileTypes: true }); + const nextServerDirs = dirEntries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("next@")) + .map((entry) => { + const versionMatch = entry.name.match(/^next@(\d+)\./); + if (!versionMatch) { + return null; + } + + const majorVersion = Number(versionMatch[1]); + // This guard targets current Next 16 dev runtimes only; older installed versions + // (e.g. transitive Next 14) may not contain the same runtime structure. + if (majorVersion < MIN_TARGET_NEXT_MAJOR) { + return null; + } + + const nextServerDir = path.join( + pnpmVirtualStoreDir, + entry.name, + "node_modules", + "next", + "dist", + "compiled", + "next-server", + ); + + return fs.existsSync(nextServerDir) ? nextServerDir : null; + }) + .filter((nextServerDir) => nextServerDir !== null); + + if (nextServerDirs.length === 0) { + fail(`No installed Next.js runtimes with major >= ${MIN_TARGET_NEXT_MAJOR} found in node_modules/.pnpm.`); + } + + return nextServerDirs; +} + +function patchAllNextRuntimeDirs(repoRoot) { + const nextServerDirs = listInstalledNextServerDirs(repoRoot); + + const summary = { + nextServerDirs: nextServerDirs.length, + candidateFiles: 0, + fingerprintedFiles: 0, + patchedFiles: 0, + alreadyPatchedFiles: 0, + }; + + for (const nextServerDir of nextServerDirs) { + const runtimeFiles = fs.readdirSync(nextServerDir) + .filter((fileName) => APP_PAGE_RUNTIME_FILE_REGEX.test(fileName)) + .map((fileName) => path.join(nextServerDir, fileName)); + + if (runtimeFiles.length === 0) { + fail(`No app-page*.runtime.dev.js files found in ${nextServerDir}.`); + } + + summary.candidateFiles += runtimeFiles.length; + + let touchedFingerprintFileInDir = 0; + for (const runtimeFile of runtimeFiles) { + const result = patchRuntimeFile(runtimeFile); + if (result.status === "ignored") { + continue; + } + + touchedFingerprintFileInDir += 1; + summary.fingerprintedFiles += 1; + + if (result.status === "patched") { + summary.patchedFiles += 1; + } else if (result.status === "already") { + summary.alreadyPatchedFiles += 1; + } else { + fail(`Unexpected patch status "${result.status}" for ${runtimeFile}.`); + } + } + + if (touchedFingerprintFileInDir === 0) { + fail(`Found app-page runtimes in ${nextServerDir}, but none matched expected async debug fingerprints.`); + } + } + + if (summary.fingerprintedFiles === 0) { + fail("No runtime files matched expected async debug fingerprints."); + } + + if (summary.patchedFiles === 0 && summary.alreadyPatchedFiles === 0) { + fail("Patch script completed without touching any files."); + } + + return summary; +} + +function main() { + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(scriptDir, ".."); + const summary = patchAllNextRuntimeDirs(repoRoot); + + // Emit a compact machine-readable summary for local debugging and CI logs. + console.log( + `${LOG_PREFIX} patched=${summary.patchedFiles} alreadyPatched=${summary.alreadyPatchedFiles} ` + + `fingerprinted=${summary.fingerprintedFiles} candidates=${summary.candidateFiles} nextDirs=${summary.nextServerDirs}`, + ); +} + +main();