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', () {