From 9b1ed4f4b30723a718fb43ec036a588b2f5f4f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Sinan=20A=C4=9Facan?= Date: Thu, 19 Sep 2024 10:42:38 +0200 Subject: [PATCH] Add support for parsing Wasm stack frames of Chrome (V8), Firefox, Safari (#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 --- CHANGELOG.md | 1 + lib/src/frame.dart | 234 ++++++++++++++++++++++++++++++++----------- test/frame_test.dart | 79 +++++++++++++++ test/trace_test.dart | 124 +++++++++++++++++++++++ 4 files changed, 377 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c4c77c..029f155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/src/frame.dart b/lib/src/frame.dart index 7cd9516..bd0a582 100644 --- a/lib/src/frame.dart +++ b/lib/src/frame.dart @@ -17,12 +17,39 @@ 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 = 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 +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 +// +// Matches named groups: +// +// - "member": optional, `Error.f` in the first example, NA in the second. +// - "uri": `wasm://wasm/0006d966`. +// - "index": `119`. +// - "offset": (hex number) `bb13`. +// +// To avoid having multiple groups for the same part of the frame, this regex +// matches unmatched parentheses after the member name. +final _v8WasmFrame = RegExp(r'^\s*at (?:(?.+) )?' + r'(?:\(?(?:(?wasm:\S+):wasm-function\[(?\d+)\]' + r'\:0x(?[0-9a-fA-F]+))\)?)$'); // 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) @@ -41,7 +68,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. @@ -56,6 +83,58 @@ 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 +// +// JSShell in the command line uses a different format, which this regex also +// parses. +// +// With names: +// +// main@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378 +// +// Without names: +// +// @/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378 +// +// Matches named groups: +// +// - "member": Function name, may be empty: `g`. +// - "uri": `http://localhost:8080/test.wasm`. +// - "index": `796`. +// - "offset": (in hex) `143b4`. +final _firefoxWasmFrame = + RegExp(r'^(?.*?)@(?:(?\S+).*?:wasm-function' + r'\[(?\d+)\]:0x(?[0-9a-fA-F]+))$'); + +// With names: +// +// (Note: Lines below are literal text, e.g. is not a placeholder, it's a +// part of the stack frame.) +// +// .wasm-function[g]@[wasm code] +// .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] +// +// Matches named group "member": `g` or `796`. +final _safariWasmFrame = + RegExp(r'^.*?wasm-function\[(?.*)\]@\[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 @@ -163,48 +242,62 @@ 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.namedGroup('member'); + final uri = _uriOrPathToUri(match.namedGroup('uri')!); + final functionIndex = match.namedGroup('index')!; + final functionOffset = + int.parse(match.namedGroup('offset')!, radix: 16); + return Frame(uri, 1, functionOffset + 1, member ?? functionIndex); + } - // 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 "", while + // IE10 lists them as "Anonymous function". + return parseJsLocation( + match[2]!, + match[1]! + .replaceAll('', '') + .replaceAll('Anonymous function', '') + .replaceAll('(anonymous function)', '')); + } else { + // The second form looks like " at LOCATION", and is used for + // anonymous functions. + return parseJsLocation(match[3]!, ''); } - - 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 "", while IE10 - // lists them as "Anonymous function". - return parseLocation( - match[2]!, - match[1]! - .replaceAll('', '') - .replaceAll('Anonymous function', '') - .replaceAll('(anonymous function)', '')); - } else { - // The second form looks like " at LOCATION", and is used for - // anonymous functions. - return parseLocation(match[3]!, ''); - } + return UnparsedFrame(frame); }); /// Parses a string representation of a JavaScriptCore stack trace. @@ -237,35 +330,54 @@ 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, '.').join(); + if (member == '') member = ''; - var member = match[1]; - if (member != null) { - member += - List.filled('/'.allMatches(match[2]!).length, '.').join(); - if (member == '') member = ''; + // Some Firefox members have initial dots. We remove them for + // consistency with other platforms. + member = member.replaceFirst(_initialDot, ''); + } else { + member = ''; + } - // Some Firefox members have initial dots. We remove them for - // consistency with other platforms. - member = member.replaceFirst(_initialDot, ''); - } else { - 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); } - 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.namedGroup('member')!; + final uri = _uriOrPathToUri(match.namedGroup('uri')!); + final functionIndex = match.namedGroup('index')!; + final functionOffset = + int.parse(match.namedGroup('offset')!, radix: 16); + return Frame(uri, 1, functionOffset + 1, + member.isNotEmpty ? member : functionIndex); + } + + match = _safariWasmFrame.firstMatch(frame); + if (match != null) { + final member = match.namedGroup('member')!; + return Frame(Uri(path: 'wasm code'), null, null, member); + } + + return UnparsedFrame(frame); }); /// Parses a string representation of a Safari 6.0 stack frame. diff --git a/test/frame_test.dart b/test/frame_test.dart index 6e8df72..e62e843 100644 --- a/test/frame_test.dart +++ b/test/frame_test.dart @@ -632,6 +632,85 @@ 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')); + expect(frame.line, 1); + expect(frame.column, 0xbb13 + 1); + expect(frame.member, 'Error._throwWithCurrentStackTrace'); + }); + + test('parses a V8 Wasm frame with a name with spaces', () { + var frame = Frame.parseV8(' at main tear-off trampoline ' + '(wasm://wasm/0017fbea:wasm-function[863]:0x23cc8)'); + expect(frame.uri, Uri.parse('wasm://wasm/0017fbea')); + expect(frame.line, 1); + expect(frame.column, 0x23cc8 + 1); + expect(frame.member, 'main tear-off trampoline'); + }); + + 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')); + expect(frame.line, 1); + expect(frame.column, 0xbb13 + 1); + 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')); + expect(frame.line, 1); + expect(frame.column, 0x143b4 + 1); + expect(frame.member, 'g'); + }); + + test('parses a Firefox Wasm frame with a name with spaces', () { + var frame = Frame.parseFirefox( + 'main tear-off trampoline@http://localhost:8080/test.wasm:wasm-function[794]:0x14387'); + expect(frame.uri, Uri.parse('http://localhost:8080/test.wasm')); + expect(frame.line, 1); + expect(frame.column, 0x14387 + 1); + expect(frame.member, 'main tear-off trampoline'); + }); + + 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')); + expect(frame.line, 1); + expect(frame.column, 0x143b4 + 1); + 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 with a name', () { + var frame = Frame.parseSafari( + '.wasm-function[main tear-off trampoline]@[wasm code]'); + expect(frame.uri, Uri.parse('wasm code')); + expect(frame.line, null); + expect(frame.column, null); + expect(frame.member, 'main tear-off trampoline'); + }); + + 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'); + }); } void expectIsUnparsed(Frame Function(String) constructor, String text) { diff --git a/test/trace_test.dart b/test/trace_test.dart index d4bce76..e09de95 100644 --- a/test/trace_test.dart +++ b/test/trace_test.dart @@ -296,6 +296,130 @@ void main() { expect(trace.frames[0].uri, equals(Uri.parse('file:///pull.dart'))); expect(trace.frames[1].uri, equals(Uri.parse('dart:the/future.dart'))); }); + + test('parses a V8 stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + '\tat Error._throwWithCurrentStackTrace (wasm://wasm/0006d892:wasm-function[119]:0xbaf8)\n' + '\tat main (wasm://wasm/0006d892:wasm-function[792]:0x14378)\n' + '\tat main tear-off trampoline (wasm://wasm/0006d892:wasm-function[794]:0x14387)\n' + '\tat _invokeMain (wasm://wasm/0006d892:wasm-function[70]:0xa56c)\n' + '\tat InstantiatedApp.invokeMain (/home/user/test.mjs:361:37)\n' + '\tat main (/home/user/run_wasm.js:416:21)\n' + '\tat async action (/home/user/run_wasm.js:353:38)\n' + '\tat async eventLoop (/home/user/run_wasm.js:329:9)'); + + expect(trace.frames.length, 8); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('wasm://wasm/0006d892')); + expect(trace.frames[0].line, 1); + expect(trace.frames[0].column, 0xbaf8 + 1); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[4].line, 361); + expect(trace.frames[4].column, 37); + expect(trace.frames[4].member, 'InstantiatedApp.invokeMain'); + + expect(trace.frames[5].uri, Uri.parse('file:///home/user/run_wasm.js')); + expect(trace.frames[5].line, 416); + expect(trace.frames[5].column, 21); + expect(trace.frames[5].member, 'main'); + }); + + test('parses Firefox stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + 'Error._throwWithCurrentStackTrace@http://localhost:8080/test.wasm:wasm-function[119]:0xbaf8\n' + 'main@http://localhost:8080/test.wasm:wasm-function[792]:0x14378\n' + 'main tear-off trampoline@http://localhost:8080/test.wasm:wasm-function[794]:0x14387\n' + '_invokeMain@http://localhost:8080/test.wasm:wasm-function[70]:0xa56c\n' + 'invoke@http://localhost:8080/test.mjs:48:26'); + + expect(trace.frames.length, 5); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('http://localhost:8080/test.wasm')); + expect(trace.frames[0].line, 1); + expect(trace.frames[0].column, 0xbaf8 + 1); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('http://localhost:8080/test.mjs')); + expect(trace.frames[4].line, 48); + expect(trace.frames[4].column, 26); + expect(trace.frames[4].member, 'invoke'); + }); + + test('parses JSShell stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + 'Error._throwWithCurrentStackTrace@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[119]:0xbaf8\n' + 'main@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[792]:0x14378\n' + 'main tear-off trampoline@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[794]:0x14387\n' + '_invokeMain@/home/user/test.mjs line 29 > WebAssembly.compile:wasm-function[70]:0xa56c\n' + 'invokeMain@/home/user/test.mjs:361:37\n' + 'main@/home/user/run_wasm.js:416:21\n' + 'async*action@/home/user/run_wasm.js:353:44\n' + 'eventLoop@/home/user/run_wasm.js:329:15\n' + 'self.dartMainRunner@/home/user/run_wasm.js:354:14\n' + '@/home/user/run_wasm.js:419:15'); + + expect(trace.frames.length, 10); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[0].line, 1); + expect(trace.frames[0].column, 0xbaf8 + 1); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[4].line, 361); + expect(trace.frames[4].column, 37); + expect(trace.frames[4].member, 'invokeMain'); + + expect(trace.frames[9].uri, Uri.parse('file:///home/user/run_wasm.js')); + expect(trace.frames[9].line, 419); + expect(trace.frames[9].column, 15); + expect(trace.frames[9].member, ''); + }); + + test('parses Safari stack frace with Wasm frames correctly', () { + var trace = Trace.parse( + '.wasm-function[Error._throwWithCurrentStackTrace]@[wasm code]\n' + '.wasm-function[main]@[wasm code]\n' + '.wasm-function[main tear-off trampoline]@[wasm code]\n' + '.wasm-function[_invokeMain]@[wasm code]\n' + 'invokeMain@/home/user/test.mjs:361:48\n' + '@/home/user/run_wasm.js:416:31'); + + expect(trace.frames.length, 6); + + for (final frame in trace.frames) { + expect(frame is UnparsedFrame, false); + } + + expect(trace.frames[0].uri, Uri.parse('wasm code')); + expect(trace.frames[0].line, null); + expect(trace.frames[0].column, null); + expect(trace.frames[0].member, 'Error._throwWithCurrentStackTrace'); + + expect(trace.frames[4].uri, Uri.parse('file:///home/user/test.mjs')); + expect(trace.frames[4].line, 361); + expect(trace.frames[4].column, 48); + expect(trace.frames[4].member, 'invokeMain'); + + expect(trace.frames[5].uri, Uri.parse('file:///home/user/run_wasm.js')); + expect(trace.frames[5].line, 416); + expect(trace.frames[5].column, 31); + expect(trace.frames[5].member, ''); + }); }); test('.toString() nicely formats the stack trace', () {