Skip to content

Commit 875fad0

Browse files
authored
Add support for parsing Wasm stack frames of Chrome (V8), Firefox, Safari (dart-archive/stack_trace#159)
Instead of updating existing regexes to handle new formats, this adds new regexes for Wasm frames. Parser functions are updated to try to parse the frame with Wasm regexes. Column numbers can be used by the `source_map_stack_trace` package to map Wasm frames to source locations. Tests added for parsing all combinations of: - Frames with and without function names - In Chrome, Firefox, Safari formats
1 parent 145be47 commit 875fad0

File tree

4 files changed

+377
-61
lines changed

4 files changed

+377
-61
lines changed

pkgs/stack_trace/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## 1.11.2-wip
22

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

56
## 1.11.1
67

pkgs/stack_trace/lib/src/frame.dart

Lines changed: 173 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,39 @@ final _vmFrame = RegExp(r'^#\d+\s+(\S.*) \((.+?)((?::\d+){0,2})\)$');
1717
// at VW.call$0 (eval as fn
1818
// (https://example.com/stuff.dart.js:560:28), efn:3:28)
1919
// at https://example.com/stuff.dart.js:560:28
20-
final _v8Frame =
20+
final _v8JsFrame =
2121
RegExp(r'^\s*at (?:(\S.*?)(?: \[as [^\]]+\])? \((.*)\)|(.*))$');
2222

2323
// https://example.com/stuff.dart.js:560:28
2424
// https://example.com/stuff.dart.js:560
25-
final _v8UrlLocation = RegExp(r'^(.*?):(\d+)(?::(\d+))?$|native$');
25+
//
26+
// Group 1: URI, required
27+
// Group 2: line number, required
28+
// Group 3: column number, optional
29+
final _v8JsUrlLocation = RegExp(r'^(.*?):(\d+)(?::(\d+))?$|native$');
30+
31+
// With names:
32+
//
33+
// at Error.f (wasm://wasm/0006d966:wasm-function[119]:0xbb13)
34+
// at g (wasm://wasm/0006d966:wasm-function[796]:0x143b4)
35+
//
36+
// Without names:
37+
//
38+
// at wasm://wasm/0005168a:wasm-function[119]:0xbb13
39+
// at wasm://wasm/0005168a:wasm-function[796]:0x143b4
40+
//
41+
// Matches named groups:
42+
//
43+
// - "member": optional, `Error.f` in the first example, NA in the second.
44+
// - "uri": `wasm://wasm/0006d966`.
45+
// - "index": `119`.
46+
// - "offset": (hex number) `bb13`.
47+
//
48+
// To avoid having multiple groups for the same part of the frame, this regex
49+
// matches unmatched parentheses after the member name.
50+
final _v8WasmFrame = RegExp(r'^\s*at (?:(?<member>.+) )?'
51+
r'(?:\(?(?:(?<uri>wasm:\S+):wasm-function\[(?<index>\d+)\]'
52+
r'\:0x(?<offset>[0-9a-fA-F]+))\)?)$');
2653

2754
// eval as function (https://example.com/stuff.dart.js:560:28), efn:3:28
2855
// eval as function (https://example.com/stuff.dart.js:560:28)
@@ -41,7 +68,7 @@ final _firefoxEvalLocation =
4168
// .VW.call$0/name<@https://example.com/stuff.dart.js:560
4269
// .VW.call$0@https://example.com/stuff.dart.js:560:36
4370
// https://example.com/stuff.dart.js:560
44-
final _firefoxSafariFrame = RegExp(r'^'
71+
final _firefoxSafariJSFrame = RegExp(r'^'
4572
r'(?:' // Member description. Not present in some Safari frames.
4673
r'([^@(/]*)' // The actual name of the member.
4774
r'(?:\(.*\))?' // Arguments to the member, sometimes captured by Firefox.
@@ -56,6 +83,58 @@ final _firefoxSafariFrame = RegExp(r'^'
5683
// empty in Safari if it's unknown.
5784
r'$');
5885

86+
// With names:
87+
//
88+
// g@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4
89+
// f@http://localhost:8080/test.wasm:wasm-function[795]:0x143a8
90+
// main@http://localhost:8080/test.wasm:wasm-function[792]:0x14390
91+
//
92+
// Without names:
93+
//
94+
// @http://localhost:8080/test.wasm:wasm-function[796]:0x143b4
95+
// @http://localhost:8080/test.wasm:wasm-function[795]:0x143a8
96+
// @http://localhost:8080/test.wasm:wasm-function[792]:0x14390
97+
//
98+
// JSShell in the command line uses a different format, which this regex also
99+
// parses.
100+
//
101+
// With names:
102+
//
103+
// main@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378
104+
//
105+
// Without names:
106+
//
107+
// @/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378
108+
//
109+
// Matches named groups:
110+
//
111+
// - "member": Function name, may be empty: `g`.
112+
// - "uri": `http://localhost:8080/test.wasm`.
113+
// - "index": `796`.
114+
// - "offset": (in hex) `143b4`.
115+
final _firefoxWasmFrame =
116+
RegExp(r'^(?<member>.*?)@(?:(?<uri>\S+).*?:wasm-function'
117+
r'\[(?<index>\d+)\]:0x(?<offset>[0-9a-fA-F]+))$');
118+
119+
// With names:
120+
//
121+
// (Note: Lines below are literal text, e.g. <?> is not a placeholder, it's a
122+
// part of the stack frame.)
123+
//
124+
// <?>.wasm-function[g]@[wasm code]
125+
// <?>.wasm-function[f]@[wasm code]
126+
// <?>.wasm-function[main]@[wasm code]
127+
//
128+
// Without names:
129+
//
130+
// <?>.wasm-function[796]@[wasm code]
131+
// <?>.wasm-function[795]@[wasm code]
132+
// <?>.wasm-function[792]@[wasm code]
133+
//
134+
// Matches named group "member": `g` or `796`.
135+
final _safariWasmFrame =
136+
RegExp(r'^.*?wasm-function\[(?<member>.*)\]@\[wasm code\]$');
137+
59138
// foo/bar.dart 10:11 Foo._bar
60139
// foo/bar.dart 10:11 (anonymous function).dart.fn
61140
// https://dart.dev/foo/bar.dart Foo._bar
@@ -163,48 +242,62 @@ class Frame {
163242

164243
/// Parses a string representation of a Chrome/V8 stack frame.
165244
factory Frame.parseV8(String frame) => _catchFormatException(frame, () {
166-
var match = _v8Frame.firstMatch(frame);
167-
if (match == null) return UnparsedFrame(frame);
245+
// Try to match a Wasm frame first: the Wasm frame regex won't match a
246+
// JS frame but the JS frame regex may match a Wasm frame.
247+
var match = _v8WasmFrame.firstMatch(frame);
248+
if (match != null) {
249+
final member = match.namedGroup('member');
250+
final uri = _uriOrPathToUri(match.namedGroup('uri')!);
251+
final functionIndex = match.namedGroup('index')!;
252+
final functionOffset =
253+
int.parse(match.namedGroup('offset')!, radix: 16);
254+
return Frame(uri, 1, functionOffset + 1, member ?? functionIndex);
255+
}
168256

169-
// v8 location strings can be arbitrarily-nested, since it adds a layer
170-
// of nesting for each eval performed on that line.
171-
Frame parseLocation(String location, String member) {
172-
var evalMatch = _v8EvalLocation.firstMatch(location);
173-
while (evalMatch != null) {
174-
location = evalMatch[1]!;
175-
evalMatch = _v8EvalLocation.firstMatch(location);
257+
match = _v8JsFrame.firstMatch(frame);
258+
if (match != null) {
259+
// v8 location strings can be arbitrarily-nested, since it adds a
260+
// layer of nesting for each eval performed on that line.
261+
Frame parseJsLocation(String location, String member) {
262+
var evalMatch = _v8EvalLocation.firstMatch(location);
263+
while (evalMatch != null) {
264+
location = evalMatch[1]!;
265+
evalMatch = _v8EvalLocation.firstMatch(location);
266+
}
267+
268+
if (location == 'native') {
269+
return Frame(Uri.parse('native'), null, null, member);
270+
}
271+
272+
var urlMatch = _v8JsUrlLocation.firstMatch(location);
273+
if (urlMatch == null) return UnparsedFrame(frame);
274+
275+
final uri = _uriOrPathToUri(urlMatch[1]!);
276+
final line = int.parse(urlMatch[2]!);
277+
final columnMatch = urlMatch[3];
278+
final column = columnMatch != null ? int.parse(columnMatch) : null;
279+
return Frame(uri, line, column, member);
176280
}
177281

178-
if (location == 'native') {
179-
return Frame(Uri.parse('native'), null, null, member);
282+
// V8 stack frames can be in two forms.
283+
if (match[2] != null) {
284+
// The first form looks like " at FUNCTION (LOCATION)". V8 proper
285+
// lists anonymous functions within eval as "<anonymous>", while
286+
// IE10 lists them as "Anonymous function".
287+
return parseJsLocation(
288+
match[2]!,
289+
match[1]!
290+
.replaceAll('<anonymous>', '<fn>')
291+
.replaceAll('Anonymous function', '<fn>')
292+
.replaceAll('(anonymous function)', '<fn>'));
293+
} else {
294+
// The second form looks like " at LOCATION", and is used for
295+
// anonymous functions.
296+
return parseJsLocation(match[3]!, '<fn>');
180297
}
181-
182-
var urlMatch = _v8UrlLocation.firstMatch(location);
183-
if (urlMatch == null) return UnparsedFrame(frame);
184-
185-
final uri = _uriOrPathToUri(urlMatch[1]!);
186-
final line = int.parse(urlMatch[2]!);
187-
final columnMatch = urlMatch[3];
188-
final column = columnMatch != null ? int.parse(columnMatch) : null;
189-
return Frame(uri, line, column, member);
190298
}
191299

192-
// V8 stack frames can be in two forms.
193-
if (match[2] != null) {
194-
// The first form looks like " at FUNCTION (LOCATION)". V8 proper
195-
// lists anonymous functions within eval as "<anonymous>", while IE10
196-
// lists them as "Anonymous function".
197-
return parseLocation(
198-
match[2]!,
199-
match[1]!
200-
.replaceAll('<anonymous>', '<fn>')
201-
.replaceAll('Anonymous function', '<fn>')
202-
.replaceAll('(anonymous function)', '<fn>'));
203-
} else {
204-
// The second form looks like " at LOCATION", and is used for
205-
// anonymous functions.
206-
return parseLocation(match[3]!, '<fn>');
207-
}
300+
return UnparsedFrame(frame);
208301
});
209302

210303
/// Parses a string representation of a JavaScriptCore stack trace.
@@ -237,35 +330,54 @@ class Frame {
237330
return Frame(uri, line, null, member);
238331
});
239332

240-
/// Parses a string representation of a Firefox stack frame.
333+
/// Parses a string representation of a Firefox or Safari stack frame.
241334
factory Frame.parseFirefox(String frame) => _catchFormatException(frame, () {
242-
var match = _firefoxSafariFrame.firstMatch(frame);
243-
if (match == null) return UnparsedFrame(frame);
335+
var match = _firefoxSafariJSFrame.firstMatch(frame);
336+
if (match != null) {
337+
if (match[3]!.contains(' line ')) {
338+
return Frame._parseFirefoxEval(frame);
339+
}
244340

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

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

252-
var member = match[1];
253-
if (member != null) {
254-
member +=
255-
List.filled('/'.allMatches(match[2]!).length, '.<fn>').join();
256-
if (member == '') member = '<fn>';
350+
// Some Firefox members have initial dots. We remove them for
351+
// consistency with other platforms.
352+
member = member.replaceFirst(_initialDot, '');
353+
} else {
354+
member = '<fn>';
355+
}
257356

258-
// Some Firefox members have initial dots. We remove them for
259-
// consistency with other platforms.
260-
member = member.replaceFirst(_initialDot, '');
261-
} else {
262-
member = '<fn>';
357+
var line = match[4] == '' ? null : int.parse(match[4]!);
358+
var column =
359+
match[5] == null || match[5] == '' ? null : int.parse(match[5]!);
360+
return Frame(uri, line, column, member);
263361
}
264362

265-
var line = match[4] == '' ? null : int.parse(match[4]!);
266-
var column =
267-
match[5] == null || match[5] == '' ? null : int.parse(match[5]!);
268-
return Frame(uri, line, column, member);
363+
match = _firefoxWasmFrame.firstMatch(frame);
364+
if (match != null) {
365+
final member = match.namedGroup('member')!;
366+
final uri = _uriOrPathToUri(match.namedGroup('uri')!);
367+
final functionIndex = match.namedGroup('index')!;
368+
final functionOffset =
369+
int.parse(match.namedGroup('offset')!, radix: 16);
370+
return Frame(uri, 1, functionOffset + 1,
371+
member.isNotEmpty ? member : functionIndex);
372+
}
373+
374+
match = _safariWasmFrame.firstMatch(frame);
375+
if (match != null) {
376+
final member = match.namedGroup('member')!;
377+
return Frame(Uri(path: 'wasm code'), null, null, member);
378+
}
379+
380+
return UnparsedFrame(frame);
269381
});
270382

271383
/// Parses a string representation of a Safari 6.0 stack frame.

pkgs/stack_trace/test/frame_test.dart

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,85 @@ baz@https://pub.dev/buz.js:56355:55
632632
equals('$relative 5:10 in Foo'));
633633
});
634634
});
635+
636+
test('parses a V8 Wasm frame with a name', () {
637+
var frame = Frame.parseV8(' at Error._throwWithCurrentStackTrace '
638+
'(wasm://wasm/0006d966:wasm-function[119]:0xbb13)');
639+
expect(frame.uri, Uri.parse('wasm://wasm/0006d966'));
640+
expect(frame.line, 1);
641+
expect(frame.column, 0xbb13 + 1);
642+
expect(frame.member, 'Error._throwWithCurrentStackTrace');
643+
});
644+
645+
test('parses a V8 Wasm frame with a name with spaces', () {
646+
var frame = Frame.parseV8(' at main tear-off trampoline '
647+
'(wasm://wasm/0017fbea:wasm-function[863]:0x23cc8)');
648+
expect(frame.uri, Uri.parse('wasm://wasm/0017fbea'));
649+
expect(frame.line, 1);
650+
expect(frame.column, 0x23cc8 + 1);
651+
expect(frame.member, 'main tear-off trampoline');
652+
});
653+
654+
test('parses a V8 Wasm frame without a name', () {
655+
var frame =
656+
Frame.parseV8(' at wasm://wasm/0006d966:wasm-function[119]:0xbb13');
657+
expect(frame.uri, Uri.parse('wasm://wasm/0006d966'));
658+
expect(frame.line, 1);
659+
expect(frame.column, 0xbb13 + 1);
660+
expect(frame.member, '119');
661+
});
662+
663+
test('parses a Firefox Wasm frame with a name', () {
664+
var frame = Frame.parseFirefox(
665+
'g@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4');
666+
expect(frame.uri, Uri.parse('http://localhost:8080/test.wasm'));
667+
expect(frame.line, 1);
668+
expect(frame.column, 0x143b4 + 1);
669+
expect(frame.member, 'g');
670+
});
671+
672+
test('parses a Firefox Wasm frame with a name with spaces', () {
673+
var frame = Frame.parseFirefox(
674+
'main tear-off trampoline@http://localhost:8080/test.wasm:wasm-function[794]:0x14387');
675+
expect(frame.uri, Uri.parse('http://localhost:8080/test.wasm'));
676+
expect(frame.line, 1);
677+
expect(frame.column, 0x14387 + 1);
678+
expect(frame.member, 'main tear-off trampoline');
679+
});
680+
681+
test('parses a Firefox Wasm frame without a name', () {
682+
var frame = Frame.parseFirefox(
683+
'@http://localhost:8080/test.wasm:wasm-function[796]:0x143b4');
684+
expect(frame.uri, Uri.parse('http://localhost:8080/test.wasm'));
685+
expect(frame.line, 1);
686+
expect(frame.column, 0x143b4 + 1);
687+
expect(frame.member, '796');
688+
});
689+
690+
test('parses a Safari Wasm frame with a name', () {
691+
var frame = Frame.parseSafari('<?>.wasm-function[g]@[wasm code]');
692+
expect(frame.uri, Uri.parse('wasm code'));
693+
expect(frame.line, null);
694+
expect(frame.column, null);
695+
expect(frame.member, 'g');
696+
});
697+
698+
test('parses a Safari Wasm frame with a name', () {
699+
var frame = Frame.parseSafari(
700+
'<?>.wasm-function[main tear-off trampoline]@[wasm code]');
701+
expect(frame.uri, Uri.parse('wasm code'));
702+
expect(frame.line, null);
703+
expect(frame.column, null);
704+
expect(frame.member, 'main tear-off trampoline');
705+
});
706+
707+
test('parses a Safari Wasm frame without a name', () {
708+
var frame = Frame.parseSafari('<?>.wasm-function[796]@[wasm code]');
709+
expect(frame.uri, Uri.parse('wasm code'));
710+
expect(frame.line, null);
711+
expect(frame.column, null);
712+
expect(frame.member, '796');
713+
});
635714
}
636715

637716
void expectIsUnparsed(Frame Function(String) constructor, String text) {

0 commit comments

Comments
 (0)