Skip to content

Commit

Permalink
feat: WebFrameMain.collectJavaScriptCallStack() (electron#44204)
Browse files Browse the repository at this point in the history
* feat: WebFrameMain.unresponsiveDocumentJSCallStack

* Revert "feat: WebFrameMain.unresponsiveDocumentJSCallStack"

This reverts commit e0612bc.

* feat: frame.collectJavaScriptCallStack()

* feat: frame.collectJavaScriptCallStack()

* Update web-frame-main.md
  • Loading branch information
samuelmaddock authored Dec 3, 2024
1 parent 6d4c271 commit 2222920
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 1 deletion.
23 changes: 23 additions & 0 deletions docs/api/web-frame-main.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
59 changes: 59 additions & 0 deletions shell/browser/api/electron_api_web_frame_main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions shell/browser/api/electron_api_web_frame_main.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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_;
Expand Down
33 changes: 32 additions & 1 deletion spec/api-web-frame-main-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>`);
Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 6 additions & 0 deletions spec/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit 2222920

Please sign in to comment.