diff --git a/.changeset/brave-koalas-catch.md b/.changeset/brave-koalas-catch.md new file mode 100644 index 000000000..5d889e278 --- /dev/null +++ b/.changeset/brave-koalas-catch.md @@ -0,0 +1,5 @@ +--- +"skycrypt-frontend": patch +--- + +Stop the production container restart loop caused by unhandled promise rejections under Node 24. Two changes: (1) the `/stats/[ign]/[[profile]]/card` endpoint now uses `Promise.allSettled` rather than `Promise.all` for the parallel `getProfileStats` / `getNetworth` / `getCombined` calls — when one rejects first, the losing-side promises no longer become orphaned rejections that crash the process. (2) A `process.on("unhandledRejection")` safety net in `instrumentation.server.ts` logs + reports any future orphans to Sentry instead of exiting, since Node 24's default `--unhandled-rejections=throw` is fatal. Combined this ends the ~2-minute restart cycle visible in production logs. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000..fde79e6e2 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,10 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "skycrypt-frontend": "3.6.1" + }, + "changesets": [ + "brave-koalas-catch" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index cf84a6a0d..16fdd4af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.6.2-beta.0 + +### Patch Changes + +- Stop the production container restart loop caused by unhandled promise rejections under Node 24. Two changes: (1) the `/stats/[ign]/[[profile]]/card` endpoint now uses `Promise.allSettled` rather than `Promise.all` for the parallel `getProfileStats` / `getNetworth` / `getCombined` calls — when one rejects first, the losing-side promises no longer become orphaned rejections that crash the process. (2) A `process.on("unhandledRejection")` safety net in `instrumentation.server.ts` logs + reports any future orphans to Sentry instead of exiting, since Node 24's default `--unhandled-rejections=throw` is fatal. Combined this ends the ~2-minute restart cycle visible in production logs. ([`9ffadbc`](https://github.com/SkyCryptWebsite/SkyCrypt-Frontend/commit/9ffadbcf6e4c5730756a06039dc23cf9a56eda98)) + ## 3.6.1 ### Patch Changes diff --git a/package.json b/package.json index cd260d81b..b19d5ef5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skycrypt-frontend", - "version": "3.6.1", + "version": "3.6.2-beta.0", "private": true, "type": "module", "repository": { diff --git a/src/instrumentation.server.ts b/src/instrumentation.server.ts index 8a1eca51a..417e25d58 100644 --- a/src/instrumentation.server.ts +++ b/src/instrumentation.server.ts @@ -18,3 +18,11 @@ Sentry.init({ enabled: !dev, environment: dev ? "development" : "production" }); + +// Node 24's default --unhandled-rejections=throw will kill the process on any +// orphaned promise rejection (e.g. losing-side promises in Promise.all when +// one rejects first). Log and report so we get visibility, but don't crash. +process.on("unhandledRejection", (reason) => { + console.error("Unhandled promise rejection:", reason); + Sentry.captureException(reason); +}); diff --git a/src/routes/stats/[ign]/[[profile]]/card/+server.ts b/src/routes/stats/[ign]/[[profile]]/card/+server.ts index 4be5a3ce5..4a25e5b82 100644 --- a/src/routes/stats/[ign]/[[profile]]/card/+server.ts +++ b/src/routes/stats/[ign]/[[profile]]/card/+server.ts @@ -28,18 +28,29 @@ export const GET: RequestHandler = async ({ params, request, url }) => { try { const user = (await getApiUuidUsername(ign)).data as ModelsPlayerResolve; + // allSettled (not Promise.all) so a losing-side rejection is never orphaned: + // Promise.all rejects on the first failure but leaves the others running, and + // their later rejection becomes an unhandled rejection that crashes Node 24. const [ // prettier-ignore - profileData, - networthData, - combinedData - ] = await Promise.all([ + profileResult, + networthResult, + combinedResult + ] = await Promise.allSettled([ // prettier-ignore getProfileStats({ uuid: user.uuid ?? ign, profileId: profile ?? "" }), getNetworth({ uuid: user.uuid ?? ign, profileId: profile ?? "" }), getCombined({ uuid: user.uuid ?? ign, profileId: profile ?? "" }) ]); + if (profileResult.status === "rejected") throw profileResult.reason; + if (networthResult.status === "rejected") throw networthResult.reason; + if (combinedResult.status === "rejected") throw combinedResult.reason; + + const profileData = profileResult.value; + const networthData = networthResult.value; + const combinedData = combinedResult.value; + const { body: renderedHTML } = render(DefaultCard, { props: { profile: profileData,