marko-js / marko

A declarative, HTML-based language that makes building web apps fun

Home Page:https://markojs.com/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Errors thrown within <await><@catch> are not handled

vwong opened this issue · comments

Marko Version

5.21.2

Details

In an <await> tag, if I don't specify the <@catch>, then the error is forwarded to the server (ExpressJS, in my case), so that the server can handle it correctly - such as prematurely terminating the response stream, so that clients know the response is bad. If I do specify the <@catch>, then the error is swallowed, and not forwarded to the server; as far as the stream is concerned, nothing is wrong.

I want to be able to render a user-friendly message AND indicate to the server to terminate the response stream.

I try re-throwing the error...

<await(somePromise)>
  <@then|res|>
    <success res=res/>
  </@then>
  <@catch|err|>
    <failure err=err/>
    $ throw err
  </@catch>
</await>

Expected Behavior

Thrown error is handled, and does not crash the server

Actual Behavior

Error is uncaught, and crashes the server (makes it unresponsive)

Possible Fix

Handle the (re) thrown error (or any other newly thrown one), the same way as if when there was no <@catch>.

diff --git a/node_modules/marko/dist/core-tags/core/await/renderer.js b/node_modules/marko/dist/core-tags/core/await/renderer.js
index 58722fd..5a329f3 100644
--- a/node_modules/marko/dist/core-tags/core/await/renderer.js
+++ b/node_modules/marko/dist/core-tags/core/await/renderer.js
@@ -220,7 +220,11 @@ function renderContents(err, data, input, out) {
   if (err) {
     if (input.catch) {
       if (errorRenderer) {
-        errorRenderer(out, err);
+        var renderErr = safeRenderBody(errorRenderer, out, err);
+
+        if (renderErr) {
+          out.error(err);
+        }
       }
     } else {
       out.error(err);

I'm happy to help create a PR to fix this (with the appropriate tests), if this is deemed desirable and correct.

This definitely seems like it should work, but we may have to make the fix back-compat friendly. We're figuring out how.

Another point to add to your thinking: I find myself adding the following (in addition to the patch above)

// main usage
<await(somePromise)>
  <@then|res|>
    <success res=res/>
  </@then>
  <@catch|err|>
    <failure err=err/>
    <break-chunk/>
    $ throw err;
  </@catch>
</await>

// break-chunk.marko
$ const delay = new Promise((resolve) => setTimeout(resolve, 0));
<await(delay)><@then>${" "}</@then></await>

It seems that I need to add in another <await> in the error handler to force the stream to break the chunk, otherwise, if the main <await> is the last one on the page, it seems to be flushed together with the null chunk anyway. By forcing the chunk to break, I give the server (ExpressJS in my case) a chance to omit the null chunk. It's a bit fiddly, and may be a case of a leaky abstraction. It would be good if the patch itself could include something like out.beginAsync() or something to automatically break the chunk.

This last bit, (the flushing of chunks) is somewhat guess work at this point, I haven't dug deep enough into the code yet. So, I could be wrong entirely! Apologies if this causes a wild goose chase!

@vwong what issue are you having with the null chunk being flushed with the rest of the await?

FYI @mlrawlings was floating the idea of having something like:

<@catch|err| rethrow>

However I think if this manual throw case isn't working it probably should also.

If I just had:

<@catch|error|>
  $ throw error;
</@catch>

The stream, in some cases, completes fine (with the patch applied), with null terminating chunk and all. I haven't had time to dig into exactly why, but I suspect this happens when this is already the last <await>, and any remaining data are just closing tags and synchronous content. By the time ExpressJS (really finalhandler) gets to terminate the stream, the null chunk has already been sent (I suspect this without proof).

That's why I attempted to inject another dummy <await>:

<@catch|error|>
  <component-that-awaits-with-settimeout-zero />
  $ throw error;
</@catch>

I was hoping that this would cause the stream to pause, enough for it to disconnect before the null chunk. In my testing, this seems to work.

I've been thinking about <@catch|err| rethrow> too, or eof, or similar. It's certainly less leaky than having to explicit throw, and having to know that an uncaught exception terminates the stream.

A better end-result, I think, is to continue rendering the rest of the page, but keep track that an error occurred at all, then corrupt the page right at the end. But I'd accept the mid-stream termination if we can't achieve the near-end-termination.

Looks like this is now handled by #2005.