Skip to content

Exception thrown in hydratable promise crashes entire node process #17313

@kylejrp

Description

@kylejrp

Describe the bug

Hey, thanks for launching the new hydratable feature! We're already taking advantage of it here at Stack Overflow.

We've noticed one problem with the current implementation though. If a promise rejects (eg. through a thrown exception), hydratable reasonably fails to serialize the Error object returned by the promise. However, it then throws an exception outside of the promise chain. This causes the entire node process to crash since I think the exception was thrown in a background thread, not the main thread.

cc @elliott-with-the-longest-name-on-github @Rich-Harris @kaysef

I'm happy to open a PR to help fix this, I just need some guidance on what the intent is on these promise chains below.

Source of the problem

I think the problematic bit is in the entry.serialized assignment:

entry.serialized = devalue.uneval(entry.value, (value, uneval) => {
if (is_promise(value)) {
const p = value
.then((v) => `r(${uneval(v)})`)
.catch((devalue_error) =>
e.hydratable_serialization_failed(
key,
serialization_stack(entry.stack, devalue_error?.stack)
)
);
// prevent unhandled rejections from crashing the server
p.catch(() => {});
// track which promises are still resolving when render is complete
unresolved?.set(p, key);
p.finally(() => unresolved?.delete(p));
// we serialize promises as `"${i}"`, because it's impossible for that string
// to occur 'naturally' (since the quote marks would have to be escaped)
const placeholder = `"${uid++}"`;
(entry.promises ??= []).push(
p.then((s) => {
entry.serialized = entry.serialized.replace(placeholder, s);
})
);
return placeholder;
}
});

