summaryrefslogtreecommitdiff
path: root/js
diff options
context:
space:
mode:
authorKitson Kelly <me@kitsonkelly.com>2019-07-29 19:11:08 +1000
committerRyan Dahl <ry@tinyclouds.org>2019-07-29 09:11:08 +0000
commit5083f5fd908057cbc8649b79c13ab78a7f7ebf34 (patch)
treed0336672fd7760593695b2e0d351c401ed1d8c02 /js
parentff96e3dc637974c6f9853f3bf9565bfd63f22b17 (diff)
Remap stack traces of unthrown errors. (#2693)
Diffstat (limited to 'js')
-rw-r--r--js/deno.ts4
-rw-r--r--js/error_stack.ts298
-rw-r--r--js/error_stack_test.ts111
-rw-r--r--js/globals.ts23
-rw-r--r--js/main.ts3
-rw-r--r--js/unit_tests.ts1
6 files changed, 440 insertions, 0 deletions
diff --git a/js/deno.ts b/js/deno.ts
index 32899e045..8dc4fd791 100644
--- a/js/deno.ts
+++ b/js/deno.ts
@@ -58,6 +58,7 @@ export { statSync, lstatSync, stat, lstat } from "./stat";
export { linkSync, link } from "./link";
export { symlinkSync, symlink } from "./symlink";
export { writeFileSync, writeFile, WriteFileOptions } from "./write_file";
+export { applySourceMap } from "./error_stack";
export { ErrorKind, DenoError } from "./errors";
export {
permissions,
@@ -88,6 +89,9 @@ export const args: string[] = [];
/** @internal */
export { core } from "./core";
+/** @internal */
+export { setPrepareStackTrace } from "./error_stack";
+
// TODO Don't expose Console nor stringifyArgs.
/** @internal */
export { Console, stringifyArgs } from "./console";
diff --git a/js/error_stack.ts b/js/error_stack.ts
new file mode 100644
index 000000000..e97020687
--- /dev/null
+++ b/js/error_stack.ts
@@ -0,0 +1,298 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+// Some of the code here is adapted directly from V8 and licensed under a BSD
+// style license available here: https://github.com/v8/v8/blob/24886f2d1c565287d33d71e4109a53bf0b54b75c/LICENSE.v8
+
+import * as msg from "gen/cli/msg_generated";
+import * as flatbuffers from "./flatbuffers";
+import * as dispatch from "./dispatch";
+import { assert } from "./util";
+
+export interface Location {
+ /** The full url for the module, e.g. `file://some/file.ts` or
+ * `https://some/file.ts`. */
+ filename: string;
+
+ /** The line number in the file. It is assumed to be 1-indexed. */
+ line: number;
+
+ /** The column number in the file. It is assumed to be 1-indexed. */
+ column: number;
+}
+
+function req(
+ filename: string,
+ line: number,
+ column: number
+): [flatbuffers.Builder, msg.Any.ApplySourceMap, flatbuffers.Offset] {
+ const builder = flatbuffers.createBuilder();
+ const filename_ = builder.createString(filename);
+ const inner = msg.ApplySourceMap.createApplySourceMap(
+ builder,
+ filename_,
+ // On this side, line/column are 1 based, but in the source maps, they are
+ // 0 based, so we have to convert back and forth
+ line - 1,
+ column - 1
+ );
+ return [builder, msg.Any.ApplySourceMap, inner];
+}
+
+function res(baseRes: msg.Base | null): Location {
+ assert(baseRes != null);
+ assert(baseRes!.innerType() === msg.Any.ApplySourceMap);
+ const res = new msg.ApplySourceMap();
+ assert(baseRes!.inner(res) != null);
+ const filename = res.filename()!;
+ assert(filename != null);
+ return {
+ filename,
+ // On this side, line/column are 1 based, but in the source maps, they are
+ // 0 based, so we have to convert back and forth
+ line: res.line() + 1,
+ column: res.column() + 1
+ };
+}
+
+/** Given a current location in a module, lookup the source location and
+ * return it.
+ *
+ * When Deno transpiles code, it keep source maps of the transpiled code. This
+ * function can be used to lookup the original location. This is automatically
+ * done when accessing the `.stack` of an error, or when an uncaught error is
+ * logged. This function can be used to perform the lookup for creating better
+ * error handling.
+ *
+ * **Note:** `line` and `column` are 1 indexed, which matches display
+ * expectations, but is not typical of most index numbers in Deno.
+ *
+ * An example:
+ *
+ * const orig = Deno.applySourceMap({
+ * location: "file://my/module.ts",
+ * line: 5,
+ * column: 15
+ * });
+ * console.log(`${orig.filename}:${orig.line}:${orig.column}`);
+ *
+ */
+export function applySourceMap(location: Location): Location {
+ const { filename, line, column } = location;
+ return res(dispatch.sendSync(...req(filename, line, column)));
+}
+
+/** Mutate the call site so that it returns the location, instead of its
+ * original location.
+ */
+function patchCallSite(callSite: CallSite, location: Location): CallSite {
+ return {
+ getThis(): unknown {
+ return callSite.getThis();
+ },
+ getTypeName(): string {
+ return callSite.getTypeName();
+ },
+ getFunction(): Function {
+ return callSite.getFunction();
+ },
+ getFunctionName(): string {
+ return callSite.getFunctionName();
+ },
+ getMethodName(): string {
+ return callSite.getMethodName();
+ },
+ getFileName(): string {
+ return location.filename;
+ },
+ getLineNumber(): number {
+ return location.line;
+ },
+ getColumnNumber(): number {
+ return location.column;
+ },
+ getEvalOrigin(): string | null {
+ return callSite.getEvalOrigin();
+ },
+ isToplevel(): boolean {
+ return callSite.isToplevel();
+ },
+ isEval(): boolean {
+ return callSite.isEval();
+ },
+ isNative(): boolean {
+ return callSite.isNative();
+ },
+ isConstructor(): boolean {
+ return callSite.isConstructor();
+ },
+ isAsync(): boolean {
+ return callSite.isAsync();
+ },
+ isPromiseAll(): boolean {
+ return callSite.isPromiseAll();
+ },
+ getPromiseIndex(): number | null {
+ return callSite.getPromiseIndex();
+ }
+ };
+}
+
+/** Return a string representations of a CallSite's method call name
+ *
+ * This is adapted directly from V8.
+ */
+function getMethodCall(callSite: CallSite): string {
+ let result = "";
+
+ const typeName = callSite.getTypeName();
+ const methodName = callSite.getMethodName();
+ const functionName = callSite.getFunctionName();
+
+ if (functionName) {
+ if (typeName) {
+ const startsWithTypeName = functionName.startsWith(typeName);
+ if (!startsWithTypeName) {
+ result += `${typeName}.`;
+ }
+ }
+ result += functionName;
+
+ if (methodName) {
+ if (!functionName.endsWith(methodName)) {
+ result += ` [as ${methodName}]`;
+ }
+ }
+ } else {
+ if (typeName) {
+ result += `${typeName}.`;
+ }
+ if (methodName) {
+ result += methodName;
+ } else {
+ result += "<anonymous>";
+ }
+ }
+
+ return result;
+}
+
+/** Return a string representations of a CallSite's file location
+ *
+ * This is adapted directly from V8.
+ */
+function getFileLocation(callSite: CallSite): string {
+ if (callSite.isNative()) {
+ return "native";
+ }
+
+ let result = "";
+
+ const fileName = callSite.getFileName();
+ if (!fileName && callSite.isEval()) {
+ const evalOrigin = callSite.getEvalOrigin();
+ assert(evalOrigin != null);
+ result += `${evalOrigin}, `;
+ }
+
+ if (fileName) {
+ result += fileName;
+ } else {
+ result += "<anonymous>";
+ }
+
+ const lineNumber = callSite.getLineNumber();
+ if (lineNumber != null) {
+ result += `:${lineNumber}`;
+
+ const columnNumber = callSite.getColumnNumber();
+ if (columnNumber != null) {
+ result += `:${columnNumber}`;
+ }
+ }
+
+ return result;
+}
+
+/** Convert a CallSite to a string.
+ *
+ * This is adapted directly from V8.
+ */
+function callSiteToString(callSite: CallSite): string {
+ let result = "";
+ const functionName = callSite.getFunctionName();
+
+ const isTopLevel = callSite.isToplevel();
+ const isAsync = callSite.isAsync();
+ const isPromiseAll = callSite.isPromiseAll();
+ const isConstructor = callSite.isConstructor();
+ const isMethodCall = !(isTopLevel || isConstructor);
+
+ if (isAsync) {
+ result += "async ";
+ }
+ if (isPromiseAll) {
+ result += `Promise.all (index ${callSite.getPromiseIndex})`;
+ return result;
+ }
+ if (isMethodCall) {
+ result += getMethodCall(callSite);
+ } else if (isConstructor) {
+ result += "new ";
+ if (functionName) {
+ result += functionName;
+ } else {
+ result += "<anonymous>";
+ }
+ } else if (functionName) {
+ result += functionName;
+ } else {
+ result += getFileLocation(callSite);
+ return result;
+ }
+
+ result += ` (${getFileLocation(callSite)})`;
+ return result;
+}
+
+/** A replacement for the default stack trace preparer which will op into Rust
+ * to apply source maps to individual sites
+ */
+function prepareStackTrace(
+ error: Error,
+ structuredStackTrace: CallSite[]
+): string {
+ return (
+ `${error.name}: ${error.message}\n` +
+ structuredStackTrace
+ .map(
+ (callSite): CallSite => {
+ const filename = callSite.getFileName();
+ const line = callSite.getLineNumber();
+ const column = callSite.getColumnNumber();
+ if (filename && line != null && column != null) {
+ return patchCallSite(
+ callSite,
+ applySourceMap({
+ filename,
+ line,
+ column
+ })
+ );
+ }
+ return callSite;
+ }
+ )
+ .map((callSite): string => ` at ${callSiteToString(callSite)}`)
+ .join("\n")
+ );
+}
+
+/** Sets the `prepareStackTrace` method on the Error constructor which will
+ * op into Rust to remap source code for caught errors where the `.stack` is
+ * being accessed.
+ *
+ * See: https://v8.dev/docs/stack-trace-api
+ */
+// @internal
+export function setPrepareStackTrace(ErrorConstructor: typeof Error): void {
+ ErrorConstructor.prepareStackTrace = prepareStackTrace;
+}
diff --git a/js/error_stack_test.ts b/js/error_stack_test.ts
new file mode 100644
index 000000000..0df491f39
--- /dev/null
+++ b/js/error_stack_test.ts
@@ -0,0 +1,111 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+import { test, assert } from "./test_util.ts";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const { setPrepareStackTrace } = Deno as any;
+
+interface CallSite {
+ getThis(): unknown;
+ getTypeName(): string;
+ getFunction(): Function;
+ getFunctionName(): string;
+ getMethodName(): string;
+ getFileName(): string;
+ getLineNumber(): number | null;
+ getColumnNumber(): number | null;
+ getEvalOrigin(): string | null;
+ isToplevel(): boolean;
+ isEval(): boolean;
+ isNative(): boolean;
+ isConstructor(): boolean;
+ isAsync(): boolean;
+ isPromiseAll(): boolean;
+ getPromiseIndex(): number | null;
+}
+
+function getMockCallSite(
+ filename: string,
+ line: number | null,
+ column: number | null
+): CallSite {
+ return {
+ getThis(): unknown {
+ return undefined;
+ },
+ getTypeName(): string {
+ return "";
+ },
+ getFunction(): Function {
+ return (): void => {};
+ },
+ getFunctionName(): string {
+ return "";
+ },
+ getMethodName(): string {
+ return "";
+ },
+ getFileName(): string {
+ return filename;
+ },
+ getLineNumber(): number | null {
+ return line;
+ },
+ getColumnNumber(): number | null {
+ return column;
+ },
+ getEvalOrigin(): null {
+ return null;
+ },
+ isToplevel(): false {
+ return false;
+ },
+ isEval(): false {
+ return false;
+ },
+ isNative(): false {
+ return false;
+ },
+ isConstructor(): false {
+ return false;
+ },
+ isAsync(): false {
+ return false;
+ },
+ isPromiseAll(): false {
+ return false;
+ },
+ getPromiseIndex(): null {
+ return null;
+ }
+ };
+}
+
+test(function prepareStackTrace(): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const MockError = {} as any;
+ setPrepareStackTrace(MockError);
+ assert(typeof MockError.prepareStackTrace === "function");
+ const prepareStackTrace: (
+ error: Error,
+ structuredStackTrace: CallSite[]
+ ) => string = MockError.prepareStackTrace;
+ const result = prepareStackTrace(new Error("foo"), [
+ getMockCallSite("gen/cli/bundle/main.js", 23, 0)
+ ]);
+ assert(result.startsWith("Error: foo\n"));
+ assert(
+ result.includes("<anonymous> (js/"),
+ "should remap to something in 'js/'"
+ );
+});
+
+test(function applySourceMap(): void {
+ const result = Deno.applySourceMap({
+ filename: "gen/cli/bundle/main.js",
+ line: 23,
+ column: 0
+ });
+ assert(result.filename.startsWith("js/"));
+ assert(result.line != null);
+ assert(result.column != null);
+});
diff --git a/js/globals.ts b/js/globals.ts
index cff4b7dd9..27cef3909 100644
--- a/js/globals.ts
+++ b/js/globals.ts
@@ -40,6 +40,29 @@ import { immutableDefine } from "./util";
declare global {
const console: consoleTypes.Console;
const setTimeout: typeof timers.setTimeout;
+
+ interface CallSite {
+ getThis(): unknown;
+ getTypeName(): string;
+ getFunction(): Function;
+ getFunctionName(): string;
+ getMethodName(): string;
+ getFileName(): string;
+ getLineNumber(): number | null;
+ getColumnNumber(): number | null;
+ getEvalOrigin(): string | null;
+ isToplevel(): boolean;
+ isEval(): boolean;
+ isNative(): boolean;
+ isConstructor(): boolean;
+ isAsync(): boolean;
+ isPromiseAll(): boolean;
+ getPromiseIndex(): number | null;
+ }
+
+ interface ErrorConstructor {
+ prepareStackTrace(error: Error, structuredStackTrace: CallSite[]): string;
+ }
}
// A self reference to the global object.
diff --git a/js/main.ts b/js/main.ts
index cb27690b5..5687a6926 100644
--- a/js/main.ts
+++ b/js/main.ts
@@ -8,6 +8,7 @@ import "./globals";
import { assert, log } from "./util";
import * as os from "./os";
import { args } from "./deno";
+import { setPrepareStackTrace } from "./error_stack";
import { replLoop } from "./repl";
import { xevalMain, XevalFunc } from "./xeval";
import { setVersions } from "./version";
@@ -30,6 +31,8 @@ export default function denoMain(name?: string): void {
os.exit(0);
}
+ setPrepareStackTrace(Error);
+
const mainModule = startResMsg.mainModule();
if (mainModule) {
assert(mainModule.length > 0);
diff --git a/js/unit_tests.ts b/js/unit_tests.ts
index ff9f459e5..b55c1954a 100644
--- a/js/unit_tests.ts
+++ b/js/unit_tests.ts
@@ -13,6 +13,7 @@ import "./console_test.ts";
import "./copy_file_test.ts";
import "./custom_event_test.ts";
import "./dir_test.ts";
+import "./error_stack_test.ts";
import "./event_test.ts";
import "./event_target_test.ts";
import "./fetch_test.ts";