Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for parsing Wasm stack frames of Chrome (V8), Firefox, Safari #159

Merged
merged 25 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## 1.11.2-wip

* Require Dart 3.1 or greater
* Added support for parsing Wasm frames of Chrome (V8), Firefox, Safari.

## 1.11.1

Expand Down
208 changes: 147 additions & 61 deletions lib/src/frame.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,38 @@ final _vmFrame = RegExp(r'^#\d+\s+(\S.*) \((.+?)((?::\d+){0,2})\)$');
// at VW.call$0 (eval as fn
// (https://example.com/stuff.dart.js:560:28), efn:3:28)
// at https://example.com/stuff.dart.js:560:28
final _v8Frame =
final _v8JsFrame =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(While here, it may be worth saying that these regexps are applied to individual lines. That's why the ^ doesn't require multiLine: true and why it's not a problem that [^\]+ can match newlines. Helps the reader set expectations.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's clear from the use sites a few lines below that these parse a single "frame", as passed to factories like Frame.parseVM(String frame).

However the exact format of those "frame"s is not described by this library even though the Frame type and its factories are public.

It would be good to describe the format for the users who may want to parse a single frame.

Let's do it separately though.

RegExp(r'^\s*at (?:(\S.*?)(?: \[as [^\]]+\])? \((.*)\)|(.*))$');

// https://example.com/stuff.dart.js:560:28
// https://example.com/stuff.dart.js:560
final _v8UrlLocation = RegExp(r'^(.*?):(\d+)(?::(\d+))?$|native$');
//
// Group 1: URI, required
// Group 2: line number, required
// Group 3: column number, optional
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(If you are changing things anyway, consider using named capture groups here too, for consistency. Or not, can be done at any later time too.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do unrelated changes separately. It helps with debugging, bisecting and reverting.

final _v8JsUrlLocation = RegExp(r'^(.*?):(\d+)(?::(\d+))?$|native$');

// With names:
//
// at Error.f (wasm://wasm/0006d966:wasm-function[119]:0xbb13)
// at g (wasm://wasm/0006d966:wasm-function[796]:0x143b4)
//
// Without names:
//
// at wasm://wasm/0005168a:wasm-function[119]:0xbb13
// at wasm://wasm/0005168a:wasm-function[796]:0x143b4
//
// Group 1: Function name, optional
//
// When group 1 is available:
osa1 marked this conversation as resolved.
Show resolved Hide resolved
// Group 2: URI
// Group 3: Function index
//
// Otherwise:
// Group 4: URI
// Group 5: function index
final _v8WasmFrame = RegExp(r'^\s*at(?: (\S+))? '
r'(?:\((wasm:\S+\[(\d+)\]\S+)\)|(wasm:\S+\[(\d+)\]\S+))$');

// eval as function (https://example.com/stuff.dart.js:560:28), efn:3:28
// eval as function (https://example.com/stuff.dart.js:560:28)
Expand All @@ -41,7 +67,7 @@ final _firefoxEvalLocation =
// .VW.call$0/name<@https://example.com/stuff.dart.js:560
// .VW.call$0@https://example.com/stuff.dart.js:560:36
// https://example.com/stuff.dart.js:560
final _firefoxSafariFrame = RegExp(r'^'
final _firefoxSafariJSFrame = RegExp(r'^'
r'(?:' // Member description. Not present in some Safari frames.
r'([^@(/]*)' // The actual name of the member.
r'(?:\(.*\))?' // Arguments to the member, sometimes captured by Firefox.
Expand All @@ -56,6 +82,38 @@ final _firefoxSafariFrame = RegExp(r'^'
// empty in Safari if it's unknown.
r'$');

// With names:
//
// g@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4
// f@http://localhost:8080/test.wasm:wasm-function[795]:0x143a8
// main@http://localhost:8080/test.wasm:wasm-function[792]:0x14390
//
// Without names:
//
// @http://localhost:8080/test.wasm:wasm-function[796]:0x143b4
// @http://localhost:8080/test.wasm:wasm-function[795]:0x143a8
// @http://localhost:8080/test.wasm:wasm-function[792]:0x14390
//
// Group 1: Function name, may be empty.
// Group 2: URI after the '@'.
// Group 3: Function index.
final _firefoxWasmFrame = RegExp(r'^(.*)@(.*\[(\d+)\]:.*)$');

// With names:
//
// <?>.wasm-function[g]@[wasm code]
osa1 marked this conversation as resolved.
Show resolved Hide resolved
// <?>.wasm-function[f]@[wasm code]
// <?>.wasm-function[main]@[wasm code]
//
// Without names:
//
// <?>.wasm-function[796]@[wasm code]
// <?>.wasm-function[795]@[wasm code]
// <?>.wasm-function[792]@[wasm code]
//
// Group 1: Function name or index.
final _safariWasmFrame = RegExp(r'^.*\[(.*)\]@\[wasm code\]$');

// foo/bar.dart 10:11 Foo._bar
// foo/bar.dart 10:11 (anonymous function).dart.fn
// https://dart.dev/foo/bar.dart Foo._bar
Expand Down Expand Up @@ -163,48 +221,59 @@ class Frame {

/// Parses a string representation of a Chrome/V8 stack frame.
factory Frame.parseV8(String frame) => _catchFormatException(frame, () {
var match = _v8Frame.firstMatch(frame);
if (match == null) return UnparsedFrame(frame);
// Try to match a Wasm frame first: the Wasm frame regex won't match a
// JS frame but the JS frame regex may match a Wasm frame.
var match = _v8WasmFrame.firstMatch(frame);
if (match != null) {
final member = match[1];
final uri = _uriOrPathToUri(member != null ? match[2]! : match[4]!);
return Frame(uri, null, null, member ?? match[5]!);
}

// v8 location strings can be arbitrarily-nested, since it adds a layer
// of nesting for each eval performed on that line.
Frame parseLocation(String location, String member) {
var evalMatch = _v8EvalLocation.firstMatch(location);
while (evalMatch != null) {
location = evalMatch[1]!;
evalMatch = _v8EvalLocation.firstMatch(location);
match = _v8JsFrame.firstMatch(frame);
if (match != null) {
// v8 location strings can be arbitrarily-nested, since it adds a
// layer of nesting for each eval performed on that line.
Frame parseJsLocation(String location, String member) {
var evalMatch = _v8EvalLocation.firstMatch(location);
while (evalMatch != null) {
location = evalMatch[1]!;
evalMatch = _v8EvalLocation.firstMatch(location);
}

if (location == 'native') {
return Frame(Uri.parse('native'), null, null, member);
}

var urlMatch = _v8JsUrlLocation.firstMatch(location);
if (urlMatch == null) return UnparsedFrame(frame);

final uri = _uriOrPathToUri(urlMatch[1]!);
final line = int.parse(urlMatch[2]!);
final columnMatch = urlMatch[3];
final column = columnMatch != null ? int.parse(columnMatch) : null;
return Frame(uri, line, column, member);
}

if (location == 'native') {
return Frame(Uri.parse('native'), null, null, member);
// V8 stack frames can be in two forms.
if (match[2] != null) {
// The first form looks like " at FUNCTION (LOCATION)". V8 proper
// lists anonymous functions within eval as "<anonymous>", while
// IE10 lists them as "Anonymous function".
return parseJsLocation(
match[2]!,
match[1]!
.replaceAll('<anonymous>', '<fn>')
.replaceAll('Anonymous function', '<fn>')
.replaceAll('(anonymous function)', '<fn>'));
} else {
// The second form looks like " at LOCATION", and is used for
// anonymous functions.
return parseJsLocation(match[3]!, '<fn>');
}

var urlMatch = _v8UrlLocation.firstMatch(location);
if (urlMatch == null) return UnparsedFrame(frame);

final uri = _uriOrPathToUri(urlMatch[1]!);
final line = int.parse(urlMatch[2]!);
final columnMatch = urlMatch[3];
final column = columnMatch != null ? int.parse(columnMatch) : null;
return Frame(uri, line, column, member);
}

// V8 stack frames can be in two forms.
if (match[2] != null) {
// The first form looks like " at FUNCTION (LOCATION)". V8 proper
// lists anonymous functions within eval as "<anonymous>", while IE10
// lists them as "Anonymous function".
return parseLocation(
match[2]!,
match[1]!
.replaceAll('<anonymous>', '<fn>')
.replaceAll('Anonymous function', '<fn>')
.replaceAll('(anonymous function)', '<fn>'));
} else {
// The second form looks like " at LOCATION", and is used for
// anonymous functions.
return parseLocation(match[3]!, '<fn>');
}
return UnparsedFrame(frame);
});

/// Parses a string representation of a JavaScriptCore stack trace.
Expand Down Expand Up @@ -234,35 +303,52 @@ class Frame {
return Frame(uri, line, null, member);
});

/// Parses a string representation of a Firefox stack frame.
/// Parses a string representation of a Firefox or Safari stack frame.
factory Frame.parseFirefox(String frame) => _catchFormatException(frame, () {
var match = _firefoxSafariFrame.firstMatch(frame);
if (match == null) return UnparsedFrame(frame);
var match = _firefoxSafariJSFrame.firstMatch(frame);
if (match != null) {
if (match[3]!.contains(' line ')) {
return Frame._parseFirefoxEval(frame);
}

if (match[3]!.contains(' line ')) {
return Frame._parseFirefoxEval(frame);
}
// Normally this is a URI, but in a jsshell trace it can be a path.
var uri = _uriOrPathToUri(match[3]!);

// Normally this is a URI, but in a jsshell trace it can be a path.
var uri = _uriOrPathToUri(match[3]!);
var member = match[1];
if (member != null) {
member +=
List.filled('/'.allMatches(match[2]!).length, '.<fn>').join();
if (member == '') member = '<fn>';

var member = match[1];
if (member != null) {
member +=
List.filled('/'.allMatches(match[2]!).length, '.<fn>').join();
if (member == '') member = '<fn>';
// Some Firefox members have initial dots. We remove them for
// consistency with other platforms.
member = member.replaceFirst(_initialDot, '');
} else {
member = '<fn>';
}

// Some Firefox members have initial dots. We remove them for
// consistency with other platforms.
member = member.replaceFirst(_initialDot, '');
} else {
member = '<fn>';
var line = match[4] == '' ? null : int.parse(match[4]!);
var column =
match[5] == null || match[5] == '' ? null : int.parse(match[5]!);
return Frame(uri, line, column, member);
}

var line = match[4] == '' ? null : int.parse(match[4]!);
var column =
match[5] == null || match[5] == '' ? null : int.parse(match[5]!);
return Frame(uri, line, column, member);
match = _firefoxWasmFrame.firstMatch(frame);
if (match != null) {
final member = match[1]!;
final uri = _uriOrPathToUri(match[2]!);
final functionIndex = match[3]!;
return Frame(
uri, null, null, member.isNotEmpty ? member : functionIndex);
}

match = _safariWasmFrame.firstMatch(frame);
if (match != null) {
final member = match[1]!;
return Frame(Uri(path: 'wasm code'), null, null, member);
}

return UnparsedFrame(frame);
});

/// Parses a string representation of a Safari 6.0 stack frame.
Expand Down
60 changes: 60 additions & 0 deletions test/frame_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,66 @@ baz@https://pub.dev/buz.js:56355:55
equals('$relative 5:10 in Foo'));
});
});

test('parses a V8 Wasm frame with a name', () {
var frame = Frame.parseV8(' at Error._throwWithCurrentStackTrace '
'(wasm://wasm/0006d966:wasm-function[119]:0xbb13)');
expect(
frame.uri, Uri.parse('wasm://wasm/0006d966:wasm-function[119]:0xbb13'));
expect(frame.line, null);
expect(frame.column, null);
expect(frame.member, 'Error._throwWithCurrentStackTrace');
});

test('parses a V8 Wasm frame without a name', () {
var frame =
Frame.parseV8(' at wasm://wasm/0006d966:wasm-function[119]:0xbb13');
expect(
frame.uri, Uri.parse('wasm://wasm/0006d966:wasm-function[119]:0xbb13'));
expect(frame.line, null);
expect(frame.column, null);
expect(frame.member, '119');
});

test('parses a Firefox Wasm frame with a name', () {
var frame = Frame.parseFirefox(
'g@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4');
expect(
frame.uri,
Uri.parse(
'http://localhost:8080/test.wasm:wasm-function[796]:0x143b4'));
expect(frame.line, null);
expect(frame.column, null);
expect(frame.member, 'g');
});

test('parses a Firefox Wasm frame without a name', () {
var frame = Frame.parseFirefox(
'@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4');
expect(
frame.uri,
Uri.parse(
'http://localhost:8080/test.wasm:wasm-function[796]:0x143b4'));
expect(frame.line, null);
expect(frame.column, null);
expect(frame.member, '796');
});

test('parses a Safari Wasm frame with a name', () {
var frame = Frame.parseSafari('<?>.wasm-function[g]@[wasm code]');
expect(frame.uri, Uri.parse('wasm code'));
expect(frame.line, null);
expect(frame.column, null);
expect(frame.member, 'g');
});

test('parses a Safari Wasm frame without a name', () {
var frame = Frame.parseSafari('<?>.wasm-function[796]@[wasm code]');
expect(frame.uri, Uri.parse('wasm code'));
expect(frame.line, null);
expect(frame.column, null);
expect(frame.member, '796');
});
osa1 marked this conversation as resolved.
Show resolved Hide resolved
}

void expectIsUnparsed(Frame Function(String) constructor, String text) {
Expand Down