I think that this accidentally introduces three branches of promise chains, only one of which is actually used:

  1. The first promise is defined on line 60 with const p = value.then((v) => r(${uneval(v)})).catch((d) => e.hydratable_serialization_failed(...));. The promise we gave it throws an exception, so the uneval never runs and we jump to the catch. However, because catch is returning the error object instead of throwing it, the promise is now an accepted promise of type Promise<Error>. It is not the expected type of a rejected promise.
  2. A second promise is created on line 70 with p.catch(() => {});. p is an accepted promise, so the catch handler is never called. Notably, this new promise created by p.catch(...) is not assigned back to p, it's just discarded, so even if it did run, it wouldn't ever transform the promise p.
  3. A third promise is created on line 74: p.finally(() => unresolved?.delete(p));. This refers back to the first promise, not the second promise, because p was never reassigned. This third promise is then discarded and not reassigned to p. (It's unclear to me if the finally() block will ever execute, because this third promise is discarded and not assigned to p?)
  4. A fourth promise is created on line 81 with p.then((s) => { entry.serialized = entry.serialized.replace(placeholder, s);}). This promise refers again to the first promise. p is an accepted promise containing an Error object, so this new fourth promise is happily (and incorrectly) pushed as a value to entry.promises.
  5. The data has now been corrupted in entry.promises - it contains an Promise<Error> object. We probably should have thrown by now and not corrupted entry.promises. Presumably, something later tries to do something with that Error object and crashes in a background thread, taking the whole node process with it.

Diagram of promise chains

value = new Promise(() => { throw new Error("nope")})
p = value
1+4--> p.then(uneval).catch(hydratable_serialization_failed).then(entry.serialized = ...)
2  --> p.catch(() => {})
3  --> p.finally(() => unresolved?.delete(value))

Possible fix

I think if you did throw e.hydratable_serialization_failed(...) inside the first catch(...), it might work? However, the promise chains are still kind of messy - promises 2 and 3 aren't being assigned back to p and are thus being discarded (and possibly never having their extra then(...)/catch(...)/finally(...) executed?)

Reproduction

Here's my minimal repro project. It's a Fastify server that tries to render the Svelte component using a Vite-built SSR file.
https://github.com/kylejrp/vite-ssr-crash

Steps to reproduce:

npm install
npm run dev

Then, open http://localhost:3000/. The first request will show an error object, but then the server crashes in the background. Trying to reload the page will time out because the server never responds.

Failing component

You can reproduce this with the minimal Svelte component:

<script lang="ts">
  import { hydratable } from "svelte";
  const foo = await hydratable("foo", () => new Promise(() => { throw new Error("nope")}));
</script>

{foo}

This crashes the entire node process on the background thread. This exception is not caught by Fastify's global error handler and the entire server becomes unresponsive. Visiting http://localhost:3000/ will respond with an error message on the first request, but will then crash shortly after on the background thread and make the entire server unresponsive.

Working component

If you move the promise from being called by hydratable, it instead correctly bubbles up the exception on the main thread and is handled by Fastify's global error handler. You can make repeated requests to http://localhost:3000/ and Fastify responds with a 500 error each time.

<script lang="ts">
    const foo = await new Promise(() => { throw new Error("nope")});
</script>

{foo}

Logs

{"level":30,"time":1764963489130,"pid":80592,"hostname":"so-win-28552095","msg":"Server listening at http://127.0.0.1:3000"}
[1] {"level":30,"time":1764963489132,"pid":80592,"hostname":"so-win-28552095","msg":"Server listening at http://[::1]:3000"}
[1] {"level":30,"time":1764963493147,"pid":80592,"hostname":"so-win-28552095","reqId":"req-1","req":{"method":"GET","url":"/","host":"localhost:3000","remoteAddress":"::1","remotePort":53918},"msg":"incoming request"}
[1] {"level":50,"time":1764963493157,"pid":80592,"hostname":"so-win-28552095","reqId":"req-1","req":{"method":"GET","url":"/","host":"localhost:3000","remoteAddress":"::1","remotePort":53918},"res":{"statusCode":500},"err":{"type":"Error","message":"nope","stack":"Error: nope\n    at <anonymous> (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:1029:15)\n    at new Promise (<anonymous>)\n    at <anonymous> (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:1028:55)\n    at hydratable (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:980:17)\n    at Array.<anonymous> (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:1028:31)\n    at Renderer.run (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:425:44)\n    at <anonymous> (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:1027:34)\n    at Renderer.child (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:456:20)\n    at Renderer.component (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:477:24)\n    at App (C:\\code\\vite-ssr\\dist\\server\\entry-server.js:1025:14)"},"msg":"nope"}
[1] {"level":30,"time":1764963493163,"pid":80592,"hostname":"so-win-28552095","reqId":"req-1","res":{"statusCode":500},"responseTime":15.511900007724762,"msg":"request completed"}
[1] C:\code\vite-ssr\dist\server\entry-server.js:249
[1]   const error = new Error(`hydratable_serialization_failed
[1]                 ^
[1]
[1]
[1] Svelte error: hydratable_serialization_failed
[1] Failed to serialize `hydratable` data for key `foo`.
[1]
[1] `hydratable` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/uneval) can, plus Promises.
[1]
[1] Cause:
[1] Caused by:
[1] Error: nope
[1]     at <anonymous> (C:\code\vite-ssr\dist\server\entry-server.js:1029:15)
[1]     at new Promise (<anonymous>)
[1]     at <anonymous> (C:\code\vite-ssr\dist\server\entry-server.js:1028:55)
[1]     at hydratable (C:\code\vite-ssr\dist\server\entry-server.js:980:17)
[1]     at Array.<anonymous> (C:\code\vite-ssr\dist\server\entry-server.js:1028:31)
[1]     at Renderer.run (C:\code\vite-ssr\dist\server\entry-server.js:425:44)
[1]     at <anonymous> (C:\code\vite-ssr\dist\server\entry-server.js:1027:34)
[1]     at Renderer.child (C:\code\vite-ssr\dist\server\entry-server.js:456:20)
[1]     at Renderer.component (C:\code\vite-ssr\dist\server\entry-server.js:477:24)
[1]     at App (C:\code\vite-ssr\dist\server\entry-server.js:1025:14)
[1]
[1] https://svelte.dev/e/hydratable_serialization_failed
[1]     at hydratable_serialization_failed (C:\code\vite-ssr\dist\server\entry-server.js:249:17)      
[1]     at <anonymous> (C:\code\vite-ssr\dist\server\entry-server.js:991:28)
[1]
[1] Node.js v20.19.5

System Info

System:
    OS: Windows 11 10.0.26200
    CPU: (20) x64 13th Gen Intel(R) Core(TM) i9-13900H
    Memory: 28.90 GB / 63.68 GB
  Binaries:
    Node: 20.19.5 - C:\Program Files\nodejs\node.EXE
    npm: 10.9.3 - C:\Program Files\nodejs\npm.CMD
  Browsers:
    Edge: Chromium (141.0.3537.71)
    Firefox Developer Edition: 146.0 - C:\Program Files\Firefox Developer Edition\firefox.exe
  npmPackages:
    svelte: ^5.45.5 => 5.45.5

Severity

blocking an upgrade

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions