Repository: electron/electron
Pull request: feat: WebFrameMain.collectJavaScriptCallStack()

#### Description of Change

Adds `WebFrameMain.collectJavaScriptCallStack()` to allow accessing the JavaScript call stack of unresponsive renderers. This is available in Chromium as part of the [Crash Reporting API.](https://wicg.github.io/crash-reporting/)

With this API, a dialog shown when a renderer becomes unresponsive could have an option to copy the stack trace. This would help application developers figure out why their website might be stuck on long-running JavaScript.

I've marked the API as experimental given that the proposal is still a draft.

-----

Here's an example of the output from a [fiddle gist](https://gist.github.com/dfddc4c3e4459f097ca7f9bb35218d88):
```
Renderer unresponsive
 
    at startInfiniteLoop (<anonymous>:5:5)
    at HTMLHtmlElement.clickHandler (<anonymous>:11:5)
```

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant documentation, tutorials, templates and examples are changed or added
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Added `WebFrameMain.collectJavaScriptCallStack()` for accessing the JavaScript call stack of unresponsive renderers.


Changed files:
filename: docs/api/web-frame-main.md
patch: @@ -142,6 +142,29 @@ ipcRenderer.on('port', (e, msg) => {
 })
 ```
 
+#### `frame.collectJavaScriptCallStack()` _Experimental_
+
+Returns `Promise<string> | Promise<void>` - A promise that resolves with the currently running JavaScript call
+stack. If no JavaScript runs in the frame, the promise will never resolve. In cases where the call stack is
+otherwise unable to be collected, it will return `undefined`.
+
+This can be useful to determine why the frame is unresponsive in cases where there's long-running JavaScript.
+For more information, see the [proposed Crash Reporting API.](https://wicg.github.io/crash-reporting/)
+
+```js
+const { app } = require('electron')
+
+app.commandLine.appendSwitch('enable-features', 'DocumentPolicyIncludeJSCallStacksInCrashReports')
+
+app.on('web-contents-created', (_, webContents) => {
+  webContents.on('unresponsive', async () => {
+    // Interrupt execution and collect call stack from unresponsive renderer
+    const callStack = await webContents.mainFrame.collectJavaScriptCallStack()
+    console.log('Renderer unresponsive\n', callStack)
+  })
+})
+```
+
 ### Instance Properties
 
 #### `frame.ipc` _Readonly_

filename: shell/browser/api/electron_api_web_frame_main.cc
patch: @@ -9,9 +9,11 @@
 #include <utility>
 #include <vector>
 
+#include "base/feature_list.h"
 #include "base/logging.h"
 #include "base/no_destructor.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"  // nogncheck
+#include "content/browser/renderer_host/render_process_host_impl.h"  // nogncheck
 #include "content/public/browser/frame_tree_node_id.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/common/isolated_world_ids.h"
@@ -429,6 +431,61 @@ std::vector<content::RenderFrameHost*> WebFrameMain::FramesInSubtree() const {
   return frame_hosts;
 }
 
+v8::Local<v8::Promise> WebFrameMain::CollectDocumentJSCallStack(
+    gin::Arguments* args) {
+  gin_helper::Promise<base::Value> promise(args->isolate());
+  v8::Local<v8::Promise> handle = promise.GetHandle();
+
+  if (render_frame_disposed_) {
+    promise.RejectWithErrorMessage(
+        "Render frame was disposed before WebFrameMain could be accessed");
+    return handle;
+  }
+
+  if (!base::FeatureList::IsEnabled(
+          blink::features::kDocumentPolicyIncludeJSCallStacksInCrashReports)) {
+    promise.RejectWithErrorMessage(
+        "DocumentPolicyIncludeJSCallStacksInCrashReports is not enabled");
+    return handle;
+  }
+
+  content::RenderProcessHostImpl* rph_impl =
+      static_cast<content::RenderProcessHostImpl*>(render_frame_->GetProcess());
+
+  rph_impl->GetJavaScriptCallStackGeneratorInterface()
+      ->CollectJavaScriptCallStack(
+          base::BindOnce(&WebFrameMain::CollectedJavaScriptCallStack,
+                         weak_factory_.GetWeakPtr(), std::move(promise)));
+
+  return handle;
+}
+
+void WebFrameMain::CollectedJavaScriptCallStack(
+    gin_helper::Promise<base::Value> promise,
+    const std::string& untrusted_javascript_call_stack,
+    const std::optional<blink::LocalFrameToken>& remote_frame_token) {
+  if (render_frame_disposed_) {
+    promise.RejectWithErrorMessage(
+        "Render frame was disposed before call stack was received");
+    return;
+  }
+
+  const blink::LocalFrameToken& frame_token = render_frame_->GetFrameToken();
+  if (remote_frame_token == frame_token) {
+    base::Value base_value(untrusted_javascript_call_stack);
+    promise.Resolve(base_value);
+  } else if (!remote_frame_token) {
+    // Failed to collect call stack. See logic in:
+    // third_party/blink/renderer/controller/javascript_call_stack_collector.cc
+    promise.Resolve(base::Value());
+  } else {
+    // Requests for call stacks can be initiated on an old RenderProcessHost
+    // then be received after a frame swap.
+    LOG(ERROR) << "Received call stack from old RPH";
+    promise.Resolve(base::Value());
+  }
+}
+
 void WebFrameMain::DOMContentLoaded() {
   Emit("dom-ready");
 }
@@ -461,6 +518,8 @@ void WebFrameMain::FillObjectTemplate(v8::Isolate* isolate,
                                       v8::Local<v8::ObjectTemplate> templ) {
   gin_helper::ObjectTemplateBuilder(isolate, templ)
       .SetMethod("executeJavaScript", &WebFrameMain::ExecuteJavaScript)
+      .SetMethod("collectJavaScriptCallStack",
+                 &WebFrameMain::CollectDocumentJSCallStack)
       .SetMethod("reload", &WebFrameMain::Reload)
       .SetMethod("isDestroyed", &WebFrameMain::IsDestroyed)
       .SetMethod("_send", &WebFrameMain::Send)

filename: shell/browser/api/electron_api_web_frame_main.h
patch: @@ -36,6 +36,11 @@ template <typename T>
 class Handle;
 }  // namespace gin
 
+namespace gin_helper {
+template <typename T>
+class Promise;
+}  // namespace gin_helper
+
 namespace electron::api {
 
 class WebContents;
@@ -128,6 +133,12 @@ class WebFrameMain final : public gin::Wrappable<WebFrameMain>,
   std::vector<content::RenderFrameHost*> Frames() const;
   std::vector<content::RenderFrameHost*> FramesInSubtree() const;
 
+  v8::Local<v8::Promise> CollectDocumentJSCallStack(gin::Arguments* args);
+  void CollectedJavaScriptCallStack(
+      gin_helper::Promise<base::Value> promise,
+      const std::string& untrusted_javascript_call_stack,
+      const std::optional<blink::LocalFrameToken>& remote_frame_token);
+
   void DOMContentLoaded();
 
   mojo::Remote<mojom::ElectronRenderer> renderer_api_;

filename: spec/api-web-frame-main-spec.ts
patch: @@ -21,8 +21,16 @@ describe('webFrameMain module', () => {
   type Server = { server: http.Server, url: string, crossOriginUrl: string }
 
   /** Creates an HTTP server whose handler embeds the given iframe src. */
-  const createServer = async (): Promise<Server> => {
+  const createServer = async (options: {
+    headers?: Record<string, string>
+  } = {}): Promise<Server> => {
     const server = http.createServer((req, res) => {
+      if (options.headers) {
+        for (const [k, v] of Object.entries(options.headers)) {
+          res.setHeader(k, v);
+        }
+      }
+
       const params = new URLSearchParams(new URL(req.url || '', `http://${req.headers.host}`).search || '');
       if (params.has('frameSrc')) {
         res.end(`<iframe src="${params.get('frameSrc')}"></iframe>`);
@@ -444,6 +452,29 @@ describe('webFrameMain module', () => {
     });
   });
 
+  describe('webFrameMain.collectJavaScriptCallStack', () => {
+    let server: Server;
+    before(async () => {
+      server = await createServer({
+        headers: {
+          'Document-Policy': 'include-js-call-stacks-in-crash-reports'
+        }
+      });
+    });
+    after(() => {
+      server.server.close();
+    });
+
+    it('collects call stack during JS execution', async () => {
+      const w = new BrowserWindow({ show: false });
+      await w.loadURL(server.url);
+      const callStackPromise = w.webContents.mainFrame.collectJavaScriptCallStack();
+      w.webContents.mainFrame.executeJavaScript('"run a lil js"');
+      const callStack = await callStackPromise;
+      expect(callStack).to.be.a('string');
+    });
+  });
+
   describe('"frame-created" event', () => {
     it('emits when the main frame is created', async () => {
       const w = new BrowserWindow({ show: false });

filename: spec/index.js
patch: @@ -33,6 +33,12 @@ app.commandLine.appendSwitch('host-resolver-rules', [
   'MAP notfound.localhost2 ~NOTFOUND'
 ].join(', '));
 
+// Enable features required by tests.
+app.commandLine.appendSwitch('enable-features', [
+  // spec/api-web-frame-main-spec.ts
+  'DocumentPolicyIncludeJSCallStacksInCrashReports'
+].join(','));
+
 global.standardScheme = 'app';
 global.zoomScheme = 'zoom';
 global.serviceWorkerScheme = 'sw';