From 9fb4749feaa83c770b51f201542e4e123562cd67 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Mon, 27 Apr 2026 00:39:07 +0200 Subject: [PATCH 01/14] refactor step 1: integrate trbr --- esbuild.js | 1 - package-lock.json | 956 +------------- package.json | 3 +- src/crashDecoder.ts | 6 +- src/vendor/trbr/abort.js | 11 + src/vendor/trbr/capturer/capturer.js | 776 +++++++++++ src/vendor/trbr/capturer/framer.js | 152 +++ src/vendor/trbr/capturer/lineDecoder.js | 58 + src/vendor/trbr/capturer/types.js | 83 ++ src/vendor/trbr/decode/_tinyrainbow.js | 22 + src/vendor/trbr/decode/addr2Line.js | 236 ++++ src/vendor/trbr/decode/coredump.js | 350 +++++ src/vendor/trbr/decode/decode.js | 643 +++++++++ src/vendor/trbr/decode/decode.text.js | 6 + src/vendor/trbr/decode/decodeParams.js | 195 +++ src/vendor/trbr/decode/gdbMi.js | 378 ++++++ src/vendor/trbr/decode/globals.js | 335 +++++ src/vendor/trbr/decode/regAddr.js | 207 +++ src/vendor/trbr/decode/regs.js | 84 ++ src/vendor/trbr/decode/riscv.js | 1368 ++++++++++++++++++++ src/vendor/trbr/decode/riscvPanicParse.js | 230 ++++ src/vendor/trbr/decode/stringify.js | 258 ++++ src/vendor/trbr/decode/xtensa.js | 157 +++ src/vendor/trbr/decode/xtensaPanicParse.js | 104 ++ src/vendor/trbr/exec.js | 25 + src/vendor/trbr/index.d.ts | 223 ++++ src/vendor/trbr/index.js | 20 + src/vendor/trbr/os.js | 6 + src/vendor/trbr/tool.js | 231 ++++ 29 files changed, 6176 insertions(+), 948 deletions(-) create mode 100644 src/vendor/trbr/abort.js create mode 100644 src/vendor/trbr/capturer/capturer.js create mode 100644 src/vendor/trbr/capturer/framer.js create mode 100644 src/vendor/trbr/capturer/lineDecoder.js create mode 100644 src/vendor/trbr/capturer/types.js create mode 100644 src/vendor/trbr/decode/_tinyrainbow.js create mode 100644 src/vendor/trbr/decode/addr2Line.js create mode 100644 src/vendor/trbr/decode/coredump.js create mode 100644 src/vendor/trbr/decode/decode.js create mode 100644 src/vendor/trbr/decode/decode.text.js create mode 100644 src/vendor/trbr/decode/decodeParams.js create mode 100644 src/vendor/trbr/decode/gdbMi.js create mode 100644 src/vendor/trbr/decode/globals.js create mode 100644 src/vendor/trbr/decode/regAddr.js create mode 100644 src/vendor/trbr/decode/regs.js create mode 100644 src/vendor/trbr/decode/riscv.js create mode 100644 src/vendor/trbr/decode/riscvPanicParse.js create mode 100644 src/vendor/trbr/decode/stringify.js create mode 100644 src/vendor/trbr/decode/xtensa.js create mode 100644 src/vendor/trbr/decode/xtensaPanicParse.js create mode 100644 src/vendor/trbr/exec.js create mode 100644 src/vendor/trbr/index.d.ts create mode 100644 src/vendor/trbr/index.js create mode 100644 src/vendor/trbr/os.js create mode 100644 src/vendor/trbr/tool.js diff --git a/esbuild.js b/esbuild.js index ab3abb1..9d11526 100644 --- a/esbuild.js +++ b/esbuild.js @@ -19,7 +19,6 @@ async function main() { 'vscode', 'serialport', '@serialport/bindings-cpp', - 'trbr', ], logLevel: 'info', plugins: [], diff --git a/package-lock.json b/package-lock.json index c91e0b9..e8af01a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,7 @@ "version": "0.22.2", "license": "GPL-3.0", "dependencies": { - "serialport": "^13.0.0", - "trbr": "^0.3.1" + "serialport": "^13.0.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -1695,20 +1694,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1719,19 +1704,6 @@ "node": ">=12" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "dev": true, @@ -1781,47 +1753,6 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1840,28 +1771,6 @@ "node": ">=18" } }, - "node_modules/clipboardy": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "execa": "^8.0.1", - "is-wsl": "^3.1.0", - "is64bit": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clone": { - "version": "2.1.2", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-response": { "version": "1.0.3", "dev": true, @@ -1873,13 +1782,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/commander": { - "version": "12.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1889,6 +1791,7 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1939,36 +1842,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -1984,6 +1857,7 @@ }, "node_modules/define-data-property": { "version": "1.1.4", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -1999,6 +1873,7 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -2027,18 +1902,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "dev": true, @@ -2049,6 +1912,7 @@ }, "node_modules/es-define-property": { "version": "1.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2056,44 +1920,17 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-module-lexer": { "version": "2.0.0", "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es6-error": { "version": "4.1.1", "dev": true, @@ -2328,37 +2165,6 @@ "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "8.0.1", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "8.0.1", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/expect-type": { "version": "1.3.0", "dev": true, @@ -2443,27 +2249,6 @@ "dev": true, "license": "ISC" }, - "node_modules/for-each": { - "version": "0.3.5", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fqbn": { - "version": "1.4.0", - "license": "MIT", - "dependencies": { - "clone": "^2.1.2", - "deep-equal": "^2.2.3" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2479,53 +2264,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "5.2.0", "dev": true, @@ -2584,6 +2322,7 @@ }, "node_modules/gopd": { "version": "1.2.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2616,18 +2355,9 @@ "url": "https://github.com/sindresorhus/got?sponsor=1" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -2636,39 +2366,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/http-cache-semantics": { "version": "4.2.0", "dev": true, @@ -2686,13 +2383,6 @@ "node": ">=10.19.0" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/ignore": { "version": "7.0.5", "dev": true, @@ -2709,18 +2399,6 @@ "node": ">=0.8.19" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ip-regex": { "version": "4.3.0", "dev": true, @@ -2729,99 +2407,6 @@ "node": ">=8" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -2841,166 +2426,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-url": { "version": "1.2.4", "dev": true, "license": "MIT" }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is2": { "version": "2.0.9", "dev": true, @@ -3014,25 +2444,9 @@ "node": ">=v0.10.0" } }, - "node_modules/is64bit": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "system-architecture": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", + "dev": true, "license": "ISC" }, "node_modules/json-buffer": { @@ -3367,10 +2781,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "license": "MIT" - }, "node_modules/lowercase-keys": { "version": "2.0.0", "dev": true, @@ -3400,27 +2810,6 @@ "node": ">=10" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "license": "MIT" - }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "1.0.1", "dev": true, @@ -3521,78 +2910,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.1", "dev": true, @@ -3610,19 +2935,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -3685,6 +2997,7 @@ }, "node_modules/path-key": { "version": "3.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3736,13 +3049,6 @@ "node": ">=25" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", @@ -3808,24 +3114,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-alpn": { "version": "1.2.1", "dev": true, @@ -3892,21 +3180,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/semver": { "version": "7.7.4", "dev": true, @@ -3982,36 +3255,9 @@ } } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/shebang-command": { "version": "2.0.0", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4022,90 +3268,17 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4131,37 +3304,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/system-architecture": { - "version": "0.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tar": { "version": "7.5.13", "dev": true, @@ -4237,25 +3379,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/trbr": { - "version": "0.3.1", - "license": "GPL-3.0", - "dependencies": { - "clipboardy": "^4.0.0", - "commander": "^12.1.0", - "debug": "^4.4.3", - "fqbn": "^1.4.0", - "lodash.debounce": "^4.0.8", - "tinyrainbow": "^2.0.0" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4506,6 +3629,7 @@ }, "node_modules/which": { "version": "2.0.2", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4517,58 +3641,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "dev": true, diff --git a/package.json b/package.json index cf65e90..3690fed 100644 --- a/package.json +++ b/package.json @@ -204,8 +204,7 @@ "@serialport/bindings-cpp": "npm:@jason2866/serialport-bindings-cpp@^13.0.5" }, "dependencies": { - "serialport": "^13.0.0", - "trbr": "^0.3.1" + "serialport": "^13.0.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/src/crashDecoder.ts b/src/crashDecoder.ts index 2c202d0..ca66260 100644 --- a/src/crashDecoder.ts +++ b/src/crashDecoder.ts @@ -8,7 +8,7 @@ import { promisify } from 'util'; const execFileAsync = promisify(execFile); -// Import trbr as a library dependency +// Vendored trbr implementation (lives in ./vendor/trbr). import { decode, stringifyDecodeResult, @@ -16,13 +16,13 @@ import { isParsedGDBLine, isGDBLine, createCapturer, -} from 'trbr'; +} from './vendor/trbr'; import type { Capturer, CapturerEvent, DecodeOptions, DecodeParams, -} from 'trbr'; +} from './vendor/trbr'; import { getPioPackagesDir } from './pioIntegration'; import { Addr2linePool } from './addr2lineResolver'; diff --git a/src/vendor/trbr/abort.js b/src/vendor/trbr/abort.js new file mode 100644 index 0000000..f937c30 --- /dev/null +++ b/src/vendor/trbr/abort.js @@ -0,0 +1,11 @@ +// @ts-check + +export class AbortError extends Error { + constructor() { + super('User abort') + this.name = 'AbortError' + this.code = 'ABORT_ERR' + } +} + +export const neverSignal = new AbortController().signal diff --git a/src/vendor/trbr/capturer/capturer.js b/src/vendor/trbr/capturer/capturer.js new file mode 100644 index 0000000..14cb68e --- /dev/null +++ b/src/vendor/trbr/capturer/capturer.js @@ -0,0 +1,776 @@ +// @ts-check + +import { EventEmitter } from 'node:events' + +import { AbortError } from '../abort.js' +import { parseRiscvPanicOutput } from '../decode/riscvPanicParse.js' +import { + parseESP32PanicOutput, + parseESP8266PanicOutput, +} from '../decode/xtensaPanicParse.js' +import { CrashFramer } from './framer.js' +import { LineDecoder } from './lineDecoder.js' + +/** @typedef {import('./types.js').CapturerEvent} CapturerEvent */ +/** @typedef {import('./types.js').CapturerLightweight} CapturerLightweight */ +/** @typedef {import('./types.js').CapturerOptions} CapturerOptions */ +/** @typedef {import('./types.js').ResolvedCapturerOptions} ResolvedCapturerOptions */ +/** @typedef {import('./types.js').CapturerEventName} CapturerEventName */ +/** @typedef {import('./types.js').CapturerListener} CapturerListener */ +/** @typedef {import('./types.js').CapturerEvaluateOptions} CapturerEvaluateOptions */ +/** @typedef {import('./types.js').FramedCrashBlock} FramedCrashBlock */ +/** @typedef {import('./types.js').CapturerEvaluated} CapturerEvaluated */ +/** @typedef {import('./types.js').CapturerRawState} CapturerRawState */ +/** @typedef {import('./types.js').CapturerEvaluateContext} CapturerEvaluateContext */ + +const defaultOptions = /** @type {const} */ ({ + quietPeriodMs: 200, + dedupWindowMs: 5000, + maxEvents: 100, + maxRawBytes: 128 * 1024, + maxRawLines: 2000, +}) + +export class Capturer { + _eventBus = new EventEmitter() + _lineDecoder = new LineDecoder() + /** @type {CrashFramer} */ + _framer + /** @type {ResolvedCapturerOptions} */ + _options + /** @type {CapturerEvent[]} */ + _events = [] + /** @type {Map} */ + _eventsById = new Map() + /** @type {Map} */ + _signatureIndex = new Map() + /** @type {Map>} */ + _inFlightEvaluations = new Map() + /** @type {Uint8Array[]} */ + _rawBytes = [] + _rawByteLength = 0 + /** @type {string[]} */ + _rawLines = [] + _nextId = 1 + + /** @param {CapturerOptions} [options] */ + constructor(options = {}) { + this._options = resolveOptions(options) + this._framer = new CrashFramer({ + quietPeriodMs: this._options.quietPeriodMs, + }) + } + + /** + * @param {Uint8Array} chunk + * @returns {void} + */ + push(chunk) { + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('Expected a Uint8Array monitor chunk') + } + this._rememberRawBytes(chunk) + const lines = this._lineDecoder.push(chunk) + this._processLines(lines) + } + + /** @returns {void} */ + flush() { + this._processLines(this._lineDecoder.flush()) + this._finalizeBlocks(this._framer.flush(this._options.now())) + } + + /** @returns {CapturerEvent[]} */ + getEvents() { + return structuredClone(this._events) + } + + /** @returns {CapturerRawState} */ + getRawState() { + return { + bytes: this._rawBytes.map((chunk) => chunk.slice()), + byteLength: this._rawByteLength, + lines: [...this._rawLines], + } + } + + /** + * @param {CapturerEventName} eventName + * @param {CapturerListener} listener + * @returns {() => void} + */ + on(eventName, listener) { + this._eventBus.on(eventName, listener) + return () => { + this._eventBus.off(eventName, listener) + } + } + + /** + * @param {string} eventId + * @param {CapturerEvaluateOptions} [options] + * @returns {Promise} + */ + async evaluate(eventId, options = {}) { + const event = this._eventsById.get(eventId) + if (!event) { + throw new Error(`Unknown event id: ${eventId}`) + } + if (event.evaluated) { + return event.evaluated + } + const existingJob = this._inFlightEvaluations.get(eventId) + if (existingJob) { + return existingJob + } + + const job = this._evaluateEvent(event, options.signal) + this._inFlightEvaluations.set(eventId, job) + + try { + return await job + } finally { + this._inFlightEvaluations.delete(eventId) + } + } + + /** + * @param {CapturerEvent} event + * @param {AbortSignal | undefined} signal + * @returns {Promise} + */ + async _evaluateEvent(event, signal) { + if (signal?.aborted) { + throw new AbortError() + } + + const evaluator = this._options.evaluateEvent + + if (evaluator) { + const context = /** @type {CapturerEvaluateContext} */ ({ + event: structuredClone(event), + signal, + }) + const evaluated = await evaluator(context) + + if (signal?.aborted) { + throw new AbortError() + } + + event.evaluated = evaluated + this._emit('eventUpdated', event) + return evaluated + } + + await Promise.resolve() + + if (signal?.aborted) { + throw new AbortError() + } + + const addrs = event.lightweight.backtraceAddrs.length + ? event.lightweight.backtraceAddrs + : toArray(event.lightweight.programCounter) + const evaluated = { + eventId: event.id, + evaluatedAt: this._options.now(), + status: /** @type {const} */ ('stub'), + frames: addrs.map((addr) => ({ + addr, + location: toHex(addr), + })), + } + + event.evaluated = evaluated + this._emit('eventUpdated', event) + return evaluated + } + + /** + * @param {Uint8Array} chunk + * @returns {void} + */ + _rememberRawBytes(chunk) { + this._rawBytes.push(chunk.slice()) + this._rawByteLength += chunk.length + + while ( + this._rawByteLength > this._options.maxRawBytes && + this._rawBytes[0] + ) { + const overflow = this._rawByteLength - this._options.maxRawBytes + const first = this._rawBytes[0] + if (overflow >= first.length) { + this._rawBytes.shift() + this._rawByteLength -= first.length + continue + } + this._rawBytes[0] = first.slice(overflow) + this._rawByteLength -= overflow + } + } + + /** + * @param {string[]} lines + * @returns {void} + */ + _processLines(lines) { + for (const line of lines) { + this._rememberRawLine(line) + const blocks = this._framer.pushLine(line, this._options.now()) + this._finalizeBlocks(blocks) + } + } + + /** + * @param {string} line + * @returns {void} + */ + _rememberRawLine(line) { + this._rawLines.push(line) + while (this._rawLines.length > this._options.maxRawLines) { + this._rawLines.shift() + } + } + + /** + * @param {FramedCrashBlock[]} blocks + * @returns {void} + */ + _finalizeBlocks(blocks) { + for (const block of blocks) { + this._mergeEvent(block) + } + } + + /** + * @param {FramedCrashBlock} block + * @returns {void} + */ + _mergeEvent(block) { + const rawText = block.lines.join('\n') + const kind = detectKind(block.lines) + const lightweight = parseLightweight(rawText, kind, block.reasonLine) + const signature = createSignature(kind, lightweight.reasonLine, lightweight) + const candidate = { + kind, + lines: [...block.lines], + rawText, + firstSeenAt: block.startedAt, + lastSeenAt: block.lastAt, + lightweight, + signature, + } + + const exact = this._findRecentBySignature(candidate) + if (exact) { + this._mergeIntoExisting( + exact, + candidate, + shouldPreferCandidate(exact, candidate) + ) + return + } + + const byContainment = this._findRecentContainmentMatch(candidate) + if (byContainment) { + this._mergeIntoExisting( + byContainment, + candidate, + shouldPreferCandidate(byContainment, candidate) + ) + return + } + + const byFingerprint = this._findRecentFingerprintMatch(candidate) + if (byFingerprint) { + this._mergeIntoExisting( + byFingerprint, + candidate, + shouldPreferCandidate(byFingerprint, candidate) + ) + return + } + + const next = /** @type {CapturerEvent} */ ({ + id: `event-${String(this._nextId).padStart(6, '0')}`, + signature, + kind, + lines: [...block.lines], + rawText, + firstSeenAt: block.startedAt, + lastSeenAt: block.lastAt, + count: 1, + lightweight, + fastFrames: undefined, + evaluated: undefined, + }) + this._nextId++ + + this._events.push(next) + this._eventsById.set(next.id, next) + this._signatureIndex.set(next.signature, next.id) + this._trimEvents() + this._emit('eventDetected', next) + } + + /** + * @param {{ + * signature: string + * lastSeenAt: number + * }} candidate + * @returns {CapturerEvent | undefined} + */ + _findRecentBySignature(candidate) { + const existingId = this._signatureIndex.get(candidate.signature) + const existing = existingId ? this._eventsById.get(existingId) : undefined + if (!existing) { + return undefined + } + if (!this._isWithinDedupWindow(existing, candidate.lastSeenAt)) { + return undefined + } + return existing + } + + /** + * @param {{ + * kind: import('./types.js').CapturerEventKind + * rawText: string + * lastSeenAt: number + * lightweight: CapturerLightweight + * }} candidate + * @returns {CapturerEvent | undefined} + */ + _findRecentContainmentMatch(candidate) { + for (let i = this._events.length - 1; i >= 0; i--) { + const existing = this._events[i] + if (!this._isWithinDedupWindow(existing, candidate.lastSeenAt)) { + continue + } + if (!isLikelySameCrash(existing, candidate)) { + continue + } + const existingContains = containsNormalizedText( + existing.rawText, + candidate.rawText + ) + const candidateContains = containsNormalizedText( + candidate.rawText, + existing.rawText + ) + if (existingContains || candidateContains) { + return existing + } + } + return undefined + } + + /** + * @param {{ + * kind: import('./types.js').CapturerEventKind + * lightweight: CapturerLightweight + * lastSeenAt: number + * }} candidate + * @returns {CapturerEvent | undefined} + */ + _findRecentFingerprintMatch(candidate) { + const candidateFingerprint = createFingerprint( + candidate.kind, + candidate.lightweight + ) + if (!candidateFingerprint) { + return undefined + } + + for (let i = this._events.length - 1; i >= 0; i--) { + const existing = this._events[i] + if (!this._isWithinDedupWindow(existing, candidate.lastSeenAt)) { + continue + } + if ( + createFingerprint(existing.kind, existing.lightweight) === + candidateFingerprint + ) { + return existing + } + } + return undefined + } + + /** + * @param {CapturerEvent} existing + * @param {{ + * lines: string[] + * rawText: string + * lightweight: CapturerLightweight + * signature: string + * lastSeenAt: number + * }} candidate + * @param {boolean} preferCandidate + * @returns {void} + */ + _mergeIntoExisting(existing, candidate, preferCandidate) { + existing.count += 1 + existing.lastSeenAt = Math.max(existing.lastSeenAt, candidate.lastSeenAt) + + if (preferCandidate) { + existing.lines = [...candidate.lines] + existing.rawText = candidate.rawText + existing.lightweight = candidate.lightweight + this._reindexSignature(existing, candidate.signature) + } + + this._emit('eventUpdated', existing) + } + + /** + * @param {CapturerEvent} existing + * @param {string} signature + * @returns {void} + */ + _reindexSignature(existing, signature) { + if (existing.signature === signature) { + this._signatureIndex.set(signature, existing.id) + return + } + + const currentOwner = this._signatureIndex.get(existing.signature) + if (currentOwner === existing.id) { + this._signatureIndex.delete(existing.signature) + } + existing.signature = signature + this._signatureIndex.set(signature, existing.id) + } + + /** + * @param {CapturerEvent} event + * @param {number} atMs + * @returns {boolean} + */ + _isWithinDedupWindow(event, atMs) { + return atMs - event.lastSeenAt <= this._options.dedupWindowMs + } + + /** @returns {void} */ + _trimEvents() { + while (this._events.length > this._options.maxEvents) { + const removed = this._events.shift() + if (!removed) { + break + } + this._eventsById.delete(removed.id) + if (this._signatureIndex.get(removed.signature) === removed.id) { + this._signatureIndex.delete(removed.signature) + } + } + } + + /** + * @param {CapturerEventName} eventName + * @param {CapturerEvent} event + * @returns {void} + */ + _emit(eventName, event) { + this._eventBus.emit(eventName, structuredClone(event)) + } +} + +/** + * @param {CapturerOptions} [options] + * @returns {Capturer} + */ +export function createCapturer(options) { + return new Capturer(options) +} + +/** + * @param {CapturerOptions} options + * @returns {ResolvedCapturerOptions} + */ +function resolveOptions(options) { + return { + quietPeriodMs: options.quietPeriodMs ?? defaultOptions.quietPeriodMs, + dedupWindowMs: options.dedupWindowMs ?? defaultOptions.dedupWindowMs, + maxEvents: options.maxEvents ?? defaultOptions.maxEvents, + maxRawBytes: options.maxRawBytes ?? defaultOptions.maxRawBytes, + maxRawLines: options.maxRawLines ?? defaultOptions.maxRawLines, + now: options.now ?? Date.now, + evaluateEvent: options.evaluateEvent, + } +} + +/** + * @param {string[]} lines + * @returns {import('./types.js').CapturerEventKind} + */ +function detectKind(lines) { + const riscvHints = [/MCAUSE/, /\bMEPC\b/, /Stack memory:/, /MHARTID/] + if (lines.some((line) => riscvHints.some((hint) => hint.test(line)))) { + return 'riscv' + } + const xtensaHints = [ + /Backtrace:/, + /EXCCAUSE/, + /EXCVADDR/, + /Guru Meditation Error:/, + ] + if (lines.some((line) => xtensaHints.some((hint) => hint.test(line)))) { + return 'xtensa' + } + return 'unknown' +} + +/** + * @param {string} rawText + * @param {import('./types.js').CapturerEventKind} kind + * @param {string | undefined} reasonLine + * @returns {CapturerLightweight} + */ +function parseLightweight(rawText, kind, reasonLine) { + try { + if (kind === 'riscv') { + const parsed = parseRiscvPanicOutput({ input: rawText }) + return { + reasonLine: reasonLine?.trim(), + programCounter: parsed.programCounter, + faultCode: parsed.faultCode, + faultAddr: parsed.faultAddr, + regs: parsed.regs, + backtraceAddrs: toArray(parsed.programCounter), + } + } + + if (kind === 'xtensa') { + let parsed = parseESP32PanicOutput(rawText) + if ( + Object.keys(parsed.regs).length === 0 && + parsed.backtraceAddrs.length === 0 && + !parsed.programCounter + ) { + parsed = parseESP8266PanicOutput(rawText) + } + return { + reasonLine: reasonLine?.trim(), + programCounter: parsed.programCounter, + faultCode: parsed.faultCode, + faultAddr: parsed.faultAddr, + regs: parsed.regs, + backtraceAddrs: /** @type {number[]} */ ( + parsed.backtraceAddrs.filter((addr) => Number.isFinite(addr)) + ), + } + } + } catch { + // Keep capturer best-effort even when parsing fails. + } + + return { + reasonLine: reasonLine?.trim(), + programCounter: undefined, + faultCode: undefined, + faultAddr: undefined, + regs: {}, + backtraceAddrs: [], + } +} + +/** + * @param {import('./types.js').CapturerEventKind} kind + * @param {string | undefined} reasonLine + * @param {CapturerLightweight} lightweight + * @returns {string} + */ +function createSignature(kind, reasonLine, lightweight) { + const normalizedReason = normalizeReason(reasonLine) + const addrs = lightweight.backtraceAddrs.slice(0, 5) + if (addrs.length === 0 && lightweight.programCounter !== undefined) { + addrs.push(lightweight.programCounter) + } + const pcs = addrs.map((addr) => toHex(addr)).join(',') + const faultCode = lightweight.faultCode + return `${kind}|${normalizedReason || 'unknown'}|fc:${faultCode ?? 'na'}|${pcs || 'nopc'}` +} + +/** + * @param {string | undefined} line + * @returns {string} + */ +function normalizeReason(line) { + const text = String(line ?? '').replace(/\r/g, '') + const guruStart = text.toLowerCase().indexOf('guru meditation error:') + const scoped = guruStart >= 0 ? text.slice(guruStart) : text + return scoped + .toLowerCase() + .replace(/0x[0-9a-f]+/gi, '0x') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * @param {CapturerEvent + * | { + * rawText: string + * lightweight: CapturerLightweight + * }} existing + * @param {{ + * rawText: string + * lightweight: CapturerLightweight + * }} candidate + * @returns {boolean} + */ +function shouldPreferCandidate(existing, candidate) { + const existingScore = computeCompleteness( + existing.rawText, + existing.lightweight + ) + const candidateScore = computeCompleteness( + candidate.rawText, + candidate.lightweight + ) + if (candidateScore > existingScore) { + return true + } + if (candidateScore < existingScore) { + return false + } + return candidate.rawText.length > existing.rawText.length +} + +/** + * @param {string} rawText + * @param {CapturerLightweight} lightweight + * @returns {number} + */ +function computeCompleteness(rawText, lightweight) { + let score = 0 + if (normalizeReason(lightweight.reasonLine)) { + score += 1 + } + if (lightweight.faultCode !== undefined) { + score += 1 + } + if (lightweight.programCounter !== undefined) { + score += 2 + } + score += Math.min(lightweight.backtraceAddrs.length, 5) * 2 + if (Object.keys(lightweight.regs).length > 0) { + score += 1 + } + if (/Backtrace:|Stack memory:/i.test(rawText)) { + score += 2 + } + if (/Rebooting\.\.\./i.test(rawText)) { + score += 1 + } + return score +} + +/** + * @param {import('./types.js').CapturerEventKind} kind + * @param {CapturerLightweight} lightweight + * @returns {string} + */ +function createFingerprint(kind, lightweight) { + const reason = normalizeReason(lightweight.reasonLine) + const pc = getPrimaryAddr(lightweight) + if (!reason && lightweight.faultCode === undefined && pc === undefined) { + return '' + } + return `${kind}|${reason || 'unknown'}|fc:${lightweight.faultCode ?? 'na'}|pc:${pc !== undefined ? toHex(pc) : 'na'}` +} + +/** + * @param {string} container + * @param {string} candidate + * @returns {boolean} + */ +function containsNormalizedText(container, candidate) { + const containerNormalized = normalizeTextForContainment(container) + const candidateNormalized = normalizeTextForContainment(candidate) + if (!containerNormalized || !candidateNormalized) { + return false + } + return ( + containerNormalized === candidateNormalized || + containerNormalized.includes(candidateNormalized) + ) +} + +/** + * @param {string} input + * @returns {string} + */ +function normalizeTextForContainment(input) { + return input + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n') +} + +/** + * @param {CapturerEvent + * | { + * kind: import('./types.js').CapturerEventKind + * rawText: string + * lightweight: CapturerLightweight + * }} existing + * @param {{ + * kind: import('./types.js').CapturerEventKind + * rawText: string + * lightweight: CapturerLightweight + * }} candidate + * @returns {boolean} + */ +function isLikelySameCrash(existing, candidate) { + if (existing.kind !== candidate.kind) { + return false + } + + const existingReason = normalizeReason(existing.lightweight.reasonLine) + const candidateReason = normalizeReason(candidate.lightweight.reasonLine) + if (existingReason && candidateReason) { + return existingReason === candidateReason + } + + const existingPc = getPrimaryAddr(existing.lightweight) + const candidatePc = getPrimaryAddr(candidate.lightweight) + if (existingPc !== undefined && candidatePc !== undefined) { + return existingPc === candidatePc + } + + if ( + existing.lightweight.faultCode !== undefined && + candidate.lightweight.faultCode !== undefined + ) { + return existing.lightweight.faultCode === candidate.lightweight.faultCode + } + + return containsNormalizedText(existing.rawText, candidate.rawText) +} + +/** + * @param {CapturerLightweight} lightweight + * @returns {number | undefined} + */ +function getPrimaryAddr(lightweight) { + return lightweight.backtraceAddrs[0] ?? lightweight.programCounter +} + +/** + * @param {number} value + * @returns {string} + */ +function toHex(value) { + return `0x${(value >>> 0).toString(16)}` +} + +/** + * @param {number | undefined} value + * @returns {number[]} + */ +function toArray(value) { + return value === undefined ? [] : [value] +} diff --git a/src/vendor/trbr/capturer/framer.js b/src/vendor/trbr/capturer/framer.js new file mode 100644 index 0000000..4147967 --- /dev/null +++ b/src/vendor/trbr/capturer/framer.js @@ -0,0 +1,152 @@ +// @ts-check + +/** @typedef {import('./types.js').FramedCrashBlock} FramedCrashBlock */ + +const startPatterns = [ + /Guru Meditation Error:/i, + /panic'ed/i, + /^Exception\s+\(\d+\):?/i, +] + +const reasonPatterns = [ + /Guru Meditation Error:/i, + /panic'ed/i, + /^Exception\s+\(\d+\):?/i, +] + +/** + * @typedef {Object} FramerState + * @property {string[]} lines + * @property {number} startedAt + * @property {number} lastAt + * @property {string | undefined} reasonLine + */ + +export class CrashFramer { + _quietPeriodMs + /** @type {FramerState | undefined} */ + _active + + /** @param {{ quietPeriodMs: number }} options */ + constructor(options) { + this._quietPeriodMs = options.quietPeriodMs + } + + /** + * @param {string} line + * @param {number} atMs + * @returns {FramedCrashBlock[]} + */ + pushLine(line, atMs) { + /** @type {FramedCrashBlock[]} */ + const finalized = [] + + this._finalizeIfQuiet(finalized, atMs) + + if (isStartLine(line)) { + this._finalize(finalized) + this._active = { + lines: [], + startedAt: atMs, + lastAt: atMs, + reasonLine: undefined, + } + } + + if (!this._active) { + return finalized + } + + this._active.lines.push(line) + this._active.lastAt = atMs + if (!this._active.reasonLine && isReasonLine(line)) { + this._active.reasonLine = line.trim() + } + + if (/^Rebooting\.\.\./i.test(line.trim())) { + this._finalize(finalized) + } + + return finalized + } + + /** + * @param {number} atMs + * @returns {FramedCrashBlock[]} + */ + flush(atMs) { + /** @type {FramedCrashBlock[]} */ + const finalized = [] + this._finalizeIfQuiet(finalized, atMs) + // Finalize on flush only when the active crash already looks complete. + // This avoids trailing partial events at stop-capture while still + // emitting complete blocks without waiting for an extra quiet period. + if (this._active && isCompleteBlock(this._active.lines)) { + this._finalize(finalized) + } + return finalized + } + + /** + * @param {FramedCrashBlock[]} finalized + * @param {number} atMs + */ + _finalizeIfQuiet(finalized, atMs) { + if (!this._active) { + return + } + if (atMs - this._active.lastAt < this._quietPeriodMs) { + return + } + this._finalize(finalized) + } + + /** @param {FramedCrashBlock[]} finalized */ + _finalize(finalized) { + if (!this._active) { + return + } + const lines = this._active.lines + const hasSignal = lines.some((line) => isStartLine(line)) + if (hasSignal && lines.length > 0) { + finalized.push({ + lines: [...lines], + startedAt: this._active.startedAt, + lastAt: this._active.lastAt, + reasonLine: this._active.reasonLine, + }) + } + this._active = undefined + } +} + +/** + * @param {string} line + * @returns {boolean} + */ +function isStartLine(line) { + return startPatterns.some((pattern) => pattern.test(line)) +} + +/** + * @param {string} line + * @returns {boolean} + */ +function isReasonLine(line) { + return reasonPatterns.some((pattern) => pattern.test(line)) +} + +/** + * @param {string[]} lines + * @returns {boolean} + */ +function isCompleteBlock(lines) { + return lines.some((line) => + [ + /Backtrace:/i, + /^Stack memory:/i, + /^Rebooting\.\.\./i, + /ELF file SHA256:/i, + ].some((pattern) => pattern.test(line.trim())) + ) +} diff --git a/src/vendor/trbr/capturer/lineDecoder.js b/src/vendor/trbr/capturer/lineDecoder.js new file mode 100644 index 0000000..af09403 --- /dev/null +++ b/src/vendor/trbr/capturer/lineDecoder.js @@ -0,0 +1,58 @@ +// @ts-check + +export class LineDecoder { + _decoder = new TextDecoder('utf-8') + _buffer = '' + + /** + * @param {Uint8Array} chunk + * @returns {string[]} + */ + push(chunk) { + this._buffer += this._decoder.decode(chunk, { stream: true }) + return this._drain(false) + } + + /** @returns {string[]} */ + flush() { + this._buffer += this._decoder.decode() + return this._drain(true) + } + + /** + * @param {boolean} isFinal + * @returns {string[]} + */ + _drain(isFinal) { + /** @type {string[]} */ + const lines = [] + let start = 0 + + for (let i = 0; i < this._buffer.length; i++) { + const ch = this._buffer.charCodeAt(i) + if (ch !== 10 && ch !== 13) { + continue + } + if (ch === 13 && i === this._buffer.length - 1 && !isFinal) { + break + } + + lines.push(this._buffer.slice(start, i)) + if (ch === 13 && this._buffer.charCodeAt(i + 1) === 10) { + i++ + } + start = i + 1 + } + + if (isFinal) { + if (start < this._buffer.length) { + lines.push(this._buffer.slice(start)) + } + this._buffer = '' + return lines + } + + this._buffer = this._buffer.slice(start) + return lines + } +} diff --git a/src/vendor/trbr/capturer/types.js b/src/vendor/trbr/capturer/types.js new file mode 100644 index 0000000..d79703a --- /dev/null +++ b/src/vendor/trbr/capturer/types.js @@ -0,0 +1,83 @@ +// @ts-check + +/** @typedef {'eventDetected' | 'eventUpdated'} CapturerEventName */ +/** @typedef {'xtensa' | 'riscv' | 'unknown'} CapturerEventKind */ + +/** + * @typedef {Object} CapturerLightweight + * @property {string | undefined} reasonLine + * @property {number | undefined} programCounter + * @property {number | undefined} faultCode + * @property {number | undefined} faultAddr + * @property {Record} regs + * @property {number[]} backtraceAddrs + */ + +/** + * @typedef {Object} CapturerEvaluated + * @property {string} eventId + * @property {number} evaluatedAt + * @property {'stub' | 'decoded'} status + * @property {import('../decode/decode.js').AddrLine[]} frames + * @property {import('../decode/decode.js').DecodeResult} [decodeResult] + */ + +/** + * @typedef {Object} CapturerEvent + * @property {string} id + * @property {string} signature + * @property {CapturerEventKind} kind + * @property {string[]} lines + * @property {string} rawText + * @property {number} firstSeenAt + * @property {number} lastSeenAt + * @property {number} count + * @property {CapturerLightweight} lightweight + * @property {import('../decode/decode.js').AddrLine[] | undefined} fastFrames + * @property {CapturerEvaluated | undefined} evaluated + */ + +/** @typedef {(event: CapturerEvent) => void} CapturerListener */ +/** @typedef {{ event: CapturerEvent; signal?: AbortSignal }} CapturerEvaluateContext */ +/** @typedef {(context: CapturerEvaluateContext) => Promise} CapturerEvaluateFn */ + +/** + * @typedef {Object} CapturerOptions + * @property {number} [quietPeriodMs] + * @property {number} [dedupWindowMs] + * @property {number} [maxEvents] + * @property {number} [maxRawBytes] + * @property {number} [maxRawLines] + * @property {() => number} [now] + * @property {CapturerEvaluateFn} [evaluateEvent] + */ + +/** + * @typedef {Object} ResolvedCapturerOptions + * @property {number} quietPeriodMs + * @property {number} dedupWindowMs + * @property {number} maxEvents + * @property {number} maxRawBytes + * @property {number} maxRawLines + * @property {() => number} now + * @property {CapturerEvaluateFn | undefined} evaluateEvent + */ + +/** @typedef {{ signal?: AbortSignal }} CapturerEvaluateOptions */ + +/** + * @typedef {Object} FramedCrashBlock + * @property {string[]} lines + * @property {number} startedAt + * @property {number} lastAt + * @property {string | undefined} reasonLine + */ + +/** + * @typedef {Object} CapturerRawState + * @property {Uint8Array[]} bytes + * @property {number} byteLength + * @property {string[]} lines + */ + +export const __types = /** @type {const} */ ({}) diff --git a/src/vendor/trbr/decode/_tinyrainbow.js b/src/vendor/trbr/decode/_tinyrainbow.js new file mode 100644 index 0000000..16c090b --- /dev/null +++ b/src/vendor/trbr/decode/_tinyrainbow.js @@ -0,0 +1,22 @@ +// Minimal in-tree replacement for the `tinyrainbow` package consumed by +// stringify.js. The original library auto-detects TTY/FORCE_COLOR and emits +// ANSI escape sequences. esp-decoder always invokes stringifyDecodeResult with +// `{ color: 'disable' }`, so the colored code paths are never executed and an +// identity-function shim is sufficient. + +/** @typedef {(text: string) => string} ColorFn */ + +/** @type {ColorFn} */ +const identity = (text) => String(text) + +const palette = { + red: identity, + green: identity, + blue: identity, +} + +export function createColors() { + return { ...palette } +} + +export default palette diff --git a/src/vendor/trbr/decode/addr2Line.js b/src/vendor/trbr/decode/addr2Line.js new file mode 100644 index 0000000..90053c2 --- /dev/null +++ b/src/vendor/trbr/decode/addr2Line.js @@ -0,0 +1,236 @@ +// @ts-check + +import { spawn } from 'node:child_process' + +import { isParsedGDBLine } from './decode.js' +import { parseLines } from './regAddr.js' +import { toHexString } from './regs.js' + +/** + * @typedef {Object} CommandQueueItem + * @property {string} cmd + * @property {(result: string) => void} resolve + * @property {(reason: unknown) => void} reject + */ + +const prompt = '(gdb)' +const notExecutableFormat = 'not in executable format' +const fileFormatNotRecognized = 'file format not recognized' +const noSuchFileOrDirectory = 'No such file or directory' + +class GDBSession { + /** + * @param {Pick} params + * @param {DecodeOptions} [options={}] Default is `{}` + */ + constructor({ toolPath, elfPath }, options = {}) { + this.toolPath = toolPath + this.elfPath = elfPath + this.error = null + this.didExecuteFirstCommand = false + this.gdb = spawn(toolPath, [elfPath], { + stdio: 'pipe', + signal: options.signal, + }) + this.buffer = '' + /** @type {CommandQueueItem[]} */ + this.queue = [] + this.current = null + this.gdb.stdout.on('data', (chunk) => this._onData(chunk)) + this.gdb.stderr.on('data', (chunk) => this._onData(chunk)) + this.gdb.on('error', (err) => this.current?.reject(err)) + this.gdb.on('exit', (code, signal) => { + if (code !== 0 && signal !== 'SIGTERM') { + console.warn(`GDB exited with code ${code} or signal ${signal}`) + } + }) + } + + /** @param {Buffer} chunk */ + _onData(chunk) { + this.buffer += chunk.toString() + if (!this.current) { + return + } + const idx = this.buffer.indexOf(prompt) + if (idx === -1) { + return + } + const output = this.buffer.slice(0, idx) + this.buffer = this.buffer.slice(idx + prompt.length) + const { resolve } = this.current + this.current = null + resolve(output) + this._processQueue() + } + + _processQueue() { + const item = this.queue.shift() + if (this.current || !item) { + return + } + const { cmd, resolve, reject } = item + this.current = { resolve, reject } + this.gdb.stdin.write(cmd + '\n') + } + + start() { + return new Promise((resolve, reject) => { + // GDB not found + const onError = (/** @type {Error} */ error) => { + let userError = error + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + userError = new Error(`GDB tool not found at ${this.toolPath}`) + } + reject(userError) + } + + const onData = (/** @type {Buffer} */ chunk) => { + // ELF is not found + if ( + !this.didExecuteFirstCommand && + this.buffer.includes(noSuchFileOrDirectory) + ) { + if (!this.error) { + this.error = new Error( + `The ELF file does not exist or is not readable: ${this.elfPath}` + ) + reject(this.error) + } + return + } + + // Not an ELF + if ( + !this.didExecuteFirstCommand && + (this.buffer.includes(notExecutableFormat) || + this.buffer.includes(fileFormatNotRecognized)) + ) { + if (!this.error) { + this.error = new Error( + `The ELF file is not in executable format: ${this.elfPath}` + ) + reject(this.error) + } + return + } + + this.buffer += chunk.toString() + const idx = this.buffer.indexOf(prompt) + if (idx !== -1) { + this.buffer = this.buffer.slice(idx + prompt.length) + resolve('') + } + } + this.gdb.on('error', onError) + this.gdb.stdout.on('data', onData) + this.gdb.stderr.on('data', onData) + }) + } + + /** @param {string} cmd */ + async exec(cmd) { + if (this.error) { + this.close() + return Promise.reject(this.error) + } + const result = await new Promise((resolve, reject) => { + this.queue.push({ cmd, resolve, reject }) + this._processQueue() + }) + + this.didExecuteFirstCommand = true + return result + } + + async close() { + if (!this.gdb.killed) { + this.gdb.stdin.end() + this.gdb.kill('SIGTERM') + if (typeof this.gdb.exitCode !== 'number') { + await new Promise((resolve) => this.gdb.once('exit', resolve)) + } + } + } +} + +/** @typedef {import('./decode.js').DecodeParams} DecodeParams */ +/** @typedef {import('./decode.js').DecodeOptions} DecodeOptions */ +/** @typedef {import('./decode.js').GDBLine} GDBLine */ +/** @typedef {import('./decode.js').ParsedGDBLine} ParsedGDBLine */ +/** @typedef {import('./decode.js').AddrLine} AddrLine */ + +/** + * @param {(number | AddrLine | undefined)[]} addrs + * @returns {number[]} + */ +function buildAddr2LineAddrs(addrs) { + /** @type {Set} */ + const dedupedAddrs = new Set() + for (const addr of addrs) { + let addrNumber + if (typeof addr === 'object') { + addrNumber = addr.addr + } else if (typeof addr === 'number') { + addrNumber = addr + } + if (addrNumber !== undefined && !dedupedAddrs.has(addrNumber)) { + dedupedAddrs.add(addrNumber) + } + } + return Array.from(dedupedAddrs.values()) +} + +/** + * @typedef {Object} RegsInfo + * @property {Record>} threadRegs + * @property {number} [currentThreadAddr] + */ + +/** + * @param {Pick} params + * @param {(number | AddrLine | undefined)[]} addrs + * @param {DecodeOptions} [options={}] Default is `{}` + * @returns {Promise} + */ +export async function addr2line({ elfPath, toolPath }, addrs, options = {}) { + const addresses = buildAddr2LineAddrs(addrs) + if (!addresses.length) { + throw new Error('No register addresses found to decode') + } + + const results = new Map() + const session = new GDBSession({ elfPath, toolPath }, options) + + try { + await session.start() + await session.exec('set pagination off') + await session.exec('set listsize 1') + for (const addr of addresses) { + const hex = toHexString(addr) + const listOutput = await session.exec(`list *${hex}`) + let parsedLines = parseLines(listOutput, options.debug) + let location = parsedLines.find(isParsedGDBLine) + if (!location) { + const lineOutput = await session.exec(`info line *${hex}`) + parsedLines = parseLines(lineOutput, options.debug) + location = parsedLines.find(isParsedGDBLine) + } + results.set(addr, { + addr, + location: location ?? { regAddr: hex, lineNumber: '??' }, + }) + } + } finally { + await session.close() + } + + return addrs.map((addrOrLine) => { + const addr = typeof addrOrLine === 'object' ? addrOrLine.addr : addrOrLine + return results.get(addr) || { location: '??' } + }) +} diff --git a/src/vendor/trbr/decode/coredump.js b/src/vendor/trbr/decode/coredump.js new file mode 100644 index 0000000..98c6c33 --- /dev/null +++ b/src/vendor/trbr/decode/coredump.js @@ -0,0 +1,350 @@ +// @ts-check + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { GdbMiClient, extractMiListContent } from './gdbMi.js' +import { resolveGlobalSymbols } from './globals.js' +import { toHexString } from './regs.js' + +/** @typedef {import('./decode.js').Debug} Debug */ + +const coredumpLogPrefix = '[trbr][coredump]' + +/** + * @param {Debug | undefined} debug + * @returns {Debug} + */ +function createCoredumpLogger(debug) { + const writer = + debug ?? (process.env.TRBR_DEBUG === 'true' ? console.log : undefined) + return writer ? (...args) => writer(coredumpLogPrefix, ...args) : () => {} +} + +/** @typedef {import('./decode.js').DecodeResult} DecodeResult */ +/** @typedef {import('./decode.js').DecodeInputFileSource} DecodeInputFileSource */ +/** @typedef {import('./decode.js').FrameArg} FrameArg */ +/** @typedef {import('./decodeParams.js').DecodeCoredumpParams} DecodeCoredumpParams */ + +/** + * Attempt to extract an embedded ELF from a raw ESP32 flash dump. + * + * @param {DecodeCoredumpParams} params + * @param {Buffer} raw + * @param {import('./decode.js').DecodeOptions} [options={}] Default is `{}` + * @returns {Promise} + */ +async function tryRawElfFallback(params, raw, options) { + const expectedMagic = Buffer.from([0x7f, 0x45, 0x4c, 0x46]) + const offset = raw.indexOf(expectedMagic) + if (offset !== -1) { + // Estimate the total ELF size using program headers + const e_phoff = raw.readUInt32LE(offset + 28) + const e_phentsize = raw.readUInt16LE(offset + 42) + const e_phnum = raw.readUInt16LE(offset + 44) + const maxEnd = (() => { + let max = 0 + for (let i = 0; i < e_phnum; i++) { + const entryOffset = offset + e_phoff + i * e_phentsize + const p_offset = raw.readUInt32LE(entryOffset + 4) + const p_filesz = raw.readUInt32LE(entryOffset + 16) + const end = p_offset + p_filesz + if (end > max) { + max = end + } + } + return max + })() + const elfTotalSize = maxEnd + if (raw.length >= offset + elfTotalSize) { + const elfBuffer = raw.subarray(offset, offset + elfTotalSize) + const tmpDirPath = await fs.mkdtemp(path.join(os.tmpdir(), 'trbr-')) + const extractedElfPath = path.join(tmpDirPath, 'extracted.elf') + await fs.writeFile(extractedElfPath, elfBuffer) + try { + const result = await decodeCoredump( + { + ...params, + }, + { inputPath: extractedElfPath }, + options, + false + ) + return result + } finally { + await fs + .rm(tmpDirPath, { recursive: true, force: true }) + .catch((err) => console.warn('Failed to clean up temp dir:', err)) + } + } + } + return undefined +} + +/** + * @typedef {Object} ThreadDecodeResult + * @property {string} threadId + * @property {number} TCB + * @property {string} [threadName] + * @property {DecodeResult} result + * @property {boolean} [current] + */ + +/** @typedef {ThreadDecodeResult[]} CoredumpDecodeResult */ + +/** + * Parses register values from MI output or "info registers" raw output. + * + * @param {string} regsRaw + * @returns {Record} + */ +function parseRegisters(regsRaw) { + /** @type {Record} */ + const result = {} + + // Try MI-style first: match optional frame-prefix then register-values + const miMatch = regsRaw.match( + /(?:frame=\{[^}]*\},)?register-values=\[([^\]]*)\]/ + ) + if (miMatch) { + const inner = miMatch[1] + const regex = /\{number="(\d+)",value="(0x[a-fA-F0-9]+)"\}/g + for (const m of inner.matchAll(regex)) { + result[m[1]] = m[2] + } + return result + } + + // Try "info registers" style as fallback + const lines = regsRaw.split(/\r?\n/) + for (const line of lines) { + const match = line.match(/^~?"?(\w+)\s+(0x[a-fA-F0-9]+)\b/) + if (match) { + const [, name, value] = match + result[name] = value + } + } + + return result +} + +/** + * @param {string} raw + * @returns {Record[]} + */ +function parseBacktrace(raw) { + const entries = [...raw.matchAll(/frame=\{([^}]+)\}/g)].map((match) => { + /** @type {Record} */ + const obj = {} + for (const pair of match[1].split(',')) { + const [key, val] = pair.split('=') + obj[key] = val?.startsWith('"') ? val.slice(1, -1) : val + } + return obj + }) + return entries +} + +/** + * @param {string} str + * @param {string} key + * @returns {string | undefined} + */ +/** + * @param {DecodeCoredumpParams} params + * @param {DecodeInputFileSource} input + * @param {boolean} [tryRepair] + * @param {import('./decode.js').DecodeOptions} [options={}] Default is `{}` + * @returns {Promise} + */ +export async function decodeCoredump( + params, + input, + options = {}, + tryRepair = true +) { + const { elfPath, toolPath } = params + const { inputPath } = input + const log = createCoredumpLogger(options.debug) + log('start', { toolPath, elfPath, inputPath }) + const client = new GdbMiClient( + toolPath, + ['--interpreter=mi2', '-c', inputPath, elfPath], + options + ) + /** @type {ThreadDecodeResult[]} */ + const results = [] + + try { + // Use -thread-info for a more reliable MI listing of threads + await client.drainHandshake() + const globals = await resolveGlobalSymbols(params, options) + log('globals count', globals.length) + + const threadsRaw = await client.sendCommand('-thread-info') + log('thread-info raw length', threadsRaw.length) + + const currentThreadMatch = threadsRaw.match(/current-thread-id="(\d+)"/) + const currentThreadId = currentThreadMatch ? currentThreadMatch[1] : null + + // Extract the contents of the top-level threads=[ ... ] block, handling nested brackets + const threadsContent = extractMiListContent(threadsRaw, 'threads') + /** @type {[string, string][]} */ + const threadEntries = [] + if (threadsContent) { + // Split into individual thread objects by balanced braces + const objs = [] + let depth = 0 + let objStart = -1 + for (let i = 0; i < threadsContent.length; i++) { + const ch = threadsContent[i] + if (ch === '{') { + if (depth === 0) { + objStart = i + } + depth++ + } else if (ch === '}') { + depth-- + if (depth === 0 && objStart >= 0) { + objs.push(threadsContent.slice(objStart, i + 1)) + } + } + } + // Extract id and TCB (target-id) from each object + for (const objStr of objs) { + const m = /id="([^"]+)"\s*,\s*target-id="process\s+(\d+)"/.exec(objStr) + if (m) { + threadEntries.push([m[1], m[2]]) + } + } + } + const threadIds = threadEntries.map(([id]) => id) + const threadTcbs = Object.fromEntries( + threadEntries.map(([id, tcb]) => [id, Number(tcb)]) + ) + log('threads parsed', { threadIds, threadTcbs }) + + for (const tid of threadIds) { + log('select thread', tid) + await client.sendCommand(`-thread-select ${tid}`) + + const regNamesRaw = await client.sendCommand('-data-list-register-names') + const regNameMatch = regNamesRaw.match(/register-names=\[(.*?)\]/) + const regNames = regNameMatch + ? regNameMatch[1] + .split(',') + .map((s) => s.trim().replace(/^"|"$/g, '')) + .map((name, i) => [i.toString(), name]) + .filter(([, name]) => !!name) + : [] + const regNameMap = Object.fromEntries(regNames) + + const regsOut = await client.sendCommand('-data-list-register-values x') + const parsedRegs = parseRegisters(regsOut) + + const regsAsNamed = Object.fromEntries( + Object.entries(parsedRegs) + .map(([num, val]) => [regNameMap[num], Number(val)]) + .filter(([name]) => !!name) + ) + log('regs', tid, Object.keys(regsAsNamed)) + + const programCounter = regsAsNamed['pc'] + + const btOut = await client.sendCommand('-stack-list-frames') + log('stack frames raw length', btOut.length) + + const argsOut = await client.sendCommand( + '-stack-list-arguments --simple-values 0 100' + ) + log('stack args raw length', argsOut.length) + // Parse frame arguments safely, splitting on top-level frame boundaries + const argsListMatch = argsOut.match(/stack-args=\[([\s\S]*)\]/) + /** @type {{ level?: string; args: FrameArg[] }[]} */ + let frameArgs = [] + if (argsListMatch) { + const content = argsListMatch[1] + // Split on '},frame={' to avoid inner brace conflicts + const parts = content.split(/},\s*frame=\{/).map((part, idx) => { + if (idx === 0) { + return part + '}' + } + return 'frame={' + part + '}' + }) + frameArgs = parts.map((frameStr) => { + const rawMatch = frameStr.match(/frame=\{([\s\S]*)\}/) + const raw = rawMatch ? rawMatch[1] : '' + /** @type {{ level?: string; args: FrameArg[] }} */ + const obj = { args: [] } + // extract frame level + const levelMatch = raw.match(/level="(\d+)"/) + if (levelMatch) { + obj.level = levelMatch[1] + } + // extract args array content + const argsMatchInner = raw.match(/args=\[([\s\S]*)\]/) + if (argsMatchInner && argsMatchInner[1].trim()) { + const argsContent = argsMatchInner[1] + const argRegex = + /\{name="([^"]+)",type="([^"]+)",value="([^"]+)"\}/g + let m + while ((m = argRegex.exec(argsContent))) { + obj.args.push({ name: m[1], type: m[2], value: m[3] }) + } + } + return obj + }) + } else { + frameArgs = [] + } + + const btParsed = parseBacktrace(btOut) + const stacktraceLines = btParsed.map((frame, index) => { + const args = frameArgs[index]?.args || '' + return { + regAddr: frame.addr, + lineNumber: frame.line ?? '??', + ...(frame.func && frame.file + ? { method: frame.func, file: frame.file, args } + : {}), + } + }) + log('stacktrace lines', tid, stacktraceLines.length) + + results.push({ + threadId: tid, + TCB: threadTcbs[tid], + result: { + faultInfo: { + coreId: parseInt(tid), + programCounter: { + addr: programCounter, + location: stacktraceLines[0] ?? { + regAddr: toHexString(programCounter), + lineNumber: '??', + }, + }, + faultCode: undefined, // Xtensa includes exccause, but the RISC-V variant does not include mcause, mtval, etc. + }, + regs: regsAsNamed, + stacktraceLines, + globals, + }, + current: tid === currentThreadId, + }) + } + } finally { + client.close() + } + + if (!results.length && tryRepair) { + const raw = await fs.readFile(input.inputPath) + const fallback = await tryRawElfFallback(params, raw) + if (fallback) { + return fallback + } + } + + return results +} diff --git a/src/vendor/trbr/decode/decode.js b/src/vendor/trbr/decode/decode.js new file mode 100644 index 0000000..3941b51 --- /dev/null +++ b/src/vendor/trbr/decode/decode.js @@ -0,0 +1,643 @@ +// @ts-check + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { pipeline } from 'node:stream/promises' + +import { AbortError } from '../abort.js' +import { addr2line } from './addr2Line.js' +import { decodeCoredump } from './coredump.js' +import { texts } from './decode.text.js' +import { isCoredumpModeParams } from './decodeParams.js' +import { riscvDecoders } from './riscv.js' +import { decodeXtensa } from './xtensa.js' + +/** @typedef {import('./decodeParams.js').DecodeParams} DecodeParams */ +/** @typedef {import('./decodeParams.js').DecodeCoredumpParams} DecodeCoredumpParams */ + +/** @typedef {string} RegAddr `'0x12345678'` or `'this::loop'` */ + +/** + * @typedef {Object} GDBLine + * @property {RegAddr} regAddr + * @property {string} lineNumber `'36'` or `'??'` + */ + +/** + * @typedef {Object} FrameArg + * @property {string} name - When name and value are absent, type is the name + * @property {string} [type] + * @property {string} [value] + */ + +/** + * @typedef {Object} FrameVar + * @property {string} name + * @property {string} [type] + * @property {string} [value] + * @property {string} [address] + * @property {FrameVar[]} [children] + * @property {'local' | 'argument' | 'global'} [scope] + */ + +/** + * @typedef {GDBLine & { + * file: string + * method: string + * args?: FrameArg[] + * locals?: FrameVar[] + * globals?: FrameVar[] + * }} ParsedGDBLine + */ + +/** @typedef {RegAddr | GDBLine | ParsedGDBLine} AddrLocation */ + +/** + * @typedef {Object} AddrLine + * @property {number} [addr] + * @property {AddrLocation} location + */ + +/** + * @param {unknown} arg + * @returns {arg is AddrLine} + */ +export function isAddrLine(arg) { + return ( + arg !== null && + typeof arg === 'object' && + 'addr' in arg && + (typeof arg.addr === 'number' || arg.addr === undefined) && + 'location' in arg && + (typeof arg.location === 'string' || + isGDBLine(arg.location) || + isParsedGDBLine(arg.location)) + ) +} + +/** @param {AddrLocation} [addrLocation] */ +// TODO: is it needed? +export function getAddr(addrLocation) { + if (!addrLocation) { + return undefined + } + const parsedAddr = parseInt( + isGDBLine(addrLocation) ? addrLocation.regAddr : (addrLocation ?? '0'), + 16 + ) + return isNaN(parsedAddr) ? undefined : parsedAddr +} + +/** @param {AddrLocation} addrLocation */ +export function stringifyAddr(addrLocation) { + if (isParsedGDBLine(addrLocation)) { + return `${addrLocation.regAddr} in ${addrLocation.method} at ${addrLocation.file}:${addrLocation.lineNumber}` + } + if (isGDBLine(addrLocation)) { + return `${addrLocation.regAddr} in ${addrLocation.lineNumber}` + } + return `${addrLocation} in ?? ()` +} + +/** + * @callback DecodeFunction + * @param {DecodeParams} params + * @param {DecodeFunctionInput} input + * @param {DecodeOptions} [options] + * @returns {Promise} + */ + +/** + * @typedef {Object} DecodeInputFileSource + * @property {string} inputPath + */ + +/** + * @typedef {Object} DecodeInputStreamSource + * @property {NodeJS.ReadableStream} inputStream + */ + +/** + * @param {unknown} arg + * @returns {arg is DecodeInputFileSource} + */ +export function isDecodeInputFileSource(arg) { + return ( + arg !== null && + typeof arg === 'object' && + 'inputPath' in arg && + typeof arg.inputPath === 'string' + ) +} + +/** + * @param {unknown} arg + * @returns {arg is DecodeInputStreamSource} + */ +export function isDecodeInputStreamSource(arg) { + return ( + arg !== null && + typeof arg === 'object' && + 'inputStream' in arg && + arg.inputStream instanceof require('stream').Readable + ) +} + +/** @typedef {Awaited>[number] | string} DecodeFunctionInput */ + +/** + * @typedef {DecodeInputFileSource + * | DecodeInputStreamSource + * | DecodeFunctionInput} DecodeInput + */ + +/** + * @typedef {Object} AllocInfo + * @property {AddrLocation} allocAddr + * @property {number} allocSize + */ + +/** + * @typedef {Object} FaultInfo + * @property {number} coreId + * @property {AddrLine} programCounter PC at fault (PC for ESP32, MEPC for + * RISC-V, EPC1 for ESP8266) + * @property {AddrLine} [faultAddr] EXCVADDR for ESP32, EXCVADDR for RISC-V and + * ESP8266 + * @property {number} [faultCode] EXCCAUSE for ESP32, EXCCODE for RISC-V + * @property {string} [faultMessage] + */ + +/** + * @typedef {Object} DecodeResult + * @property {FaultInfo} [faultInfo] + * @property {Record} [regs] + * @property {(GDBLine | ParsedGDBLine)[]} stacktraceLines + * @property {AllocInfo} [allocInfo] + * @property {FrameVar[]} [globals] + */ + +/** + * @typedef {Object} DecodeOptions + * @property {AbortSignal} [signal] + * @property {Debug} [debug] + * @property {boolean} [includeFrameVars] Enables heavy variable collection + * (locals/globals). Default is `false`. + */ + +/** + * @callback Debug + * @param {any} formatter + * @param {...any} args + * @returns {void} + */ + +/** + * @typedef {Object} PanicInfo + * @property {number} coreId + * @property {number} [programCounter] + * @property {number} [faultAddr] + * @property {number} [faultCode] + * @property {Record} regs + */ + +/** + * @typedef {PanicInfo & { + * stackBaseAddr: number + * stackData: Buffer + * target: keyof typeof riscvDecoders + * }} PanicInfoWithStackData + */ + +/** + * @typedef {PanicInfo & { + * backtraceAddrs: (AddrLine | number)[] + * }} PanicInfoWithBacktrace + */ + +/** + * @callback DecodeCoredumpFunction + * @param {DecodeParams} params + * @param {string} coredumpInput + * @param {DecodeOptions} options + * @returns {Promise<(PanicInfoWithBacktrace | PanicInfoWithStackData)[]>} + */ + +export const defaultTargetArch = /** @type {const} */ ('xtensa') + +/** @typedef {import('../tool.js').DecodeTarget} DecodeTarget */ + +const envDebugEnabled = process.env.TRBR_DEBUG === 'true' +const decodeLogPrefix = '[trbr][decode]' + +/** + * @param {Debug | undefined} debug + * @returns {Debug} + */ +function createDecodeLogger(debug) { + const writer = debug ?? (envDebugEnabled ? console.log : undefined) + return writer ? (...args) => writer(decodeLogPrefix, ...args) : () => {} +} + +const decoders = /** @type {const} */ ({ + [defaultTargetArch]: decodeXtensa, + ...riscvDecoders, +}) + +export const arches = /** @type {DecodeTarget[]} */ (Object.keys(decoders)) + +/** + * @param {unknown} arg + * @returns {arg is DecodeTarget} + */ +export function isDecodeTarget(arg) { + return typeof arg === 'string' && arg in decoders +} + +/** + * @param {DecodeParams} params + * @param {DecodeInput} decodeInput + * @param {DecodeOptions} options + * @returns {Promise< + * DecodeResult | import('./coredump.js').CoredumpDecodeResult + * >} + */ +export async function decode( + params, + decodeInput, + options = { debug: () => {}, signal: new AbortController().signal } +) { + const log = createDecodeLogger(options?.debug) + log('start', { + targetArch: params?.targetArch, + coredumpMode: isCoredumpModeParams(params), + inputType: typeof decodeInput, + }) + /** @type {(() => Promise)[]} */ + const toDispose = [] + + try { + if (isCoredumpModeParams(params)) { + let coredumpInput + if (isDecodeInputFileSource(decodeInput)) { + coredumpInput = decodeInput.inputPath + } else if (isDecodeInputStreamSource(decodeInput)) { + const tmpDirPath = await fs.mkdtemp(path.join(os.tmpdir(), 'trbr-')) + coredumpInput = path.join(tmpDirPath, 'coredump.elf') + toDispose.push(async () => { + try { + await fs.rm(tmpDirPath, { recursive: true, force: true }) + } catch (err) { + console.error('Failed to delete temporary coredump directory:', err) + } + }) + const fd = await fs.open(coredumpInput, 'w') + /** @type {import('node:fs').WriteStream | undefined} */ + let target + try { + target = fd.createWriteStream() + await pipeline(decodeInput.inputStream, target) + } finally { + Promise.allSettled([ + fd.close(), + new Promise((resolve, reject) => + target?.close((err) => { + if (err) { + reject(err) + } else { + resolve(undefined) + } + }) + ), + ]).then((cleanupTasks) => + cleanupTasks.forEach((task) => { + if (task.status === 'rejected') { + console.error('Failed to close stream:', task.reason) + } + }) + ) + } + } + if (!coredumpInput) { + throw new Error( + `Could not determine coredump path from input: ${JSON.stringify( + decodeInput + )}` + ) + } + + const result = await decodeCoredump( + params, + { inputPath: coredumpInput }, + options + ) + + return result.map((threadDecodeResult) => ({ + ...threadDecodeResult, + ...fixDecodeResult(threadDecodeResult.result), + })) + } + + const { targetArch } = params + const decoder = decoders[targetArch] + if (!decoder) { + throw new Error(texts.unsupportedTargetArch(targetArch)) + } + + /** @type {DecodeFunctionInput} */ + let input + if (typeof decodeInput === 'string') { + input = decodeInput + } else if (isDecodeInputFileSource(decodeInput)) { + input = await fs.readFile(decodeInput.inputPath, 'utf8') + } else if (isDecodeInputStreamSource(decodeInput)) { + input = '' + for await (const chunk of decodeInput.inputStream) { + input = chunk.toString() + } + } else { + input = decodeInput + } + + const result = await decoder(params, input, options) + const fixedResult = fixDecodeResult(result) + if (options.signal?.aborted) { + throw new AbortError() + } + return fixedResult + } catch (err) { + if (err instanceof Error && 'code' in err && err.code === 'ABORT_ERR') { + throw new AbortError() + } + throw err + } finally { + for (const dispose of toDispose) { + try { + await dispose() + } catch (err) { + console.error('Failed to dispose resource:', err) + } + } + } +} + +/** @param {DecodeResult} result */ +function fixDecodeResult(result) { + const fixedPathsResult = fixWindowsPaths(result) + let filteredResult = filterFreeRTOSStackLines(fixedPathsResult) + filteredResult = filterStackPointerLines(filteredResult) // let users decide if they want to filter stack pointer lines + const fixedFaultInfoResult = fixFaultInfo(filteredResult) + const dedupedResult = dedupeGDBLines(fixedFaultInfoResult) + return dedupedResult +} + +/** + * @param {unknown} arg + * @returns {arg is GDBLine} + */ +export function isGDBLine(arg) { + return ( + arg !== null && + typeof arg === 'object' && + 'regAddr' in arg && + typeof arg.regAddr === 'string' && + 'lineNumber' in arg && + typeof arg.lineNumber === 'string' + ) +} + +/** + * @param {unknown} arg + * @returns {arg is ParsedGDBLine} + */ +export function isParsedGDBLine(arg) { + return ( + isGDBLine(arg) && + 'file' in arg && + typeof arg.file === 'string' && + 'method' in arg && + typeof arg.method === 'string' + ) +} + +/** + * @param {DecodeResult} result + * @returns {DecodeResult} + */ +function fixWindowsPaths(result) { + return { + ...result, + faultInfo: result.faultInfo + ? { + ...result.faultInfo, + programCounter: fixWindowsPathInLocation( + result.faultInfo.programCounter + ), + faultAddr: fixWindowsPathInLocation(result.faultInfo.faultAddr), + } + : undefined, + stacktraceLines: result.stacktraceLines.map(fixWindowsPathInLocation), + allocInfo: result.allocInfo + ? { + ...result.allocInfo, + allocAddr: fixWindowsPathInLocation(result.allocInfo.allocAddr), + } + : undefined, + } +} + +/** + * @template {AddrLine | AddrLocation | undefined} T + * @param {T} locationAware + * @returns {T} + */ +function fixWindowsPathInLocation(locationAware) { + if (!locationAware) { + return locationAware + } + + if (isAddrLine(locationAware)) { + const location = locationAware.location + if (isParsedGDBLine(location)) { + const copy = JSON.parse(JSON.stringify(locationAware)) + copy.location.file = fixWindowsPath(location.file) + return copy + } + } + + if (isParsedGDBLine(locationAware)) { + const copy = JSON.parse(JSON.stringify(locationAware)) + copy.file = fixWindowsPath(locationAware.file) + return copy + } + + return locationAware +} + +// To fix the path separator issue on Windows: +// - "file": "D:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1/riscv_1.ino" +// + "file": "d:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1\\riscv_1.ino" +/** @param {string} path */ +function fixWindowsPath(path) { + return process.platform === 'win32' && /^[a-zA-Z]:\\/.test(path) + ? path.replace(/\//g, '\\') + : path +} + +/** (non-API) */ +export const __tests = /** @type {const} */ ({ + fixWindowsPath, + fixWindowsPaths, +}) + +/** + * Debug utility to log all decoded address info using addr2line. + * + * @param {string} toolPath + * @param {string} elfPath + * @param {number[]} rawAddresses + */ +export async function debugAllAddrs(toolPath, elfPath, rawAddresses) { + const lines = await addr2line({ toolPath, elfPath }, rawAddresses) + console.log('Decoded Addresses:') + console.log('done') + console.log('----------------------') + const padding = String(lines.length - 1).length + console.log( + lines + .map((line) => line.location) + .map(stringifyAddr) + .map( + (line, index) => `#${index.toString().padStart(padding, ' ')} ${line}` + ) + .join('\n') + ) + console.log('----------------------') +} + +/** + * @param {GDBLine | ParsedGDBLine} left + * @param {GDBLine | ParsedGDBLine} right + */ +function equalsGDBLine(left, right) { + if (isParsedGDBLine(left) && !isParsedGDBLine(right)) { + return false + } + if (!isParsedGDBLine(left) && isParsedGDBLine(right)) { + return false + } + + if (isParsedGDBLine(left) && isParsedGDBLine(right)) { + return ( + left.regAddr === right.regAddr && + left.lineNumber === right.lineNumber && + left.file === right.file && + left.method === right.method + ) + } + + return left.regAddr === right.regAddr && left.lineNumber === right.lineNumber +} + +/** + * @param {DecodeResult} result + * @returns + */ +function filterFreeRTOSStackLines(result) { + return { + ...result, + stacktraceLines: result.stacktraceLines.filter((line) => { + if ( + isGDBLine(line) && + line.lineNumber === '??' && + line.regAddr.toLowerCase() === '0xfeefeffe' + ) { + return false + } + return true + }), + } +} + +/** + * @param {DecodeResult} result + * @returns {DecodeResult} + */ +function filterStackPointerLines(result) { + return { + ...result, + stacktraceLines: result.stacktraceLines.reduce( + (acc, currentLine, index, thisArray) => { + const prevLine = thisArray[index - 1] + if (prevLine && isStackPointerLine(currentLine, prevLine)) { + return acc + } + return [...acc, currentLine] + }, + /** @type {(GDBLine | ParsedGDBLine)[]} */ ([]) + ), + } +} + +/** + * @param {GDBLine | ParsedGDBLine} line + * @param {GDBLine | ParsedGDBLine} prevLine + * @returns + */ +function isStackPointerLine(line, prevLine) { + if (!isParsedGDBLine(prevLine)) { + return false + } + + const prevAddr = getAddr(prevLine.regAddr) + const addr = getAddr(line.regAddr) + if (addr === undefined || prevAddr === undefined) { + return false + } + + const isAddr3x = addr >>> 28 === 0x3 + const isPrevAddr4x = prevAddr >>> 28 === 0x4 + + return line.lineNumber === '??' && isAddr3x && isPrevAddr4x +} + +/** + * @param {DecodeResult} result + * @returns {DecodeResult} + */ +function dedupeGDBLines(result) { + return { + ...result, + stacktraceLines: result.stacktraceLines.reduce( + (acc, currentLine, index) => { + const previousLine = acc[index - 1] + if (previousLine && equalsGDBLine(previousLine, currentLine)) { + return acc + } + return [...acc, currentLine] + }, + /** @type {(GDBLine | ParsedGDBLine)[]} */ ([]) + ), + } +} + +/** + * @param {DecodeResult} result + * @returns {DecodeResult} + */ +function fixFaultInfo(result) { + if ( + result.faultInfo && + isGDBLine(result.faultInfo.faultAddr?.location) && + result.faultInfo.faultAddr.location.lineNumber === '??' && + !result.faultInfo.faultAddr.addr + ) { + // removes {regAddr: '0x00000000', lineNumber: '??'} + const copy = JSON.parse(JSON.stringify(result)) + delete copy.faultInfo.faultAddr + return copy + } + + return result +} diff --git a/src/vendor/trbr/decode/decode.text.js b/src/vendor/trbr/decode/decode.text.js new file mode 100644 index 0000000..ae60b2e --- /dev/null +++ b/src/vendor/trbr/decode/decode.text.js @@ -0,0 +1,6 @@ +// @ts-check + +export const texts = { + unsupportedTargetArch: (/** @type {string} */ targetArch) => + `Unsupported decode target: ${targetArch}`, +} diff --git a/src/vendor/trbr/decode/decodeParams.js b/src/vendor/trbr/decode/decodeParams.js new file mode 100644 index 0000000..ef8bc76 --- /dev/null +++ b/src/vendor/trbr/decode/decodeParams.js @@ -0,0 +1,195 @@ +// @ts-check + +import { + defaultTargetArch, + findTargetArch, + resolveBuildProperties, + resolveToolPath, +} from '../tool.js' + +/** @typedef {import('../tool.js').DecodeTarget} DecodeTarget */ +/** @typedef {import('fqbn').FQBN} FQBN */ + +// --- Provides + +/** + * @typedef {Object} DecodeParams + * @property {string} toolPath + * @property {string} elfPath + * @property {DecodeTarget} targetArch + */ + +/** @typedef {DecodeParams & CoredumpMode} DecodeCoredumpParams */ + +// --- Base + +/** + * @typedef {Object} CreateDecodeParamsParams + * @property {string} elfPath + */ + +/** + * @typedef {Object} ArduinoCliParams + * @property {string} arduinoCliPath + * @property {string} [arduinoCliConfigPath] + * @property {string} [additionalUrls] + */ + +/** + * @typedef {Object} ToolParams + * @property {string} toolPath + * @property {DecodeTarget} [targetArch] + */ + +/** + * @typedef {Object} CoredumpMode + * @property {true} coredumpMode + */ + +/** + * @typedef {Object} BacktraceMode + * @property {false} [coredumpMode] + */ + +/** + * @typedef {Object} WithFQBN + * @property {FQBN} fqbn + */ + +/** + * @typedef {WithFQBN & { + * buildProperties: Record + * }} WithBuildProperties + */ + +// --- Backtrace + +/** @typedef {CreateDecodeParamsParams & ToolParams & BacktraceMode} CreateDecodeParamsFromToolParams */ +/** + * @typedef {CreateDecodeParamsParams & + * ArduinoCliParams & + * WithFQBN & + * BacktraceMode} CreateDecodeParamsFromFQBNParams + */ +/** @typedef {CreateDecodeParamsParams & WithBuildProperties & BacktraceMode} CreateDecodeParamsFromBuildPropertiesParams */ +/** + * @typedef {CreateDecodeParamsFromToolParams + * | CreateDecodeParamsFromFQBNParams + * | CreateDecodeParamsFromBuildPropertiesParams} CreateDecodeParamsFromParams + */ + +/** + * @callback CreateDecodeParams + * @param {CreateDecodeParamsFromParams} params + * @returns {Promise} + */ + +// --- Coredump + +/** @typedef {CreateDecodeParamsParams & ToolParams & CoredumpMode} CreateCoredumpDecodeParamsFromToolParams */ +/** + * @typedef {CreateDecodeParamsParams & + * ArduinoCliParams & + * WithFQBN & + * CoredumpMode} CreateCoredumpDecodeParamsFromFQBNParams + */ +/** @typedef {CreateDecodeParamsParams & WithBuildProperties & CoredumpMode} CreateCoredumpDecodeParamsFromBuildPropertiesParams */ +/** + * @typedef {CreateCoredumpDecodeParamsFromToolParams + * | CreateCoredumpDecodeParamsFromFQBNParams + * | CreateCoredumpDecodeParamsFromBuildPropertiesParams} CreateCoredumpDecodeParamsFromParams + */ + +/** + * @callback CreateCoredumpDecodeParams + * @param {CreateCoredumpDecodeParamsFromParams} params + * @returns {Promise} + */ + +/** + * @param {CreateDecodeParamsParams} params + * @returns {params is CreateCoredumpDecodeParamsFromParams} + */ +export function isCoredumpModeParams(params) { + return 'coredumpMode' in params && Boolean(params.coredumpMode) +} + +/** + * @param {CreateDecodeParamsParams} params + * @returns {params is CreateDecodeParamsFromToolParams|CreateCoredumpDecodeParamsFromToolParams} + */ +function isToolPathParams(params) { + return 'toolPath' in params && typeof params.toolPath === 'string' +} + +/** + * @param {CreateDecodeParamsParams} params + * @returns {params is CreateDecodeParamsFromBuildPropertiesParams|CreateCoredumpDecodeParamsFromBuildPropertiesParams} + */ +function isBuildPropertiesParams(params) { + return ( + 'buildProperties' in params && typeof params.buildProperties === 'object' + ) +} + +/** + * @param {CreateDecodeParamsParams} params + * @returns {params is CreateDecodeParamsFromFQBNParams|CreateCoredumpDecodeParamsFromFQBNParams} + */ +function isArduinoCliParams(params) { + return 'arduinoCliPath' in params && typeof params.arduinoCliPath === 'string' +} + +/** + * @overload + * @param {CreateDecodeParamsFromParams} params + * @returns {Promise} + */ +/** + * @overload + * @param {CreateCoredumpDecodeParamsFromParams} params + * @returns {Promise} + */ +/** + * @param {CreateDecodeParamsFromParams + * | CreateCoredumpDecodeParamsFromParams} params + * @returns {Promise} + */ +export async function createDecodeParams(params) { + /** @type {string | undefined} */ + let toolPath + /** @type {DecodeTarget | undefined} */ + let targetArch + + if (isToolPathParams(params)) { + toolPath = params.toolPath + targetArch = params.targetArch ?? defaultTargetArch + } else if (isBuildPropertiesParams(params)) { + toolPath = await resolveToolPath(params) + targetArch = findTargetArch(params) + } else if (isArduinoCliParams(params)) { + const buildProperties = await resolveBuildProperties(params) + toolPath = await resolveToolPath({ + fqbn: params.fqbn, + buildProperties, + }) + targetArch = findTargetArch({ buildProperties }) + } else { + throw new Error( + `Unexpected create decode params input: ${JSON.stringify(params)}` + ) + } + + /** @type {DecodeParams} */ + const decodeParams = { + elfPath: params.elfPath, + toolPath, + targetArch, + } + + if (!isCoredumpModeParams(params)) { + return decodeParams + } + + return { ...decodeParams, coredumpMode: true } +} diff --git a/src/vendor/trbr/decode/gdbMi.js b/src/vendor/trbr/decode/gdbMi.js new file mode 100644 index 0000000..026947e --- /dev/null +++ b/src/vendor/trbr/decode/gdbMi.js @@ -0,0 +1,378 @@ +// @ts-check + +import cp from 'node:child_process' + +import { AbortError } from '../abort.js' + +let clientSeq = 0 + +/** + * @param {number} id + * @param {import('./decode.js').Debug | undefined} debug + * @returns {(...args: unknown[]) => void} + */ +function createLogger(id, debug) { + const prefix = `[trbr][gdb-mi:${id}]` + const writer = + debug ?? (process.env.TRBR_DEBUG === 'true' ? console.log : undefined) + return writer ? (...args) => writer(prefix, ...args) : () => {} +} + +/** + * @param {string} text + * @param {number} [limit=400] Default is `400` + * @returns {string} + */ +function preview(text, limit = 400) { + if (text.length <= limit) { + return text + } + return `${text.slice(0, limit)}...[truncated ${text.length - limit} chars]` +} + +/** Minimal GDB MI client for queueing commands and reading result records. */ +export class GdbMiClient { + /** + * @param {string} gdbPath + * @param {string[]} args + * @param {import('./decode.js').DecodeOptions} [options={}] Default is `{}` + */ + constructor(gdbPath, args, options = {}) { + this.cp = cp.spawn(gdbPath, args, { stdio: 'pipe', signal: options.signal }) + this.id = ++clientSeq + this.log = createLogger(this.id, options.debug) + this.log('spawn', gdbPath, args.join(' ')) + /** @type {Error | undefined} */ + this.error = undefined + /** + * @type {{ + * resolve: (value: string) => void + * reject: (err: Error) => void + * command: string + * startedAt: number + * }[]} + */ + this.commandQueue = [] + + this.signal = options.signal + if (this.signal) { + this.signal.addEventListener('abort', () => { + const abortErr = new AbortError() + this.error = abortErr + this.log('abort signal received') + this.commandQueue.forEach((executor) => executor.reject(abortErr)) + this.commandQueue = [] + }) + } + + this.stdoutBuffer = '' + this.cp.stdout.on('data', (chunk) => this._onData(chunk)) + this.cp.stderr.on('data', (chunk) => this._onData(chunk)) + this.cp.on('error', (err) => { + this.error = err + this.log('process error', err) + this.commandQueue.forEach((executor) => executor.reject(err)) + this.commandQueue = [] + }) + this.cp.on('exit', (code, signal) => { + this.log('process exit', { code, signal }) + }) + } + + /** + * @param {string} command + * @returns {Promise} + */ + sendCommand(command) { + if (this.error) { + return Promise.reject(this.error) + } + this.log('send', command) + return new Promise((resolve, reject) => { + const executor = { resolve, reject, command, startedAt: Date.now() } + this.commandQueue.push(executor) + this.cp.stdin.write(`${command}\n`) + }) + } + + /** @param {Buffer} chunk */ + _onData(chunk) { + if (this.error) { + const error = this.error + this.commandQueue.forEach((executor) => executor.reject(error)) + this.commandQueue = [] + } + + this.stdoutBuffer += chunk.toString() + if (/\(gdb\)\s*$/m.test(this.stdoutBuffer)) { + const output = this.stdoutBuffer + this.stdoutBuffer = '' + const executor = this.commandQueue.shift() + if (executor) { + const elapsed = Date.now() - executor.startedAt + this.log('recv', `${executor.command} (${elapsed}ms)`, preview(output)) + } else { + this.log('recv without queued command', preview(output)) + } + executor?.resolve(output) + } + } + + close() { + this.log('close') + this.cp.stdin.end() + this.cp.kill() + } + + /** @returns {Promise} */ + async drainHandshake() { + this.log('handshake start') + return new Promise((resolve, reject) => { + const startedAt = Date.now() + const onData = (/** @type {Buffer} */ chunk) => { + if (this.error) { + this.cp.stdout.off('data', onData) + this.log('handshake error', this.error) + reject(this.error) + return + } + this.stdoutBuffer += chunk.toString() + if (/\(gdb\)\s*$/m.test(this.stdoutBuffer)) { + this.cp.stdout.off('data', onData) + if (this.signal) { + this.signal.removeEventListener('abort', onAbort) + } + this.stdoutBuffer = '' + this.log('handshake done', `${Date.now() - startedAt}ms`) + resolve() + } + } + const onAbort = () => { + this.cp.stdout.off('data', onData) + this.signal?.removeEventListener('abort', onAbort) + this.log('handshake aborted') + reject(new AbortError()) + } + this.cp.stdout.on('data', onData) + this.signal?.addEventListener('abort', onAbort) + }) + } +} + +/** + * @param {string} str + * @param {string} key + * @returns {string | undefined} + */ +export function extractMiListContent(str, key) { + const keyPattern = `${key}=[` + const idx = str.indexOf(keyPattern) + if (idx < 0) { + return undefined + } + const start = str.indexOf('[', idx) + if (start < 0) { + return undefined + } + let depth = 0 + for (let i = start; i < str.length; i++) { + const c = str[i] + if (c === '[') { + depth++ + } else if (c === ']') { + depth-- + if (depth === 0) { + return str.substring(start + 1, i) + } + } + } + return undefined +} + +/** + * @param {string} listContent + * @returns {string[]} + */ +function splitMiListItems(listContent) { + const items = [] + let current = '' + let depth = 0 + let inQuotes = false + let escape = false + + for (const char of listContent) { + if (escape) { + current += char + escape = false + continue + } + + if (inQuotes && char === '\\') { + current += char + escape = true + continue + } + + if (char === '"') { + inQuotes = !inQuotes + current += char + continue + } + + if (!inQuotes) { + if (char === '{' || char === '[') { + depth++ + } else if (char === '}' || char === ']') { + depth = Math.max(0, depth - 1) + } else if (char === ',' && depth === 0) { + if (current.trim()) { + items.push(current.trim()) + } + current = '' + continue + } + } + + current += char + } + + if (current.trim()) { + items.push(current.trim()) + } + + return items +} + +/** + * @param {string} value + * @returns {string} + */ +function unescapeMiString(value) { + let result = '' + + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (char !== '\\') { + result += char + continue + } + + const next = value[i + 1] + if (!next) { + result += '\\' + continue + } + i++ + + switch (next) { + case '\\': + result += '\\' + break + case '"': + result += '"' + break + case 'n': + result += '\n' + break + case 'r': + result += '\r' + break + case 't': + result += '\t' + break + default: + result += next + break + } + } + + return result +} + +/** + * @param {string} tupleContent + * @returns {Record} + */ +function parseMiTuple(tupleContent) { + /** @type {Record} */ + const result = {} + for (const field of splitMiListItems(tupleContent)) { + const idx = field.indexOf('=') + if (idx === -1) { + continue + } + const key = field.slice(0, idx).trim() + const rawValue = field.slice(idx + 1).trim() + const value = + rawValue.startsWith('"') && rawValue.endsWith('"') + ? unescapeMiString(rawValue.slice(1, -1)) + : rawValue + result[key] = value + } + return result +} + +/** + * @param {string} raw + * @returns {Record} + */ +export function parseMiResultRecord(raw) { + const match = raw.match(/(?:^|\n)\d*\^done(?:,([^\r\n]*))?/) + if (!match) { + return {} + } + const content = (match[1] ?? '').trim() + if (!content) { + return {} + } + return parseMiTuple(content) +} + +/** + * @param {string | undefined} value + * @returns {string | undefined} + */ +export function stripMiList(value) { + if (!value) { + return undefined + } + const trimmed = value.trim() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed.slice(1, -1) + } + return undefined +} + +/** + * @param {string | undefined} listContent + * @param {string} [tupleKey] + * @returns {Record[]} + */ +export function parseMiTupleList(listContent, tupleKey) { + if (!listContent) { + return [] + } + const items = splitMiListItems(listContent) + /** @type {Record[]} */ + const tuples = [] + + for (const item of items) { + const trimmed = item.trim() + let tupleBody + if (tupleKey) { + const prefix = `${tupleKey}={` + if (!trimmed.startsWith(prefix) || !trimmed.endsWith('}')) { + continue + } + tupleBody = trimmed.slice(prefix.length, -1) + } else { + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + continue + } + tupleBody = trimmed.slice(1, -1) + } + tuples.push(parseMiTuple(tupleBody)) + } + + return tuples +} diff --git a/src/vendor/trbr/decode/globals.js b/src/vendor/trbr/decode/globals.js new file mode 100644 index 0000000..74031ca --- /dev/null +++ b/src/vendor/trbr/decode/globals.js @@ -0,0 +1,335 @@ +// @ts-check + +import { GdbMiClient, extractMiListContent, parseMiTupleList } from './gdbMi.js' + +/** @typedef {import('./decode.js').DecodeParams} DecodeParams */ +/** @typedef {import('./decode.js').DecodeOptions} DecodeOptions */ +/** @typedef {import('./decode.js').FrameVar} FrameVar */ +/** @typedef {import('./decode.js').Debug} Debug */ + +const envDebugEnabled = process.env.TRBR_DEBUG === 'true' +const allowInfoFallback = + process.env.TRBR_GLOBALS_ALLOW_INFO_VARIABLES === 'true' +const miErrorPattern = /^\^error/m +const miUnsupportedPattern = /code="undefined-command"/ +const globalsLogPrefix = '[trbr][globals]' +const defaultGlobalsTimeoutMs = 20_000 +const xtensaLx106ToolHint = 'xtensa-lx106-elf-gdb' + +/** + * @param {Debug | undefined} debug + * @returns {Debug} + */ +function createGlobalsLogger(debug) { + const writer = debug ?? (envDebugEnabled ? console.log : undefined) + return writer ? (...args) => writer(globalsLogPrefix, ...args) : () => {} +} + +/** @returns {number} */ +function getGlobalsTimeoutMs() { + const raw = process.env.TRBR_GLOBALS_TIMEOUT_MS + if (!raw) { + return defaultGlobalsTimeoutMs + } + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : defaultGlobalsTimeoutMs +} + +/** + * @param {Pick & { + * coredumpMode?: boolean + * }} params + * @returns {boolean} + */ +function shouldAllowInfoFallback(params) { + if (allowInfoFallback) { + return true + } + return Boolean(params.coredumpMode) +} + +/** + * @param {string} value + * @returns {string} + */ +function unescapeMiString(value) { + return value + .replace(/\\\\/g, '\\') + .replace(/\\"/g, '"') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') +} + +/** + * @param {string} raw + * @returns {string} + */ +function extractMiConsoleText(raw) { + let text = '' + for (const line of raw.split(/\r?\n/)) { + const match = line.match(/^~"(.*)"$/) + if (!match) { + continue + } + text += unescapeMiString(match[1]) + } + return text +} + +/** + * @param {string} line + * @returns {{ name: string; type?: string; address?: string } | undefined} + */ +function parseVariableLine(line) { + const trimmed = line.trim() + if (!trimmed) { + return undefined + } + + const addrMatch = trimmed.match(/^(0x[0-9a-fA-F]+)\s+(.+)$/) + if (addrMatch) { + return { address: addrMatch[1], name: addrMatch[2].trim() } + } + + const sanitized = trimmed.endsWith(';') + ? trimmed.slice(0, -1).trim() + : trimmed + const lastSpace = sanitized.lastIndexOf(' ') + if (lastSpace === -1) { + return undefined + } + + let rawName = sanitized.slice(lastSpace + 1).trim() + let type = sanitized.slice(0, lastSpace).trim() + if (!rawName) { + return undefined + } + + const pointerPrefix = rawName.match(/^[*&]+/) + if (pointerPrefix) { + rawName = rawName.slice(pointerPrefix[0].length) + type = `${type} ${pointerPrefix[0]}`.trim() + } + + const arrayMatch = rawName.match(/^(.*)(\[[^\]]+\])$/) + if (arrayMatch) { + rawName = arrayMatch[1] + type = `${type} ${arrayMatch[2]}`.trim() + } + + if (!rawName) { + return undefined + } + + return { name: rawName, type } +} + +/** + * @param {string} consoleText + * @returns {FrameVar[]} + */ +function parseInfoVariables(consoleText) { + /** @type {FrameVar[]} */ + const vars = [] + for (const line of consoleText.split(/\r?\n/)) { + const trimmed = line.trim() + if ( + !trimmed || + trimmed.endsWith(':') || + trimmed.startsWith('All defined variables') || + trimmed.startsWith('File ') || + trimmed.startsWith('Non-debugging symbols') + ) { + continue + } + + const parsed = parseVariableLine(trimmed) + if (!parsed?.name) { + continue + } + + vars.push({ + scope: 'global', + name: parsed.name, + type: parsed.type, + address: parsed.address, + }) + } + return vars +} + +/** + * @param {Record} tuple + * @returns {FrameVar | undefined} + */ +function toGlobalVar(tuple) { + if (!tuple.name) { + return undefined + } + /** @type {FrameVar} */ + const variable = { scope: 'global', name: tuple.name } + if (tuple.type) { + variable.type = tuple.type + } + if (tuple.addr || tuple.address) { + variable.address = tuple.addr || tuple.address + } + return variable +} + +/** + * @param {GdbMiClient} client + * @param {string} flag + * @returns {Promise} + */ +async function tryMiSymbolList(client, flag) { + const raw = await client.sendCommand(`-symbol-list-variables ${flag}`) + if (miErrorPattern.test(raw)) { + if (miUnsupportedPattern.test(raw)) { + return null + } + return undefined + } + + const listContent = extractMiListContent(raw, 'variables') + if (listContent === undefined) { + return [] + } + + return /** @type {FrameVar[]} */ ( + parseMiTupleList(listContent) + .map(toGlobalVar) + .filter((value) => Boolean(value)) + ) +} + +/** + * @param {FrameVar[]} vars + * @returns {FrameVar[]} + */ +function dedupeGlobals(vars) { + /** @type {Map} */ + const map = new Map() + for (const variable of vars) { + if (!variable.name) { + continue + } + const existing = map.get(variable.name) + if (!existing) { + map.set(variable.name, variable) + continue + } + if (!existing.address && variable.address) { + map.set(variable.name, { ...existing, ...variable }) + continue + } + if (!existing.type && variable.type) { + map.set(variable.name, { ...existing, ...variable }) + } + } + return Array.from(map.values()) +} + +/** + * @param {Pick & { + * coredumpMode?: boolean + * }} params + * @param {DecodeOptions} [options={}] Default is `{}` + * @returns {Promise} + */ +export async function listGlobalSymbols(params, options = {}) { + const { toolPath, elfPath } = params + const allowInfo = shouldAllowInfoFallback(params) + const log = createGlobalsLogger(options.debug) + log('start', { toolPath, elfPath }) + const client = new GdbMiClient( + toolPath, + ['--interpreter=mi2', '-n', elfPath], + options + ) + + try { + await client.drainHandshake() + + const globalVars = await tryMiSymbolList(client, '--global') + const staticVars = await tryMiSymbolList(client, '--static') + log('mi globals', globalVars ? globalVars.length : undefined) + log('mi statics', staticVars ? staticVars.length : undefined) + const miUnsupported = globalVars === null || staticVars === null + + let combined = [...(globalVars ?? []), ...(staticVars ?? [])] + + if (!combined.length && miUnsupported && !allowInfo) { + log('skip info variables fallback', { reason: 'mi-unsupported' }) + return [] + } + + if (!combined.length) { + log('fallback to info variables') + const infoRaw = await client.sendCommand( + '-interpreter-exec console "info variables"' + ) + combined = parseInfoVariables(extractMiConsoleText(infoRaw)) + log('info variables count', combined.length) + } + + const deduped = dedupeGlobals(combined) + log('done', deduped.length) + return deduped + } finally { + client.close() + } +} + +/** + * @param {Pick} params + * @param {DecodeOptions} [options={}] Default is `{}` + * @returns {Promise} + */ +export async function resolveGlobalSymbols(params, options = {}) { + const log = createGlobalsLogger(options.debug) + try { + if (params.toolPath.includes(xtensaLx106ToolHint)) { + log('skip globals for xtensa-lx106 gdb', params) + return [] + } + + const timeoutMs = getGlobalsTimeoutMs() + const controller = new AbortController() + let timedOut = false + const timeoutId = setTimeout(() => { + timedOut = true + controller.abort() + }, timeoutMs) + + const onAbort = () => { + if (!controller.signal.aborted) { + controller.abort() + } + } + options.signal?.addEventListener('abort', onAbort) + + log('resolve start', { timeoutMs }) + try { + return await listGlobalSymbols(params, { + ...options, + signal: controller.signal, + }) + } finally { + clearTimeout(timeoutId) + options.signal?.removeEventListener('abort', onAbort) + if (timedOut) { + log('resolve timeout', timeoutMs) + } + } + } catch (err) { + log('resolve error', err) + if (options.debug) { + options.debug('Failed to list global symbols:', err) + } + return [] + } +} diff --git a/src/vendor/trbr/decode/regAddr.js b/src/vendor/trbr/decode/regAddr.js new file mode 100644 index 0000000..6b0c3df --- /dev/null +++ b/src/vendor/trbr/decode/regAddr.js @@ -0,0 +1,207 @@ +// @ts-check + +import { isGDBLine, isParsedGDBLine } from './decode.js' + +/** @typedef {import('./decode.js').Debug} Debug */ + +const envDebugEnabled = process.env.TRBR_DEBUG === 'true' +const regAddrLogPrefix = '[trbr][reg-addr]' + +/** + * @param {Debug} [debug] + * @returns {Debug} + */ +function createRegAddrLogger(debug) { + const writer = debug ?? (envDebugEnabled ? console.log : undefined) + return writer ? (...args) => writer(regAddrLogPrefix, ...args) : () => {} +} + +/** + * @param {GDBLine | ParsedGDBLine | undefined} entry + * @returns {entry is GDBLine | ParsedGDBLine} + */ +function isParsedOrGdbLine(entry) { + return Boolean(entry) && (isGDBLine(entry) || isParsedGDBLine(entry)) +} + +/** + * Parses a method signature string into its name and argument list. + * + * @param {string} sig + * @returns {{ + * name: string + * args: { name: string; value?: string; type?: string }[] + * }} + */ +function parseMethodSignature(sig) { + const nameMatch = sig.match(/^([^(]+)\s*\((.*)\)$/s) + if (!nameMatch) { + return { name: sig.trim(), args: [] } + } + + const [, name, argsStr] = nameMatch + const args = + argsStr.match(/(?:[^,"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => { + const parts = arg.split('=') + if (parts.length === 2) { + const [name, value] = parts.map((s) => s.trim()) + return { name, value } + } else { + return { name: arg.trim() } + } + }) || [] + return { name: name.trim(), args } +} + +/** @typedef {string} RegAddr `'0x12345678'` or `'this::loop'` */ + +/** + * @typedef {Object} GDBLine + * @property {RegAddr} regAddr + * @property {string} lineNumber `'36'` or `'??'` + */ + +/** + * @typedef {GDBLine & { + * file: string + * method: string + * }} ParsedGDBLine + */ + +/** + * @param {string} stdout + * @param {Debug} [debug] + * @returns {(GDBLine | ParsedGDBLine)[]} + */ +export function parseLines(stdout, debug) { + const log = createRegAddrLogger(debug) + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => parseLine(line, log)) + .filter(isParsedOrGdbLine) +} + +/** + * @param {string} line + * @param {Debug} [log] + * @returns {GDBLine | ParsedGDBLine | undefined} + */ +export function parseLine(line, log = createRegAddrLogger()) { + log('parse line', line) + const patterns = [ + // GDB style with frame number and numeric file/line + /#\d+\s+(0x[0-9a-f]+)\s+in\s+([^(]+\((?:"(?:\\.|[^"\\])*"|[^()])*?\))\s+at\s+(.+):(\d+)/i, + // GDB style without frame number and numeric file/line + /(0x[0-9a-f]+)\s+in\s+([^(]+(?:\([^()]*\))?)\s+at\s+(.+):(\d+)/i, + // GDB style with frame number and ?? file/line + /(?:#\d+\s+)?(0x[0-9a-f]+)\s+in\s+([^(]+(?:\([^()]*\))?)\s+at\s+(\?+):(\?+)/i, + // "is in" format with numeric file/line + /(0x[0-9a-f]+)\s+is\s+in\s+([^(]+(?:\([^()]*\))?)\s+\((.+):(\d+)\)/i, + // "is in" format without line number + /(0x[0-9a-f]+)\s+is\s+in\s+([^(]+(?:\([^()]*\))?)\s+\(([^():]+)\)/i, + // Address with "is at" and numeric file/line + /(0x[0-9a-f]+)\s+is\s+at\s+(.+):(\d+)/i, + // Method with file/line but no address + /(?:#\d+\s+)?([^(]+(?:\([^()]*\))?)\s+at\s+(.+):(\d+)/, + ] + + for (const [i, pattern] of patterns.entries()) { + const match = line.match(pattern) + if (match) { + // Numeric file/line after "in" + if (i === 0 || i === 1) { + const [, regAddr, method, file, lineNumber] = match + return normalizeParsedLine({ regAddr, method, file, lineNumber }) + } + // ?? file/line after "in" + if (i === 2) { + const [, regAddr, method, file, lineNumber] = match + return normalizeParsedLine({ regAddr, method, file, lineNumber }) + } + // "is in" with numeric file/line + if (i === 3) { + const [, regAddr, method, file, lineNumber] = match + return normalizeParsedLine({ regAddr, method, file, lineNumber }) + } + // "is in" without line number + if (i === 4) { + const [, regAddr, method, file] = match + return normalizeParsedLine({ regAddr, method, file, lineNumber: '??' }) + } + // "is at" with numeric file/line + if (i === 5) { + const [, regAddr, file, lineNumber] = match + return normalizeParsedLine({ regAddr, method: '??', file, lineNumber }) + } + // Method with file/line but no address + if (i === 6) { + const [, method, file, lineNumber] = match + return normalizeParsedLine({ regAddr: '??', method, file, lineNumber }) + } + } + } + // Fallback for addresses without file/line info + const fallbackMatch = line.match( + /(?:#\d+\s+)?(0x[0-9a-f]+)\s+(?:is\s+in|in)\s+([^(]+(?:\([^()]*\))?)/i + ) + if (fallbackMatch) { + const [, regAddr, method] = fallbackMatch + return normalizeParsedLine({ + regAddr, + method: method.trim(), + file: '??', + lineNumber: '??', + }) + } + log('no pattern matched', line) + return undefined +} + +/** + * Normalize a parsed GDB line entry. If both file and lineNumber are missing or + * unknown, omit them. If either is present but unknown, default to '??'. + * + * @param {ParsedGDBLine} entry + * @returns {GDBLine | ParsedGDBLine} + */ +/** + * Normalize a parsed GDB line entry. If both file and lineNumber are missing or + * unknown, omit them. If either is present but unknown, default to '??'. + * + * @param {ParsedGDBLine} entry + * @returns {GDBLine | ParsedGDBLine} + */ +function normalizeParsedLine(entry) { + const { file, lineNumber, method, regAddr } = entry + const parsedMethod = parseMethodSignature(method) + + const hasValidFile = file && file !== '' && file !== '??' + const hasValidLine = lineNumber && lineNumber !== '' && lineNumber !== '??' + + if (hasValidFile || hasValidLine) { + const result = { + regAddr, + method: parsedMethod.name, + file: hasValidFile ? file : '??', + lineNumber: hasValidLine ? lineNumber : '??', + } + if (parsedMethod.args && parsedMethod.args.length > 0) { + Object.assign(result, { args: parsedMethod.args }) + } + return result + } + + const hasValidMethod = + parsedMethod.name && + parsedMethod.name !== '' && + parsedMethod.name !== '??' && + parsedMethod.name !== '?? ()' + + if (!hasValidLine && hasValidMethod) { + return { regAddr, lineNumber: parsedMethod.name } + } + + return { regAddr, lineNumber: lineNumber || '??' } +} diff --git a/src/vendor/trbr/decode/regs.js b/src/vendor/trbr/decode/regs.js new file mode 100644 index 0000000..3addcb1 --- /dev/null +++ b/src/vendor/trbr/decode/regs.js @@ -0,0 +1,84 @@ +// @ts-check + +export const registerSets = /** @type {const} */ ({ + // Xtensa exception frame order used in ESP‑IDF v5 core dumps + xtensa: [ + 'PC', + 'PS', + // loop registers + SAR + 'LBEG', + 'LEND', + 'LCOUNT', + 'SAR', + // general‑purpose registers + 'A0', + 'A1', + 'A2', + 'A3', + 'A4', + 'A5', + 'A6', + 'A7', + 'A8', + 'A9', + 'A10', + 'A11', + 'A12', + 'A13', + 'A14', + 'A15', + // remaining exception/window registers + 'EXCCAUSE', + 'EXCVADDR', + 'WINDOWBASE', + 'WINDOWSTART', + ], + // TODO: compare with gdbRegsInfoRiscvIlp32 + riscv: [ + 'MEPC', + 'RA', + 'SP', + 'GP', + 'TP', + 'T0', + 'T1', + 'T2', + 'S0', + 'S1', + 'A0', + 'A1', + 'A2', + 'A3', + 'A4', + 'A5', + 'A6', + 'A7', + 'S2', + 'S3', + 'S4', + 'S5', + 'S6', + 'S7', + 'S8', + 'S9', + 'S10', + 'S11', + 'T3', + 'T4', + 'T5', + 'T6', + 'MSTATUS', + 'MTVEC', + 'MCAUSE', + 'MTVAL', + 'MHARTID', + ], +}) + +/** + * @param {number} number + * @returns {string} + */ +export function toHexString(number = 0) { + return `0x${number.toString(16).padStart(8, '0')}` +} diff --git a/src/vendor/trbr/decode/riscv.js b/src/vendor/trbr/decode/riscv.js new file mode 100644 index 0000000..36aeba1 --- /dev/null +++ b/src/vendor/trbr/decode/riscv.js @@ -0,0 +1,1368 @@ +// @ts-check + +import net from 'node:net' + +import { AbortError, neverSignal } from '../abort.js' +import { isRiscvTargetArch } from '../tool.js' +import { addr2line } from './addr2Line.js' +import { + GdbMiClient, + extractMiListContent, + parseMiResultRecord, + parseMiTupleList, + stripMiList, +} from './gdbMi.js' +import { resolveGlobalSymbols } from './globals.js' +import { parseLines } from './regAddr.js' +import { toHexString } from './regs.js' + +// Based on the work of: +// - [Peter Dragun](https://github.com/peterdragun) +// - [Ivan Grokhotkov](https://github.com/igrr) +// - [suda-morris](https://github.com/suda-morris) +// +// https://github.com/espressif/esp-idf-monitor/blob/fae383ecf281655abaa5e65433f671e274316d10/esp_idf_monitor/gdb_panic_server.py + +const riscvLogPrefix = '[trbr][riscv]' + +/** + * @param {Debug | undefined} debug + * @returns {Debug} + */ +function createRiscvLogger(debug) { + const writer = + debug ?? (process.env.TRBR_DEBUG === 'true' ? console.log : undefined) + return writer ? (...args) => writer(riscvLogPrefix, ...args) : () => {} +} + +/** @typedef {import('./decode.js').DecodeParams} DecodeParams */ +/** @typedef {import('./decode.js').DecodeResult} DecodeResult */ +/** @typedef {import('./decode.js').DecodeFunction} DecodeFunction */ +/** @typedef {import('./decode.js').DecodeOptions} DecodeOptions */ +/** @typedef {import('./decode.js').GDBLine} GDBLine */ +/** @typedef {import('./decode.js').ParsedGDBLine} ParsedGDBLine */ +/** @typedef {import('./decode.js').FrameArg} FrameArg */ +/** @typedef {import('./decode.js').FrameVar} FrameVar */ +/** @typedef {import('./decode.js').Debug} Debug */ +/** @typedef {import('./decode.js').RegAddr} RegAddr */ +/** @typedef {import('./decode.js').AddrLine} AddrLine */ +/** @typedef {import('./decode.js').PanicInfoWithStackData} PanicInfoWithStackData */ +/** @typedef {import('../tool.js').RiscvTargetArch} RiscvTargetArch */ +const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ + 'X0', + 'RA', + 'SP', + 'GP', + 'TP', + 'T0', + 'T1', + 'T2', + 'S0/FP', + 'S1', + 'A0', + 'A1', + 'A2', + 'A3', + 'A4', + 'A5', + 'A6', + 'A7', + 'S2', + 'S3', + 'S4', + 'S5', + 'S6', + 'S7', + 'S8', + 'S9', + 'S10', + 'S11', + 'T3', + 'T4', + 'T5', + 'T6', + 'MEPC', // where execution is happening (PC) and where it resumes after exception (MEPC). +]) + +/** @type {Record} */ +export const riscvDecoders = /** @type {const} */ ({ + esp32c2: decodeRiscv, + esp32c3: decodeRiscv, + esp32c6: decodeRiscv, + esp32h2: decodeRiscv, + esp32h4: decodeRiscv, + esp32p4: decodeRiscv, +}) + +/** @type {Record} */ +const gdbRegsInfo = { + esp32c2: gdbRegsInfoRiscvIlp32, + esp32c3: gdbRegsInfoRiscvIlp32, + esp32c6: gdbRegsInfoRiscvIlp32, + esp32h2: gdbRegsInfoRiscvIlp32, + esp32h4: gdbRegsInfoRiscvIlp32, + esp32p4: gdbRegsInfoRiscvIlp32, +} + +/** + * @template {RiscvTargetArch} T + * @param {T} type + */ +function createRegNameValidator(type) { + const regsInfo = gdbRegsInfo[type] + if (!regsInfo) { + throw new Error(`Unsupported target: ${type}`) + } + /** @type {(regName: unknown) => regName is gdbRegsInfoRiscvIlp32} */ + return (regName) => + regsInfo.includes( + /** @type {(typeof gdbRegsInfoRiscvIlp32)[number]} */ (regName) + ) +} + +/** + * @typedef {Object} RegisterDump + * @property {number} coreId + * @property {Record} regs + */ + +/** + * @typedef {Object} StackDump + * @property {number} baseAddr + * @property {number[]} data + */ + +/** + * @typedef {Object} ParsePanicOutputParams + * @property {string} input + * @property {RiscvTargetArch} target + */ + +/** + * @typedef {Object} ParsePanicOutputResult + * @property {RegisterDump[]} regDumps + * @property {StackDump[]} stackDump + * @property {number} programCounter + * @property {number} [faultCode] + * @property {number} [faultAddr] + */ + +/** + * @param {ParsePanicOutputParams} params + * @returns {ParsePanicOutputResult} + */ +function parse({ input, target }) { + const lines = input.split(/\r?\n|\r/) + /** @type {RegisterDump[]} */ + const regDumps = [] + /** @type {StackDump[]} */ + const stackDump = [] + /** @type {RegisterDump | undefined} */ + let currentRegDump + let inStackMemory = false + /** @type {number | undefined} */ + let faultCode + /** @type {number | undefined} */ + let faultAddr + let programCounter = 0 + + const regNameValidator = createRegNameValidator(target) + + lines.forEach((line) => { + if (line.startsWith('Core')) { + const match = line.match(/^Core\s+(\d+)\s+register dump:/) + if (match) { + currentRegDump = { + coreId: parseInt(match[1], 10), + regs: {}, + } + regDumps.push(currentRegDump) + } + } else if (currentRegDump && !inStackMemory) { + const regMatches = line.matchAll(/([A-Z_0-9/]+)\s*:\s*(0x[0-9a-fA-F]+)/g) + for (const match of regMatches) { + const regName = match[1] + const regAddr = parseInt(match[2], 16) + if (regAddr && regNameValidator(regName)) { + currentRegDump.regs[regName] = regAddr + if (regName === 'MEPC') { + programCounter = regAddr // PC equivalent + } + } else if (regName === 'MCAUSE') { + faultCode = regAddr // EXCCAUSE equivalent + } else if (regName === 'MTVAL') { + faultAddr = regAddr // EXCVADDR equivalent + } + } + if (line.trim() === 'Stack memory:') { + inStackMemory = true + } + } else if (inStackMemory) { + const match = line.match(/^([0-9a-fA-F]+):\s*((?:0x[0-9a-fA-F]+\s*)+)/) + if (match) { + const baseAddr = parseInt(match[1], 16) + const data = match[2] + .trim() + .split(/\s+/) + .map((hex) => parseInt(hex, 16)) + stackDump.push({ baseAddr, data }) + } + } + }) + + return { regDumps, stackDump, faultCode, faultAddr, programCounter } +} + +/** + * @typedef {Object} GetStackAddrAndDataParams + * @property {StackDump[]} stackDump + */ + +/** + * @typedef {Object} GetStackAddrAndDataResult + * @property {number} stackBaseAddr + * @property {Buffer} stackData + */ + +/** + * @param {GetStackAddrAndDataParams} params + * @returns {GetStackAddrAndDataResult} + */ +function getStackAddrAndData({ stackDump }) { + let stackBaseAddr = 0 + let baseAddr = 0 + let bytesInLine = 0 + let stackData = Buffer.alloc(0) + + stackDump.forEach((line) => { + const prevBaseAddr = baseAddr + baseAddr = line.baseAddr + if (stackBaseAddr === 0) { + stackBaseAddr = baseAddr + } else { + if (baseAddr !== prevBaseAddr + bytesInLine) { + throw new Error('Invalid base address') + } + } + + const lineData = Buffer.concat( + line.data.map((word) => { + const buf = Buffer.alloc(4) + // Stack memory is little-endian; preserve byte order for GDB reads. + buf.writeUInt32LE(word >>> 0) + return buf + }) + ) + bytesInLine = lineData.length + stackData = Buffer.concat([stackData, lineData]) + }) + + return { stackBaseAddr, stackData } +} + +/** + * @typedef {Object} ParseIdfRiscvPanicOutputParams + * @property {string} input + * @property {RiscvTargetArch} target + */ + +/** + * @param {ParseIdfRiscvPanicOutputParams} params + * @returns {PanicInfoWithStackData} + */ +function parsePanicOutput({ input, target }) { + const { regDumps, stackDump, programCounter, faultAddr, faultCode } = parse({ + input, + target, + }) + if (regDumps.length === 0) { + throw new Error('No register dumps found') + } + if (regDumps.length > 1) { + throw new Error('Handling of multi-core register dumps not implemented') + } + + const { coreId, regs } = regDumps[0] + const { stackBaseAddr, stackData } = getStackAddrAndData({ stackDump }) + + return { + coreId, + programCounter, + faultAddr, + faultCode, + regs, + stackBaseAddr, + stackData, + target, + } +} + +/** + * @typedef {Object} GdbServerParams + * @property {PanicInfoWithStackData} panicInfo + * @property {Debug} [debug] + */ + +/** + * @typedef {Object} StartGdbServerParams + * @property {AbortSignal} [signal] + */ + +export class GdbServer { + /** @param {GdbServerParams} params */ + constructor(params) { + this.panicInfo = params.panicInfo + this.regList = gdbRegsInfo[params.panicInfo.target] + this.debug = params.debug ?? (() => {}) + } + + /** + * @param {StartGdbServerParams} [params] + * @returns {Promise} + */ + async start(params = {}) { + if (this.server) { + throw new Error('Server already started') + } + + const { signal = neverSignal } = params + const server = net.createServer() + this.server = server + + await new Promise((resolve, reject) => { + const abortHandler = () => { + this.debug('User abort') + reject(new AbortError()) + this.close() + } + + if (signal.aborted) { + abortHandler() + return + } + + signal.addEventListener('abort', abortHandler) + server.on('listening', () => { + signal.removeEventListener('abort', abortHandler) + resolve(undefined) + }) + server.listen(0) + }) + + const address = server.address() + if (!address) { + this.close() + throw new Error('Failed to start server') + } + if (typeof address === 'string') { + this.close() + throw new Error( + `Expected an address info object. Got a string: ${address}` + ) + } + + server.on('connection', (socket) => { + let pending = '' + socket.on('data', (data) => { + pending = this._consumePackets(pending + data.toString(), socket) + }) + }) + + return address + } + + /** + * @param {string} pending + * @param {net.Socket} socket + * @returns {string} + */ + _consumePackets(pending, socket) { + while (pending.length > 0) { + if (pending.startsWith('+')) { + pending = pending.slice(1) + continue + } + + if (pending.startsWith('-')) { + this.debug(`Invalid command: ${pending}`) + socket.write('-') + socket.end() + return '' + } + + const packetStart = pending.indexOf('$') + if (packetStart === -1) { + this.debug(`Discarding non-packet data: ${JSON.stringify(pending)}`) + return '' + } + if (packetStart > 0) { + const ignored = pending.slice(0, packetStart) + this.debug(`Discarding packet prefix: ${JSON.stringify(ignored)}`) + pending = pending.slice(packetStart) + } + + const checksumMark = pending.indexOf('#', 1) + if (checksumMark === -1 || checksumMark + 2 >= pending.length) { + return pending + } + + const packet = pending.slice(0, checksumMark + 3) + pending = pending.slice(checksumMark + 3) + this.debug(`Command: ${packet}`) + this._handleCommand(packet, socket) + } + + return pending + } + + close() { + this.server?.close() + this.server = undefined + } + + /** + * @param {string} buffer + * @param {net.Socket} socket + */ + _handleCommand(buffer, socket) { + if (buffer.startsWith('+')) { + buffer = buffer.slice(1) // ignore the leading '+' + } + + const command = buffer.slice(1, -3) // ignore checksums + // Acknowledge the command + socket.write('+') + this.debug( + `Raw buffer (length ${buffer.length}): ${JSON.stringify(buffer)}` + ) + this.debug(`Got command: ${command}`) + if (command === '?') { + // report sigtrap as the stop reason; the exact reason doesn't matter for backtracing + this.debug('Responding with: T05') + this._respond('T05', socket) + } else if (command.startsWith('Hg') || command.startsWith('Hc')) { + // Select thread command + this.debug('Responding with: OK') + this._respond('OK', socket) + } else if (command === 'qfThreadInfo') { + // Get list of threads. + // Only one thread for now, can be extended to show one thread for each core, + // if we dump both cores (e.g. on an interrupt watchdog) + this.debug('Responding with: m1') + this._respond('m1', socket) + } else if (command === 'qC') { + // That single thread is selected. + this.debug('Responding with: QC1') + this._respond('QC1', socket) + } else if (command === 'g') { + // Registers read + this._respondRegs(socket) + } else if (command.startsWith('m')) { + // Memory read + const [addr, size] = command + .slice(1) + .split(',') + .map((v) => parseInt(v, 16)) + this._respondMem(addr, size, socket) + } else if (command.startsWith('vKill') || command === 'k') { + // Quit + this.debug('Responding with: OK') + this._respond('OK', socket) + socket.end() + } else { + // Empty response required for any unknown command + this.debug('Responding with: (empty)') + this._respond('', socket) + } + } + + /** + * @param {string} data + * @param {net.Socket} socket + */ + _respond(data, socket) { + // calculate checksum + const dataBytes = Buffer.from(data, 'ascii') + const checksum = dataBytes.reduce((sum, byte) => sum + byte, 0) & 0xff + // format and write the response + const res = `$${data}#${checksum.toString(16).padStart(2, '0')}` + socket.write(res) + this.debug(`Wrote: ${res}`) + } + + /** @param {net.Socket} socket */ + _respondRegs(socket) { + let response = '' + // https://github.com/espressif/esp-idf-monitor/blob/fae383ecf281655abaa5e65433f671e274316d10/esp_idf_monitor/gdb_panic_server.py#L242-L247 + // It loops over the list of register names. + // For each register name, it gets the register value from panicInfo.regs. + // It converts the register value to bytes in little-endian byte order. + // It converts each byte to a hexadecimal string and joins them together. + // It appends the hexadecimal string to the response string. + for (const regName of this.regList) { + const regVal = this.panicInfo.regs[regName] || 0 + const regBytes = Buffer.alloc(4) + regBytes.writeUInt32LE(regVal) + const regValHex = regBytes.toString('hex') + response += regValHex + } + this.debug( + `Register values: ${this.regList + .map((r) => `${r}=${toHexString(this.panicInfo.regs[r] || 0)}`) + .join(', ')}` + ) + this.debug(`Register response: ${response}`) + this._respond(response, socket) + } + + /** + * @param {number} startAddr + * @param {number} size + * @param {net.Socket} socket + */ + _respondMem(startAddr, size, socket) { + const stackAddrMin = this.panicInfo.stackBaseAddr + const stackData = this.panicInfo.stackData + const stackLen = stackData.length + const stackAddrMax = stackAddrMin + stackLen + + const inStack = (/** @type {number} */ addr) => + stackAddrMin <= addr && addr < stackAddrMax + + let result = '' + for (let addr = startAddr; addr < startAddr + size; addr++) { + if (!inStack(addr)) { + result += '00' + } else { + result += stackData[addr - stackAddrMin].toString(16).padStart(2, '0') + } + } + + this.debug( + `Memory read request from 0x${startAddr.toString(16)} for ${size} bytes` + ) + this.debug(`Responding with memory: ${result}`) + this._respond(result, socket) + } +} + +const miErrorPattern = /^\^error/m + +/** + * @param {string} raw + * @returns {string | undefined} + */ +function parseMiErrorMessage(raw) { + const match = raw.match(/\^error(?:,[^\n]*?msg="((?:\\.|[^"])*)")?/m) + if (!match?.[1]) { + return undefined + } + return match[1] + .replace(/\\\\/g, '\\') + .replace(/\\"/g, '"') + .replace(/\\n/g, '\n') +} + +/** + * @param {string} raw + * @returns {string} + */ +function summarizeMiOutput(raw) { + const normalized = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + if (!normalized) { + return 'empty MI response' + } + const maxLength = 240 + if (normalized.length <= maxLength) { + return normalized + } + return `${normalized.slice(0, maxLength)}...[truncated ${normalized.length - maxLength} chars]` +} + +/** + * @param {string} raw + * @returns {string} + */ +function describeMiFailure(raw) { + return parseMiErrorMessage(raw) ?? summarizeMiOutput(raw) +} + +/** + * @param {DecodeOptions | undefined} options + * @returns {boolean} + */ +function shouldIncludeFrameVars(options) { + return options?.includeFrameVars === true +} + +/** + * @param {Record} tuple + * @returns {FrameArg | undefined} + */ +function toFrameArg(tuple) { + if (!tuple.name) { + return undefined + } + /** @type {FrameArg} */ + const arg = { name: tuple.name } + if (tuple.type) { + arg.type = tuple.type + } + if (tuple.value !== undefined) { + arg.value = tuple.value + } + return arg +} + +/** + * @param {Record} tuple + * @param {'local' | 'argument' | 'global'} scope + * @returns {FrameVar | undefined} + */ +function toFrameVar(tuple, scope) { + if (!tuple.name) { + return undefined + } + /** @type {FrameVar} */ + const variable = { scope, name: tuple.name } + if (tuple.type) { + variable.type = tuple.type + } + if (tuple.value !== undefined) { + variable.value = tuple.value + } + if (tuple.addr || tuple.address) { + variable.address = tuple.addr || tuple.address + } + return variable +} + +/** + * @param {Record} frame + * @returns {GDBLine | ParsedGDBLine} + */ +function toParsedFrame(frame) { + const regAddr = frame.addr || '??' + const method = frame.func && frame.func !== '??' ? frame.func : undefined + const fileRaw = frame.fullname || frame.file + const file = fileRaw && fileRaw !== '??' ? fileRaw : undefined + const lineNumber = frame.line && frame.line !== '??' ? frame.line : undefined + + if (!method && !file && !lineNumber) { + return { regAddr, lineNumber: '??' } + } + + return { + regAddr, + method: method || '??', + file: file || '??', + lineNumber: lineNumber || '??', + } +} + +/** + * @param {string} raw + * @returns {Record[]} + */ +function parseMiFrames(raw) { + const listContent = extractMiListContent(raw, 'stack') + return parseMiTupleList(listContent, 'frame') +} + +/** + * @template T + * @param {T | undefined} value + * @returns {value is T} + */ +function isDefined(value) { + return value !== undefined +} + +/** + * @param {string} raw + * @param {string} frameLevel + * @returns {FrameArg[] | undefined} + */ +function parseMiStackArgs(raw, frameLevel) { + if (miErrorPattern.test(raw)) { + return undefined + } + const listContent = extractMiListContent(raw, 'stack-args') + if (listContent === undefined) { + return undefined + } + const frames = parseMiTupleList(listContent, 'frame') + const frame = frames.find((entry) => entry.level === frameLevel) ?? frames[0] + if (!frame || !frame.args) { + return [] + } + const argsList = stripMiList(frame.args) ?? '' + return parseMiTupleList(argsList).map(toFrameArg).filter(isDefined) +} + +/** + * @param {string} raw + * @returns {FrameVar[] | undefined} + */ +function parseMiLocals(raw) { + if (miErrorPattern.test(raw)) { + return undefined + } + const listContent = extractMiListContent(raw, 'variables') + if (listContent === undefined) { + return undefined + } + return parseMiTupleList(listContent) + .map((tuple) => toFrameVar(tuple, 'local')) + .filter(isDefined) +} + +/** + * @param {string} name + * @returns {string} + */ +function stripEntrySuffix(name) { + return name.replace(/@entry$/, '') +} + +/** + * @param {FrameArg[]} [args] + * @returns {Set} + */ +function collectArgNames(args) { + const names = new Set() + if (!args) { + return names + } + for (const arg of args) { + if (!arg?.name) { + continue + } + names.add(arg.name) + names.add(stripEntrySuffix(arg.name)) + } + return names +} + +/** + * @param {FrameArg[]} [args] + * @returns {FrameArg[]} + */ +function dedupeArgs(args) { + if (!args || !args.length) { + return [] + } + /** @type {Map} */ + const byName = new Map() + /** @type {string[]} */ + const order = [] + + for (const arg of args) { + if (!arg?.name) { + continue + } + const baseName = stripEntrySuffix(arg.name) + const fromEntry = arg.name.endsWith('@entry') + const existing = byName.get(baseName) + if (!existing) { + byName.set(baseName, { arg: { ...arg, name: baseName }, fromEntry }) + order.push(baseName) + continue + } + + if (existing.fromEntry && !fromEntry) { + byName.set(baseName, { arg: { ...arg, name: baseName }, fromEntry }) + continue + } + + if (!existing.arg.type && arg.type) { + existing.arg.type = arg.type + } + if ( + (!existing.arg.value || existing.arg.value === '') && + arg.value && + arg.value !== '' + ) { + existing.arg.value = arg.value + } + } + + return order + .map((name) => byName.get(name)) + .filter(isDefined) + .map((entry) => entry.arg) +} + +/** + * @param {FrameVar[]} locals + * @param {FrameArg[]} [args] + * @returns {FrameVar[]} + */ +function filterArgLocals(locals, args) { + const argNames = collectArgNames(args) + if (!argNames.size) { + return locals + } + return locals.filter((local) => { + if (!local?.name) { + return false + } + const name = local.name + return !argNames.has(name) && !argNames.has(stripEntrySuffix(name)) + }) +} + +/** + * @param {string} type + * @returns {string} + */ +function normalizeType(type) { + return type + .replace(/\bconst\b/g, '') + .replace(/\bvolatile\b/g, '') + .replace(/\bstatic\b/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * @param {string} type + * @returns {boolean} + */ +function isPrimitiveType(type) { + const cleaned = normalizeType(type) + .replace(/\bstruct\b|\bclass\b|\bunion\b|\benum\b/g, '') + .trim() + return /^(unsigned|signed)?\s*(char|short|int|long|long long|float|double|bool|size_t|uintptr_t|intptr_t|uint\d+_t|int\d+_t)$/.test( + cleaned + ) +} + +/** + * @param {FrameVar} variable + * @returns {boolean} + */ +function shouldExpandVar(variable) { + const type = variable.type + if (!type) { + return false + } + if (/\[.*\]/.test(type)) { + return true + } + if (type.includes('*') || type.includes('&')) { + return false + } + if (/\bstruct\b|\bclass\b|\bunion\b/.test(type)) { + return true + } + return !isPrimitiveType(type) +} + +/** + * @param {string} value + * @returns {string} + */ +function quoteMiArg(value) { + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + return `"${escaped}"` +} + +/** + * @param {Record} tuple + * @returns {FrameVar | undefined} + */ +/** + * @typedef {Object} VarChildEntry + * @property {string} [varObject] + * @property {number} numChildren + * @property {FrameVar} variable + */ + +/** + * @param {Record} tuple + * @returns {VarChildEntry | undefined} + */ +function toVarChildEntry(tuple) { + const displayName = tuple.exp || tuple.name + if (!displayName) { + return undefined + } + /** @type {FrameVar} */ + const variable = { name: displayName } + if (tuple.type) { + variable.type = tuple.type + } + if (tuple.value !== undefined) { + variable.value = tuple.value + } + return { + varObject: tuple.name, + numChildren: parseInt(tuple.numchild ?? '0', 10), + variable, + } +} + +/** + * @param {GdbMiClient} client + * @param {string} expression + * @returns {Promise} + */ +async function evaluateExpression(client, expression) { + const raw = await client.sendCommand( + `-data-evaluate-expression ${quoteMiArg(expression)}` + ) + if (miErrorPattern.test(raw)) { + return undefined + } + const record = parseMiResultRecord(raw) + return record.value +} + +/** + * @param {GdbMiClient} client + * @param {string} varObject + * @returns {Promise} + */ +async function listVarChildren(client, varObject) { + const raw = await client.sendCommand( + `-var-list-children --simple-values ${varObject}` + ) + if (miErrorPattern.test(raw)) { + return undefined + } + const listContent = extractMiListContent(raw, 'children') + if (listContent === undefined) { + return undefined + } + return parseMiTupleList(listContent, 'child') + .map(toVarChildEntry) + .filter(isDefined) +} + +/** + * @param {string} name + * @returns {boolean} + */ +function isAccessSpecifier(name) { + return name === 'public' || name === 'private' || name === 'protected' +} + +/** + * @param {GdbMiClient} client + * @param {string} varObject + * @param {{ maxChildren: number; maxDepth: number }} options + * @param {number} depth + * @param {Set} visited + * @returns {Promise} + */ +async function expandVarObjectChildren( + client, + varObject, + options, + depth, + visited +) { + if (!varObject || depth > options.maxDepth) { + return [] + } + if (visited.has(varObject)) { + return [] + } + visited.add(varObject) + + const children = await listVarChildren(client, varObject) + if (!children || !children.length) { + return [] + } + + /** @type {FrameVar[]} */ + const result = [] + + for (const entry of children) { + if (!entry) { + continue + } + const variable = entry.variable + const childVarObject = entry.varObject + + if (isAccessSpecifier(variable.name)) { + if (childVarObject && depth < options.maxDepth) { + const expanded = await expandVarObjectChildren( + client, + childVarObject, + options, + depth + 1, + visited + ) + for (const child of expanded) { + result.push(child) + if (result.length >= options.maxChildren) { + return result.slice(0, options.maxChildren) + } + } + } + continue + } + + if ( + childVarObject && + entry.numChildren > 0 && + depth < options.maxDepth && + shouldExpandVar(variable) + ) { + variable.children = await expandVarObjectChildren( + client, + childVarObject, + options, + depth + 1, + visited + ) + } + + result.push(variable) + if (result.length >= options.maxChildren) { + return result.slice(0, options.maxChildren) + } + } + + return result +} + +/** + * @param {GdbMiClient} client + * @param {FrameVar} variable + * @param {{ maxChildren: number; maxDepth: number }} options + * @returns {Promise} + */ +async function expandVariable(client, variable, options) { + const expression = variable.name + const createRaw = await client.sendCommand( + `-var-create - * ${quoteMiArg(expression)}` + ) + if (miErrorPattern.test(createRaw)) { + return + } + const record = parseMiResultRecord(createRaw) + const varObject = record.name + const numChildren = parseInt(record.numchild ?? '0', 10) + if (variable.value === undefined || variable.value === '') { + if (record.value !== undefined) { + variable.value = record.value + } else { + const evaluated = await evaluateExpression(client, expression) + if (evaluated !== undefined) { + variable.value = evaluated + } + } + } + if (varObject) { + try { + if (numChildren > 0) { + const children = await expandVarObjectChildren( + client, + varObject, + { maxChildren: options.maxChildren, maxDepth: options.maxDepth }, + 0, + new Set() + ) + if (children.length) { + variable.children = children + } + } + } finally { + await client.sendCommand(`-var-delete ${varObject}`) + } + } +} + +/** + * @param {GdbMiClient} client + * @param {FrameVar[]} locals + * @param {Debug} log + * @returns {Promise} + */ +async function expandLocals(client, locals, log) { + const maxVars = 12 + const maxChildren = 16 + const maxDepth = 3 + let expanded = 0 + for (const variable of locals) { + if (!shouldExpandVar(variable) || expanded >= maxVars) { + continue + } + expanded += 1 + try { + await expandVariable(client, variable, { maxChildren, maxDepth }) + } catch (error) { + log('expand variable failed', variable.name, error) + } + } + return locals +} + +/** + * @param {DecodeParams} params + * @param {PanicInfoWithStackData} panicInfo + * @param {DecodeOptions} options + * @param {Debug} [log] + * @returns {Promise<(GDBLine | ParsedGDBLine)[]>} + */ +async function fetchStacktraceWithMi( + params, + panicInfo, + options = {}, + log = createRiscvLogger(options.debug) +) { + const { elfPath, toolPath } = params + const includeFrameVars = shouldIncludeFrameVars(options) + let server + /** @type {GdbMiClient | undefined} */ + let client + + try { + log('fetch stacktrace start', { elfPath, toolPath }) + const { signal, debug } = options + const gdbServer = new GdbServer({ panicInfo, debug }) + const { port } = await gdbServer.start({ signal }) + server = gdbServer + log('gdb server started', { port }) + + client = new GdbMiClient( + toolPath, + ['--interpreter=mi2', '-n', elfPath], + options + ) + await client.drainHandshake() + const targetResult = await client.sendCommand( + `-target-select remote :${port}` + ) + if (miErrorPattern.test(targetResult)) { + throw new Error( + `Failed to connect to GDB remote target: ${describeMiFailure(targetResult)}` + ) + } + log('gdb remote connected') + + const framesRaw = await client.sendCommand('-stack-list-frames') + if (miErrorPattern.test(framesRaw)) { + throw new Error( + `Failed to list stack frames: ${describeMiFailure(framesRaw)}` + ) + } + const frames = parseMiFrames(framesRaw) + const stacktraceLines = frames.map(toParsedFrame) + log('frames parsed', frames.length) + frames.forEach((frame, index) => { + log('frame', index, frame) + }) + + for (let i = 0; i < frames.length; i++) { + const frameLevel = frames[i].level ?? `${i}` + log('select frame', frameLevel) + await client.sendCommand(`-stack-select-frame ${frameLevel}`) + + const argsRaw = await client.sendCommand( + `-stack-list-arguments --simple-values ${frameLevel} ${frameLevel}` + ) + const rawArgs = parseMiStackArgs(argsRaw, frameLevel) + const args = rawArgs ? dedupeArgs(rawArgs) : undefined + const parsedFrame = + 'method' in stacktraceLines[i] + ? /** @type {ParsedGDBLine} */ (stacktraceLines[i]) + : undefined + if (args !== undefined && parsedFrame) { + parsedFrame.args = args.length ? args : [] + log('frame args', frameLevel, parsedFrame.args) + } + + if (includeFrameVars) { + const localsRaw = await client.sendCommand( + '-stack-list-variables --simple-values' + ) + let locals = parseMiLocals(localsRaw) + if (locals !== undefined && parsedFrame) { + locals = filterArgLocals(locals, args) + locals = await expandLocals(client, locals, log) + parsedFrame.locals = locals.length ? locals : [] + log('frame locals', frameLevel, parsedFrame.locals) + } + } + } + + log('fetch stacktrace done', stacktraceLines.length) + return stacktraceLines + } finally { + client?.close() + server?.close() + } +} + +const exceptions = [ + { code: 0x0, description: 'Instruction address misaligned' }, + { code: 0x1, description: 'Instruction access fault' }, + { code: 0x2, description: 'Illegal instruction' }, + { code: 0x3, description: 'Breakpoint' }, + { code: 0x4, description: 'Load address misaligned' }, + { code: 0x5, description: 'Load access fault' }, + { code: 0x6, description: 'Store/AMO address misaligned' }, + { code: 0x7, description: 'Store/AMO access fault' }, + { code: 0x8, description: 'Environment call from U-mode' }, + { code: 0x9, description: 'Environment call from S-mode' }, + { code: 0xb, description: 'Environment call from M-mode' }, + { code: 0xc, description: 'Instruction page fault' }, + { code: 0xd, description: 'Load page fault' }, + { code: 0xf, description: 'Store/AMO page fault' }, +] + +/** + * @param {string} elfPath + * @param {number} port + * @returns {string[]} + */ +function buildPanicServerArgs(elfPath, port) { + return [ + '--batch', + '-n', + elfPath, + // '-ex', // executes a command + // `set remotetimeout ${debug ? 300 : 2}`, // Set the timeout limit to wait for the remote target to respond to num seconds. The default is 2 seconds. (https://sourceware.org/gdb/current/onlinedocs/gdb.html/Remote-Configuration.html) + '-ex', + `target remote :${port}`, // https://sourceware.org/gdb/current/onlinedocs/gdb.html/Server.html#Server + '-ex', + 'bt', + ] +} + +/** + * @param {DecodeParams} params + * @param {PanicInfoWithStackData} panicInfo + * @param {DecodeOptions} options + * @param {Debug} [log] + * @returns {Promise<(GDBLine | ParsedGDBLine)[]>} + */ +async function processPanicOutput( + params, + panicInfo, + options = {}, + log = createRiscvLogger(options.debug) +) { + return fetchStacktraceWithMi(params, panicInfo, options, log) +} + +/** + * @param {PanicInfoWithStackData} panicInfo + * @param {AddrLine} programCounter + * @param {AddrLine | undefined} faultAddr + * @param {(GDBLine | ParsedGDBLine)[]} stacktraceLines + * @param {FrameVar[]} [globals] + * @returns {DecodeResult} + */ +function createDecodeResult( + panicInfo, + programCounter, + faultAddr, + stacktraceLines, + globals +) { + const exception = exceptions.find((e) => e.code === panicInfo.faultCode) + + return { + faultInfo: { + coreId: panicInfo.coreId, + programCounter, + faultAddr, + faultCode: panicInfo.faultCode, + faultMessage: exception ? exception.description : undefined, + }, + regs: panicInfo.regs, + stacktraceLines, + allocInfo: undefined, + globals, + } +} + +/** @type {import('./decode.js').DecodeFunction} */ +export async function decodeRiscv(params, input, options) { + const log = createRiscvLogger(options?.debug) + const target = params.targetArch + if (!isRiscvTargetArch(target)) { + throw new Error(`Unsupported target: ${target}`) + } + log('decode start', { target, inputType: typeof input }) + + /** @type {Exclude} */ + let panicInfo + if (typeof input === 'string') { + panicInfo = parsePanicOutput({ + input, + target, + }) + } else { + panicInfo = input + } + + if ('backtraceAddrs' in panicInfo) { + throw new Error( + 'Unexpectedly received a panic info with backtrace addresses for RISC-V' + ) + } + log('panic info', { + coreId: panicInfo.coreId, + programCounter: panicInfo.programCounter, + faultAddr: panicInfo.faultAddr, + faultCode: panicInfo.faultCode, + }) + + const includeFrameVars = shouldIncludeFrameVars(options) + const [stacktraceLines, [programCounter, faultAdd], globals] = + await Promise.all([ + processPanicOutput(params, panicInfo, options, log), + addr2line( + params, + [panicInfo.programCounter, panicInfo.faultAddr], + options + ), + includeFrameVars + ? resolveGlobalSymbols(params, options) + : Promise.resolve([]), + ]) + if (!includeFrameVars) { + log('skip globals/locals (includeFrameVars=false)') + } + log('addr2line done', { programCounter, faultAdd }) + log('globals count', globals.length) + stacktraceLines.forEach((line, index) => { + log('stacktrace line', index, line) + }) + + return createDecodeResult( + panicInfo, + programCounter, + faultAdd, + stacktraceLines, + globals + ) +} + +/** (non-API) */ +export const __tests = /** @type {const} */ ({ + createRegNameValidator, + parsePanicOutput, + buildPanicServerArgs, + processPanicOutput, + parseMiFrames, + parseMiStackArgs, + parseMiLocals, + toParsedFrame, + toHexString, + parseGDBOutput: parseLines, + getStackAddrAndData, + gdbRegsInfoRiscvIlp32, + gdbRegsInfo, + createDecodeResult, +}) diff --git a/src/vendor/trbr/decode/riscvPanicParse.js b/src/vendor/trbr/decode/riscvPanicParse.js new file mode 100644 index 0000000..832f0bb --- /dev/null +++ b/src/vendor/trbr/decode/riscvPanicParse.js @@ -0,0 +1,230 @@ +// @ts-check + +/** @typedef {import('../tool.js').RiscvTargetArch} RiscvTargetArch */ + +const defaultRiscvTarget = /** @type {const} */ ('esp32c3') + +const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ + 'X0', + 'RA', + 'SP', + 'GP', + 'TP', + 'T0', + 'T1', + 'T2', + 'S0/FP', + 'S1', + 'A0', + 'A1', + 'A2', + 'A3', + 'A4', + 'A5', + 'A6', + 'A7', + 'S2', + 'S3', + 'S4', + 'S5', + 'S6', + 'S7', + 'S8', + 'S9', + 'S10', + 'S11', + 'T3', + 'T4', + 'T5', + 'T6', + 'MEPC', +]) + +/** + * @typedef {Object} RegisterDump + * @property {number} coreId + * @property {Record} regs + */ + +/** + * @typedef {Object} StackDump + * @property {number} baseAddr + * @property {number[]} data + */ + +/** + * @typedef {Object} ParsePanicOutputParams + * @property {string} input + * @property {RiscvTargetArch} [target] + */ + +/** + * @typedef {Object} ParsePanicOutputResult + * @property {RegisterDump[]} regDumps + * @property {StackDump[]} stackDump + * @property {number} programCounter + * @property {number} [faultCode] + * @property {number} [faultAddr] + */ + +/** @type {Record} */ +const gdbRegsInfo = { + esp32c2: gdbRegsInfoRiscvIlp32, + esp32c3: gdbRegsInfoRiscvIlp32, + esp32c6: gdbRegsInfoRiscvIlp32, + esp32h2: gdbRegsInfoRiscvIlp32, + esp32h4: gdbRegsInfoRiscvIlp32, + esp32p4: gdbRegsInfoRiscvIlp32, +} + +/** + * @template {RiscvTargetArch} T + * @param {T} type + */ +function createRegNameValidator(type) { + const regsInfo = gdbRegsInfo[type] + if (!regsInfo) { + throw new Error(`Unsupported target: ${type}`) + } + /** @type {(regName: unknown) => regName is gdbRegsInfoRiscvIlp32} */ + return (regName) => + regsInfo.includes( + /** @type {(typeof gdbRegsInfoRiscvIlp32)[number]} */ (regName) + ) +} + +/** + * @param {ParsePanicOutputParams} params + * @returns {ParsePanicOutputResult} + */ +function parse({ input, target }) { + const lines = input.split(/\r?\n|\r/) + /** @type {RegisterDump[]} */ + const regDumps = [] + /** @type {StackDump[]} */ + const stackDump = [] + /** @type {RegisterDump | undefined} */ + let currentRegDump + let inStackMemory = false + /** @type {number | undefined} */ + let faultCode + /** @type {number | undefined} */ + let faultAddr + let programCounter = 0 + + const regNameValidator = createRegNameValidator(target ?? defaultRiscvTarget) + + lines.forEach((line) => { + if (line.startsWith('Core')) { + const match = line.match(/^Core\s+(\d+)\s+register dump:/) + if (match) { + currentRegDump = { + coreId: parseInt(match[1], 10), + regs: {}, + } + regDumps.push(currentRegDump) + } + } else if (currentRegDump && !inStackMemory) { + const regMatches = line.matchAll(/([A-Z_0-9/]+)\s*:\s*(0x[0-9a-fA-F]+)/g) + for (const match of regMatches) { + const regName = match[1] + const regAddr = parseInt(match[2], 16) + if (regAddr && regNameValidator(regName)) { + currentRegDump.regs[regName] = regAddr + if (regName === 'MEPC') { + programCounter = regAddr + } + } else if (regName === 'MCAUSE') { + faultCode = regAddr + } else if (regName === 'MTVAL') { + faultAddr = regAddr + } + } + if (line.trim() === 'Stack memory:') { + inStackMemory = true + } + } else if (inStackMemory) { + const match = line.match(/^([0-9a-fA-F]+):\s*((?:0x[0-9a-fA-F]+\s*)+)/) + if (match) { + const baseAddr = parseInt(match[1], 16) + const data = match[2] + .trim() + .split(/\s+/) + .map((hex) => parseInt(hex, 16)) + stackDump.push({ baseAddr, data }) + } + } + }) + + return { regDumps, stackDump, faultCode, faultAddr, programCounter } +} + +/** + * @typedef {Object} GetStackAddrAndDataParams + * @property {StackDump[]} stackDump + */ + +/** + * @param {GetStackAddrAndDataParams} params + * @returns {{ stackBaseAddr: number; stackData: Buffer }} + */ +function getStackAddrAndData({ stackDump }) { + let stackBaseAddr = 0 + let baseAddr = 0 + let bytesInLine = 0 + let stackData = Buffer.alloc(0) + + stackDump.forEach((line) => { + const prevBaseAddr = baseAddr + baseAddr = line.baseAddr + if (stackBaseAddr === 0) { + stackBaseAddr = baseAddr + } else if (baseAddr !== prevBaseAddr + bytesInLine) { + throw new Error('Invalid base address') + } + + const lineData = Buffer.concat( + line.data.map((word) => { + const buf = Buffer.alloc(4) + buf.writeUInt32LE(word >>> 0) + return buf + }) + ) + bytesInLine = lineData.length + stackData = Buffer.concat([stackData, lineData]) + }) + + return { stackBaseAddr, stackData } +} + +/** + * @param {{ input: string; target?: RiscvTargetArch }} params + * @returns {import('./decode.js').PanicInfoWithStackData} + */ +export function parseRiscvPanicOutput({ input, target }) { + const resolvedTarget = target ?? defaultRiscvTarget + const { regDumps, stackDump, programCounter, faultAddr, faultCode } = parse({ + input, + target: resolvedTarget, + }) + if (regDumps.length === 0) { + throw new Error('No register dumps found') + } + if (regDumps.length > 1) { + throw new Error('Handling of multi-core register dumps not implemented') + } + + const { coreId, regs } = regDumps[0] + const { stackBaseAddr, stackData } = getStackAddrAndData({ stackDump }) + + return { + coreId, + programCounter, + faultAddr, + faultCode, + regs, + stackBaseAddr, + stackData, + target: resolvedTarget, + } +} diff --git a/src/vendor/trbr/decode/stringify.js b/src/vendor/trbr/decode/stringify.js new file mode 100644 index 0000000..13f1ddf --- /dev/null +++ b/src/vendor/trbr/decode/stringify.js @@ -0,0 +1,258 @@ +// @ts-check + +import colors, { createColors as tinyrainbowCreateColors } from './_tinyrainbow.js' + +import { isParsedGDBLine } from './decode.js' + +/** @typedef {import('./coredump.js').CoredumpDecodeResult} CoredumpDecodeResult */ +/** @typedef {import('./coredump.js').ThreadDecodeResult} ThreadDecodeResult */ + +const defaultOptions = { + forceColor: false, + lineSeparator: '\r\n', +} + +/** + * @typedef {Object} StringifyOptions + * @property {'force' | 'disable'} [color] + * @property {string} [lineSeparator='\n'] Default is `'\n'` + */ + +/** + * @param {CoredumpDecodeResult} result + * @param {ColorizeFn} colorizeFn + */ +function stringifyCoredumpDecodeResult(result, colorizeFn) { + const lines = [...stringifyThreadsInfo(result, colorizeFn), ''] + + for (let i = 0; i < result.length; i++) { + const thread = result[i] + lines.push( + formatThreadHeader(thread), + ...stringifyThreadDecodeResult(thread, colorizeFn) + ) + if (i < result.length - 1) { + lines.push('') + } + } + + return lines +} + +/** + * @param {import('./decode.js').DecodeResult | CoredumpDecodeResult} result + * @param {StringifyOptions} [options] + */ +export function stringifyDecodeResult(result, options = defaultOptions) { + options = { ...defaultOptions, ...options } + const { colorizeFn, resetColor } = createColorFn(options) + + try { + const lines = Array.isArray(result) + ? stringifyCoredumpDecodeResult(result, colorizeFn) + : stringifySingleDecodeResult(result, colorizeFn) + return lines.join(options.lineSeparator) + } finally { + resetColor() + } +} + +/** + * @typedef {'red' | 'green' | 'blue'} Color + * + * @callback ColorizeFn + * @param {string} text + * @param {Color} [color] + * @returns {string} + */ + +/** + * @param {Pick} options + * @returns {{ colorizeFn: ColorizeFn; resetColor: () => void }} + */ +function createColorFn(options) { + const create = + ( + /** @type {(arg: string) => string} */ red, + /** @type {(arg: string) => string} */ green, + /** @type {(arg: string) => string} */ blue + ) => + (/** @type {string} */ text, /** @type {Color | undefined} */ color) => { + switch (color) { + case 'red': + return red(text) + case 'green': + return green(text) + case 'blue': + return blue(text) + default: + return text + } + } + /** @type {() => void} */ + let resetColor = () => { + /* NOOP */ + } + + if (options.color === 'disable') { + return { + colorizeFn: (text) => text, + resetColor, + } + } + + if (options.color === 'force') { + if (!process.env.FORCE_COLOR) { + process.env.FORCE_COLOR = '1' + resetColor = () => { + delete process.env.FORCE_COLOR + } + } + + const { red, green, blue } = tinyrainbowCreateColors() + const colorizeFn = create(red, green, blue) + return { + colorizeFn, + resetColor, + } + } + + const { red, green, blue } = colors + const colorizeFn = create(red, green, blue) + return { + colorizeFn, + resetColor, + } +} + +/** + * @param {import('./decode.js').DecodeResult} result + * @param {ColorizeFn} colorizeFn + */ +function stringifySingleDecodeResult(result, colorizeFn) { + const lines = [] + if (typeof result.faultInfo?.faultCode === 'number') { + let faultCodeLine = `${result.faultInfo.coreId}` + if (result.faultInfo.faultMessage) { + faultCodeLine += ` | ${result.faultInfo.faultMessage}` + } + faultCodeLine += ` | ${result.faultInfo.faultCode}` + lines.push(colorizeFn(faultCodeLine, 'red')) + } + + const pc = result.faultInfo?.programCounter.location + if (pc) { + if (lines.length) { + lines.push('') + } + lines.push( + `${colorizeFn('PC -> ', 'red')}${stringifyAddrLocation(pc, colorizeFn)}` + ) + } + + const faultAddr = result.faultInfo?.faultAddr?.location + if (faultAddr) { + lines.push( + `${colorizeFn('Fault -> ', 'red')}${stringifyAddrLocation( + faultAddr, + colorizeFn + )}` + ) + } + + if (result.stacktraceLines.length && lines.length) { + lines.push('') + } + + for (const line of result.stacktraceLines) { + lines.push(stringifyAddrLocation(line, colorizeFn)) + } + + if (result.allocInfo) { + if (lines.length) { + lines.push('') + } + lines.push( + `${colorizeFn( + `Memory allocation of ${result.allocInfo.allocSize} bytes failed`, + 'red' + )}${colorizeFn(' at ')}${stringifyAddrLocation( + result.allocInfo.allocAddr, + colorizeFn + )}` + ) + } + + return lines +} + +/** + * @param {CoredumpDecodeResult} result + * @param {ColorizeFn} colorizeFn + */ +function stringifyThreadsInfo(result, colorizeFn) { + const lines = [] + lines.push('==================== THREADS INFO ====================') + lines.push(' ID Target ID Frame') + + for (const thread of result) { + const mark = thread.current ? '*' : ' ' + const tid = thread.threadId.toString().padStart(2) + const tcb = thread.TCB.toString().padEnd(12) + const top = thread.result.stacktraceLines?.[0] + lines.push( + ` ${mark}${tid} process ${tcb} ${stringifyAddrLocation(top, colorizeFn)}` + ) + } + return lines +} + +/** @param {ThreadDecodeResult} thread */ +function formatThreadHeader(thread) { + return `==================== THREAD ${ + thread.threadId + } (TCB: 0x${(+thread.TCB).toString(16)}) ====================` +} + +/** + * @param {ThreadDecodeResult} result + * @param {ColorizeFn} colorizeFn + */ +function stringifyThreadDecodeResult(result, colorizeFn) { + return result.result.stacktraceLines.map((line) => + stringifyAddrLocation(line, colorizeFn) + ) +} + +/** + * @typedef {Object} StringifyAddrLocationOptions + * @property {(text: string, color?: 'green' | 'blue') => string} color + */ + +/** + * @param {import('./decode.js').AddrLocation} location + * @param {ColorizeFn} colorizeFn + */ +function stringifyAddrLocation(location, colorizeFn) { + if (typeof location === 'string') { + return colorizeFn(location) + } + if (!isParsedGDBLine(location)) { + const regAddr = colorizeFn(location.regAddr, 'green') + const suffix = colorizeFn(`: ${location.lineNumber}`) + return `${regAddr}${suffix}` + } + + const args = + location.args + ?.map((arg) => `${arg.name}${arg.value ? `=${arg.value}` : ''}`) + .join(', ') ?? '' + + const signature = `${location.method} (${args})` + + return `${colorizeFn(location.regAddr, 'green')}${colorizeFn( + ': ' + )}${colorizeFn(signature, 'blue')}${colorizeFn( + ` at ${location.file}:${location.lineNumber}` + )}` +} diff --git a/src/vendor/trbr/decode/xtensa.js b/src/vendor/trbr/decode/xtensa.js new file mode 100644 index 0000000..9975d80 --- /dev/null +++ b/src/vendor/trbr/decode/xtensa.js @@ -0,0 +1,157 @@ +// @ts-check + +import { addr2line } from './addr2Line.js' +import { isGDBLine } from './decode.js' +import { resolveGlobalSymbols } from './globals.js' +import { + parseESP32PanicOutput, + parseESP8266PanicOutput, +} from './xtensaPanicParse.js' + +const xtensaLogPrefix = '[trbr][xtensa]' + +/** + * @param {import('./decode.js').Debug | undefined} debug + * @returns {import('./decode.js').Debug} + */ +function createXtensaLogger(debug) { + const writer = + debug ?? (process.env.TRBR_DEBUG === 'true' ? console.log : undefined) + return writer ? (...args) => writer(xtensaLogPrefix, ...args) : () => {} +} + +/** @typedef {import('./decode.js').DecodeParams} DecodeParams */ +/** @typedef {import('./decode.js').DecodeResult} DecodeResult */ +/** @typedef {import('./decode.js').DecodeOptions} DecodeOptions */ +/** @typedef {import('./decode.js').GDBLine} GDBLine */ +/** @typedef {import('./decode.js').ParsedGDBLine} ParsedGDBLine */ +/** @typedef {import('./decode.js').Debug} Debug */ + +/** + * @param {DecodeOptions | undefined} options + * @returns {boolean} + */ +function shouldIncludeFrameVars(options) { + return options?.includeFrameVars === true +} + +/** @type {import('./decode.js').DecodeFunction} */ +export async function decodeXtensa(params, input, options) { + const logXtensa = createXtensaLogger(options?.debug) + logXtensa('decode start', { + targetArch: params.targetArch, + inputType: typeof input, + }) + /** @type {Exclude} */ + let panicInfo + if (typeof input === 'string') { + panicInfo = parseESP32PanicOutput(input) + if ( + !Object.keys(panicInfo.regs).length && + !panicInfo.backtraceAddrs.length + ) { + panicInfo = parseESP8266PanicOutput(input) + } + } else { + panicInfo = input + } + + if ('stackBaseAddr' in panicInfo) { + console.error('input contains stackBaseAddr', JSON.stringify(panicInfo)) + throw new Error('panicInfo must not contain stackBaseAddr') + } + + const includeFrameVars = shouldIncludeFrameVars(options) + const [globals, decodedAddrs] = await Promise.all([ + includeFrameVars + ? resolveGlobalSymbols(params, options) + : Promise.resolve([]), + addr2line( + params, + [ + panicInfo.programCounter, + panicInfo.faultAddr, + ...(panicInfo.backtraceAddrs ?? []), + ], + options + ), + ]) + if (!includeFrameVars) { + logXtensa('skip globals (includeFrameVars=false)') + } + logXtensa('globals count', globals.length) + const [programCounter, faultAddr, ...addrLines] = decodedAddrs + logXtensa('addr2line done', { + programCounter, + faultAddr, + frames: addrLines.length, + }) + let faultMessage + if (panicInfo.faultCode) { + faultMessage = exceptions[panicInfo.faultCode] + } + + /** @type {import('./decode.js').FaultInfo} */ + const faultInfo = { + coreId: panicInfo.coreId, + programCounter, + faultAddr, + faultCode: panicInfo.faultCode, + faultMessage, + } + + return { + faultInfo, + regs: panicInfo.regs, + stacktraceLines: addrLines + .map(({ location }) => location) + .filter(isGDBLine), + allocInfo: undefined, + globals, + } +} + +// Taken from https://github.com/me-no-dev/EspExceptionDecoder/blob/ff4fc36bdaf0bfd6e750086ac01554867ede76d3/src/EspExceptionDecoder.java#L59-L90 +const reserved = 'reserved' +const exceptions = [ + 'Illegal instruction', + 'SYSCALL instruction', + 'InstructionFetchError: Processor internal physical address or data error during instruction fetch', + 'LoadStoreError: Processor internal physical address or data error during load or store', + 'Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT register', + "Alloca: MOVSP instruction, if caller's registers are not in the register file", + 'IntegerDivideByZero: QUOS, QUOU, REMS, or REMU divisor operand is zero', + reserved, + 'Privileged: Attempt to execute a privileged operation when CRING ? 0', + 'LoadStoreAlignmentCause: Load or store to an unaligned address', + reserved, + reserved, + 'InstrPIFDataError: PIF data error during instruction fetch', + 'LoadStorePIFDataError: Synchronous PIF data error during LoadStore access', + 'InstrPIFAddrError: PIF address error during instruction fetch', + 'LoadStorePIFAddrError: Synchronous PIF address error during LoadStore access', + 'InstTLBMiss: Error during Instruction TLB refill', + 'InstTLBMultiHit: Multiple instruction TLB entries matched', + 'InstFetchPrivilege: An instruction fetch referenced a virtual address at a ring level less than CRING', + reserved, + 'InstFetchProhibited: An instruction fetch referenced a page mapped with an attribute that does not permit instruction fetch', + reserved, + reserved, + reserved, + 'LoadStoreTLBMiss: Error during TLB refill for a load or store', + 'LoadStoreTLBMultiHit: Multiple TLB entries matched for a load or store', + 'LoadStorePrivilege: A load or store referenced a virtual address at a ring level less than CRING', + reserved, + 'LoadProhibited: A load referenced a page mapped with an attribute that does not permit loads', + 'StoreProhibited: A store referenced a page mapped with an attribute that does not permit stores', +] + +export { parseESP32PanicOutput, parseESP8266PanicOutput } + +/** (non-API) */ +export const __tests = /** @type {const} */ ({ + exceptions, + decodeAddrs: addr2line, + parseESP32PanicOutput, + parseESP8266PanicOutput, +}) diff --git a/src/vendor/trbr/decode/xtensaPanicParse.js b/src/vendor/trbr/decode/xtensaPanicParse.js new file mode 100644 index 0000000..f8de59a --- /dev/null +++ b/src/vendor/trbr/decode/xtensaPanicParse.js @@ -0,0 +1,104 @@ +// @ts-check + +/** + * @param {string} input + * @returns {import('./decode.js').PanicInfoWithBacktrace} + */ +export function parseESP8266PanicOutput(input) { + const lines = input.split(/\r?\n|\r/) + /** @type {Record} */ + const regs = {} + const coreId = 0 + /** @type {number[]} */ + const backtraceAddrs = [] + /** @type {number | undefined} */ + let faultCode + /** @type {number | undefined} */ + let faultAddr + + const regLine = input.match(/Exception\s+\((\d+)\)/) + if (regLine) { + faultCode = parseInt(regLine[1], 10) + } + + for (const line of lines) { + const epcMatches = line.matchAll( + /(epc\d+|excvaddr|depc)=(0x[0-9a-fA-F]{8})/g + ) + for (const match of epcMatches) { + const [, reg, hex] = match + regs[reg.toUpperCase()] = parseInt(hex, 16) + if (reg.toLowerCase() === 'excvaddr') { + faultAddr = parseInt(hex, 16) + } + } + + // Example line: 3fff10b0: 4021a5d4 00000033 3fff20dc 40201ed3 + const stackMatch = line.match(/^\s*[0-9a-f]{8}:\s+((?:[0-9a-f]{8}\s*)+)/i) + if (stackMatch) { + const words = stackMatch[1].trim().split(/\s+/) + for (const word of words) { + const addr = parseInt(word, 16) + if (!Number.isNaN(addr) && addr & 0x40000000) { + backtraceAddrs.push(addr) + } + } + } + } + + return { + coreId, + regs, + backtraceAddrs, + faultCode, + faultAddr, + programCounter: regs.EPC1, + } +} + +/** + * @param {string} input + * @returns {import('./decode.js').PanicInfoWithBacktrace} + */ +export function parseESP32PanicOutput(input) { + const lines = input.split(/\r?\n|\r/) + /** @type {Record} */ + const regs = {} + let coreId = 0 + /** @type {number[]} */ + const backtraceAddrs = [] + const coreIdMatch = input.match(/Guru Meditation Error: Core\s+(\d+)/) + if (coreIdMatch) { + coreId = parseInt(coreIdMatch[1], 10) + } + + const regRegex = /([A-Z]+[0-9]*)\s*:\s*(0x[0-9a-fA-F]+)/g + for (const line of lines) { + for (const match of line.matchAll(regRegex)) { + const [, regName, hexValue] = match + const value = parseInt(hexValue, 16) + if (!Number.isNaN(value)) { + regs[regName] = value + } + } + + if (line.startsWith('Backtrace:')) { + const matches = Array.from(line.matchAll(/0x[0-9a-fA-F]{8}/g)) + for (const match of matches) { + const addr = parseInt(match[0], 16) + if (!Number.isNaN(addr)) { + backtraceAddrs.push(addr) + } + } + } + } + + return { + coreId, + regs, + backtraceAddrs, + faultCode: regs.EXCCAUSE, + faultAddr: regs.EXCVADDR, + programCounter: regs.PC, + } +} diff --git a/src/vendor/trbr/exec.js b/src/vendor/trbr/exec.js new file mode 100644 index 0000000..960c098 --- /dev/null +++ b/src/vendor/trbr/exec.js @@ -0,0 +1,25 @@ +// @ts-check + +import { execFile } from 'node:child_process' + +/** + * @param {string} file + * @param {string[]} [args=[]] Default is `[]` + * @param {import('node:child_process').ExecFileOptions} [options={}] Default is + * `{}` + * @returns {Promise<{ stdout: string; stderr: string }>} + */ +export async function exec(file, args = [], options = {}) { + return new Promise((resolve, reject) => { + execFile(file, args, options, (error, stdout, stderr) => { + if (error) { + reject(error) + } else { + resolve({ + stdout: stdout.toString(), + stderr: stderr.toString(), + }) + } + }) + }) +} diff --git a/src/vendor/trbr/index.d.ts b/src/vendor/trbr/index.d.ts new file mode 100644 index 0000000..4bc1f71 --- /dev/null +++ b/src/vendor/trbr/index.d.ts @@ -0,0 +1,223 @@ +// TypeScript declarations for the vendored `trbr` JavaScript source under +// src/vendor/trbr/. Only the surface consumed by esp-decoder is declared. +// The runtime implementation lives in ./index.js (and the files it imports). + +import type { EventEmitter } from 'node:events'; + +// --------------------------------------------------------------------------- +// Core types +// --------------------------------------------------------------------------- + +export type RegAddr = string; + +export interface GDBLine { + regAddr: RegAddr; + lineNumber: string; +} + +export interface FrameArg { + name: string; + type?: string; + value?: string; +} + +export interface FrameVar { + name: string; + type?: string; + value?: string; + address?: string; + children?: FrameVar[]; + scope?: 'global' | 'local' | 'argument'; +} + +export interface ParsedGDBLine extends GDBLine { + file: string; + method: string; + args?: FrameArg[]; + locals?: FrameVar[]; + globals?: FrameVar[]; +} + +export type AddrLocation = RegAddr | GDBLine | ParsedGDBLine; + +export interface AddrLine { + addr?: number; + location: AddrLocation; +} + +export interface AllocInfo { + allocAddr: AddrLocation; + allocSize: number; +} + +export interface FaultInfo { + coreId: number; + programCounter: AddrLine; + faultAddr?: AddrLine; + faultCode?: number; + faultMessage?: string; +} + +export interface DecodeResult { + faultInfo?: FaultInfo; + regs?: Record; + stacktraceLines: (GDBLine | ParsedGDBLine)[]; + allocInfo?: AllocInfo; + globals?: FrameVar[]; +} + +export interface ThreadDecodeResult { + threadId: string; + TCB: number; + threadName?: string; + result: DecodeResult; + current?: boolean; +} + +export type CoredumpDecodeResult = ThreadDecodeResult[]; + +export type DecodeTarget = + | 'xtensa' + | 'esp32c2' + | 'esp32c3' + | 'esp32c6' + | 'esp32h2' + | 'esp32h4' + | 'esp32p4'; + +export interface DecodeParams { + toolPath: string; + elfPath: string; + targetArch: DecodeTarget; +} + +export type DecodeCoredumpParams = DecodeParams & { coredumpMode: true }; + +export type Debug = (formatter: unknown, ...args: unknown[]) => void; + +export interface DecodeOptions { + signal?: AbortSignal; + debug?: Debug; + includeFrameVars?: boolean; +} + +export interface DecodeInputFileSource { + inputPath: string; +} + +export interface DecodeInputStreamSource { + inputStream: NodeJS.ReadableStream; +} + +export type DecodeInput = DecodeInputFileSource | DecodeInputStreamSource | string; + +export interface CreateDecodeParamsParams { + elfPath: string; + toolPath: string; + targetArch?: DecodeTarget; + coredumpMode?: boolean; +} + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +export function decode( + params: DecodeParams, + decodeInput: DecodeInput, + options?: DecodeOptions +): Promise; + +export function isGDBLine(arg: unknown): arg is GDBLine; +export function isParsedGDBLine(arg: unknown): arg is ParsedGDBLine; + +export function createDecodeParams( + params: CreateDecodeParamsParams +): Promise; + +export interface StringifyOptions { + color?: 'force' | 'disable'; + lineSeparator?: string; +} + +export function stringifyDecodeResult( + result: DecodeResult | CoredumpDecodeResult, + options?: StringifyOptions +): string; + +// --------------------------------------------------------------------------- +// Capturer +// --------------------------------------------------------------------------- + +export type CapturerEventName = 'eventDetected' | 'eventUpdated'; +export type CapturerEventKind = 'xtensa' | 'riscv' | 'unknown'; + +export interface CapturerLightweight { + reasonLine: string | undefined; + programCounter: number | undefined; + faultCode: number | undefined; + faultAddr: number | undefined; + regs: Record; + backtraceAddrs: number[]; +} + +export interface CapturerEvaluated { + eventId: string; + evaluatedAt: number; + status: 'stub' | 'decoded'; + frames: AddrLine[]; + decodeResult?: DecodeResult; +} + +export interface CapturerEvent { + id: string; + signature: string; + kind: CapturerEventKind; + lines: string[]; + rawText: string; + firstSeenAt: number; + lastSeenAt: number; + count: number; + lightweight: CapturerLightweight; + fastFrames: AddrLine[] | undefined; + evaluated: CapturerEvaluated | undefined; +} + +export type CapturerListener = (event: CapturerEvent) => void; + +export interface CapturerEvaluateOptions { + signal?: AbortSignal; +} + +export interface CapturerRawState { + bytes: Uint8Array[]; + byteLength: number; + lines: string[]; +} + +export interface CapturerOptions { + quietPeriodMs?: number; + dedupWindowMs?: number; + maxEvents?: number; + maxRawBytes?: number; + maxRawLines?: number; + now?: () => number; +} + +export class Capturer { + constructor(options?: CapturerOptions); + push(chunk: Uint8Array): void; + flush(): void; + getEvents(): CapturerEvent[]; + getRawState(): CapturerRawState; + on(eventName: CapturerEventName, listener: CapturerListener): () => void; + evaluate(eventId: string, options?: CapturerEvaluateOptions): Promise; + _eventBus: EventEmitter; +} + +export function createCapturer(options?: CapturerOptions): Capturer; + +export class AbortError extends Error { + constructor(); + code: string; +} diff --git a/src/vendor/trbr/index.js b/src/vendor/trbr/index.js new file mode 100644 index 0000000..86fc35e --- /dev/null +++ b/src/vendor/trbr/index.js @@ -0,0 +1,20 @@ +// @ts-check + +export { AbortError } from './abort.js' +export { Capturer, createCapturer } from './capturer/capturer.js' +export { + arches, + decode, + defaultTargetArch, + isDecodeTarget, + isGDBLine, + isParsedGDBLine, +} from './decode/decode.js' +export { createDecodeParams } from './decode/decodeParams.js' +export { stringifyDecodeResult } from './decode/stringify.js' +export { + findTargetArch, + findToolPath, + isRiscvTargetArch, + resolveToolPath, +} from './tool.js' diff --git a/src/vendor/trbr/os.js b/src/vendor/trbr/os.js new file mode 100644 index 0000000..bd1a9e2 --- /dev/null +++ b/src/vendor/trbr/os.js @@ -0,0 +1,6 @@ +// @ts-check + +/** @param {string} filename */ +export function appendDotExeOnWindows(filename) { + return `${filename}${process.platform === 'win32' ? '.exe' : ''}` +} diff --git a/src/vendor/trbr/tool.js b/src/vendor/trbr/tool.js new file mode 100644 index 0000000..e5cc099 --- /dev/null +++ b/src/vendor/trbr/tool.js @@ -0,0 +1,231 @@ +// @ts-check + +import fs from 'node:fs/promises' +import path from 'node:path' + +import { exec } from './exec.js' +import { appendDotExeOnWindows } from './os.js' + +/** + * @typedef {Object} FindTooPathParams + * @property {string} arduinoCliPath + * @property {import('fqbn').FQBN} fqbn + * @property {string} [arduinoCliConfigPath] + * @property {string} [additionalUrls] + */ + +/** + * @param {FindTooPathParams} params + * @param {import('./decode/decode.js').DecodeOptions} [options] + */ +export async function findToolPath( + { arduinoCliPath, fqbn, arduinoCliConfigPath, additionalUrls }, + options +) { + const buildProperties = await resolveBuildProperties( + { + arduinoCliPath, + fqbn, + additionalUrls, + arduinoCliConfigPath, + }, + options + ) + return resolveToolPath({ fqbn, buildProperties }) +} + +/** + * @param {FindTooPathParams} params + * @param {import('./decode/decode.js').DecodeOptions} [options] + */ +export async function resolveBuildProperties( + { arduinoCliPath, fqbn, arduinoCliConfigPath, additionalUrls }, + options +) { + const { stdout } = await execBoardDetails({ + arduinoCliPath, + fqbn, + arduinoCliConfigPath, + additionalUrls, + signal: options?.signal, + }) + + const { build_properties } = JSON.parse(stdout) + return parseBuildProperties(build_properties) +} + +/** + * @typedef {Object} FindTargetArchParams + * @property {Record} buildProperties + */ + +const riscTargetArchs = /** @type {const} */ ([ + 'esp32c2', + 'esp32c3', + 'esp32c6', + 'esp32h2', + 'esp32h4', // XXX: there is no such build.mcu in the latest (3.2.1) ESP32 core for Arduino (https://github.com/espressif/esp-idf-monitor/blob/fae383ecf281655abaa5e65433f671e274316d10/esp_idf_monitor/gdb_panic_server.py#L63), + 'esp32p4', +]) +export const defaultTargetArch = /** @type {const} */ ('xtensa') + +export const targetArchs = /** @type {const} */ ([ + defaultTargetArch, + ...riscTargetArchs, +]) + +/** @typedef {(typeof targetArchs)[number]} DecodeTarget */ + +/** @typedef {(typeof riscTargetArchs)[number]} RiscvTargetArch */ + +/** + * @param {unknown} arg + * @returns {arg is RiscvTargetArch} + */ +export function isRiscvTargetArch(arg) { + return ( + typeof arg === 'string' && + riscTargetArchs.includes(/** @type {RiscvTargetArch} */ (arg)) + ) +} + +const buildMcu = 'build.mcu' + +/** + * @param {FindTargetArchParams} params + * @returns {DecodeTarget} + */ +export function findTargetArch({ buildProperties }) { + const mcu = buildProperties[buildMcu] + if (isRiscvTargetArch(mcu)) { + return mcu + } + return defaultTargetArch +} + +const esp32 = 'esp32' +const esp8266 = 'esp8266' +const supportedArchitectures = new Set([esp32, esp8266]) + +const defaultTarch = 'xtensa' +const defaultTarget = 'lx106' + +const buildTarch = 'build.tarch' +const buildTarget = 'build.target' + +/** + * @typedef {Object} ResolveToolPathParams + * @property {import('fqbn').FQBN} fqbn + * @property {Record} buildProperties + */ + +/** + * @param {ResolveToolPathParams} params + * @returns {Promise} + */ +export async function resolveToolPath({ fqbn, buildProperties }) { + const { arch } = fqbn + if (!supportedArchitectures.has(arch)) { + throw new Error(`Unsupported board architecture: '${fqbn}'`) + } + let tarch = defaultTarch + let target = defaultTarget + if (arch === esp32) { + tarch = buildProperties[buildTarch] ?? defaultTarch + target = buildProperties[buildTarget] ?? defaultTarget + } + + const toolchain = `${tarch}-${target}-elf` + const gdbTool = `${tarch}-esp-elf-gdb` + const gdb = appendDotExeOnWindows(`${toolchain}-gdb`) + + /** @type {(key: string) => Promise} */ + async function find(key) { + const value = buildProperties[key] + if (value) { + const toolPath = path.join(value, 'bin', gdb) + try { + await fs.access(toolPath) + return toolPath + } catch {} + } + return undefined + } + + // `runtime.tools.*` won't work for ESP32 installed from Git. See https://github.com/arduino/arduino-cli/issues/2197#issuecomment-1572921357. + // `runtime.tools.*` ESP8266 requires this. Hence, the fallback here. + const gdbToolPath = `tools.${gdbTool}.path` + const toolChainGCCPath = `tools.${toolchain}-gcc.path` + const toolPaths = await Promise.all([ + find(`runtime.${gdbToolPath}`), + find(`runtime.${toolChainGCCPath}`), + find(gdbToolPath), + find(toolChainGCCPath), + ]) + const toolPath = toolPaths.find((p) => p) + if (!toolPath) { + throw new Error(`Could not find GDB tool for '${fqbn}'`) + } + return toolPath +} + +/** + * @typedef {Object} ExecBoardDetailsParams + * @property {import('fqbn').FQBN} fqbn + * @property {string} arduinoCliPath + * @property {string} [arduinoCliConfigPath] + * @property {string} [additionalUrls] + * @property {AbortSignal} [signal] + */ + +/** @param {ExecBoardDetailsParams} params */ +async function execBoardDetails({ + fqbn, + arduinoCliPath, + arduinoCliConfigPath, + additionalUrls, + signal, +}) { + const args = ['board', 'details', '-b', fqbn.toString(), '--format', 'json'] + if (arduinoCliConfigPath) { + args.push('--config-file', arduinoCliConfigPath) + } + if (additionalUrls) { + args.push('--additional-urls', additionalUrls) + } + return exec(arduinoCliPath, args, { signal }) +} + +/** @param {string[]} properties */ +function parseBuildProperties(properties) { + return properties.reduce((acc, curr) => { + const entry = parseProperty(curr) + if (entry) { + const [key, value] = entry + acc[key] = value + } + return acc + }, /** @type {Record} */ ({})) +} + +const propertySep = '=' +/** @param {string} property */ +function parseProperty(property) { + const segments = property.split(propertySep) + if (segments.length < 2) { + console.warn(`Could not parse build property: ${property}.`) + return undefined + } + const [key, ...rest] = segments + if (!key) { + console.warn(`Could not determine property key from raw: ${property}.`) + return undefined + } + const value = rest.join(propertySep) + return [key, value] +} + +/** (non-API) */ +export const __tests = /** @type {const} */ ({ + parseProperty, +}) From 2a2ee80f027cb4fe3b30bcf985f66e021e595882 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Mon, 27 Apr 2026 11:23:42 +0200 Subject: [PATCH 02/14] Part 1 of fixes --- src/vendor/trbr/decode/coredump.js | 11 ++----- src/vendor/trbr/decode/decode.js | 36 ++++++----------------- src/vendor/trbr/decode/regAddr.js | 19 ++++++------ src/vendor/trbr/decode/riscv.js | 12 +++++--- src/vendor/trbr/decode/riscvPanicParse.js | 14 +++++++-- src/vendor/trbr/decode/stringify.js | 8 ++--- src/vendor/trbr/decode/xtensa.js | 3 +- 7 files changed, 45 insertions(+), 58 deletions(-) diff --git a/src/vendor/trbr/decode/coredump.js b/src/vendor/trbr/decode/coredump.js index 98c6c33..76fb161 100644 --- a/src/vendor/trbr/decode/coredump.js +++ b/src/vendor/trbr/decode/coredump.js @@ -146,11 +146,6 @@ function parseBacktrace(raw) { return entries } -/** - * @param {string} str - * @param {string} key - * @returns {string | undefined} - */ /** * @param {DecodeCoredumpParams} params * @param {DecodeInputFileSource} input @@ -301,7 +296,7 @@ export async function decodeCoredump( const btParsed = parseBacktrace(btOut) const stacktraceLines = btParsed.map((frame, index) => { - const args = frameArgs[index]?.args || '' + const args = frameArgs[index]?.args || [] return { regAddr: frame.addr, lineNumber: frame.line ?? '??', @@ -317,7 +312,7 @@ export async function decodeCoredump( TCB: threadTcbs[tid], result: { faultInfo: { - coreId: parseInt(tid), + coreId: parseInt(tid, 10), programCounter: { addr: programCounter, location: stacktraceLines[0] ?? { @@ -340,7 +335,7 @@ export async function decodeCoredump( if (!results.length && tryRepair) { const raw = await fs.readFile(input.inputPath) - const fallback = await tryRawElfFallback(params, raw) + const fallback = await tryRawElfFallback(params, raw, options) if (fallback) { return fallback } diff --git a/src/vendor/trbr/decode/decode.js b/src/vendor/trbr/decode/decode.js index 3941b51..bebae89 100644 --- a/src/vendor/trbr/decode/decode.js +++ b/src/vendor/trbr/decode/decode.js @@ -3,6 +3,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { AbortError } from '../abort.js' @@ -140,7 +141,7 @@ export function isDecodeInputStreamSource(arg) { arg !== null && typeof arg === 'object' && 'inputStream' in arg && - arg.inputStream instanceof require('stream').Readable + arg.inputStream instanceof Readable ) } @@ -293,31 +294,12 @@ export async function decode( } }) const fd = await fs.open(coredumpInput, 'w') - /** @type {import('node:fs').WriteStream | undefined} */ - let target - try { - target = fd.createWriteStream() - await pipeline(decodeInput.inputStream, target) - } finally { - Promise.allSettled([ - fd.close(), - new Promise((resolve, reject) => - target?.close((err) => { - if (err) { - reject(err) - } else { - resolve(undefined) - } - }) - ), - ]).then((cleanupTasks) => - cleanupTasks.forEach((task) => { - if (task.status === 'rejected') { - console.error('Failed to close stream:', task.reason) - } - }) - ) - } + // The WriteStream takes ownership of the FileHandle and closes it + // when the pipeline finishes (success or error). Do not close `fd` + // manually — that would race with the stream and double-close the + // underlying descriptor. + const target = fd.createWriteStream() + await pipeline(decodeInput.inputStream, target) } if (!coredumpInput) { throw new Error( @@ -354,7 +336,7 @@ export async function decode( } else if (isDecodeInputStreamSource(decodeInput)) { input = '' for await (const chunk of decodeInput.inputStream) { - input = chunk.toString() + input += chunk.toString() } } else { input = decodeInput diff --git a/src/vendor/trbr/decode/regAddr.js b/src/vendor/trbr/decode/regAddr.js index 6b0c3df..1c69ec8 100644 --- a/src/vendor/trbr/decode/regAddr.js +++ b/src/vendor/trbr/decode/regAddr.js @@ -159,13 +159,6 @@ export function parseLine(line, log = createRegAddrLogger()) { return undefined } -/** - * Normalize a parsed GDB line entry. If both file and lineNumber are missing or - * unknown, omit them. If either is present but unknown, default to '??'. - * - * @param {ParsedGDBLine} entry - * @returns {GDBLine | ParsedGDBLine} - */ /** * Normalize a parsed GDB line entry. If both file and lineNumber are missing or * unknown, omit them. If either is present but unknown, default to '??'. @@ -196,11 +189,17 @@ function normalizeParsedLine(entry) { const hasValidMethod = parsedMethod.name && parsedMethod.name !== '' && - parsedMethod.name !== '??' && - parsedMethod.name !== '?? ()' + parsedMethod.name !== '??' if (!hasValidLine && hasValidMethod) { - return { regAddr, lineNumber: parsedMethod.name } + // Carry the method name in the dedicated `method` field instead of + // overloading `lineNumber`, which must be either a numeric string or '??'. + return { + regAddr, + method: parsedMethod.name, + file: '??', + lineNumber: '??', + } } return { regAddr, lineNumber: lineNumber || '??' } diff --git a/src/vendor/trbr/decode/riscv.js b/src/vendor/trbr/decode/riscv.js index 36aeba1..67df961 100644 --- a/src/vendor/trbr/decode/riscv.js +++ b/src/vendor/trbr/decode/riscv.js @@ -183,7 +183,11 @@ function parse({ input, target }) { for (const match of regMatches) { const regName = match[1] const regAddr = parseInt(match[2], 16) - if (regAddr && regNameValidator(regName)) { + if (Number.isNaN(regAddr)) { + continue + } + if (regNameValidator(regName)) { + // Record the register even when its value is 0 (e.g. X0 is always 0). currentRegDump.regs[regName] = regAddr if (regName === 'MEPC') { programCounter = regAddr // PC equivalent @@ -1319,7 +1323,7 @@ export async function decodeRiscv(params, input, options) { }) const includeFrameVars = shouldIncludeFrameVars(options) - const [stacktraceLines, [programCounter, faultAdd], globals] = + const [stacktraceLines, [programCounter, faultAddr], globals] = await Promise.all([ processPanicOutput(params, panicInfo, options, log), addr2line( @@ -1334,7 +1338,7 @@ export async function decodeRiscv(params, input, options) { if (!includeFrameVars) { log('skip globals/locals (includeFrameVars=false)') } - log('addr2line done', { programCounter, faultAdd }) + log('addr2line done', { programCounter, faultAddr }) log('globals count', globals.length) stacktraceLines.forEach((line, index) => { log('stacktrace line', index, line) @@ -1343,7 +1347,7 @@ export async function decodeRiscv(params, input, options) { return createDecodeResult( panicInfo, programCounter, - faultAdd, + faultAddr, stacktraceLines, globals ) diff --git a/src/vendor/trbr/decode/riscvPanicParse.js b/src/vendor/trbr/decode/riscvPanicParse.js index 832f0bb..17a2943 100644 --- a/src/vendor/trbr/decode/riscvPanicParse.js +++ b/src/vendor/trbr/decode/riscvPanicParse.js @@ -129,7 +129,11 @@ function parse({ input, target }) { for (const match of regMatches) { const regName = match[1] const regAddr = parseInt(match[2], 16) - if (regAddr && regNameValidator(regName)) { + if (Number.isNaN(regAddr)) { + continue + } + if (regNameValidator(regName)) { + // Record the register even when its value is 0 (e.g. X0 is always 0). currentRegDump.regs[regName] = regAddr if (regName === 'MEPC') { programCounter = regAddr @@ -172,7 +176,9 @@ function getStackAddrAndData({ stackDump }) { let stackBaseAddr = 0 let baseAddr = 0 let bytesInLine = 0 - let stackData = Buffer.alloc(0) + /** @type {Buffer[]} */ + const chunks = [] + let totalLength = 0 stackDump.forEach((line) => { const prevBaseAddr = baseAddr @@ -191,9 +197,11 @@ function getStackAddrAndData({ stackDump }) { }) ) bytesInLine = lineData.length - stackData = Buffer.concat([stackData, lineData]) + chunks.push(lineData) + totalLength += bytesInLine }) + const stackData = Buffer.concat(chunks, totalLength) return { stackBaseAddr, stackData } } diff --git a/src/vendor/trbr/decode/stringify.js b/src/vendor/trbr/decode/stringify.js index 13f1ddf..2144901 100644 --- a/src/vendor/trbr/decode/stringify.js +++ b/src/vendor/trbr/decode/stringify.js @@ -8,14 +8,13 @@ import { isParsedGDBLine } from './decode.js' /** @typedef {import('./coredump.js').ThreadDecodeResult} ThreadDecodeResult */ const defaultOptions = { - forceColor: false, lineSeparator: '\r\n', } /** * @typedef {Object} StringifyOptions * @property {'force' | 'disable'} [color] - * @property {string} [lineSeparator='\n'] Default is `'\n'` + * @property {string} [lineSeparator='\r\n'] Default is `'\r\n'` */ /** @@ -200,9 +199,8 @@ function stringifyThreadsInfo(result, colorizeFn) { const tid = thread.threadId.toString().padStart(2) const tcb = thread.TCB.toString().padEnd(12) const top = thread.result.stacktraceLines?.[0] - lines.push( - ` ${mark}${tid} process ${tcb} ${stringifyAddrLocation(top, colorizeFn)}` - ) + const topText = top ? stringifyAddrLocation(top, colorizeFn) : '' + lines.push(` ${mark}${tid} process ${tcb} ${topText}`) } return lines } diff --git a/src/vendor/trbr/decode/xtensa.js b/src/vendor/trbr/decode/xtensa.js index 9975d80..b3c9617 100644 --- a/src/vendor/trbr/decode/xtensa.js +++ b/src/vendor/trbr/decode/xtensa.js @@ -87,7 +87,8 @@ export async function decodeXtensa(params, input, options) { frames: addrLines.length, }) let faultMessage - if (panicInfo.faultCode) { + if (typeof panicInfo.faultCode === 'number') { + // Use explicit type check so fault code 0 ("Illegal instruction") is preserved. faultMessage = exceptions[panicInfo.faultCode] } From af2ea7b2513a5a1c01640daefa852a9812635333 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 11:29:22 +0200 Subject: [PATCH 03/14] Part 2 of fixes --- src/vendor/trbr/capturer/framer.js | 10 +++--- src/vendor/trbr/decode/addr2Line.js | 49 ++++++++++++++++++++++++++--- src/vendor/trbr/exec.js | 8 ++++- src/vendor/trbr/index.d.ts | 16 +++++++--- src/vendor/trbr/tool.js | 14 ++++++++- 5 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/vendor/trbr/capturer/framer.js b/src/vendor/trbr/capturer/framer.js index 4147967..ea2e0fb 100644 --- a/src/vendor/trbr/capturer/framer.js +++ b/src/vendor/trbr/capturer/framer.js @@ -2,17 +2,15 @@ /** @typedef {import('./types.js').FramedCrashBlock} FramedCrashBlock */ -const startPatterns = [ +const crashPatterns = [ /Guru Meditation Error:/i, /panic'ed/i, /^Exception\s+\(\d+\):?/i, ] -const reasonPatterns = [ - /Guru Meditation Error:/i, - /panic'ed/i, - /^Exception\s+\(\d+\):?/i, -] +// Both the start and reason heuristics use the same set of crash markers. +const startPatterns = crashPatterns +const reasonPatterns = crashPatterns /** * @typedef {Object} FramerState diff --git a/src/vendor/trbr/decode/addr2Line.js b/src/vendor/trbr/decode/addr2Line.js index 90053c2..230207a 100644 --- a/src/vendor/trbr/decode/addr2Line.js +++ b/src/vendor/trbr/decode/addr2Line.js @@ -38,14 +38,42 @@ class GDBSession { this.current = null this.gdb.stdout.on('data', (chunk) => this._onData(chunk)) this.gdb.stderr.on('data', (chunk) => this._onData(chunk)) - this.gdb.on('error', (err) => this.current?.reject(err)) + this.gdb.on('error', (err) => + this._terminate(err instanceof Error ? err : new Error(String(err))) + ) this.gdb.on('exit', (code, signal) => { if (code !== 0 && signal !== 'SIGTERM') { - console.warn(`GDB exited with code ${code} or signal ${signal}`) + const exitErr = new Error( + `GDB exited unexpectedly (code=${code}, signal=${signal})` + ) + console.warn(exitErr.message) + this._terminate(exitErr) } }) } + /** + * Mark the session as terminally failed and reject the in-flight command + * plus every queued command with the given error so callers don't hang. + * + * @param {Error} err + */ + _terminate(err) { + if (!this.error) { + this.error = err + } + if (this.current) { + const { reject } = this.current + this.current = null + reject(this.error) + } + /** @type {CommandQueueItem | undefined} */ + let item + while ((item = this.queue.shift())) { + item.reject(this.error) + } + } + /** @param {Buffer} chunk */ _onData(chunk) { this.buffer += chunk.toString() @@ -76,6 +104,12 @@ class GDBSession { start() { return new Promise((resolve, reject) => { + const cleanup = () => { + this.gdb.off('error', onError) + this.gdb.stdout.off('data', onData) + this.gdb.stderr.off('data', onData) + } + // GDB not found const onError = (/** @type {Error} */ error) => { let userError = error @@ -86,10 +120,15 @@ class GDBSession { ) { userError = new Error(`GDB tool not found at ${this.toolPath}`) } + cleanup() reject(userError) } - const onData = (/** @type {Buffer} */ chunk) => { + const onData = () => { + // The constructor's _onData listener has already appended the chunk + // to this.buffer. Inspect it (do not re-append) and only consume the + // initial GDB banner prompt here. + // ELF is not found if ( !this.didExecuteFirstCommand && @@ -99,6 +138,7 @@ class GDBSession { this.error = new Error( `The ELF file does not exist or is not readable: ${this.elfPath}` ) + cleanup() reject(this.error) } return @@ -114,15 +154,16 @@ class GDBSession { this.error = new Error( `The ELF file is not in executable format: ${this.elfPath}` ) + cleanup() reject(this.error) } return } - this.buffer += chunk.toString() const idx = this.buffer.indexOf(prompt) if (idx !== -1) { this.buffer = this.buffer.slice(idx + prompt.length) + cleanup() resolve('') } } diff --git a/src/vendor/trbr/exec.js b/src/vendor/trbr/exec.js index 960c098..964d3fc 100644 --- a/src/vendor/trbr/exec.js +++ b/src/vendor/trbr/exec.js @@ -2,6 +2,11 @@ import { execFile } from 'node:child_process' +// Default child-process stdout/stderr buffer cap. Node's built-in default is +// only ~1 MiB which is easily exceeded by tools like `arduino-cli board +// details` or large addr2line dumps. Callers may override via `options`. +const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024 + /** * @param {string} file * @param {string[]} [args=[]] Default is `[]` @@ -10,8 +15,9 @@ import { execFile } from 'node:child_process' * @returns {Promise<{ stdout: string; stderr: string }>} */ export async function exec(file, args = [], options = {}) { + const mergedOptions = { maxBuffer: DEFAULT_MAX_BUFFER, ...options } return new Promise((resolve, reject) => { - execFile(file, args, options, (error, stdout, stderr) => { + execFile(file, args, mergedOptions, (error, stdout, stderr) => { if (error) { reject(error) } else { diff --git a/src/vendor/trbr/index.d.ts b/src/vendor/trbr/index.d.ts index 4bc1f71..de339b8 100644 --- a/src/vendor/trbr/index.d.ts +++ b/src/vendor/trbr/index.d.ts @@ -2,8 +2,6 @@ // src/vendor/trbr/. Only the surface consumed by esp-decoder is declared. // The runtime implementation lives in ./index.js (and the files it imports). -import type { EventEmitter } from 'node:events'; - // --------------------------------------------------------------------------- // Core types // --------------------------------------------------------------------------- @@ -185,6 +183,15 @@ export interface CapturerEvent { export type CapturerListener = (event: CapturerEvent) => void; +export interface CapturerEvaluateContext { + event: CapturerEvent; + signal?: AbortSignal; +} + +export type CapturerEvaluateFn = ( + context: CapturerEvaluateContext +) => Promise; + export interface CapturerEvaluateOptions { signal?: AbortSignal; } @@ -202,6 +209,7 @@ export interface CapturerOptions { maxRawBytes?: number; maxRawLines?: number; now?: () => number; + evaluateEvent?: CapturerEvaluateFn; } export class Capturer { @@ -212,12 +220,12 @@ export class Capturer { getRawState(): CapturerRawState; on(eventName: CapturerEventName, listener: CapturerListener): () => void; evaluate(eventId: string, options?: CapturerEvaluateOptions): Promise; - _eventBus: EventEmitter; } export function createCapturer(options?: CapturerOptions): Capturer; export class AbortError extends Error { constructor(); - code: string; + name: 'AbortError'; + code: 'ABORT_ERR'; } diff --git a/src/vendor/trbr/tool.js b/src/vendor/trbr/tool.js index e5cc099..70893a2 100644 --- a/src/vendor/trbr/tool.js +++ b/src/vendor/trbr/tool.js @@ -50,7 +50,19 @@ export async function resolveBuildProperties( signal: options?.signal, }) - const { build_properties } = JSON.parse(stdout) + /** @type {{ build_properties: string[] }} */ + let parsed + try { + parsed = JSON.parse(stdout) + } catch (err) { + const cause = err instanceof Error ? err : new Error(String(err)) + throw new Error( + `Failed to parse JSON output from 'arduino-cli board details' for fqbn '${fqbn}': ${cause.message}. Raw stdout: ${stdout}`, + // @ts-expect-error -- ErrorOptions are supported on Node 16.9+ + { cause } + ) + } + const { build_properties } = parsed return parseBuildProperties(build_properties) } From 20806a833845fc44cb2f0bf8bf3c584cf63515cc Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 11:41:01 +0200 Subject: [PATCH 04/14] remove not used Arduino CLI --- src/vendor/trbr/decode/decodeParams.js | 96 +--------- src/vendor/trbr/decode/riscv.js | 2 +- src/vendor/trbr/exec.js | 31 ---- src/vendor/trbr/index.js | 7 +- src/vendor/trbr/os.js | 6 - src/vendor/trbr/targets.js | 35 ++++ src/vendor/trbr/tool.js | 243 ------------------------- 7 files changed, 44 insertions(+), 376 deletions(-) delete mode 100644 src/vendor/trbr/exec.js delete mode 100644 src/vendor/trbr/os.js create mode 100644 src/vendor/trbr/targets.js delete mode 100644 src/vendor/trbr/tool.js diff --git a/src/vendor/trbr/decode/decodeParams.js b/src/vendor/trbr/decode/decodeParams.js index ef8bc76..41126c8 100644 --- a/src/vendor/trbr/decode/decodeParams.js +++ b/src/vendor/trbr/decode/decodeParams.js @@ -1,14 +1,8 @@ // @ts-check -import { - defaultTargetArch, - findTargetArch, - resolveBuildProperties, - resolveToolPath, -} from '../tool.js' +import { defaultTargetArch } from '../targets.js' -/** @typedef {import('../tool.js').DecodeTarget} DecodeTarget */ -/** @typedef {import('fqbn').FQBN} FQBN */ +/** @typedef {import('../targets.js').DecodeTarget} DecodeTarget */ // --- Provides @@ -28,13 +22,6 @@ import { * @property {string} elfPath */ -/** - * @typedef {Object} ArduinoCliParams - * @property {string} arduinoCliPath - * @property {string} [arduinoCliConfigPath] - * @property {string} [additionalUrls] - */ - /** * @typedef {Object} ToolParams * @property {string} toolPath @@ -51,32 +38,10 @@ import { * @property {false} [coredumpMode] */ -/** - * @typedef {Object} WithFQBN - * @property {FQBN} fqbn - */ - -/** - * @typedef {WithFQBN & { - * buildProperties: Record - * }} WithBuildProperties - */ - // --- Backtrace /** @typedef {CreateDecodeParamsParams & ToolParams & BacktraceMode} CreateDecodeParamsFromToolParams */ -/** - * @typedef {CreateDecodeParamsParams & - * ArduinoCliParams & - * WithFQBN & - * BacktraceMode} CreateDecodeParamsFromFQBNParams - */ -/** @typedef {CreateDecodeParamsParams & WithBuildProperties & BacktraceMode} CreateDecodeParamsFromBuildPropertiesParams */ -/** - * @typedef {CreateDecodeParamsFromToolParams - * | CreateDecodeParamsFromFQBNParams - * | CreateDecodeParamsFromBuildPropertiesParams} CreateDecodeParamsFromParams - */ +/** @typedef {CreateDecodeParamsFromToolParams} CreateDecodeParamsFromParams */ /** * @callback CreateDecodeParams @@ -87,18 +52,7 @@ import { // --- Coredump /** @typedef {CreateDecodeParamsParams & ToolParams & CoredumpMode} CreateCoredumpDecodeParamsFromToolParams */ -/** - * @typedef {CreateDecodeParamsParams & - * ArduinoCliParams & - * WithFQBN & - * CoredumpMode} CreateCoredumpDecodeParamsFromFQBNParams - */ -/** @typedef {CreateDecodeParamsParams & WithBuildProperties & CoredumpMode} CreateCoredumpDecodeParamsFromBuildPropertiesParams */ -/** - * @typedef {CreateCoredumpDecodeParamsFromToolParams - * | CreateCoredumpDecodeParamsFromFQBNParams - * | CreateCoredumpDecodeParamsFromBuildPropertiesParams} CreateCoredumpDecodeParamsFromParams - */ +/** @typedef {CreateCoredumpDecodeParamsFromToolParams} CreateCoredumpDecodeParamsFromParams */ /** * @callback CreateCoredumpDecodeParams @@ -122,24 +76,6 @@ function isToolPathParams(params) { return 'toolPath' in params && typeof params.toolPath === 'string' } -/** - * @param {CreateDecodeParamsParams} params - * @returns {params is CreateDecodeParamsFromBuildPropertiesParams|CreateCoredumpDecodeParamsFromBuildPropertiesParams} - */ -function isBuildPropertiesParams(params) { - return ( - 'buildProperties' in params && typeof params.buildProperties === 'object' - ) -} - -/** - * @param {CreateDecodeParamsParams} params - * @returns {params is CreateDecodeParamsFromFQBNParams|CreateCoredumpDecodeParamsFromFQBNParams} - */ -function isArduinoCliParams(params) { - return 'arduinoCliPath' in params && typeof params.arduinoCliPath === 'string' -} - /** * @overload * @param {CreateDecodeParamsFromParams} params @@ -156,25 +92,7 @@ function isArduinoCliParams(params) { * @returns {Promise} */ export async function createDecodeParams(params) { - /** @type {string | undefined} */ - let toolPath - /** @type {DecodeTarget | undefined} */ - let targetArch - - if (isToolPathParams(params)) { - toolPath = params.toolPath - targetArch = params.targetArch ?? defaultTargetArch - } else if (isBuildPropertiesParams(params)) { - toolPath = await resolveToolPath(params) - targetArch = findTargetArch(params) - } else if (isArduinoCliParams(params)) { - const buildProperties = await resolveBuildProperties(params) - toolPath = await resolveToolPath({ - fqbn: params.fqbn, - buildProperties, - }) - targetArch = findTargetArch({ buildProperties }) - } else { + if (!isToolPathParams(params)) { throw new Error( `Unexpected create decode params input: ${JSON.stringify(params)}` ) @@ -183,8 +101,8 @@ export async function createDecodeParams(params) { /** @type {DecodeParams} */ const decodeParams = { elfPath: params.elfPath, - toolPath, - targetArch, + toolPath: params.toolPath, + targetArch: params.targetArch ?? defaultTargetArch, } if (!isCoredumpModeParams(params)) { diff --git a/src/vendor/trbr/decode/riscv.js b/src/vendor/trbr/decode/riscv.js index 67df961..a0c7f15 100644 --- a/src/vendor/trbr/decode/riscv.js +++ b/src/vendor/trbr/decode/riscv.js @@ -3,7 +3,7 @@ import net from 'node:net' import { AbortError, neverSignal } from '../abort.js' -import { isRiscvTargetArch } from '../tool.js' +import { isRiscvTargetArch } from '../targets.js' import { addr2line } from './addr2Line.js' import { GdbMiClient, diff --git a/src/vendor/trbr/exec.js b/src/vendor/trbr/exec.js deleted file mode 100644 index 964d3fc..0000000 --- a/src/vendor/trbr/exec.js +++ /dev/null @@ -1,31 +0,0 @@ -// @ts-check - -import { execFile } from 'node:child_process' - -// Default child-process stdout/stderr buffer cap. Node's built-in default is -// only ~1 MiB which is easily exceeded by tools like `arduino-cli board -// details` or large addr2line dumps. Callers may override via `options`. -const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024 - -/** - * @param {string} file - * @param {string[]} [args=[]] Default is `[]` - * @param {import('node:child_process').ExecFileOptions} [options={}] Default is - * `{}` - * @returns {Promise<{ stdout: string; stderr: string }>} - */ -export async function exec(file, args = [], options = {}) { - const mergedOptions = { maxBuffer: DEFAULT_MAX_BUFFER, ...options } - return new Promise((resolve, reject) => { - execFile(file, args, mergedOptions, (error, stdout, stderr) => { - if (error) { - reject(error) - } else { - resolve({ - stdout: stdout.toString(), - stderr: stderr.toString(), - }) - } - }) - }) -} diff --git a/src/vendor/trbr/index.js b/src/vendor/trbr/index.js index 86fc35e..c8ea8a6 100644 --- a/src/vendor/trbr/index.js +++ b/src/vendor/trbr/index.js @@ -12,9 +12,4 @@ export { } from './decode/decode.js' export { createDecodeParams } from './decode/decodeParams.js' export { stringifyDecodeResult } from './decode/stringify.js' -export { - findTargetArch, - findToolPath, - isRiscvTargetArch, - resolveToolPath, -} from './tool.js' +export { isRiscvTargetArch } from './targets.js' diff --git a/src/vendor/trbr/os.js b/src/vendor/trbr/os.js deleted file mode 100644 index bd1a9e2..0000000 --- a/src/vendor/trbr/os.js +++ /dev/null @@ -1,6 +0,0 @@ -// @ts-check - -/** @param {string} filename */ -export function appendDotExeOnWindows(filename) { - return `${filename}${process.platform === 'win32' ? '.exe' : ''}` -} diff --git a/src/vendor/trbr/targets.js b/src/vendor/trbr/targets.js new file mode 100644 index 0000000..086fe3c --- /dev/null +++ b/src/vendor/trbr/targets.js @@ -0,0 +1,35 @@ +// @ts-check + +// Decoder target architectures supported by trbr. Keeping the constants in +// their own module lets the rest of the library reference them without +// pulling in any Arduino-CLI / FQBN tooling helpers. + +export const defaultTargetArch = /** @type {const} */ ('xtensa') + +const riscTargetArchs = /** @type {const} */ ([ + 'esp32c2', + 'esp32c3', + 'esp32c6', + 'esp32h2', + 'esp32h4', + 'esp32p4', +]) + +export const targetArchs = /** @type {const} */ ([ + defaultTargetArch, + ...riscTargetArchs, +]) + +/** @typedef {(typeof targetArchs)[number]} DecodeTarget */ +/** @typedef {(typeof riscTargetArchs)[number]} RiscvTargetArch */ + +/** + * @param {unknown} arg + * @returns {arg is RiscvTargetArch} + */ +export function isRiscvTargetArch(arg) { + return ( + typeof arg === 'string' && + riscTargetArchs.includes(/** @type {RiscvTargetArch} */ (arg)) + ) +} diff --git a/src/vendor/trbr/tool.js b/src/vendor/trbr/tool.js deleted file mode 100644 index 70893a2..0000000 --- a/src/vendor/trbr/tool.js +++ /dev/null @@ -1,243 +0,0 @@ -// @ts-check - -import fs from 'node:fs/promises' -import path from 'node:path' - -import { exec } from './exec.js' -import { appendDotExeOnWindows } from './os.js' - -/** - * @typedef {Object} FindTooPathParams - * @property {string} arduinoCliPath - * @property {import('fqbn').FQBN} fqbn - * @property {string} [arduinoCliConfigPath] - * @property {string} [additionalUrls] - */ - -/** - * @param {FindTooPathParams} params - * @param {import('./decode/decode.js').DecodeOptions} [options] - */ -export async function findToolPath( - { arduinoCliPath, fqbn, arduinoCliConfigPath, additionalUrls }, - options -) { - const buildProperties = await resolveBuildProperties( - { - arduinoCliPath, - fqbn, - additionalUrls, - arduinoCliConfigPath, - }, - options - ) - return resolveToolPath({ fqbn, buildProperties }) -} - -/** - * @param {FindTooPathParams} params - * @param {import('./decode/decode.js').DecodeOptions} [options] - */ -export async function resolveBuildProperties( - { arduinoCliPath, fqbn, arduinoCliConfigPath, additionalUrls }, - options -) { - const { stdout } = await execBoardDetails({ - arduinoCliPath, - fqbn, - arduinoCliConfigPath, - additionalUrls, - signal: options?.signal, - }) - - /** @type {{ build_properties: string[] }} */ - let parsed - try { - parsed = JSON.parse(stdout) - } catch (err) { - const cause = err instanceof Error ? err : new Error(String(err)) - throw new Error( - `Failed to parse JSON output from 'arduino-cli board details' for fqbn '${fqbn}': ${cause.message}. Raw stdout: ${stdout}`, - // @ts-expect-error -- ErrorOptions are supported on Node 16.9+ - { cause } - ) - } - const { build_properties } = parsed - return parseBuildProperties(build_properties) -} - -/** - * @typedef {Object} FindTargetArchParams - * @property {Record} buildProperties - */ - -const riscTargetArchs = /** @type {const} */ ([ - 'esp32c2', - 'esp32c3', - 'esp32c6', - 'esp32h2', - 'esp32h4', // XXX: there is no such build.mcu in the latest (3.2.1) ESP32 core for Arduino (https://github.com/espressif/esp-idf-monitor/blob/fae383ecf281655abaa5e65433f671e274316d10/esp_idf_monitor/gdb_panic_server.py#L63), - 'esp32p4', -]) -export const defaultTargetArch = /** @type {const} */ ('xtensa') - -export const targetArchs = /** @type {const} */ ([ - defaultTargetArch, - ...riscTargetArchs, -]) - -/** @typedef {(typeof targetArchs)[number]} DecodeTarget */ - -/** @typedef {(typeof riscTargetArchs)[number]} RiscvTargetArch */ - -/** - * @param {unknown} arg - * @returns {arg is RiscvTargetArch} - */ -export function isRiscvTargetArch(arg) { - return ( - typeof arg === 'string' && - riscTargetArchs.includes(/** @type {RiscvTargetArch} */ (arg)) - ) -} - -const buildMcu = 'build.mcu' - -/** - * @param {FindTargetArchParams} params - * @returns {DecodeTarget} - */ -export function findTargetArch({ buildProperties }) { - const mcu = buildProperties[buildMcu] - if (isRiscvTargetArch(mcu)) { - return mcu - } - return defaultTargetArch -} - -const esp32 = 'esp32' -const esp8266 = 'esp8266' -const supportedArchitectures = new Set([esp32, esp8266]) - -const defaultTarch = 'xtensa' -const defaultTarget = 'lx106' - -const buildTarch = 'build.tarch' -const buildTarget = 'build.target' - -/** - * @typedef {Object} ResolveToolPathParams - * @property {import('fqbn').FQBN} fqbn - * @property {Record} buildProperties - */ - -/** - * @param {ResolveToolPathParams} params - * @returns {Promise} - */ -export async function resolveToolPath({ fqbn, buildProperties }) { - const { arch } = fqbn - if (!supportedArchitectures.has(arch)) { - throw new Error(`Unsupported board architecture: '${fqbn}'`) - } - let tarch = defaultTarch - let target = defaultTarget - if (arch === esp32) { - tarch = buildProperties[buildTarch] ?? defaultTarch - target = buildProperties[buildTarget] ?? defaultTarget - } - - const toolchain = `${tarch}-${target}-elf` - const gdbTool = `${tarch}-esp-elf-gdb` - const gdb = appendDotExeOnWindows(`${toolchain}-gdb`) - - /** @type {(key: string) => Promise} */ - async function find(key) { - const value = buildProperties[key] - if (value) { - const toolPath = path.join(value, 'bin', gdb) - try { - await fs.access(toolPath) - return toolPath - } catch {} - } - return undefined - } - - // `runtime.tools.*` won't work for ESP32 installed from Git. See https://github.com/arduino/arduino-cli/issues/2197#issuecomment-1572921357. - // `runtime.tools.*` ESP8266 requires this. Hence, the fallback here. - const gdbToolPath = `tools.${gdbTool}.path` - const toolChainGCCPath = `tools.${toolchain}-gcc.path` - const toolPaths = await Promise.all([ - find(`runtime.${gdbToolPath}`), - find(`runtime.${toolChainGCCPath}`), - find(gdbToolPath), - find(toolChainGCCPath), - ]) - const toolPath = toolPaths.find((p) => p) - if (!toolPath) { - throw new Error(`Could not find GDB tool for '${fqbn}'`) - } - return toolPath -} - -/** - * @typedef {Object} ExecBoardDetailsParams - * @property {import('fqbn').FQBN} fqbn - * @property {string} arduinoCliPath - * @property {string} [arduinoCliConfigPath] - * @property {string} [additionalUrls] - * @property {AbortSignal} [signal] - */ - -/** @param {ExecBoardDetailsParams} params */ -async function execBoardDetails({ - fqbn, - arduinoCliPath, - arduinoCliConfigPath, - additionalUrls, - signal, -}) { - const args = ['board', 'details', '-b', fqbn.toString(), '--format', 'json'] - if (arduinoCliConfigPath) { - args.push('--config-file', arduinoCliConfigPath) - } - if (additionalUrls) { - args.push('--additional-urls', additionalUrls) - } - return exec(arduinoCliPath, args, { signal }) -} - -/** @param {string[]} properties */ -function parseBuildProperties(properties) { - return properties.reduce((acc, curr) => { - const entry = parseProperty(curr) - if (entry) { - const [key, value] = entry - acc[key] = value - } - return acc - }, /** @type {Record} */ ({})) -} - -const propertySep = '=' -/** @param {string} property */ -function parseProperty(property) { - const segments = property.split(propertySep) - if (segments.length < 2) { - console.warn(`Could not parse build property: ${property}.`) - return undefined - } - const [key, ...rest] = segments - if (!key) { - console.warn(`Could not determine property key from raw: ${property}.`) - return undefined - } - const value = rest.join(propertySep) - return [key, value] -} - -/** (non-API) */ -export const __tests = /** @type {const} */ ({ - parseProperty, -}) From 0273be80de5fe8a32d7a0c60de9b13d25d85d935 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 12:07:54 +0200 Subject: [PATCH 05/14] Part 3 of fixes --- src/vendor/trbr/decode/addr2Line.js | 9 ++++-- src/vendor/trbr/decode/coredump.js | 2 +- src/vendor/trbr/decode/decode.js | 5 ++-- src/vendor/trbr/decode/riscv.js | 44 +++++++++++++++++++++++++---- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/vendor/trbr/decode/addr2Line.js b/src/vendor/trbr/decode/addr2Line.js index 230207a..8a2dbf5 100644 --- a/src/vendor/trbr/decode/addr2Line.js +++ b/src/vendor/trbr/decode/addr2Line.js @@ -170,6 +170,8 @@ class GDBSession { this.gdb.on('error', onError) this.gdb.stdout.on('data', onData) this.gdb.stderr.on('data', onData) + // Process any data already buffered by the constructor's _onData + onData() }) } @@ -236,7 +238,7 @@ function buildAddr2LineAddrs(addrs) { * @param {Pick} params * @param {(number | AddrLine | undefined)[]} addrs * @param {DecodeOptions} [options={}] Default is `{}` - * @returns {Promise} + * @returns {Promise<(AddrLine | undefined)[]>} */ export async function addr2line({ elfPath, toolPath }, addrs, options = {}) { const addresses = buildAddr2LineAddrs(addrs) @@ -272,6 +274,9 @@ export async function addr2line({ elfPath, toolPath }, addrs, options = {}) { return addrs.map((addrOrLine) => { const addr = typeof addrOrLine === 'object' ? addrOrLine.addr : addrOrLine - return results.get(addr) || { location: '??' } + if (addr === undefined) { + return undefined + } + return results.get(addr) }) } diff --git a/src/vendor/trbr/decode/coredump.js b/src/vendor/trbr/decode/coredump.js index 76fb161..15d6f89 100644 --- a/src/vendor/trbr/decode/coredump.js +++ b/src/vendor/trbr/decode/coredump.js @@ -149,8 +149,8 @@ function parseBacktrace(raw) { /** * @param {DecodeCoredumpParams} params * @param {DecodeInputFileSource} input - * @param {boolean} [tryRepair] * @param {import('./decode.js').DecodeOptions} [options={}] Default is `{}` + * @param {boolean} [tryRepair=true] Default is `true` * @returns {Promise} */ export async function decodeCoredump( diff --git a/src/vendor/trbr/decode/decode.js b/src/vendor/trbr/decode/decode.js index bebae89..89ecd66 100644 --- a/src/vendor/trbr/decode/decode.js +++ b/src/vendor/trbr/decode/decode.js @@ -162,9 +162,9 @@ export function isDecodeInputStreamSource(arg) { /** * @typedef {Object} FaultInfo * @property {number} coreId - * @property {AddrLine} programCounter PC at fault (PC for ESP32, MEPC for + * @property {AddrLine | undefined} programCounter PC at fault (PC for ESP32, MEPC for * RISC-V, EPC1 for ESP8266) - * @property {AddrLine} [faultAddr] EXCVADDR for ESP32, EXCVADDR for RISC-V and + * @property {AddrLine | undefined} [faultAddr] EXCVADDR for ESP32, EXCVADDR for RISC-V and * ESP8266 * @property {number} [faultCode] EXCCAUSE for ESP32, EXCCODE for RISC-V * @property {string} [faultMessage] @@ -488,6 +488,7 @@ export async function debugAllAddrs(toolPath, elfPath, rawAddresses) { const padding = String(lines.length - 1).length console.log( lines + .filter((line) => line !== undefined) .map((line) => line.location) .map(stringifyAddr) .map( diff --git a/src/vendor/trbr/decode/riscv.js b/src/vendor/trbr/decode/riscv.js index a0c7f15..f1113d5 100644 --- a/src/vendor/trbr/decode/riscv.js +++ b/src/vendor/trbr/decode/riscv.js @@ -282,11 +282,18 @@ function parsePanicOutput({ input, target }) { if (regDumps.length === 0) { throw new Error('No register dumps found') } + // When multiple cores are present, select the first one (typically the + // crashing core in ESP-IDF panic output). Deterministic selection ensures + // we consistently decode the same core across multiple runs. + const activeCore = regDumps.length > 1 ? regDumps[0] : regDumps[0] if (regDumps.length > 1) { - throw new Error('Handling of multi-core register dumps not implemented') + console.warn( + `[trbr][riscv] Multi-core dump detected (${regDumps.length} cores); ` + + `decoding core ${activeCore.coreId} (first)` + ) } - const { coreId, regs } = regDumps[0] + const { coreId, regs } = activeCore const { stackBaseAddr, stackData } = getStackAddrAndData({ stackDump }) return { @@ -334,10 +341,27 @@ export class GdbServer { this.server = server await new Promise((resolve, reject) => { + /** @type {(() => void)[]} */ + const listeners = [] + + const cleanup = () => { + for (const remove of listeners) { + remove() + } + } + const abortHandler = () => { this.debug('User abort') + cleanup() + this.close() reject(new AbortError()) + } + + const errorHandler = (/** @type {Error} */ err) => { + this.debug('Server error', err) + cleanup() this.close() + reject(err) } if (signal.aborted) { @@ -346,10 +370,18 @@ export class GdbServer { } signal.addEventListener('abort', abortHandler) - server.on('listening', () => { - signal.removeEventListener('abort', abortHandler) + listeners.push(() => signal.removeEventListener('abort', abortHandler)) + + server.on('error', errorHandler) + listeners.push(() => server.removeListener('error', errorHandler)) + + const listeningHandler = () => { + cleanup() resolve(undefined) - }) + } + server.on('listening', listeningHandler) + listeners.push(() => server.removeListener('listening', listeningHandler)) + server.listen(0) }) @@ -1260,7 +1292,7 @@ async function processPanicOutput( /** * @param {PanicInfoWithStackData} panicInfo - * @param {AddrLine} programCounter + * @param {AddrLine | undefined} programCounter * @param {AddrLine | undefined} faultAddr * @param {(GDBLine | ParsedGDBLine)[]} stacktraceLines * @param {FrameVar[]} [globals] From 48258ed72b7189a153a148ca6426e9c1506ec901 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 12:21:52 +0200 Subject: [PATCH 06/14] esp32c5 support --- src/vendor/trbr/decode/addr2Line.js | 6 ------ src/vendor/trbr/decode/riscv.js | 2 ++ src/vendor/trbr/decode/riscvPanicParse.js | 1 + src/vendor/trbr/targets.js | 1 + 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/vendor/trbr/decode/addr2Line.js b/src/vendor/trbr/decode/addr2Line.js index 8a2dbf5..d7c4cc8 100644 --- a/src/vendor/trbr/decode/addr2Line.js +++ b/src/vendor/trbr/decode/addr2Line.js @@ -228,12 +228,6 @@ function buildAddr2LineAddrs(addrs) { return Array.from(dedupedAddrs.values()) } -/** - * @typedef {Object} RegsInfo - * @property {Record>} threadRegs - * @property {number} [currentThreadAddr] - */ - /** * @param {Pick} params * @param {(number | AddrLine | undefined)[]} addrs diff --git a/src/vendor/trbr/decode/riscv.js b/src/vendor/trbr/decode/riscv.js index f1113d5..fdd757b 100644 --- a/src/vendor/trbr/decode/riscv.js +++ b/src/vendor/trbr/decode/riscv.js @@ -88,6 +88,7 @@ const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ export const riscvDecoders = /** @type {const} */ ({ esp32c2: decodeRiscv, esp32c3: decodeRiscv, + esp32c5: decodeRiscv, esp32c6: decodeRiscv, esp32h2: decodeRiscv, esp32h4: decodeRiscv, @@ -98,6 +99,7 @@ export const riscvDecoders = /** @type {const} */ ({ const gdbRegsInfo = { esp32c2: gdbRegsInfoRiscvIlp32, esp32c3: gdbRegsInfoRiscvIlp32, + esp32c5: gdbRegsInfoRiscvIlp32, esp32c6: gdbRegsInfoRiscvIlp32, esp32h2: gdbRegsInfoRiscvIlp32, esp32h4: gdbRegsInfoRiscvIlp32, diff --git a/src/vendor/trbr/decode/riscvPanicParse.js b/src/vendor/trbr/decode/riscvPanicParse.js index 17a2943..d3edc50 100644 --- a/src/vendor/trbr/decode/riscvPanicParse.js +++ b/src/vendor/trbr/decode/riscvPanicParse.js @@ -71,6 +71,7 @@ const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ const gdbRegsInfo = { esp32c2: gdbRegsInfoRiscvIlp32, esp32c3: gdbRegsInfoRiscvIlp32, + esp32c5: gdbRegsInfoRiscvIlp32, esp32c6: gdbRegsInfoRiscvIlp32, esp32h2: gdbRegsInfoRiscvIlp32, esp32h4: gdbRegsInfoRiscvIlp32, diff --git a/src/vendor/trbr/targets.js b/src/vendor/trbr/targets.js index 086fe3c..e0c7f6d 100644 --- a/src/vendor/trbr/targets.js +++ b/src/vendor/trbr/targets.js @@ -9,6 +9,7 @@ export const defaultTargetArch = /** @type {const} */ ('xtensa') const riscTargetArchs = /** @type {const} */ ([ 'esp32c2', 'esp32c3', + 'esp32c5', 'esp32c6', 'esp32h2', 'esp32h4', From 7dee628989c8d6077704c9e247c83418550e14a5 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 12:36:21 +0200 Subject: [PATCH 07/14] guards to calc hex strings correctly --- src/vendor/trbr/decode/addr2Line.js | 11 ++++++++--- src/vendor/trbr/decode/coredump.js | 6 ++++-- src/vendor/trbr/decode/decode.js | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/vendor/trbr/decode/addr2Line.js b/src/vendor/trbr/decode/addr2Line.js index d7c4cc8..317e985 100644 --- a/src/vendor/trbr/decode/addr2Line.js +++ b/src/vendor/trbr/decode/addr2Line.js @@ -216,12 +216,17 @@ function buildAddr2LineAddrs(addrs) { const dedupedAddrs = new Set() for (const addr of addrs) { let addrNumber - if (typeof addr === 'object') { - addrNumber = addr.addr + if (typeof addr === 'object' && addr !== null) { + const a = addr.addr + if (typeof a === 'string') { + addrNumber = parseInt(a, 16) + } else if (typeof a === 'number') { + addrNumber = a + } } else if (typeof addr === 'number') { addrNumber = addr } - if (addrNumber !== undefined && !dedupedAddrs.has(addrNumber)) { + if (addrNumber !== undefined && !Number.isNaN(addrNumber) && !dedupedAddrs.has(addrNumber)) { dedupedAddrs.add(addrNumber) } } diff --git a/src/vendor/trbr/decode/coredump.js b/src/vendor/trbr/decode/coredump.js index 15d6f89..e301ad5 100644 --- a/src/vendor/trbr/decode/coredump.js +++ b/src/vendor/trbr/decode/coredump.js @@ -246,6 +246,8 @@ export async function decodeCoredump( log('regs', tid, Object.keys(regsAsNamed)) const programCounter = regsAsNamed['pc'] + const programCounterHex = + programCounter !== undefined ? toHexString(programCounter) : '??' const btOut = await client.sendCommand('-stack-list-frames') log('stack frames raw length', btOut.length) @@ -314,9 +316,9 @@ export async function decodeCoredump( faultInfo: { coreId: parseInt(tid, 10), programCounter: { - addr: programCounter, + addr: programCounterHex, location: stacktraceLines[0] ?? { - regAddr: toHexString(programCounter), + regAddr: programCounterHex, lineNumber: '??', }, }, diff --git a/src/vendor/trbr/decode/decode.js b/src/vendor/trbr/decode/decode.js index 89ecd66..be5e7e2 100644 --- a/src/vendor/trbr/decode/decode.js +++ b/src/vendor/trbr/decode/decode.js @@ -56,7 +56,7 @@ import { decodeXtensa } from './xtensa.js' /** * @typedef {Object} AddrLine - * @property {number} [addr] + * @property {number | string} [addr] Number or hex string like '0x40001234' * @property {AddrLocation} location */ @@ -69,7 +69,7 @@ export function isAddrLine(arg) { arg !== null && typeof arg === 'object' && 'addr' in arg && - (typeof arg.addr === 'number' || arg.addr === undefined) && + (typeof arg.addr === 'number' || typeof arg.addr === 'string' || arg.addr === undefined) && 'location' in arg && (typeof arg.location === 'string' || isGDBLine(arg.location) || From 1a8e31d56efb48d41fcbaca6b1b5ccfeb043fb3b Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 12:41:44 +0200 Subject: [PATCH 08/14] fix riscv list --- src/vendor/trbr/decode/regs.js | 12 ++++-------- src/vendor/trbr/decode/riscvPanicParse.js | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/vendor/trbr/decode/regs.js b/src/vendor/trbr/decode/regs.js index 3addcb1..d0fbcba 100644 --- a/src/vendor/trbr/decode/regs.js +++ b/src/vendor/trbr/decode/regs.js @@ -33,9 +33,9 @@ export const registerSets = /** @type {const} */ ({ 'WINDOWBASE', 'WINDOWSTART', ], - // TODO: compare with gdbRegsInfoRiscvIlp32 + // Order matches gdbRegsInfoRiscvIlp32 in riscvPanicParse.js (source of truth) riscv: [ - 'MEPC', + 'X0', 'RA', 'SP', 'GP', @@ -43,7 +43,7 @@ export const registerSets = /** @type {const} */ ({ 'T0', 'T1', 'T2', - 'S0', + 'S0/FP', 'S1', 'A0', 'A1', @@ -67,11 +67,7 @@ export const registerSets = /** @type {const} */ ({ 'T4', 'T5', 'T6', - 'MSTATUS', - 'MTVEC', - 'MCAUSE', - 'MTVAL', - 'MHARTID', + 'MEPC', ], }) diff --git a/src/vendor/trbr/decode/riscvPanicParse.js b/src/vendor/trbr/decode/riscvPanicParse.js index d3edc50..702c0d4 100644 --- a/src/vendor/trbr/decode/riscvPanicParse.js +++ b/src/vendor/trbr/decode/riscvPanicParse.js @@ -40,6 +40,23 @@ const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ 'MEPC', ]) +// Sanity check: verify expected register count and key indices to prevent regressions +// This must match registerSets.riscv in regs.js +if (gdbRegsInfoRiscvIlp32.length !== 33) { + throw new Error( + `gdbRegsInfoRiscvIlp32 expected 33 registers, got ${gdbRegsInfoRiscvIlp32.length}` + ) +} +if (gdbRegsInfoRiscvIlp32[0] !== 'X0') { + throw new Error(`gdbRegsInfoRiscvIlp32[0] expected 'X0', got '${gdbRegsInfoRiscvIlp32[0]}'`) +} +if (gdbRegsInfoRiscvIlp32[8] !== 'S0/FP') { + throw new Error(`gdbRegsInfoRiscvIlp32[8] expected 'S0/FP', got '${gdbRegsInfoRiscvIlp32[8]}'`) +} +if (gdbRegsInfoRiscvIlp32[32] !== 'MEPC') { + throw new Error(`gdbRegsInfoRiscvIlp32[32] expected 'MEPC', got '${gdbRegsInfoRiscvIlp32[32]}'`) +} + /** * @typedef {Object} RegisterDump * @property {number} coreId From cce1a100487589f25b7cf45d278ff383a3847353 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 12:59:01 +0200 Subject: [PATCH 09/14] one source of truth: gdbRegsInfoRiscvIlp32 --- src/test/crashDecoder.test.ts | 34 ++++++++++++++++++++ src/vendor/trbr/decode/riscv.js | 39 ++--------------------- src/vendor/trbr/decode/riscvPanicParse.js | 2 +- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/test/crashDecoder.test.ts b/src/test/crashDecoder.test.ts index 6e76701..e0343bc 100644 --- a/src/test/crashDecoder.test.ts +++ b/src/test/crashDecoder.test.ts @@ -49,6 +49,7 @@ vi.mock('vscode', () => { import { TrbrCrashCapturer, decodeCrash, decodeCoredumpElf, decodeCoredumpBase64, containsBase64Coredump } from '../crashDecoder.js'; import type { CrashEvent } from '../crashDecoder.js'; import { getPioPackagesDir } from '../pioIntegration.js'; +import { registerSets } from '../vendor/trbr/decode/regs.js'; // --------------------------------------------------------------------------- // Fixture paths @@ -682,3 +683,36 @@ describe('decodeCoredumpBase64', () => { expect(result.threads).toHaveLength(0); }); }); + +// --------------------------------------------------------------------------- +// RISC-V register layout validation +// --------------------------------------------------------------------------- + +describe('RISC-V register layout', () => { + it('has correct register count in registerSets.riscv', () => { + expect(registerSets.riscv).toHaveLength(33); + }); + + it('has X0 at index 0 in registerSets.riscv', () => { + expect(registerSets.riscv[0]).toBe('X0'); + }); + + it('has S0/FP at index 8 in registerSets.riscv', () => { + expect(registerSets.riscv[8]).toBe('S0/FP'); + }); + + it('has MEPC at index 32 in registerSets.riscv', () => { + expect(registerSets.riscv[32]).toBe('MEPC'); + }); + + it('matches the expected GDB ILP32 register order', () => { + // This must match gdbRegsInfoRiscvIlp32 in riscvPanicParse.js + const expectedOrder = [ + 'X0', 'RA', 'SP', 'GP', 'TP', 'T0', 'T1', 'T2', 'S0/FP', 'S1', + 'A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', + 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8', 'S9', 'S10', 'S11', + 'T3', 'T4', 'T5', 'T6', 'MEPC', + ]; + expect(registerSets.riscv).toEqual(expectedOrder); + }); +}); diff --git a/src/vendor/trbr/decode/riscv.js b/src/vendor/trbr/decode/riscv.js index fdd757b..c9db3af 100644 --- a/src/vendor/trbr/decode/riscv.js +++ b/src/vendor/trbr/decode/riscv.js @@ -15,6 +15,7 @@ import { import { resolveGlobalSymbols } from './globals.js' import { parseLines } from './regAddr.js' import { toHexString } from './regs.js' +import { gdbRegsInfoRiscvIlp32 } from './riscvPanicParse.js' // Based on the work of: // - [Peter Dragun](https://github.com/peterdragun) @@ -48,42 +49,6 @@ function createRiscvLogger(debug) { /** @typedef {import('./decode.js').AddrLine} AddrLine */ /** @typedef {import('./decode.js').PanicInfoWithStackData} PanicInfoWithStackData */ /** @typedef {import('../tool.js').RiscvTargetArch} RiscvTargetArch */ -const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ - 'X0', - 'RA', - 'SP', - 'GP', - 'TP', - 'T0', - 'T1', - 'T2', - 'S0/FP', - 'S1', - 'A0', - 'A1', - 'A2', - 'A3', - 'A4', - 'A5', - 'A6', - 'A7', - 'S2', - 'S3', - 'S4', - 'S5', - 'S6', - 'S7', - 'S8', - 'S9', - 'S10', - 'S11', - 'T3', - 'T4', - 'T5', - 'T6', - 'MEPC', // where execution is happening (PC) and where it resumes after exception (MEPC). -]) - /** @type {Record} */ export const riscvDecoders = /** @type {const} */ ({ esp32c2: decodeRiscv, @@ -287,7 +252,7 @@ function parsePanicOutput({ input, target }) { // When multiple cores are present, select the first one (typically the // crashing core in ESP-IDF panic output). Deterministic selection ensures // we consistently decode the same core across multiple runs. - const activeCore = regDumps.length > 1 ? regDumps[0] : regDumps[0] + const activeCore = regDumps[0] if (regDumps.length > 1) { console.warn( `[trbr][riscv] Multi-core dump detected (${regDumps.length} cores); ` + diff --git a/src/vendor/trbr/decode/riscvPanicParse.js b/src/vendor/trbr/decode/riscvPanicParse.js index 702c0d4..ef0477e 100644 --- a/src/vendor/trbr/decode/riscvPanicParse.js +++ b/src/vendor/trbr/decode/riscvPanicParse.js @@ -4,7 +4,7 @@ const defaultRiscvTarget = /** @type {const} */ ('esp32c3') -const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ +export const gdbRegsInfoRiscvIlp32 = /** @type {const} */ ([ 'X0', 'RA', 'SP', From 82bf443b0cbbe40c6a3c6ec6d4ef45c694e372f9 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 13:45:38 +0200 Subject: [PATCH 10/14] fix: trbr crash detect. remove workarounds --- src/crashDecoder.ts | 167 +-------------------------- src/vendor/trbr/capturer/capturer.js | 17 ++- src/vendor/trbr/capturer/framer.js | 3 + 3 files changed, 21 insertions(+), 166 deletions(-) diff --git a/src/crashDecoder.ts b/src/crashDecoder.ts index ca66260..a118ab7 100644 --- a/src/crashDecoder.ts +++ b/src/crashDecoder.ts @@ -133,48 +133,16 @@ export interface ThreadDecodedCrash { decoded: DecodedCrash; } -/** - * Patterns that trbr's built-in framer recognizes as crash-block starters - * AND correctly finalizes (via flush guards or quiet period). - * Used to determine whether trbr will detect a crash on its own. - */ -const TRBR_START_PATTERNS = [ - /Guru Meditation Error:/i, - /panic'ed/i, -]; - -/** - * Additional crash-start patterns handled by our fallback detector. - * - * These need fallback detection for two reasons: - * - * 1. Patterns trbr doesn't recognize at all: - * - "assert failed:" / "abort() was called" / "Core N register dump:" - * (RISC-V chips without "Guru Meditation Error:" wrapper) - * - * 2. ESP8266 "Exception (N):": trbr's framer DOES recognize this as a start - * pattern, but has two bugs that prevent it from working: - * - flush() only finalizes blocks containing Backtrace:/Stack memory:/ - * Rebooting.../ELF file SHA256: — ESP8266's >>>stack>>> is not matched - * - detectKind() uses case-sensitive /EXCVADDR/ but ESP8266 outputs - * lowercase "excvaddr=", so kind becomes "unknown" and - * parseESP8266PanicOutput is never called - * Until these are fixed upstream in trbr, the fallback handles ESP8266. - */ -const FALLBACK_START_PATTERNS = [ - /^assert failed:/i, - /^abort\(\) was called/i, - /^Core\s+\d+\s+register dump:/i, - /^Exception\s+\(\d+\):?/i, -]; - /** * Wraps trbr's Capturer to detect crash events from raw serial byte chunks. * Uses trbr's proven crash framing logic (handles Stack memory, register dumps, - * Backtrace lines, and Rebooting... terminators correctly). + * Backtrace lines, Rebooting... terminators, and all ESP-IDF crash formats). * - * Includes a fallback detector for crash formats where trbr's framer has - * limitations — see FALLBACK_START_PATTERNS for details. + * trbr now supports all crash patterns natively: + * - Guru Meditation Error: / panic'ed (ESP32 classic) + * - Exception (N): (ESP8266) + * - assert failed: / abort() was called (all chips) + * - Core N register dump: (RISC-V chips) */ export class TrbrCrashCapturer { private capturer: Capturer; @@ -182,18 +150,9 @@ export class TrbrCrashCapturer { readonly onCrashDetected = this._onCrashDetected.event; private unsubscribe: (() => void) | undefined; - // Fallback detector state - private fbLineBuffer = ''; - private fbLines: string[] = []; - private fbActive = false; - private fbQuietTimer: ReturnType | undefined; - private fbNextId = 1; - private trbrFiredForCurrentBlock = false; - constructor() { this.capturer = createCapturer({ quietPeriodMs: 500 }); this.unsubscribe = this.capturer.on('eventDetected', (capturerEvent: CapturerEvent) => { - this.trbrFiredForCurrentBlock = true; const event = capturerEventToCrashEvent(capturerEvent); this._onCrashDetected.fire(event); }); @@ -203,14 +162,10 @@ export class TrbrCrashCapturer { * Feed raw serial bytes. trbr's capturer handles line decoding, * crash block framing (including Stack memory: sections), and * deduplication internally. - * - * Also feeds data through the fallback detector for crash patterns - * that trbr misses. */ pushData(data: Buffer | Uint8Array): void { const chunk = data instanceof Uint8Array ? data : new Uint8Array(data); this.capturer.push(chunk); - this.fbPushData(data); } /** @@ -218,7 +173,6 @@ export class TrbrCrashCapturer { */ flush(): void { this.capturer.flush(); - this.fbFlush(); } reset(): void { @@ -226,124 +180,15 @@ export class TrbrCrashCapturer { this.unsubscribe?.(); this.capturer = createCapturer({ quietPeriodMs: 500 }); this.unsubscribe = this.capturer.on('eventDetected', (capturerEvent: CapturerEvent) => { - this.trbrFiredForCurrentBlock = true; const event = capturerEventToCrashEvent(capturerEvent); this._onCrashDetected.fire(event); }); - this.fbReset(); - this.fbLineBuffer = ''; } dispose(): void { this.unsubscribe?.(); - this.fbReset(); this._onCrashDetected.dispose(); } - - // --- Fallback crash detector --- - // Catches crash blocks that trbr's framer misses (e.g. assert failures - // on RISC-V chips that lack "Guru Meditation Error:" prefix). - - private fbPushData(data: Buffer | Uint8Array): void { - const text = Buffer.from(data).toString('utf-8'); - this.fbLineBuffer += text; - - const parts = this.fbLineBuffer.split(/\r?\n/); - this.fbLineBuffer = parts.pop() || ''; - for (const line of parts) { - this.fbProcessLine(line); - } - } - - private fbProcessLine(line: string): void { - if (!this.fbActive) { - // Only start fallback for patterns that trbr wouldn't catch - const trbrWouldCatch = TRBR_START_PATTERNS.some(p => p.test(line)); - if (trbrWouldCatch) { - return; // trbr will handle this crash — don't duplicate - } - if (FALLBACK_START_PATTERNS.some(p => p.test(line))) { - this.fbActive = true; - this.trbrFiredForCurrentBlock = false; - this.fbLines = []; - } - } - - if (this.fbActive) { - this.fbLines.push(line); - this.fbResetTimer(); - if (/^Rebooting\.\.\./i.test(line.trim())) { - this.fbFinalize(); - } - } - } - - private fbResetTimer(): void { - if (this.fbQuietTimer) { - clearTimeout(this.fbQuietTimer); - } - // Use a slightly longer quiet period than trbr (600ms vs 500ms) - // so trbr fires first if it's going to detect this crash. - this.fbQuietTimer = setTimeout(() => this.fbFinalize(), 600); - } - - private fbFinalize(): void { - if (this.fbQuietTimer) { - clearTimeout(this.fbQuietTimer); - this.fbQuietTimer = undefined; - } - if (!this.fbActive || this.fbLines.length === 0) { - this.fbReset(); - return; - } - // Only fire if trbr didn't already detect this crash - if (!this.trbrFiredForCurrentBlock && this.fbLooksLikeCrash()) { - const rawText = this.fbLines.join('\n'); - const kind = this.fbDetectKind(); - const event: CrashEvent = { - id: `fallback-${String(this.fbNextId++).padStart(6, '0')}`, - kind, - lines: [...this.fbLines], - rawText, - timestamp: Date.now(), - }; - this._onCrashDetected.fire(event); - } - this.fbReset(); - } - - private fbLooksLikeCrash(): boolean { - return this.fbLines.some(l => - /Core\s+\d+\s+register dump:/i.test(l) || - /^Stack memory:/i.test(l) || - /^>>>stack>>>/i.test(l) - ); - } - - private fbDetectKind(): 'xtensa' | 'riscv' | 'unknown' { - const riscvPatterns = [/MCAUSE/, /\bMEPC\b/, /MHARTID/]; - const xtensaPatterns = [/Backtrace:/, /EXCCAUSE/i, /excvaddr/i, /\bepc1=/i]; - if (this.fbLines.some(l => riscvPatterns.some(p => p.test(l)))) { return 'riscv'; } - if (this.fbLines.some(l => xtensaPatterns.some(p => p.test(l)))) { return 'xtensa'; } - return 'unknown'; - } - - private fbReset(): void { - this.fbActive = false; - this.fbLines = []; - if (this.fbQuietTimer) { - clearTimeout(this.fbQuietTimer); - this.fbQuietTimer = undefined; - } - } - - private fbFlush(): void { - if (this.fbLineBuffer) { - this.fbProcessLine(this.fbLineBuffer); - this.fbLineBuffer = ''; - } - this.fbFinalize(); - } } /** diff --git a/src/vendor/trbr/capturer/capturer.js b/src/vendor/trbr/capturer/capturer.js index 14cb68e..f564cea 100644 --- a/src/vendor/trbr/capturer/capturer.js +++ b/src/vendor/trbr/capturer/capturer.js @@ -505,15 +505,22 @@ function resolveOptions(options) { * @returns {import('./types.js').CapturerEventKind} */ function detectKind(lines) { - const riscvHints = [/MCAUSE/, /\bMEPC\b/, /Stack memory:/, /MHARTID/] + const riscvHints = [ + /MCAUSE/i, + /\bMEPC\b/i, + /MHARTID/i, + ] if (lines.some((line) => riscvHints.some((hint) => hint.test(line)))) { return 'riscv' } const xtensaHints = [ - /Backtrace:/, - /EXCCAUSE/, - /EXCVADDR/, - /Guru Meditation Error:/, + /Backtrace:/i, + /EXCCAUSE/i, + /EXCVADDR/i, + /Guru Meditation Error:/i, + /^assert failed:/i, + /^abort\(\) was called/i, + /^Exception\s+\(\d+\):?/i, ] if (lines.some((line) => xtensaHints.some((hint) => hint.test(line)))) { return 'xtensa' diff --git a/src/vendor/trbr/capturer/framer.js b/src/vendor/trbr/capturer/framer.js index ea2e0fb..cf71114 100644 --- a/src/vendor/trbr/capturer/framer.js +++ b/src/vendor/trbr/capturer/framer.js @@ -6,6 +6,8 @@ const crashPatterns = [ /Guru Meditation Error:/i, /panic'ed/i, /^Exception\s+\(\d+\):?/i, + /^assert failed:/i, + /^abort\(\) was called/i, ] // Both the start and reason heuristics use the same set of crash markers. @@ -145,6 +147,7 @@ function isCompleteBlock(lines) { /^Stack memory:/i, /^Rebooting\.\.\./i, /ELF file SHA256:/i, + />>>stack>>>/i, // ESP8266 stack marker ].some((pattern) => pattern.test(line.trim())) ) } From 1e902ed85480df48bb9ac33654fd295a7ca087b0 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 14:13:41 +0200 Subject: [PATCH 11/14] fix: decode crashed coreID (two core MCUs) --- src/test/crashDecoder.test.ts | 11 +++-------- src/vendor/trbr/capturer/framer.js | 8 ++++---- src/vendor/trbr/decode/riscv.js | 17 +++++++++++++---- src/vendor/trbr/decode/riscvPanicParse.js | 5 +++-- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/test/crashDecoder.test.ts b/src/test/crashDecoder.test.ts index e0343bc..1d14968 100644 --- a/src/test/crashDecoder.test.ts +++ b/src/test/crashDecoder.test.ts @@ -50,6 +50,7 @@ import { TrbrCrashCapturer, decodeCrash, decodeCoredumpElf, decodeCoredumpBase64 import type { CrashEvent } from '../crashDecoder.js'; import { getPioPackagesDir } from '../pioIntegration.js'; import { registerSets } from '../vendor/trbr/decode/regs.js'; +import { gdbRegsInfoRiscvIlp32 } from '../vendor/trbr/decode/riscvPanicParse.js'; // --------------------------------------------------------------------------- // Fixture paths @@ -706,13 +707,7 @@ describe('RISC-V register layout', () => { }); it('matches the expected GDB ILP32 register order', () => { - // This must match gdbRegsInfoRiscvIlp32 in riscvPanicParse.js - const expectedOrder = [ - 'X0', 'RA', 'SP', 'GP', 'TP', 'T0', 'T1', 'T2', 'S0/FP', 'S1', - 'A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', - 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8', 'S9', 'S10', 'S11', - 'T3', 'T4', 'T5', 'T6', 'MEPC', - ]; - expect(registerSets.riscv).toEqual(expectedOrder); + // Use the canonical sequence from riscvPanicParse.js to avoid drift + expect(registerSets.riscv).toEqual(gdbRegsInfoRiscvIlp32); }); }); diff --git a/src/vendor/trbr/capturer/framer.js b/src/vendor/trbr/capturer/framer.js index cf71114..a16510c 100644 --- a/src/vendor/trbr/capturer/framer.js +++ b/src/vendor/trbr/capturer/framer.js @@ -78,10 +78,10 @@ export class CrashFramer { /** @type {FramedCrashBlock[]} */ const finalized = [] this._finalizeIfQuiet(finalized, atMs) - // Finalize on flush only when the active crash already looks complete. - // This avoids trailing partial events at stop-capture while still - // emitting complete blocks without waiting for an extra quiet period. - if (this._active && isCompleteBlock(this._active.lines)) { + // Finalize any active crash block on flush. The _finalize method has + // internal protection (hasSignal check) to avoid emitting incomplete + // blocks without a valid reason line. + if (this._active) { this._finalize(finalized) } return finalized diff --git a/src/vendor/trbr/decode/riscv.js b/src/vendor/trbr/decode/riscv.js index c9db3af..3051e8e 100644 --- a/src/vendor/trbr/decode/riscv.js +++ b/src/vendor/trbr/decode/riscv.js @@ -95,6 +95,7 @@ function createRegNameValidator(type) { /** * @typedef {Object} StackDump + * @property {number} coreId * @property {number} baseAddr * @property {number[]} data */ @@ -168,7 +169,7 @@ function parse({ input, target }) { if (line.trim() === 'Stack memory:') { inStackMemory = true } - } else if (inStackMemory) { + } else if (inStackMemory && currentRegDump) { const match = line.match(/^([0-9a-fA-F]+):\s*((?:0x[0-9a-fA-F]+\s*)+)/) if (match) { const baseAddr = parseInt(match[1], 16) @@ -176,7 +177,7 @@ function parse({ input, target }) { .trim() .split(/\s+/) .map((hex) => parseInt(hex, 16)) - stackDump.push({ baseAddr, data }) + stackDump.push({ coreId: currentRegDump.coreId, baseAddr, data }) } } }) @@ -261,7 +262,10 @@ function parsePanicOutput({ input, target }) { } const { coreId, regs } = activeCore - const { stackBaseAddr, stackData } = getStackAddrAndData({ stackDump }) + // Filter stack segments to only those belonging to the selected core + // to avoid "Invalid base address" errors from non-contiguous segments + const filteredStackDump = stackDump.filter((seg) => seg.coreId === coreId) + const { stackBaseAddr, stackData } = getStackAddrAndData({ stackDump: filteredStackDump }) return { coreId, @@ -1078,7 +1082,12 @@ async function expandVariable(client, variable, options) { } } } finally { - await client.sendCommand(`-var-delete ${varObject}`) + // Best-effort cleanup: don't let delete failures mask successful decoding + try { + await client.sendCommand(`-var-delete ${varObject}`) + } catch { + // Swallow cleanup errors + } } } } diff --git a/src/vendor/trbr/decode/riscvPanicParse.js b/src/vendor/trbr/decode/riscvPanicParse.js index ef0477e..8a17244 100644 --- a/src/vendor/trbr/decode/riscvPanicParse.js +++ b/src/vendor/trbr/decode/riscvPanicParse.js @@ -65,6 +65,7 @@ if (gdbRegsInfoRiscvIlp32[32] !== 'MEPC') { /** * @typedef {Object} StackDump + * @property {number} coreId * @property {number} baseAddr * @property {number[]} data */ @@ -165,7 +166,7 @@ function parse({ input, target }) { if (line.trim() === 'Stack memory:') { inStackMemory = true } - } else if (inStackMemory) { + } else if (inStackMemory && currentRegDump) { const match = line.match(/^([0-9a-fA-F]+):\s*((?:0x[0-9a-fA-F]+\s*)+)/) if (match) { const baseAddr = parseInt(match[1], 16) @@ -173,7 +174,7 @@ function parse({ input, target }) { .trim() .split(/\s+/) .map((hex) => parseInt(hex, 16)) - stackDump.push({ baseAddr, data }) + stackDump.push({ coreId: currentRegDump.coreId, baseAddr, data }) } } }) From 98e3ffc56697ad6a0a38b142eb4b9e46668ae451 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 14:49:58 +0200 Subject: [PATCH 12/14] call isCompleteBlock() --- src/vendor/trbr/capturer/framer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vendor/trbr/capturer/framer.js b/src/vendor/trbr/capturer/framer.js index a16510c..78c8b76 100644 --- a/src/vendor/trbr/capturer/framer.js +++ b/src/vendor/trbr/capturer/framer.js @@ -108,7 +108,7 @@ export class CrashFramer { } const lines = this._active.lines const hasSignal = lines.some((line) => isStartLine(line)) - if (hasSignal && lines.length > 0) { + if (hasSignal && lines.length > 0 && isCompleteBlock(lines)) { finalized.push({ lines: [...lines], startedAt: this._active.startedAt, From 34aa7b78b5a0708b2be6e12cb9394e1818dfa7e3 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Tue, 28 Apr 2026 15:26:11 +0200 Subject: [PATCH 13/14] enhance: finalize immediate when esp8266 crash end marker detected --- src/test/crashDecoder.test.ts | 10 +++++----- src/vendor/trbr/capturer/framer.js | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/test/crashDecoder.test.ts b/src/test/crashDecoder.test.ts index 1d14968..3345fa2 100644 --- a/src/test/crashDecoder.test.ts +++ b/src/test/crashDecoder.test.ts @@ -358,14 +358,14 @@ describe('TrbrCrashCapturer – ESP8266 exception crash', () => { expect(event?.rawText).toContain('<< { - // ESP8266 fixture has no "Rebooting..." line, so the crash block is only - // finalized via flush(). Pushing data alone must NOT emit an event. + it('is captured without flush when ESP8266 stack end marker is present', () => { + // ESP8266 fixture has no "Rebooting..." line, but it includes "<< { if (!detected) { detected = e; } }); capturer.pushData(Buffer.from(ESP8266_CRASH_TEXT, 'utf8')); - // No flush — block must remain pending - expect(detected).toBeUndefined(); + expect(detected).toBeDefined(); + expect(detected?.rawText).toContain('<<>>stack>>>/i, // ESP8266 stack marker + />>>stack>>>/i, // ESP8266 stack block start marker + /^<< pattern.test(line.trim())) ) } + +/** + * End-of-crash lines that should finalize immediately (without quiet timeout). + * @param {string} line + * @returns {boolean} + */ +function isImmediateFinalizeLine(line) { + const trimmed = line.trim() + return /^Rebooting\.\.\./i.test(trimmed) || /^<< Date: Tue, 28 Apr 2026 15:57:50 +0200 Subject: [PATCH 14/14] Bump v0.23.0 --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 10 ++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a75f415..b3a3e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [0.23.0] - 2026-04-28 + +### Changed +- **Vendored trbr runtime integration** — `trbr` is now fully vendored under `src/vendor/trbr` and used directly by `src/crashDecoder.ts`, removing the external runtime dependency and related build externals (#38). +- **Crash capture pipeline cleanup** — legacy fallback/workaround paths were removed in favor of the integrated trbr framer/capturer flow, reducing duplicate crash-detection logic (#38). + +### Fixed +- **ESP8266 frame finalization** — crash blocks now finalize immediately when the `<<