-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Description
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:
svelte/packages/svelte/src/internal/server/hydratable.js
Lines 58 to 88 in 46603d9
| 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:
- 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 theunevalnever runs and we jump to thecatch. However, becausecatchis returning the error object instead of throwing it, the promise is now an accepted promise of typePromise<Error>. It is not the expected type of a rejected promise. - A second promise is created on line 70 with
p.catch(() => {});.pis an accepted promise, so the catch handler is never called. Notably, this new promise created byp.catch(...)is not assigned back top, it's just discarded, so even if it did run, it wouldn't ever transform the promisep. - A third promise is created on line 74:
p.finally(() => unresolved?.delete(p));. This refers back to the first promise, not the second promise, becausepwas never reassigned. This third promise is then discarded and not reassigned top. (It's unclear to me if thefinally()block will ever execute, because this third promise is discarded and not assigned top?) - 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.pis an accepted promise containing anErrorobject, so this new fourth promise is happily (and incorrectly) pushed as a value toentry.promises. - The data has now been corrupted in
entry.promises- it contains anPromise<Error>object. We probably should have thrown by now and not corruptedentry.promises. Presumably, something later tries to do something with thatErrorobject 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.5System 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.5Severity
blocking an upgrade