├── .eslintrc.json ├── .github └── workflows │ ├── build.yml │ └── pr-title.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── images ├── espExceptionDecoder_main.gif └── espExceptionDecoder_main.png ├── package-lock.json ├── package.json ├── release.config.js ├── src ├── decoder.ts ├── extension.ts ├── riscv.ts ├── terminal.ts ├── test │ ├── cliContext.json │ ├── envs.cli.json │ ├── envs.git.json │ ├── runTest.ts │ ├── sketches │ │ ├── AE │ │ │ └── AE.ino │ │ ├── esp32backtracetest │ │ │ ├── esp32backtracetest.ino │ │ │ ├── module1.cpp │ │ │ ├── module1.h │ │ │ ├── module2.cpp │ │ │ └── module2.h │ │ └── riscv_1 │ │ │ └── riscv_1.ino │ ├── suite │ │ ├── decoder.slow-test.ts │ │ ├── decoder.test.ts │ │ ├── index.ts │ │ ├── mock.ts │ │ ├── riscv.test.ts │ │ └── terminal.test.ts │ ├── testEnv.ts │ └── tools │ │ ├── fake-tool │ │ └── fake-tool.bat └── utils.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "extends": [ 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended", 11 | "prettier" 12 | ], 13 | "plugins": ["@typescript-eslint", "prettier"], 14 | "rules": { 15 | "@typescript-eslint/naming-convention": "off", 16 | "@typescript-eslint/semi": "warn", 17 | "curly": "warn", 18 | "eqeqeq": "warn", 19 | "no-throw-literal": "warn", 20 | "semi": "off", 21 | "prettier/prettier": "warn" 22 | }, 23 | "ignorePatterns": ["out", "dist", "**/*.d.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: test (${{ matrix.os }}, node-${{ matrix.node }}) 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [windows-latest, ubuntu-latest, macos-latest] 18 | node: [20.x] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | with: 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | - name: Use Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node }} 29 | - name: Use Python 30 | uses: actions/setup-python@v4 31 | with: 32 | python-version: '3.x' 33 | - name: Restore CLI Binaries 34 | uses: actions/cache/restore@v3 35 | with: 36 | path: test-resources/cli-releases 37 | key: ${{ runner.os }}-cli-context-${{ hashFiles('src/test/cliContext.json') }} 38 | - name: Restore `directories.data` folder (CLI) 39 | uses: actions/cache/restore@v3 40 | with: 41 | path: test-resources/envs/cli 42 | key: ${{ runner.os }}-cli-env-${{ hashFiles('src/test/envs.cli.json') }} 43 | - name: Restore `directories.data` folder (Git) 44 | uses: actions/cache/restore@v3 45 | with: 46 | path: test-resources/envs/git 47 | key: ${{ runner.os }}-git-env-${{ hashFiles('src/test/envs.git.json') }} 48 | - name: Install Dependencies 49 | run: npm ci 50 | - name: Check Format 51 | run: npm run format && git diff --exit-code 52 | - name: Test 53 | uses: coactions/setup-xvfb@v1 54 | with: 55 | run: npm run test-all 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | - name: Store CLI Binaries 59 | uses: actions/cache/save@v3 60 | with: 61 | path: test-resources/cli-releases 62 | key: ${{ runner.os }}-cli-context-${{ hashFiles('src/test/cliContext.json') }} 63 | - name: Store `directories.data` folder (CLI) 64 | uses: actions/cache/save@v3 65 | with: 66 | path: test-resources/envs/cli 67 | key: ${{ runner.os }}-cli-env-${{ hashFiles('src/test/envs.cli.json') }} 68 | - name: Store `directories.data` folder (Git) 69 | uses: actions/cache/save@v3 70 | with: 71 | path: test-resources/envs/git 72 | key: ${{ runner.os }}-git-env-${{ hashFiles('src/test/envs.git.json') }} 73 | 74 | release: 75 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 76 | runs-on: ubuntu-latest 77 | needs: [build] 78 | steps: 79 | - name: Checkout 80 | uses: actions/checkout@v3 81 | with: 82 | persist-credentials: false 83 | - name: Use Node.js 84 | uses: actions/setup-node@v1 85 | with: 86 | node-version: 18.x 87 | - name: Install Dependencies 88 | run: npm ci 89 | - name: Build 90 | run: npm run build 91 | - name: Release 92 | id: release 93 | run: npm run release 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.ADMIN_TOKEN }} 96 | OVSX_PAT: ${{ secrets.OVSX_PAT }} 97 | outputs: 98 | release_version: ${{ steps.release.outputs.release_version }} 99 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | # Taken from stephenh/ts-proto. Thank you! 2 | # https://github.com/stephenh/ts-proto/blob/1f73bad91fe33c497d5168b9f0847ad596ffec39/.github/workflows/pr-title.yml 3 | name: PR Title 4 | 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - reopened 10 | - edited 11 | - synchronize 12 | 13 | jobs: 14 | validate: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: amannn/action-semantic-pull-request@v3.1.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | out 3 | dist 4 | node_modules 5 | .vscode-test 6 | *.vsix 7 | # binary releases of the Arduino CLI for testing and the `directories.data` folder for the tests 8 | test-resources 9 | .nyc_output 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | .vscode-test 4 | test-resources 5 | CHANGELOG.md 6 | .nyc_output 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "printWidth": 80, 6 | "endOfLine": "auto", 7 | "overrides": [ 8 | { 9 | "files": "*.json", 10 | "options": { 11 | "tabWidth": 2 12 | } 13 | } 14 | ], 15 | "plugins": ["prettier-plugin-packagejson"] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "amodio.tsl-problem-matcher", 5 | "streetsidesoftware.code-spell-checker" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 9 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 10 | "preLaunchTask": "${defaultBuildTask}" 11 | }, 12 | { 13 | "name": "Extension Tests", 14 | "type": "extensionHost", 15 | "request": "launch", 16 | "args": [ 17 | "--extensionDevelopmentPath=${workspaceFolder}", 18 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 19 | ], 20 | "env": { 21 | "NO_TEST_TIMEOUT": "true", 22 | "NO_TEST_COVERAGE": "true", 23 | "TEST_DEBUG": "espExceptionDecoder*" 24 | }, 25 | "outFiles": [ 26 | "${workspaceFolder}/out/**/*.js", 27 | "${workspaceFolder}/dist/**/*.js" 28 | ], 29 | "preLaunchTask": "tasks: watch-tests" 30 | }, 31 | { 32 | "name": "Extension Tests (Slow)", 33 | "type": "extensionHost", 34 | "request": "launch", 35 | "args": [ 36 | "--extensionDevelopmentPath=${workspaceFolder}", 37 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 38 | ], 39 | "env": { 40 | "CLI_TEST_CONTEXT": "slow", 41 | "NO_TEST_TIMEOUT": "true", 42 | "NO_TEST_COVERAGE": "true", 43 | "TEST_DEBUG": "espExceptionDecoder*" 44 | }, 45 | "outFiles": [ 46 | "${workspaceFolder}/out/**/*.js", 47 | "${workspaceFolder}/dist/**/*.js" 48 | ], 49 | "preLaunchTask": "tasks: watch-tests" 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": false, 4 | "dist": false 5 | }, 6 | "search.exclude": { 7 | "out": true, 8 | "dist": true 9 | }, 10 | "typescript.tsc.autoDetect": "off", 11 | "typescript.tsdk": "./node_modules/typescript/lib", 12 | "editor.codeActionsOnSave": { 13 | "source.fixAll.eslint": "explicit" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "watch", 7 | "problemMatcher": "$ts-webpack-watch", 8 | "isBackground": true, 9 | "presentation": { 10 | "reveal": "never", 11 | "group": "watchers" 12 | }, 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | }, 18 | { 19 | "type": "npm", 20 | "script": "watch-tests", 21 | "problemMatcher": "$tsc-watch", 22 | "isBackground": true, 23 | "presentation": { 24 | "reveal": "never", 25 | "group": "watchers" 26 | }, 27 | "group": "build" 28 | }, 29 | { 30 | "label": "tasks: watch-tests", 31 | "dependsOn": ["npm: watch", "npm: watch-tests"], 32 | "problemMatcher": [] 33 | }, 34 | { 35 | // This task expects the arduino-ide repository to be checked out as a sibling folder of this repository. 36 | "label": "Update VSIX in Arduino IDE", 37 | "type": "shell", 38 | "command": "rm -rf ../arduino-ide/electron-app/plugins/esp-exception-decoder-riscv && mkdir -p ../arduino-ide/electron-app/plugins/esp-exception-decoder-riscv && npm run compile && unzip ./esp-exception-decoder-riscv-1.0.3.vsix -d ../arduino-ide/electron-app/plugins/esp-exception-decoder-riscv" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/** 4 | node_modules/** 5 | src/** 6 | .gitignore 7 | .yarnrc 8 | webpack.config.js 9 | vsc-extension-quickstart.md 10 | **/tsconfig.json 11 | **/.eslintrc.json 12 | **/*.map 13 | **/*.ts 14 | test-resources/** 15 | .prettier* 16 | .github/** 17 | images/** 18 | release.config.js 19 | CHANGELOG.md 20 | .nyc_output 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.1.0](https://github.com/dankeboy36/esp-exception-decoder/compare/1.0.2...1.1.0) (2025-03-06) 2 | 3 | 4 | ### Features 5 | 6 | * add support for exception decoding on RISC-V chips ([#37](https://github.com/dankeboy36/esp-exception-decoder/issues/37)) ([c06200f](https://github.com/dankeboy36/esp-exception-decoder/commit/c06200f186b33fa48dbff2a8c53f419c0aa0d000)) 7 | 8 | ## [1.0.2](https://github.com/dankeboy36/esp-exception-decoder/compare/1.0.1...1.0.2) (2023-07-12) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **release:** OVSX publish ([#5](https://github.com/dankeboy36/esp-exception-decoder/issues/5)) ([aeb47cc](https://github.com/dankeboy36/esp-exception-decoder/commit/aeb47cc0cdeba60f983782fcabffe8e4e105a4ef)) 14 | 15 | ## [1.0.1](https://github.com/dankeboy36/esp-exception-decoder/compare/v1.0.0...1.0.1) (2023-07-03) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **release:** undesired release behavior ([#4](https://github.com/dankeboy36/esp-exception-decoder/issues/4)) ([b581dc7](https://github.com/dankeboy36/esp-exception-decoder/commit/b581dc7d23c126ab4e551e23b765dffb578739d4)) 21 | 22 | # 1.0.0 (2023-07-03) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * added missing `publisher` to `package.json` ([#3](https://github.com/dankeboy36/esp-exception-decoder/issues/3)) ([563c69b](https://github.com/dankeboy36/esp-exception-decoder/commit/563c69b30f5accca3be575f0673d88053ae6177b)) 28 | 29 | 30 | ### Features 31 | 32 | * ESP exception decoder tool for Arduino IDE ([227732e](https://github.com/dankeboy36/esp-exception-decoder/commit/227732e620058492466c79a512a6980291720266)) 33 | 34 | # Change Log 35 | 36 | All notable changes to the "esp-exception-decoder" extension will be documented in this file. 37 | 38 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 39 | 40 | ## [Unreleased] 41 | 42 | - Initial release 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 dankeboy36 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESP Exception Decoder 2 | 3 | [![Tests](https://github.com/dankeboy36/esp-exception-decoder/actions/workflows/build.yml/badge.svg)](https://github.com/dankeboy36/esp-exception-decoder/actions/workflows/build.yml) 4 | 5 | > ⚠️ This project is in an early state. 6 | 7 | [Arduino IDE](https://github.com/arduino/arduino-ide/) extension allows you to get a more meaningful explanation of the stack traces and backtraces you encounter on ESP8266/ESP32. This extension is a reimplementation of the well-known [ESP8266/ESP32 Exception Stack Trace Decoder](https://github.com/me-no-dev/EspExceptionDecoder) tool, which was originally written in Java. The RISC-V decoder implementation was ported from [`esp_idf_monitor`](https://github.com/espressif/esp-idf-monitor/blob/fae383ecf281655abaa5e65433f671e274316d10/esp_idf_monitor/gdb_panic_server.py). 8 | 9 | ![ESP8266/ESP32 Exception Decoder Extension](./images/espExceptionDecoder_main.png) 10 | 11 | > ⚠️ This extension is not related to the [Visual Studio Code extension for Arduino](https://marketplace.visualstudio.com/items?itemName=vsciot-vscode.vscode-arduino). Please note that this extension does not work in VS Code. 12 | 13 | ## Installation 14 | 15 | 1. Download the latest extension from the GitHub [release page](https://github.com/dankeboy36/esp-exception-decoder/releases/latest). The filename should be `esp-exception-decoder-${VERSION}.vsix`, where `${VERSION}` is the latest version. 16 | 2. Make sure the Arduino IDE is not running. Then, copy the downloaded extension into the `plugins` folder located in the Arduino IDE's configuration directory. If the `plugins` folder does not exist, create it. 17 | - On Windows, it's under `%UserProfile%\.arduinoIDE\plugins` (typically `C:\Users\\.arduinoIDE\plugins` where `` is your Windows username). 18 | - On Linux and macOS, it's under `~/.arduinoIDE/plugins`. 19 | > **ⓘ** If you encounter issues, refer to the [_Installation_](https://github.com/arduino/arduino-ide/blob/main/docs/advanced-usage.md#installation) section of the documentation for Arduino IDE _3rd party themes_. The steps are very similar. 20 | 21 | ### Update 22 | 23 | To update to the latest or a more recent version of the decoder extension, simply copy the new version file into the same `plugins` folder alongside the current version. The Arduino IDE will automatically use the most recent version of the extension. If desired, you can delete the older version to keep your plugins folder organized. 24 | 25 | ## Usage 26 | 27 | 1. Open a sketch in the Arduino IDE and verify it. 28 | 2. Upload the sketch to your ESP8266/ESP32 board. 29 | 3. Open the _Serial Monitor_ view to monitor the output for exceptions. 30 | 4. When an exception occurs, open the _Exception Decoder_ terminal: 31 | - Open the _Command Palette_ using Ctrl/⌘+Shift+P. 32 | - Type `ESP Exception Decoder: Show Decoder Terminal` and press Enter. 33 | 5. Copy the exception stack trace/backtrace from the _Serial Monitor_ view. 34 | 6. Paste the stack trace/backtrace into the _Exception Decoder_ terminal. 35 | > **ⓘ** For more details on copying and pasting in the terminal, check [here](https://code.visualstudio.com/docs/terminal/basics#_copy-paste). 36 | 37 | ![ESP Exception Decoder in Action](./images/espExceptionDecoder_main.gif) 38 | 39 | ### Hints 40 | 41 | - Enable blinking cursors in the decoder terminal by setting [`"terminal.integrated.cursorBlinking": true`](https://code.visualstudio.com/docs/terminal/appearance#_terminal-cursor). 42 | - Allow pasting in the decoder terminal by setting `"terminal.enablePaste": true`. 43 | - Adjust the terminal font size with the setting [`"terminal.integrated.fontSize": 12`](https://code.visualstudio.com/docs/terminal/appearance#_text-style). 44 | 45 | > **ⓘ** Refer to the [_Advanced settings_](https://github.com/arduino/arduino-ide/blob/main/docs/advanced-usage.md#advanced-settings) documentation of the Arduino IDE for more details. 46 | 47 | > ⚠️ Customizing terminal colors with the [`workbench.colorCustomizations`](https://code.visualstudio.com/docs/terminal/appearance#_terminal-colors) setting is currently unsupported in Eclipse Theia ([eclipse-theia/theia#8060](https://github.com/eclipse-theia/theia/issues/8060)). Therefore, this feature is missing from the Arduino IDE. 48 | 49 | > ⚠️ Arduino IDE must support path links that contain spaces in the decoder terminal. ([eclipse-theia/theia#12643](https://github.com/eclipse-theia/theia/issues/12643)) 50 | 51 | > ⚠️ [`terminal.integrated.rightClickBehavior`](https://code.visualstudio.com/docs/terminal/basics#_rightclick-behavior) is not supported in the Arduino IDE. ([eclipse-theia/theia#12644](https://github.com/eclipse-theia/theia/issues/12644)) 52 | 53 | ## Development 54 | 55 | 1. Install the dependencies: 56 | 57 | ```sh 58 | npm i 59 | ``` 60 | 61 | > ⚠️ You need Node.js version `>=16.14.0`. 62 | 63 | 2. Build the extension: 64 | 65 | ```sh 66 | npm run compile 67 | ``` 68 | 69 | > **ⓘ** Use `npm run package` to bundle the VSIX for production. 70 | 71 | 3. Test the extension: 72 | 73 | ```sh 74 | npm run test 75 | ``` 76 | 77 | > **ⓘ** You can run the _slow_ test with `npm run test-slow` and all tests with `npm run test-all`. 78 | 79 | ## Hints 80 | 81 | - If you are using VS Code for development, you can take advantage of predefined _Launch Configurations_ to debug the extensions and tests. For guidance on how to [test VS Code extensions](https://code.visualstudio.com/api/working-with-extensions/testing-extension), see the documentation. 82 | - This extension utilizes the [`vscode-arduino-api`](https://github.com/dankeboy36/vscode-arduino-api/) to communicate with the Arduino IDE. 83 | - The extension was created from the [`helloworld`](https://code.visualstudio.com/api/get-started/your-first-extension) VS Code extension template. 84 | 85 | ## Acknowledgments 86 | 87 | - Special thanks to [@per1234](https://github.com/per1234) for his dedication to open-source contributions. 88 | - Thanks to [@me-no-dev](https://github.com/me-no-dev) for the [original implementation](https://github.com/me-no-dev/EspExceptionDecoder). 89 | -------------------------------------------------------------------------------- /images/espExceptionDecoder_main.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dankeboy36/esp-exception-decoder/8843c9fb96c8d9b3d2a17cad6df3bd1f72a2fb30/images/espExceptionDecoder_main.gif -------------------------------------------------------------------------------- /images/espExceptionDecoder_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dankeboy36/esp-exception-decoder/8843c9fb96c8d9b3d2a17cad6df3bd1f72a2fb30/images/espExceptionDecoder_main.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esp-exception-decoder", 3 | "displayName": "ESP Exception Decoder", 4 | "version": "1.1.0", 5 | "private": true, 6 | "description": "ESP8266/ESP32 Exception Decoder Extension for the Arduino IDE", 7 | "categories": [ 8 | "Other" 9 | ], 10 | "keywords": [ 11 | "arduino-ide", 12 | "vscode-extension", 13 | "esp8266-arduino", 14 | "esp32-arduino" 15 | ], 16 | "bugs": { 17 | "url": "https://github.com/dankeboy36/esp-exception-decoder/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/dankeboy36/esp-exception-decoder.git" 22 | }, 23 | "license": "MIT", 24 | "author": "dankeboy36", 25 | "publisher": "dankeboy36", 26 | "main": "./dist/extension.js", 27 | "scripts": { 28 | "prebuild": "rimraf dist", 29 | "build": "webpack --mode production --devtool hidden-source-map", 30 | "clean": "rimraf dist out *.vsix", 31 | "compile": "webpack && vsce package", 32 | "compile-tests": "tsc -p . --outDir out", 33 | "format": "prettier --write .", 34 | "lint": "eslint src --ext ts", 35 | "prepackage": "npm run build", 36 | "package": "vsce package", 37 | "release": "semantic-release", 38 | "pretest": "npm run compile-tests && npm run compile && npm run lint", 39 | "test": "node ./out/test/runTest.js", 40 | "pretest-all": "npm run pretest", 41 | "test-all": "node ./out/test/runTest.js --all", 42 | "pretest-slow": "npm run pretest", 43 | "test-slow": "node ./out/test/runTest.js --slow", 44 | "watch": "webpack --watch", 45 | "watch-tests": "tsc -p . -w --outDir out" 46 | }, 47 | "contributes": { 48 | "commands": [ 49 | { 50 | "command": "espExceptionDecoder.showTerminal", 51 | "title": "Show Decoder Terminal", 52 | "category": "ESP Exception Decoder" 53 | } 54 | ] 55 | }, 56 | "activationEvents": [ 57 | "onStartupFinished" 58 | ], 59 | "dependencies": { 60 | "debug": "^4.3.7", 61 | "execa": "^7.1.1", 62 | "fqbn": "^1.1.2" 63 | }, 64 | "devDependencies": { 65 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 66 | "@semantic-release/changelog": "^6.0.3", 67 | "@semantic-release/commit-analyzer": "^10.0.1", 68 | "@semantic-release/exec": "^6.0.3", 69 | "@semantic-release/git": "^10.0.1", 70 | "@semantic-release/github": "^9.0.3", 71 | "@semantic-release/npm": "^10.0.4", 72 | "@semantic-release/release-notes-generator": "^11.0.3", 73 | "@types/debug": "^4.1.8", 74 | "@types/glob": "^8.1.0", 75 | "@types/mocha": "^10.0.1", 76 | "@types/node": "20.x", 77 | "@types/temp": "^0.9.1", 78 | "@types/vscode": "^1.78.0", 79 | "@typescript-eslint/eslint-plugin": "^5.59.1", 80 | "@typescript-eslint/parser": "^5.59.1", 81 | "@vscode/test-electron": "^2.3.9", 82 | "@vscode/vsce": "^3.2.2", 83 | "eslint": "^8.39.0", 84 | "eslint-config-prettier": "^8.5.0", 85 | "eslint-plugin-prettier": "^4.2.1", 86 | "get-arduino-tools": "^1.2.3", 87 | "glob": "^8.1.0", 88 | "mocha": "^10.2.0", 89 | "nyc": "^15.1.0", 90 | "prettier": "^2.7.1", 91 | "prettier-plugin-packagejson": "^2.5.9", 92 | "rimraf": "^5.0.1", 93 | "semantic-release": "^21.0.5", 94 | "semantic-release-vsce": "^5.6.0", 95 | "semver": "^7.5.1", 96 | "temp": "^0.9.4", 97 | "ts-loader": "^9.4.2", 98 | "typescript": "^5.0.4", 99 | "vscode-arduino-api": "^0.1.2", 100 | "webpack": "^5.81.0", 101 | "webpack-cli": "^5.0.2" 102 | }, 103 | "engines": { 104 | "vscode": "^1.78.0" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 'use strict'; 3 | 4 | /** @type {import('semantic-release').Options} */ 5 | module.exports = { 6 | tagFormat: '${version}', 7 | branches: ['main'], 8 | plugins: [ 9 | '@semantic-release/commit-analyzer', 10 | '@semantic-release/release-notes-generator', 11 | '@semantic-release/changelog', 12 | '@semantic-release/npm', 13 | [ 14 | '@semantic-release/github', 15 | { 16 | assets: [ 17 | { 18 | path: '*.vsix', 19 | }, 20 | ], 21 | }, 22 | ], 23 | '@semantic-release/git', 24 | [ 25 | 'semantic-release-vsce', 26 | { 27 | packageVsix: true, 28 | publish: true, 29 | }, 30 | ], 31 | [ 32 | '@semantic-release/exec', 33 | { 34 | publishCmd: 35 | 'echo "release_version=${nextRelease.version}" >> $GITHUB_OUTPUT', 36 | }, 37 | ], 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /src/decoder.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import { FQBN } from 'fqbn'; 3 | import path from 'node:path'; 4 | import type { 5 | ArduinoState, 6 | BoardDetails, 7 | BuildProperties, 8 | } from 'vscode-arduino-api'; 9 | import { decodeRiscv, InvalidTargetError } from './riscv'; 10 | import { access, Debug, isWindows, neverSignal, run } from './utils'; 11 | 12 | const decoderDebug: Debug = debug('espExceptionDecoder:decoder'); 13 | 14 | export interface DecodeParams { 15 | readonly toolPath: string; 16 | readonly elfPath: string; 17 | readonly fqbn: FQBN; 18 | readonly sketchPath: string; // UI 19 | } 20 | 21 | type Fallback = { 22 | [P in keyof T]: () => Promise; 23 | }; 24 | 25 | type DecodeFallbackParams = Fallback>; 26 | 27 | export async function createDecodeParams( 28 | arduinoState: ArduinoState, 29 | fallbackParams?: DecodeFallbackParams 30 | ): Promise { 31 | const { boardDetails, compileSummary, sketchPath } = arduinoState; 32 | if (!sketchPath) { 33 | throw new Error('Sketch path is not set'); 34 | } 35 | if (!arduinoState.fqbn) { 36 | throw new Error('No board selected'); 37 | } 38 | const fqbn = new FQBN(arduinoState.fqbn).sanitize(); 39 | const { vendor, arch } = fqbn; 40 | if (!boardDetails) { 41 | throw new DecodeParamsError( 42 | `Platform '${vendor}:${arch}' is not installed`, 43 | { sketchPath, fqbn } 44 | ); 45 | } 46 | if (!supportedArchitectures.has(fqbn.arch)) { 47 | throw new DecodeParamsError(`Unsupported board: '${fqbn}'`, { 48 | sketchPath, 49 | fqbn, 50 | }); 51 | } 52 | if (!compileSummary) { 53 | throw new DecodeParamsError( 54 | 'The summary of the previous compilation is unavailable. Compile the sketch', 55 | { 56 | sketchPath, 57 | fqbn, 58 | } 59 | ); 60 | } 61 | const { buildPath } = compileSummary; 62 | const sketchFolderName = path.basename(sketchPath); 63 | const [toolPath, elfPath = await fallbackParams?.elfPath()] = 64 | await Promise.all([ 65 | findToolPath(boardDetails), 66 | findElfPath(sketchFolderName, buildPath), 67 | ]); 68 | if (!elfPath) { 69 | throw new DecodeParamsError( 70 | `Could not detect the '.elf' file in the build folder`, 71 | { sketchPath, fqbn } 72 | ); 73 | } 74 | if (!toolPath) { 75 | throw new DecodeParamsError('Could not detect the GDB tool path', { 76 | sketchPath, 77 | fqbn, 78 | }); 79 | } 80 | return { 81 | toolPath, 82 | elfPath, 83 | fqbn, 84 | sketchPath, 85 | }; 86 | } 87 | 88 | export class DecodeParamsError extends Error { 89 | constructor( 90 | message: string, 91 | private readonly partial: Pick 92 | ) { 93 | super(message); 94 | Object.setPrototypeOf(this, DecodeParamsError.prototype); 95 | } 96 | 97 | get fqbn(): string { 98 | return this.partial.fqbn.toString(); 99 | } 100 | 101 | get sketchPath(): string { 102 | return this.partial.sketchPath; 103 | } 104 | } 105 | 106 | export interface DecodeOptions { 107 | readonly signal?: AbortSignal; 108 | readonly debug?: Debug; 109 | } 110 | export const defaultDecodeOptions = { 111 | signal: neverSignal, 112 | debug: decoderDebug, 113 | } as const; 114 | 115 | export interface GDBLine { 116 | address: string; 117 | line: string; 118 | } 119 | export function isGDBLine(arg: unknown): arg is GDBLine { 120 | return ( 121 | typeof arg === 'object' && 122 | (arg).address !== undefined && 123 | typeof (arg).address === 'string' && 124 | (arg).line !== undefined && 125 | typeof (arg).line === 'string' 126 | ); 127 | } 128 | 129 | export interface ParsedGDBLine extends GDBLine { 130 | file: string; 131 | method: string; 132 | args?: Readonly>; // TODO: ask community if useful 133 | } 134 | export function isParsedGDBLine(gdbLine: GDBLine): gdbLine is ParsedGDBLine { 135 | return ( 136 | (gdbLine).file !== undefined && 137 | typeof (gdbLine).file === 'string' && 138 | (gdbLine).method !== undefined && 139 | typeof (gdbLine).method === 'string' 140 | ); 141 | } 142 | 143 | /** 144 | * Register address in the `0x` format. For example, `'0x4020104e'`. 145 | */ 146 | export type Address = string; 147 | /** 148 | * Successfully decoded register address, or the address. 149 | */ 150 | export type Location = GDBLine | ParsedGDBLine | Address; 151 | 152 | export interface DecodeResult { 153 | readonly exception: [message: string, code: number] | undefined; 154 | readonly registerLocations: Record; 155 | readonly stacktraceLines: (GDBLine | ParsedGDBLine)[]; 156 | readonly allocLocation: [location: Location, size: number] | undefined; 157 | } 158 | 159 | export async function decode( 160 | params: DecodeParams, 161 | input: string, 162 | options: DecodeOptions = defaultDecodeOptions 163 | ): Promise { 164 | let result: DecodeResult | undefined; 165 | try { 166 | result = await decodeRiscv(params, input, options); 167 | } catch (err) { 168 | if (err instanceof InvalidTargetError) { 169 | // try ESP32/ESP8266 170 | } else { 171 | throw err; 172 | } 173 | } 174 | 175 | if (!result) { 176 | const [exception, registerLocations, stacktraceLines, allocLocation] = 177 | await Promise.all([ 178 | parseException(input), 179 | decodeRegisters(params, input, options), 180 | decodeStacktrace(params, input, options), 181 | decodeAlloc(params, input, options), 182 | ]); 183 | 184 | result = { 185 | exception, 186 | registerLocations, 187 | stacktraceLines, 188 | allocLocation, 189 | }; 190 | } 191 | 192 | return fixWindowsPaths(result); 193 | } 194 | 195 | function fixWindowsPaths( 196 | result: DecodeResult, 197 | isWindows = process.platform === 'win32' 198 | ): DecodeResult { 199 | const [location] = result.allocLocation ?? []; 200 | if (location && isGDBLine(location) && isParsedGDBLine(location)) { 201 | location.file = fixWindowsPath(location.file, isWindows); 202 | } 203 | return { 204 | ...result, 205 | stacktraceLines: result.stacktraceLines.map((gdbLine) => 206 | isParsedGDBLine(gdbLine) 207 | ? { ...gdbLine, file: fixWindowsPath(gdbLine.file, isWindows) } 208 | : gdbLine 209 | ), 210 | registerLocations: Object.fromEntries( 211 | Object.entries(result.registerLocations).map(([key, value]) => [ 212 | key, 213 | isGDBLine(value) && isParsedGDBLine(value) 214 | ? { ...value, file: fixWindowsPath(value.file, isWindows) } 215 | : value, 216 | ]) 217 | ), 218 | }; 219 | } 220 | 221 | // To fix the path separator issue on Windows: 222 | // - "file": "D:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1/riscv_1.ino" 223 | // + "file": "d:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1\\riscv_1.ino" 224 | function fixWindowsPath( 225 | path: string, 226 | isWindows = process.platform === 'win32' 227 | ): string { 228 | return isWindows && /^[a-zA-Z]:\\/.test(path) 229 | ? path.replace(/\//g, '\\') 230 | : path; 231 | } 232 | 233 | // Taken from https://github.com/me-no-dev/EspExceptionDecoder/blob/ff4fc36bdaf0bfd6e750086ac01554867ede76d3/src/EspExceptionDecoder.java#L59-L90 234 | const reserved = 'reserved'; 235 | const exceptions = [ 236 | 'Illegal instruction', 237 | 'SYSCALL instruction', 238 | 'InstructionFetchError: Processor internal physical address or data error during instruction fetch', 239 | 'LoadStoreError: Processor internal physical address or data error during load or store', 240 | 'Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT register', 241 | "Alloca: MOVSP instruction, if caller's registers are not in the register file", 242 | 'IntegerDivideByZero: QUOS, QUOU, REMS, or REMU divisor operand is zero', 243 | reserved, 244 | 'Privileged: Attempt to execute a privileged operation when CRING ? 0', 245 | 'LoadStoreAlignmentCause: Load or store to an unaligned address', 246 | reserved, 247 | reserved, 248 | 'InstrPIFDataError: PIF data error during instruction fetch', 249 | 'LoadStorePIFDataError: Synchronous PIF data error during LoadStore access', 250 | 'InstrPIFAddrError: PIF address error during instruction fetch', 251 | 'LoadStorePIFAddrError: Synchronous PIF address error during LoadStore access', 252 | 'InstTLBMiss: Error during Instruction TLB refill', 253 | 'InstTLBMultiHit: Multiple instruction TLB entries matched', 254 | 'InstFetchPrivilege: An instruction fetch referenced a virtual address at a ring level less than CRING', 255 | reserved, 256 | 'InstFetchProhibited: An instruction fetch referenced a page mapped with an attribute that does not permit instruction fetch', 257 | reserved, 258 | reserved, 259 | reserved, 260 | 'LoadStoreTLBMiss: Error during TLB refill for a load or store', 261 | 'LoadStoreTLBMultiHit: Multiple TLB entries matched for a load or store', 262 | 'LoadStorePrivilege: A load or store referenced a virtual address at a ring level less than CRING', 263 | reserved, 264 | 'LoadProhibited: A load referenced a page mapped with an attribute that does not permit loads', 265 | 'StoreProhibited: A store referenced a page mapped with an attribute that does not permit stores', 266 | ]; 267 | 268 | function parseException( 269 | input: string 270 | ): [message: string, code: number] | undefined { 271 | const matches = input.matchAll(/Exception \(([0-9]*)\)/g); 272 | for (const match of matches) { 273 | const value = match[1]; 274 | if (value) { 275 | const code = Number.parseInt(value.trim(), 10); 276 | const exception = exceptions[code]; 277 | if (exception) { 278 | return [exception, code]; 279 | } 280 | } 281 | } 282 | return undefined; 283 | } 284 | 285 | async function decodeRegisters( 286 | params: DecodeParams, 287 | input: string, 288 | options: DecodeOptions 289 | ): Promise> { 290 | const [pc, excvaddr] = parseRegisters(input); 291 | const decode = async (address: string | undefined) => { 292 | if (address) { 293 | const lines = await decodeFunctionAtAddress(params, [address], options); 294 | const line = lines.shift(); 295 | return line ?? `0x${address}`; 296 | } 297 | return undefined; 298 | }; 299 | const [pcLine, excvaddrLine] = await Promise.all([ 300 | decode(pc), 301 | decode(excvaddr), 302 | ]); 303 | const lines = >{}; 304 | if (pcLine) { 305 | lines['PC'] = pcLine; 306 | } 307 | if (excvaddrLine) { 308 | lines['EXCVADDR'] = excvaddrLine; 309 | } 310 | return lines; 311 | } 312 | 313 | function parseRegisters( 314 | input: string 315 | ): [pc: string | undefined, excvaddr: string | undefined] { 316 | // ESP32 register format first, then the ESP8266 one 317 | const pc = 318 | parseRegister('PC\\s*:\\s*(0x)?', input) ?? parseRegister('epc1=0x', input); 319 | const excvaddr = 320 | parseRegister('EXCVADDR\\s*:\\s*(0x)?', input) ?? 321 | parseRegister('excvaddr=0x', input); 322 | return [pc, excvaddr]; 323 | } 324 | 325 | function parseRegister(regexPrefix: string, input: string): string | undefined { 326 | const matches = input.matchAll( 327 | new RegExp(`${regexPrefix}([0-9a-f]{8})`, 'gmi') 328 | ); 329 | for (const match of matches) { 330 | const value = match.find((m) => m.length === 8); // find the register address 331 | if (value) { 332 | return value; 333 | } 334 | } 335 | return undefined; 336 | } 337 | 338 | async function decodeAlloc( 339 | params: DecodeParams, 340 | input: string, 341 | options: DecodeOptions = defaultDecodeOptions 342 | ): Promise<[location: Location, size: number] | undefined> { 343 | const result = parseAlloc(input); 344 | if (!result) { 345 | return undefined; 346 | } 347 | const [address, size] = result; 348 | const lines = await decodeFunctionAtAddress(params, [address], options); 349 | const line = lines.shift(); 350 | return line ? [line, size] : [`0x${address}`, size]; 351 | } 352 | 353 | function parseAlloc( 354 | input: string 355 | ): [address: string, size: number] | undefined { 356 | const matches = input.matchAll( 357 | /last failed alloc call: (4[0-3][0-9a-f]{6})\((\d+)\)/gim 358 | ); 359 | for (const match of matches) { 360 | const [, address, rawSize] = match; 361 | const size = Number.parseInt(rawSize, 10); 362 | if (!Number.isNaN(size) && address) { 363 | return [address, size]; 364 | } 365 | } 366 | return undefined; 367 | } 368 | 369 | async function decodeStacktrace( 370 | params: DecodeParams, 371 | input: string, 372 | options: DecodeOptions 373 | ): Promise { 374 | const content = parseStacktrace(input); 375 | if (!content) { 376 | throw new Error('Could not recognize stack trace/backtrace'); 377 | } 378 | const addresses = parseInstructionAddresses(content); 379 | if (!addresses.length) { 380 | throw new Error( 381 | 'Could not detect any instruction addresses in the stack trace/backtrace' 382 | ); 383 | } 384 | return decodeFunctionAtAddress(params, addresses, options); 385 | } 386 | 387 | async function decodeFunctionAtAddress( 388 | params: DecodeParams, 389 | addresses: string[], 390 | options: DecodeOptions = defaultDecodeOptions 391 | ): Promise { 392 | const { toolPath, elfPath } = params; 393 | const flags = buildCommandFlags(addresses, elfPath); 394 | const stdout = await run(toolPath, flags, options); 395 | return parseGDBOutput(stdout, options.debug); 396 | } 397 | 398 | function parseStacktrace(input: string): string | undefined { 399 | return stripESP32Content(input) ?? stripESP8266Content(input); 400 | } 401 | 402 | function stripESP8266Content(input: string): string | undefined { 403 | const startDelimiter = '>>>stack>>>'; 404 | const startIndex = input.indexOf(startDelimiter); 405 | if (startIndex < 0) { 406 | return undefined; 407 | } 408 | const endDelimiter = '<< match[0]) 430 | .filter(Boolean); 431 | } 432 | 433 | function buildCommandFlags(addresses: string[], elfPath: string): string[] { 434 | if (!addresses.length) { 435 | throw new Error('Invalid argument: addresses.length <= 0'); 436 | } 437 | return [ 438 | '--batch', // executes in batch mode (https://sourceware.org/gdb/onlinedocs/gdb/Mode-Options.html) 439 | elfPath, 440 | '-ex', // executes a command 441 | 'set listsize 1', // set the default printed source lines to one (https://sourceware.org/gdb/onlinedocs/gdb/List.html) 442 | ...addresses 443 | .map((address) => ['-ex', `list *0x${address}`]) // lists the source at address (https://sourceware.org/gdb/onlinedocs/gdb/Address-Locations.html#Address-Locations) 444 | .reduce((acc, curr) => acc.concat(curr)), 445 | '-ex', 446 | 'q', // quit 447 | ]; 448 | } 449 | 450 | const esp32 = 'esp32'; 451 | const esp8266 = 'esp8266'; 452 | const supportedArchitectures = new Set([esp32, esp8266]); 453 | 454 | const defaultTarch = 'xtensa'; 455 | const defaultTarget = 'lx106'; 456 | 457 | const buildTarch = 'build.tarch'; 458 | const buildTarget = 'build.target'; 459 | 460 | async function findToolPath( 461 | boardDetails: BoardDetails, 462 | debug: Debug = decoderDebug 463 | ): Promise { 464 | const { fqbn, buildProperties } = boardDetails; 465 | const { arch } = new FQBN(fqbn); 466 | if (!supportedArchitectures.has(arch)) { 467 | throw new Error(`Unsupported board architecture: '${fqbn}'`); 468 | } 469 | debug(`fqbn: ${fqbn}`); 470 | let tarch = defaultTarch; 471 | let target = defaultTarget; 472 | if (arch === esp32) { 473 | let buildPropTarch = buildProperties[buildTarch]; 474 | if (!buildPropTarch) { 475 | debug( 476 | `could not find ${buildTarch} value. defaulting to ${defaultTarch}` 477 | ); 478 | buildPropTarch = defaultTarch; 479 | } 480 | tarch = getValue( 481 | { 482 | buildProperties, 483 | key: buildTarch, 484 | }, 485 | defaultTarch 486 | ); 487 | target = getValue( 488 | { 489 | buildProperties, 490 | key: buildTarget, 491 | }, 492 | defaultTarget 493 | ); 494 | } 495 | debug(`tarch: ${tarch}`); 496 | debug(`target: ${target}`); 497 | 498 | const toolchain = `${tarch}-${target}-elf`; 499 | debug(`toolchain: ${toolchain}`); 500 | const gdbTool = `${tarch}-esp-elf-gdb`; 501 | debug(`gdbTool: ${gdbTool}`); 502 | const gdb = `${toolchain}-gdb${isWindows ? '.exe' : ''}`; 503 | debug(`gdb: ${gdb}`); 504 | 505 | const find = async (key: string): Promise => { 506 | const value = getValue({ 507 | buildProperties, 508 | key, 509 | debug, 510 | }); 511 | debug(`${key}: ${value}`); 512 | if (!value) { 513 | return undefined; 514 | } 515 | const toolPath = path.join(value, 'bin', gdb); 516 | if (await access(toolPath)) { 517 | debug(`[${key}] gdb found at: ${toolPath}`); 518 | return toolPath; 519 | } 520 | debug(`[${key}] gdb not found at: ${toolPath}`); 521 | }; 522 | 523 | // `runtime.tools.*` won't work for ESP32 installed from Git. See https://github.com/arduino/arduino-cli/issues/2197#issuecomment-1572921357. 524 | // `runtime.tools.*` ESP8266 requires this. Hence, the fallback here. 525 | const gdbToolPath = `tools.${gdbTool}.path`; 526 | const toolChainGCCPath = `tools.${toolchain}-gcc.path`; 527 | return ( 528 | (await find(`runtime.${gdbToolPath}`)) ?? 529 | (await find(`runtime.${toolChainGCCPath}`)) ?? 530 | (await find(gdbToolPath)) ?? 531 | (await find(toolChainGCCPath)) 532 | ); 533 | } 534 | 535 | interface GetValueParams { 536 | readonly buildProperties: BuildProperties; 537 | readonly key: string; 538 | readonly debug?: Debug; 539 | } 540 | 541 | function getValue(params: GetValueParams): string | undefined; 542 | function getValue(params: GetValueParams, defaultValue: string): string; 543 | function getValue( 544 | params: GetValueParams, 545 | defaultValue?: string | undefined 546 | ): string | undefined { 547 | const { buildProperties, key, debug } = params; 548 | let value: string | undefined = buildProperties[key]; 549 | if (value === undefined) { 550 | debug?.( 551 | `could not find ${key} value.${ 552 | defaultValue ? `defaulting to ${defaultValue}` : '' 553 | }` 554 | ); 555 | value = defaultValue; 556 | } 557 | return value; 558 | } 559 | 560 | async function findElfPath( 561 | sketchFolderName: string, 562 | buildPath: string 563 | ): Promise { 564 | const [inoElfPath, cppElfPath] = await Promise.all( 565 | ['ino', 'cpp'].map((ext) => 566 | access(path.join(buildPath, `${sketchFolderName}.${ext}.elf`)) 567 | ) 568 | ); 569 | return inoElfPath ?? cppElfPath ?? undefined; 570 | } 571 | 572 | function parseGDBOutput( 573 | stdout: string, 574 | debug: Debug = decoderDebug 575 | ): GDBLine[] { 576 | const lines = stdout.split(/\r?\n/).map((line) => parseGDBLine(line, debug)); 577 | return lines.filter(isGDBLine); 578 | } 579 | 580 | function parseGDBLine( 581 | raw: string, 582 | debug: Debug = decoderDebug 583 | ): GDBLine | undefined { 584 | const matches = raw.matchAll( 585 | // TODO: restrict to instruction addresses? `4[0-3][0-9a-f]{6}` 586 | /^(0x[0-9a-f]{8})\s+is in\s+(\S+)\s+\((.*):(\d+)\)\.$/gi 587 | ); 588 | for (const match of matches) { 589 | const [, address, method, file, line] = match; 590 | if (address && method && file && line) { 591 | const gdbLine: ParsedGDBLine = { 592 | address, 593 | method, 594 | file, 595 | line, 596 | }; 597 | debug(`parseGDBLine, OK: ${JSON.stringify(gdbLine)}`); 598 | return gdbLine; 599 | } 600 | } 601 | const fallbackMatches = raw.matchAll(/(0x[0-9a-f]{8})(\s+is in\s+.*)/gi); 602 | for (const match of fallbackMatches) { 603 | const [, address, line] = match; 604 | if (address && line) { 605 | const gdbLine = { 606 | address, 607 | line, 608 | }; 609 | debug(`parseGDBLine, fallback: ${JSON.stringify(gdbLine)}`); 610 | return gdbLine; 611 | } 612 | } 613 | debug(`parseGDBLine, failed: ${raw}`); 614 | return undefined; 615 | } 616 | 617 | /** 618 | * (non-API) 619 | */ 620 | export const __tests = { 621 | buildCommandFlags, 622 | findToolPath, 623 | findElfPath, 624 | parseStacktrace, 625 | parseInstructionAddresses, 626 | parseGDBOutput, 627 | parseException, 628 | parseAlloc, 629 | parseRegisters, 630 | exceptions, 631 | fixWindowsPath, 632 | fixWindowsPaths, 633 | } as const; 634 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import type { ArduinoContext } from 'vscode-arduino-api'; 3 | import { activateDecoderTerminal } from './terminal'; 4 | 5 | export function activate(context: vscode.ExtensionContext): void { 6 | findArduinoContext().then((arduinoContext) => { 7 | if (!arduinoContext) { 8 | vscode.window.showErrorMessage( 9 | `Could not find th '${vscodeArduinoAPI}' extension must be installed.` 10 | ); 11 | return; 12 | } 13 | activateDecoderTerminal(context, arduinoContext); 14 | }); 15 | } 16 | 17 | async function findArduinoContext(): Promise { 18 | const apiExtension = findArduinoApiExtension(); 19 | if (apiExtension && !apiExtension.isActive) { 20 | await apiExtension.activate(); 21 | } 22 | return apiExtension?.exports; 23 | } 24 | 25 | const vscodeArduinoAPI = 'dankeboy36.vscode-arduino-api'; 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | function findArduinoApiExtension(): vscode.Extension | undefined { 28 | return vscode.extensions.getExtension(vscodeArduinoAPI); 29 | } 30 | -------------------------------------------------------------------------------- /src/riscv.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import { FQBN } from 'fqbn'; 3 | import net from 'node:net'; 4 | import { 5 | defaultDecodeOptions, 6 | GDBLine, 7 | type DecodeOptions, 8 | type DecodeParams, 9 | type DecodeResult, 10 | type ParsedGDBLine, 11 | } from './decoder'; 12 | import { AbortError, Debug, neverSignal, run } from './utils'; 13 | 14 | const riscvDebug: Debug = debug('espExceptionDecoder:riscv'); 15 | 16 | // Based on the work of: 17 | // - [Peter Dragun](https://github.com/peterdragun) 18 | // - [Ivan Grokhotkov](https://github.com/igrr) 19 | // - [suda-morris](https://github.com/suda-morris) 20 | // 21 | // https://github.com/espressif/esp-idf-monitor/blob/fae383ecf281655abaa5e65433f671e274316d10/esp_idf_monitor/gdb_panic_server.py 22 | 23 | const gdbRegsInfoRiscvIlp32 = [ 24 | 'X0', 25 | 'RA', 26 | 'SP', 27 | 'GP', 28 | 'TP', 29 | 'T0', 30 | 'T1', 31 | 'T2', 32 | 'S0/FP', 33 | 'S1', 34 | 'A0', 35 | 'A1', 36 | 'A2', 37 | 'A3', 38 | 'A4', 39 | 'A5', 40 | 'A6', 41 | 'A7', 42 | 'S2', 43 | 'S3', 44 | 'S4', 45 | 'S5', 46 | 'S6', 47 | 'S7', 48 | 'S8', 49 | 'S9', 50 | 'S10', 51 | 'S11', 52 | 'T3', 53 | 'T4', 54 | 'T5', 55 | 'T6', 56 | 'MEPC', // PC equivalent 57 | ] as const; 58 | 59 | type Target = keyof typeof gdbRegsInfo; 60 | 61 | const gdbRegsInfo = { 62 | esp32c2: gdbRegsInfoRiscvIlp32, 63 | esp32c3: gdbRegsInfoRiscvIlp32, 64 | esp32c6: gdbRegsInfoRiscvIlp32, 65 | esp32h2: gdbRegsInfoRiscvIlp32, 66 | esp32h4: gdbRegsInfoRiscvIlp32, 67 | } as const; 68 | 69 | function isTarget(arg: unknown): arg is Target { 70 | return typeof arg === 'string' && arg in gdbRegsInfo; 71 | } 72 | 73 | function createRegNameValidator(type: T) { 74 | const regsInfo = gdbRegsInfo[type]; 75 | if (!regsInfo) { 76 | throw new Error(`Unsupported target: ${type}`); 77 | } 78 | return (regName: string): regName is (typeof regsInfo)[number] => 79 | regsInfo.includes(regName as (typeof regsInfo)[number]); 80 | } 81 | 82 | interface RegisterDump { 83 | coreId: number; 84 | regs: Record; 85 | } 86 | 87 | interface StackDump { 88 | baseAddr: number; 89 | data: number[]; 90 | } 91 | 92 | interface ParsePanicOutputParams { 93 | input: string; 94 | target: Target; 95 | } 96 | 97 | interface ParsePanicOutputResult { 98 | exception?: number; 99 | MTVAL: number | undefined; 100 | regDumps: RegisterDump[]; 101 | stackDump: StackDump[]; 102 | } 103 | 104 | function parse({ 105 | input, 106 | target, 107 | }: ParsePanicOutputParams): ParsePanicOutputResult { 108 | const lines = input.split(/\r?\n|\r/); 109 | const regDumps: RegisterDump[] = []; 110 | const stackDump: StackDump[] = []; 111 | let currentRegDump: RegisterDump | undefined; 112 | let inStackMemory = false; 113 | let exception: number | undefined; 114 | let MTVAL: number | undefined; 115 | 116 | const regNameValidator = createRegNameValidator(target); 117 | 118 | lines.forEach((line) => { 119 | if (line.startsWith('Core')) { 120 | const match = line.match(/^Core\s+(\d+)\s+register dump:/); 121 | if (match) { 122 | currentRegDump = { 123 | coreId: parseInt(match[1], 10), 124 | regs: {}, 125 | }; 126 | regDumps.push(currentRegDump); 127 | } 128 | } else if (currentRegDump && !inStackMemory) { 129 | const regMatches = line.matchAll(/([A-Z_0-9/]+)\s*:\s*(0x[0-9a-fA-F]+)/g); 130 | for (const match of regMatches) { 131 | const regName = match[1]; 132 | const regAddress = parseInt(match[2], 16); 133 | if (regAddress && regNameValidator(regName)) { 134 | currentRegDump.regs[regName] = regAddress; 135 | } else if (regName === 'MCAUSE') { 136 | exception = regAddress; // it's an exception code 137 | } else if (regName === 'MTVAL') { 138 | MTVAL = regAddress; // EXCVADDR equivalent 139 | } 140 | } 141 | if (line.trim() === 'Stack memory:') { 142 | inStackMemory = true; 143 | } 144 | } else if (inStackMemory) { 145 | const match = line.match(/^([0-9a-fA-F]+):\s*((?:0x[0-9a-fA-F]+\s*)+)/); 146 | if (match) { 147 | const baseAddr = parseInt(match[1], 16); 148 | const data = match[2] 149 | .trim() 150 | .split(/\s+/) 151 | .map((hex) => parseInt(hex, 16)); 152 | stackDump.push({ baseAddr, data }); 153 | } 154 | } 155 | }); 156 | 157 | return { regDumps, stackDump, exception, MTVAL }; 158 | } 159 | 160 | interface GetStackAddrAndDataParams { 161 | stackDump: readonly StackDump[]; 162 | } 163 | 164 | interface GetStackAddrAndDataResult { 165 | stackBaseAddr: number; 166 | stackData: Buffer; 167 | } 168 | 169 | function getStackAddrAndData({ 170 | stackDump, 171 | }: GetStackAddrAndDataParams): GetStackAddrAndDataResult { 172 | let stackBaseAddr = 0; 173 | let baseAddr = 0; 174 | let bytesInLine = 0; 175 | let stackData = Buffer.alloc(0); 176 | 177 | stackDump.forEach((line) => { 178 | const prevBaseAddr = baseAddr; 179 | baseAddr = line.baseAddr; 180 | if (stackBaseAddr === 0) { 181 | stackBaseAddr = baseAddr; 182 | } else { 183 | if (baseAddr !== prevBaseAddr + bytesInLine) { 184 | throw new Error('Invalid base address'); 185 | } 186 | } 187 | 188 | const lineData = Buffer.concat( 189 | line.data.map((word) => 190 | Buffer.from(word.toString(16).padStart(8, '0'), 'hex') 191 | ) 192 | ); 193 | bytesInLine = lineData.length; 194 | stackData = Buffer.concat([stackData, lineData]); 195 | }); 196 | 197 | return { stackBaseAddr, stackData }; 198 | } 199 | 200 | interface PanicInfo { 201 | MTVAL?: number; 202 | exception?: number; 203 | coreId: number; 204 | regs: Record; 205 | stackBaseAddr: number; 206 | stackData: Buffer; 207 | target: Target; 208 | } 209 | 210 | interface ParseIdfRiscvPanicOutputParams { 211 | input: string; 212 | target: Target; 213 | } 214 | 215 | function parsePanicOutput({ 216 | input, 217 | target, 218 | }: ParseIdfRiscvPanicOutputParams): PanicInfo { 219 | const { regDumps, stackDump, MTVAL, exception } = parse({ 220 | input, 221 | target, 222 | }); 223 | if (regDumps.length === 0) { 224 | throw new Error('No register dumps found'); 225 | } 226 | if (regDumps.length > 1) { 227 | throw new Error('Handling of multi-core register dumps not implemented'); 228 | } 229 | 230 | const { coreId, regs } = regDumps[0]; 231 | const { stackBaseAddr, stackData } = getStackAddrAndData({ stackDump }); 232 | 233 | return { 234 | MTVAL, 235 | exception, 236 | coreId, 237 | regs, 238 | stackBaseAddr, 239 | stackData, 240 | target, 241 | }; 242 | } 243 | 244 | interface GdbServerParams { 245 | panicInfo: PanicInfo; 246 | debug?: Debug; 247 | } 248 | 249 | interface StartGdbServerParams { 250 | port?: number; 251 | signal?: AbortSignal; 252 | } 253 | 254 | export class GdbServer { 255 | private readonly panicInfo: PanicInfo; 256 | private readonly regList: readonly string[]; 257 | private readonly debug: Debug; 258 | private server?: net.Server; 259 | 260 | constructor(params: GdbServerParams) { 261 | this.panicInfo = params.panicInfo; 262 | this.regList = gdbRegsInfo[params.panicInfo.target]; 263 | this.debug = params.debug ?? riscvDebug; 264 | } 265 | 266 | async start(params: StartGdbServerParams = {}): Promise { 267 | if (this.server) { 268 | throw new Error('Server already started'); 269 | } 270 | 271 | const { port = 0, signal = neverSignal } = params ?? {}; 272 | const server = net.createServer(); 273 | this.server = server; 274 | 275 | await new Promise((resolve, reject) => { 276 | const abortHandler = () => { 277 | this.debug('User abort'); 278 | reject(new AbortError()); 279 | this.close(); 280 | }; 281 | 282 | if (signal.aborted) { 283 | abortHandler(); 284 | return; 285 | } 286 | 287 | signal.addEventListener('abort', abortHandler); 288 | server.on('listening', () => { 289 | signal.removeEventListener('abort', abortHandler); 290 | resolve(); 291 | }); 292 | server.listen(port); 293 | }); 294 | 295 | const address = server.address(); 296 | if (!address) { 297 | throw new Error('Failed to start server'); 298 | } 299 | if (typeof address === 'string') { 300 | throw new Error( 301 | `Expected an address info object. Got a string: ${address}` 302 | ); 303 | } 304 | 305 | server.on('connection', (socket) => { 306 | socket.on('data', (data) => { 307 | const buffer = data.toString(); 308 | if (buffer.startsWith('-')) { 309 | this.debug(`Invalid command: ${buffer}`); 310 | socket.write('-'); 311 | socket.end(); 312 | return; 313 | } 314 | 315 | if (buffer.length > 3 && buffer.slice(-3, -2) === '#') { 316 | this.debug(`Command: ${buffer}`); 317 | this._handleCommand(buffer, socket); 318 | } 319 | }); 320 | }); 321 | 322 | return address; 323 | } 324 | 325 | close() { 326 | this.server?.close(); 327 | this.server = undefined; 328 | } 329 | 330 | private _handleCommand(buffer: string, socket: net.Socket) { 331 | if (buffer.startsWith('+')) { 332 | buffer = buffer.slice(1); // ignore the leading '+' 333 | } 334 | 335 | const command = buffer.slice(1, -3); // ignore checksums 336 | // Acknowledge the command 337 | socket.write('+'); 338 | this.debug(`Got command: ${command}`); 339 | if (command === '?') { 340 | // report sigtrap as the stop reason; the exact reason doesn't matter for backtracing 341 | this._respond('T05', socket); 342 | } else if (command.startsWith('Hg') || command.startsWith('Hc')) { 343 | // Select thread command 344 | this._respond('OK', socket); 345 | } else if (command === 'qfThreadInfo') { 346 | // Get list of threads. 347 | // Only one thread for now, can be extended to show one thread for each core, 348 | // if we dump both cores (e.g. on an interrupt watchdog) 349 | this._respond('m1', socket); 350 | } else if (command === 'qC') { 351 | // That single thread is selected. 352 | this._respond('QC1', socket); 353 | } else if (command === 'g') { 354 | // Registers read 355 | this._respondRegs(socket); 356 | } else if (command.startsWith('m')) { 357 | // Memory read 358 | const [addr, size] = command 359 | .slice(1) 360 | .split(',') 361 | .map((v) => parseInt(v, 16)); 362 | this._respondMem(addr, size, socket); 363 | } else if (command.startsWith('vKill') || command === 'k') { 364 | // Quit 365 | this._respond('OK', socket); 366 | socket.end(); 367 | } else { 368 | // Empty response required for any unknown command 369 | this._respond('', socket); 370 | } 371 | } 372 | 373 | private _respond(data: string, socket: net.Socket) { 374 | // calculate checksum 375 | const dataBytes = Buffer.from(data, 'ascii'); 376 | const checksum = dataBytes.reduce((sum, byte) => sum + byte, 0) & 0xff; 377 | // format and write the response 378 | const res = `$${data}#${checksum.toString(16).padStart(2, '0')}`; 379 | socket.write(res); 380 | this.debug(`Wrote: ${res}`); 381 | } 382 | 383 | private _respondRegs(socket: net.Socket) { 384 | let response = ''; 385 | // https://github.com/espressif/esp-idf-monitor/blob/fae383ecf281655abaa5e65433f671e274316d10/esp_idf_monitor/gdb_panic_server.py#L242-L247 386 | // It loops over the list of register names. 387 | // For each register name, it gets the register value from panicInfo.regs. 388 | // It converts the register value to bytes in little-endian byte order. 389 | // It converts each byte to a hexadecimal string and joins them together. 390 | // It appends the hexadecimal string to the response string. 391 | for (const regName of this.regList) { 392 | const regVal = this.panicInfo.regs[regName] || 0; 393 | const regBytes = Buffer.alloc(4); 394 | regBytes.writeUInt32LE(regVal); 395 | const regValHex = regBytes.toString('hex'); 396 | response += regValHex; 397 | } 398 | this.debug(`Register response: ${response}`); 399 | this._respond(response, socket); 400 | } 401 | 402 | private _respondMem(startAddr: number, size: number, socket: net.Socket) { 403 | const stackAddrMin = this.panicInfo.stackBaseAddr; 404 | const stackData = this.panicInfo.stackData; 405 | const stackLen = stackData.length; 406 | const stackAddrMax = stackAddrMin + stackLen; 407 | 408 | const inStack = (addr: number) => 409 | stackAddrMin <= addr && addr < stackAddrMax; 410 | 411 | let result = ''; 412 | for (let addr = startAddr; addr < startAddr + size; addr++) { 413 | if (!inStack(addr)) { 414 | result += '00'; 415 | } else { 416 | result += stackData[addr - stackAddrMin].toString(16).padStart(2, '0'); 417 | } 418 | } 419 | 420 | this._respond(result, socket); 421 | } 422 | } 423 | 424 | const exceptions = [ 425 | { code: 0x0, description: 'Instruction address misaligned' }, 426 | { code: 0x1, description: 'Instruction access fault' }, 427 | { code: 0x2, description: 'Illegal instruction' }, 428 | { code: 0x3, description: 'Breakpoint' }, 429 | { code: 0x4, description: 'Load address misaligned' }, 430 | { code: 0x5, description: 'Load access fault' }, 431 | { code: 0x6, description: 'Store/AMO address misaligned' }, 432 | { code: 0x7, description: 'Store/AMO access fault' }, 433 | { code: 0x8, description: 'Environment call from U-mode' }, 434 | { code: 0x9, description: 'Environment call from S-mode' }, 435 | { code: 0xb, description: 'Environment call from M-mode' }, 436 | { code: 0xc, description: 'Instruction page fault' }, 437 | { code: 0xd, description: 'Load page fault' }, 438 | { code: 0xf, description: 'Store/AMO page fault' }, 439 | ]; 440 | 441 | type RiscvFQBN = FQBN & { boardId: Target }; 442 | 443 | function isRiscvFQBN(fqbn: FQBN): fqbn is RiscvFQBN { 444 | return isTarget(fqbn.boardId); 445 | } 446 | 447 | function buildPanicServerArgs(elfPath: string, port: number): string[] { 448 | return [ 449 | '--batch', 450 | '-n', 451 | elfPath, 452 | // '-ex', // executes a command 453 | // `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) 454 | '-ex', 455 | `target remote :${port}`, // https://sourceware.org/gdb/current/onlinedocs/gdb.html/Server.html#Server 456 | '-ex', 457 | 'bt', 458 | ]; 459 | } 460 | 461 | async function processPanicOutput( 462 | params: DecodeParams, 463 | panicInfo: PanicInfo, 464 | options: DecodeOptions 465 | ): Promise { 466 | const { elfPath, toolPath } = params; 467 | let server: { close: () => void } | undefined; 468 | try { 469 | const gdbServer = new GdbServer({ 470 | panicInfo, 471 | debug: options.debug, 472 | }); 473 | const { port } = await gdbServer.start({ signal: options.signal }); 474 | server = gdbServer; 475 | 476 | const args = buildPanicServerArgs(elfPath, port); 477 | 478 | const { debug, signal } = options; 479 | const stdout = await run(toolPath, args, { debug, signal }); 480 | 481 | return stdout; 482 | } finally { 483 | server?.close(); 484 | } 485 | } 486 | 487 | function toHexString(number: number): string { 488 | return `0x${number.toString(16).padStart(8, '0')}`; 489 | } 490 | 491 | function parseGDBOutput(stdout: string): (GDBLine | ParsedGDBLine)[] { 492 | const gdbLines: (GDBLine | ParsedGDBLine)[] = []; 493 | const regex = /^#\d+\s+([\w:~<>]+)\s*\(([^)]*)\)\s*(?:at\s+([\S]+):(\d+))?/; 494 | 495 | for (const line of stdout.split(/\r?\n|\r/)) { 496 | const match = regex.exec(line); 497 | if (match) { 498 | const method = match[1]; 499 | const rawArgs = match[2]; // raw args 500 | const file = match[3]; 501 | const lineNum = match[4]; 502 | 503 | const args: Record = {}; 504 | if (rawArgs) { 505 | rawArgs.split(',').forEach((arg) => { 506 | const keyValue = arg.trim().match(/(\w+)\s*=\s*(\S+)/); 507 | if (keyValue) { 508 | args[keyValue[1]] = keyValue[2]; 509 | } 510 | }); 511 | } 512 | 513 | gdbLines.push({ 514 | method, 515 | address: rawArgs || '??', // Could be a memory address if not a method 516 | file, 517 | line: lineNum, 518 | args, 519 | }); 520 | } else { 521 | // Try fallback for addresses without function names 522 | const fallbackRegex = /^#\d+\s+0x([0-9a-fA-F]+)\s*in\s+(\?\?)/; 523 | const fallbackMatch = fallbackRegex.exec(line); 524 | if (fallbackMatch) { 525 | gdbLines.push({ 526 | address: `0x${fallbackMatch[1]}`, 527 | line: '??', 528 | }); 529 | } 530 | } 531 | } 532 | return gdbLines; 533 | } 534 | 535 | function createDecodeResult( 536 | panicInfo: PanicInfo, 537 | stdout: string 538 | ): DecodeResult { 539 | const exception = exceptions.find((e) => e.code === panicInfo.exception); 540 | 541 | const registerLocations: Record = {}; 542 | if (typeof panicInfo.regs.MEPC === 'number') { 543 | registerLocations.MEPC = toHexString(panicInfo.regs.MEPC); 544 | } 545 | if (typeof panicInfo.MTVAL === 'number') { 546 | registerLocations.MTVAL = toHexString(panicInfo.MTVAL); 547 | } 548 | 549 | const stacktraceLines = parseGDBOutput(stdout); 550 | 551 | return { 552 | exception: exception ? [exception.description, exception.code] : undefined, 553 | allocLocation: undefined, 554 | registerLocations, 555 | stacktraceLines, 556 | }; 557 | } 558 | 559 | export class InvalidTargetError extends Error { 560 | constructor(fqbn: FQBN) { 561 | super(`Invalid target: ${fqbn}`); 562 | this.name = 'InvalidTargetError'; 563 | } 564 | } 565 | 566 | export async function decodeRiscv( 567 | params: DecodeParams, 568 | input: string, 569 | options: DecodeOptions = defaultDecodeOptions 570 | ): Promise { 571 | if (!isRiscvFQBN(params.fqbn)) { 572 | throw new InvalidTargetError(params.fqbn); 573 | } 574 | const target = params.fqbn.boardId; 575 | options.debug?.(`Decoding for target: ${target}`); 576 | options.debug?.(`Input: ${input}`); 577 | 578 | const panicInfo = parsePanicOutput({ 579 | input, 580 | target, 581 | }); 582 | options.debug?.(`Parsed panic info: ${JSON.stringify(panicInfo)}`); 583 | 584 | const stdout = await processPanicOutput(params, panicInfo, options); 585 | options.debug?.(`GDB output: ${stdout}`); 586 | const decodeResult = createDecodeResult(panicInfo, stdout); 587 | options.debug?.(`Decode result: ${JSON.stringify(decodeResult)}`); 588 | return decodeResult; 589 | } 590 | 591 | /** 592 | * (non-API) 593 | */ 594 | export const __tests = { 595 | createRegNameValidator, 596 | isTarget, 597 | parsePanicOutput, 598 | buildPanicServerArgs, 599 | processPanicOutput, 600 | toHexString, 601 | parseGDBOutput, 602 | getStackAddrAndData, 603 | gdbRegsInfoRiscvIlp32, 604 | gdbRegsInfo, 605 | createDecodeResult, 606 | } as const; 607 | -------------------------------------------------------------------------------- /src/terminal.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import path from 'node:path'; 3 | import vscode from 'vscode'; 4 | import type { ArduinoContext } from 'vscode-arduino-api'; 5 | import { 6 | DecodeParams, 7 | DecodeParamsError, 8 | DecodeResult, 9 | GDBLine, 10 | Location, 11 | createDecodeParams, 12 | decode, 13 | isParsedGDBLine, 14 | } from './decoder'; 15 | import { Debug } from './utils'; 16 | 17 | const terminalDebug: Debug = debug('espExceptionDecoder:terminal'); 18 | 19 | let _debugOutput: vscode.OutputChannel | undefined; 20 | function debugOutput(): vscode.OutputChannel { 21 | if (!_debugOutput) { 22 | _debugOutput = vscode.window.createOutputChannel( 23 | `${decodeTerminalTitle} (Log)` 24 | ); 25 | } 26 | return _debugOutput; 27 | } 28 | function createDebugOutput(): Debug { 29 | return (message) => debugOutput().appendLine(message); 30 | } 31 | 32 | export function activateDecoderTerminal( 33 | context: vscode.ExtensionContext, 34 | arduinoContext: ArduinoContext 35 | ): void { 36 | context.subscriptions.push( 37 | new vscode.Disposable(() => _debugOutput?.dispose()), 38 | vscode.commands.registerCommand('espExceptionDecoder.showTerminal', () => 39 | openTerminal(arduinoContext, decode, { 40 | show: true, 41 | debug: createDebugOutput(), 42 | }) 43 | ) 44 | ); 45 | } 46 | 47 | function openTerminal( 48 | arduinoContext: ArduinoContext, 49 | decoder: typeof decode = decode, 50 | options: { show: boolean; debug: Debug } = { 51 | show: true, 52 | debug: terminalDebug, 53 | } 54 | ): vscode.Terminal { 55 | const { debug, show } = options; 56 | const terminal = 57 | findDecodeTerminal() ?? 58 | createDecodeTerminal(arduinoContext, decoder, debug); 59 | if (show) { 60 | terminal.show(); 61 | } 62 | return terminal; 63 | } 64 | 65 | function findDecodeTerminal(): vscode.Terminal | undefined { 66 | return vscode.window.terminals.find( 67 | (terminal) => 68 | terminal.name === decodeTerminalName && terminal.exitStatus === undefined 69 | ); 70 | } 71 | 72 | function createDecodeTerminal( 73 | arduinoContext: ArduinoContext, 74 | decoder: typeof decode, 75 | debug: Debug 76 | ): vscode.Terminal { 77 | const pty = new DecoderTerminal(arduinoContext, decoder, debug); 78 | const options: vscode.ExtensionTerminalOptions = { 79 | name: decodeTerminalName, 80 | pty, 81 | iconPath: new vscode.ThemeIcon('debug-console'), 82 | }; 83 | return vscode.window.createTerminal(options); 84 | } 85 | 86 | const decodeTerminalTitle = 'ESP Exception Decoder'; 87 | const decodeTerminalName = 'Exception Decoder'; 88 | const initializing = 'Initializing...'; 89 | const busy = 'Decoding...'; 90 | const idle = 'Paste exception to decode...'; 91 | 92 | interface DecodeTerminalState { 93 | params: DecodeParams | Error; 94 | userInput?: string | undefined; 95 | decoderResult?: DecodeResult | Error | undefined; 96 | statusMessage?: string | undefined; 97 | } 98 | 99 | class DecoderTerminal implements vscode.Pseudoterminal { 100 | readonly onDidWrite: vscode.Event; 101 | readonly onDidClose: vscode.Event; 102 | 103 | private readonly onDidWriteEmitter: vscode.EventEmitter; 104 | private readonly onDidCloseEmitter: vscode.EventEmitter; 105 | private readonly toDispose: vscode.Disposable[]; 106 | 107 | private state: DecodeTerminalState; 108 | private abortController: AbortController | undefined; 109 | 110 | constructor( 111 | private readonly arduinoContext: ArduinoContext, 112 | private readonly decoder: typeof decode = decode, 113 | private readonly debug: Debug = terminalDebug 114 | ) { 115 | this.onDidWriteEmitter = new vscode.EventEmitter(); 116 | this.onDidCloseEmitter = new vscode.EventEmitter(); 117 | this.toDispose = [ 118 | this.onDidWriteEmitter, 119 | this.onDidCloseEmitter, 120 | this.arduinoContext.onDidChange('boardDetails')(() => 121 | this.updateParams() 122 | ), // ignore FQBN changes and listen on board details only 123 | this.arduinoContext.onDidChange('compileSummary')(() => 124 | this.updateParams() 125 | ), 126 | this.arduinoContext.onDidChange('sketchPath')(() => this.updateParams()), // In the Arduino IDE (2.x), the sketch path does not change 127 | new vscode.Disposable(() => this.abortController?.abort()), 128 | ]; 129 | this.onDidWrite = this.onDidWriteEmitter.event; 130 | this.onDidClose = this.onDidCloseEmitter.event; 131 | this.state = { 132 | params: new Error(initializing), 133 | statusMessage: idle, 134 | }; 135 | } 136 | 137 | open(): void { 138 | this.updateParams(); 139 | } 140 | 141 | close(): void { 142 | vscode.Disposable.from(...this.toDispose).dispose(); 143 | } 144 | 145 | handleInput(data: string): void { 146 | this.debug(`handleInput: ${data}`); 147 | if (data.trim().length < 2) { 148 | this.debug(`handleInput, skip: ${data}`); 149 | // ignore single keystrokes 150 | return; 151 | } 152 | if (this.state.params instanceof Error) { 153 | this.debug(`handleInput, skip: ${this.state.params.message}, ${data}`); 154 | // ignore any user input if the params is invalid 155 | return; 156 | } 157 | const params = this.state.params; 158 | this.updateState({ 159 | userInput: toTerminalEOL(data), 160 | statusMessage: busy, 161 | decoderResult: undefined, 162 | }); 163 | setTimeout(() => this.decode(params, data), 0); 164 | } 165 | 166 | private async decode(params: DecodeParams, data: string): Promise { 167 | this.abortController?.abort(); 168 | this.abortController = new AbortController(); 169 | const signal = this.abortController.signal; 170 | let decoderResult: DecodeTerminalState['decoderResult']; 171 | try { 172 | decoderResult = await this.decoder(params, data, { 173 | signal, 174 | debug: this.debug, 175 | }); 176 | } catch (err) { 177 | this.abortController.abort(); 178 | decoderResult = err instanceof Error ? err : new Error(String(err)); 179 | } 180 | this.updateState({ decoderResult, statusMessage: idle }); 181 | } 182 | 183 | private async updateParams(): Promise { 184 | let params: DecodeTerminalState['params']; 185 | try { 186 | params = await createDecodeParams(this.arduinoContext); 187 | } catch (err) { 188 | params = err instanceof Error ? err : new Error(String(err)); 189 | } 190 | this.updateState({ params }); 191 | } 192 | 193 | private updateState(partial: Partial): void { 194 | this.debug(`updateState: ${JSON.stringify(partial)}`); 195 | const shouldDiscardUserInput = 196 | !(this.state.params instanceof Error) && partial.params instanceof Error; 197 | const shouldDiscardDecoderResult = shouldDiscardUserInput; 198 | this.state = { 199 | ...this.state, 200 | ...partial, 201 | }; 202 | if (shouldDiscardUserInput) { 203 | this.state.userInput = undefined; 204 | } 205 | if (shouldDiscardDecoderResult) { 206 | this.state.decoderResult = undefined; 207 | } 208 | this.debug(`newState: ${JSON.stringify(partial)}`); 209 | this.redrawTerminal(); 210 | } 211 | 212 | private redrawTerminal(): void { 213 | const output = stringifyTerminalState(this.state); 214 | this.debug(`redrawTerminal: ${output}`); 215 | this.onDidWriteEmitter.fire(clear); 216 | this.onDidWriteEmitter.fire(output); 217 | } 218 | } 219 | 220 | function stringifyTerminalState(state: DecodeTerminalState): string { 221 | const lines = [decodeTerminalTitle]; 222 | const { params, userInput, decoderResult } = state; 223 | let { statusMessage } = state; 224 | if (params instanceof Error && !(params instanceof DecodeParamsError)) { 225 | lines.push(red(toTerminalEOL(params.message))); 226 | } else { 227 | const { fqbn, sketchPath } = params; 228 | lines.push( 229 | `Sketch: ${green(path.basename(sketchPath))} FQBN: ${green( 230 | fqbn.toString() 231 | )}` 232 | ); 233 | if (params instanceof DecodeParamsError) { 234 | // error overrules any status message 235 | statusMessage = red(toTerminalEOL(params.message)); 236 | } else { 237 | if (userInput) { 238 | lines.push('', userInput); 239 | } 240 | if (decoderResult) { 241 | lines.push( 242 | '', 243 | decoderResult instanceof Error 244 | ? red(toTerminalEOL(decoderResult.message)) 245 | : stringifyDecodeResult(decoderResult) 246 | ); 247 | } 248 | } 249 | if (statusMessage) { 250 | lines.push('', statusMessage, ''); 251 | } 252 | } 253 | return stringifyLines(lines); 254 | } 255 | 256 | function stringifyDecodeResult(decodeResult: DecodeResult): string { 257 | const lines: string[] = []; 258 | if (decodeResult.exception) { 259 | const [message, code] = decodeResult.exception; 260 | lines.push(red(`Exception ${code}: ${message}`)); 261 | } 262 | const registerLines = Object.entries(decodeResult.registerLocations); 263 | for (const [name, location] of registerLines) { 264 | lines.push(`${red(name)}: ${stringifyLocation(location)}`); 265 | } 266 | if (registerLines.length) { 267 | lines.push(''); 268 | } 269 | if (decodeResult.stacktraceLines.length) { 270 | lines.push('Decoding stack results'); 271 | } 272 | lines.push(...decodeResult.stacktraceLines.map(stringifyGDBLine)); 273 | if (decodeResult.allocLocation) { 274 | const [location, size] = decodeResult.allocLocation; 275 | lines.push( 276 | '', 277 | `${red( 278 | `Memory allocation of ${size} bytes failed at` 279 | )} ${stringifyLocation(location)}` 280 | ); 281 | } 282 | return stringifyLines(lines); 283 | } 284 | 285 | function stringifyLocation(location: Location): string { 286 | return typeof location === 'string' 287 | ? green(location) 288 | : stringifyGDBLine(location); 289 | } 290 | 291 | function stringifyGDBLine(gdbLine: GDBLine): string { 292 | const { address, line } = gdbLine; 293 | if (!isParsedGDBLine(gdbLine)) { 294 | // Something weird in the GDB output format, report what we can 295 | return `${green(address)}: ${line}`; 296 | } 297 | const filename = path.basename(gdbLine.file); 298 | const dirname = path.dirname(gdbLine.file); 299 | const file = `${dirname}${path.sep}${bold(filename)}`; 300 | return `${green(address)}: ${blue(gdbLine.method, true)} at ${file}:${bold( 301 | line 302 | )}`; 303 | } 304 | 305 | function stringifyLines(lines: string[]): string { 306 | return toTerminalEOL(lines.join(terminalEOL)); 307 | } 308 | 309 | function toTerminalEOL(data: string): string { 310 | return data.split(/\r?\n|\r/g).join(terminalEOL); 311 | } 312 | 313 | const terminalEOL = '\r\n'; 314 | const clear = '\x1b[2J\x1b[3J\x1b[;H'; 315 | const resetStyle = '\x1b[0m'; 316 | enum ANSIStyle { 317 | 'bold' = 1, 318 | 'red' = 31, 319 | 'green' = 32, 320 | 'blue' = 34, 321 | } 322 | function red(text: string): string { 323 | return color(text, ANSIStyle.red); 324 | } 325 | function green(text: string, isBold = false): string { 326 | return color(text, ANSIStyle.green, isBold); 327 | } 328 | function blue(text: string, isBold = false): string { 329 | return color(text, ANSIStyle.blue, isBold); 330 | } 331 | function bold(text: string): string { 332 | return `\x1b[${ANSIStyle.bold}m${text}${resetStyle}`; 333 | } 334 | function color( 335 | text: string, 336 | foregroundColor: ANSIStyle, 337 | isBold = false 338 | ): string { 339 | return `\x1b[${foregroundColor}${ 340 | isBold ? `;${ANSIStyle.bold}` : '' 341 | }m${text}${resetStyle}`; 342 | } 343 | 344 | /** 345 | * (non-API) 346 | */ 347 | export const __tests = { 348 | openTerminal, 349 | stringifyLines, 350 | stringifyTerminalState, 351 | decodeTerminalTitle, 352 | DecoderTerminal, 353 | red, 354 | green, 355 | blue, 356 | bold, 357 | } as const; 358 | -------------------------------------------------------------------------------- /src/test/cliContext.json: -------------------------------------------------------------------------------- 1 | { 2 | "cliVersion": "1.1.1" 3 | } 4 | -------------------------------------------------------------------------------- /src/test/envs.cli.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "vendor": "esp32", 4 | "arch": "esp32", 5 | "version": "3.1.1", 6 | "url": "https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json" 7 | }, 8 | { 9 | "vendor": "esp8266", 10 | "arch": "esp8266", 11 | "version": "3.1.2", 12 | "url": "https://arduino.esp8266.com/stable/package_esp8266com_index.json" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /src/test/envs.git.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitUrl": "https://github.com/espressif/arduino-esp32.git", 3 | "branchOrTagName": "2.0.9", 4 | "folderName": "espressif" 5 | } 6 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import { runTests } from '@vscode/test-electron'; 2 | import path from 'node:path'; 3 | 4 | async function main() { 5 | try { 6 | // The folder containing the Extension Manifest package.json 7 | // Passed to `--extensionDevelopmentPath` 8 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 9 | 10 | // The path to test runner 11 | // Passed to --extensionTestsPath 12 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 13 | 14 | const extensionTestsEnv = <{ [key: string]: string | undefined }>( 15 | JSON.parse(JSON.stringify(process.env)) 16 | ); 17 | // VS Code removes the `DEBUG` env variable 18 | // https://github.com/microsoft/vscode/blob/c248f9ec0cf272351175ccf934054b18ffbf18c6/src/vs/base/common/processes.ts#L141 19 | if (extensionTestsEnv.DEBUG) { 20 | extensionTestsEnv['TEST_DEBUG'] = extensionTestsEnv.DEBUG; 21 | } 22 | 23 | const args = process.argv.splice(2); 24 | const all = args.includes('--all'); 25 | if (all) { 26 | extensionTestsEnv.CLI_TEST_CONTEXT = 'ALL'; 27 | } else { 28 | const slow = args.includes('--slow'); 29 | if (slow) { 30 | extensionTestsEnv.CLI_TEST_CONTEXT = 'SLOW'; 31 | } 32 | } 33 | 34 | // Download VS Code, unzip it and run the integration test 35 | await runTests({ 36 | extensionDevelopmentPath, 37 | extensionTestsPath, 38 | extensionTestsEnv, 39 | }); 40 | } catch (err) { 41 | console.error('Failed to run tests', err); 42 | process.exit(1); 43 | } 44 | } 45 | 46 | main(); 47 | -------------------------------------------------------------------------------- /src/test/sketches/AE/AE.ino: -------------------------------------------------------------------------------- 1 | void setup() { 2 | Serial.begin(9600); 3 | } 4 | 5 | void loop() { 6 | delay(10000); 7 | volatile byte foo = 1 / 0; 8 | } 9 | -------------------------------------------------------------------------------- /src/test/sketches/esp32backtracetest/esp32backtracetest.ino: -------------------------------------------------------------------------------- 1 | #include "module1.h" 2 | 3 | void setup() { 4 | Serial.begin(115200); 5 | Serial.println("Starting ESP32 backtrace test..."); 6 | 7 | int value = 42; 8 | functionA(value); 9 | } 10 | 11 | void loop() { 12 | // Nothing needed here for the test 13 | } 14 | -------------------------------------------------------------------------------- /src/test/sketches/esp32backtracetest/module1.cpp: -------------------------------------------------------------------------------- 1 | #include "module1.h" 2 | #include "module2.h" 3 | #include 4 | 5 | void functionA(int value) { 6 | Serial.println("In functionA, calling functionB..."); 7 | functionB(&value); 8 | } 9 | -------------------------------------------------------------------------------- /src/test/sketches/esp32backtracetest/module1.h: -------------------------------------------------------------------------------- 1 | #ifndef MODULE1_H 2 | #define MODULE1_H 3 | 4 | void functionA(int value); 5 | 6 | #endif 7 | -------------------------------------------------------------------------------- /src/test/sketches/esp32backtracetest/module2.cpp: -------------------------------------------------------------------------------- 1 | #include "module2.h" 2 | #include 3 | 4 | void functionC(int num) { 5 | Serial.println("In functionC, about to trigger an error..."); 6 | 7 | // Intentional crash: Dereferencing a NULL pointer to trigger a backtrace 8 | int* crashPtr = nullptr; 9 | *crashPtr = num; // This will cause a crash and generate a backtrace 10 | } 11 | 12 | void functionB(int* ptr) { 13 | Serial.println("In functionB, calling functionC..."); 14 | functionC(*ptr); 15 | } 16 | -------------------------------------------------------------------------------- /src/test/sketches/esp32backtracetest/module2.h: -------------------------------------------------------------------------------- 1 | #ifndef MODULE2_H 2 | #define MODULE2_H 3 | 4 | void functionB(int* ptr); 5 | 6 | #endif 7 | -------------------------------------------------------------------------------- /src/test/sketches/riscv_1/riscv_1.ino: -------------------------------------------------------------------------------- 1 | void setup() { 2 | // put your setup code here, to run once: 3 | Serial.begin(115200); 4 | Serial.println("SETUP"); 5 | } 6 | 7 | class a { 8 | public: 9 | int a = 323; 10 | int geta() { 11 | return a; 12 | } 13 | }; 14 | 15 | void loop() { 16 | // put your main code here, to run repeatedly: 17 | Serial.println("LOOP"); 18 | delay(2000); 19 | a* _a = 0; 20 | //a* _a = new a(); 21 | Serial.println(_a->geta()); 22 | } 23 | -------------------------------------------------------------------------------- /src/test/suite/decoder.slow-test.ts: -------------------------------------------------------------------------------- 1 | import { before, Suite } from 'mocha'; 2 | import assert from 'node:assert/strict'; 3 | import path from 'node:path'; 4 | import type { 5 | ArduinoState, 6 | BoardDetails, 7 | BuildProperties, 8 | CompileSummary, 9 | } from 'vscode-arduino-api'; 10 | import { 11 | __tests, 12 | createDecodeParams, 13 | decode, 14 | DecodeResult, 15 | GDBLine, 16 | Location, 17 | ParsedGDBLine, 18 | } from '../../decoder'; 19 | import { run } from '../../utils'; 20 | import type { 21 | CliContext, 22 | TestEnv, 23 | ToolsEnv, 24 | ToolsInstallType, 25 | } from '../testEnv'; 26 | import { esp32c3Input } from './riscv.test'; 27 | 28 | const { findToolPath } = __tests; 29 | const sketchesPath = path.join(__dirname, '../../../src/test/sketches/'); 30 | 31 | type PlatformId = [vendor: string, arch: string]; 32 | const esp32Boards = ['esp32', 'esp32s2', 'esp32s3', 'esp32c3'] as const; 33 | type ESP32Board = (typeof esp32Boards)[number]; 34 | const esp8266Boards = ['generic'] as const; 35 | type ESP8266Board = (typeof esp8266Boards)[number]; 36 | 37 | interface FindToolTestParams { 38 | readonly id: PlatformId; 39 | readonly toolsInstallType: ToolsInstallType; 40 | readonly boards: ESP32Board[] | ESP8266Board[]; 41 | } 42 | 43 | const expectedToolFilenames: Record = { 44 | esp32: 'xtensa-esp32-elf-gdb', 45 | esp32s2: 'xtensa-esp32s2-elf-gdb', 46 | esp32s3: 'xtensa-esp32s3-elf-gdb', 47 | esp32c3: 'riscv32-esp-elf-gdb', 48 | generic: 'xtensa-lx106-elf-gdb', 49 | }; 50 | 51 | const findToolTestParams: FindToolTestParams[] = [ 52 | { 53 | id: ['esp32', 'esp32'], 54 | toolsInstallType: 'cli', 55 | boards: [...esp32Boards], 56 | }, 57 | { 58 | id: ['espressif', 'esp32'], 59 | toolsInstallType: 'git', 60 | boards: [...esp32Boards], 61 | }, 62 | { 63 | id: ['esp8266', 'esp8266'], 64 | toolsInstallType: 'cli', 65 | boards: [...esp8266Boards], 66 | }, 67 | ]; 68 | 69 | function describeFindToolSuite(params: FindToolTestParams): Suite { 70 | const [vendor, arch] = params.id; 71 | const platformId = `${vendor}:${arch}`; 72 | return describe(`findToolPath for '${platformId}' platform installed via '${params.toolsInstallType}'`, () => { 73 | let testEnv: TestEnv; 74 | 75 | before(function () { 76 | testEnv = this.currentTest?.ctx?.['testEnv']; 77 | assert.notEqual(testEnv, undefined); 78 | }); 79 | 80 | params.boards 81 | .map((boardId) => ({ fqbn: `${platformId}:${boardId}`, boardId })) 82 | .map(({ fqbn, boardId }) => 83 | it(`should find the tool path for '${fqbn}'`, async function () { 84 | this.slow(10_000); 85 | const { cliContext, toolsEnvs } = testEnv; 86 | const boardDetails = await getBoardDetails( 87 | cliContext, 88 | toolsEnvs[params.toolsInstallType], 89 | fqbn 90 | ); 91 | const actual = await findToolPath(boardDetails); 92 | assert.notEqual( 93 | actual, 94 | undefined, 95 | `could not find tool path for '${fqbn}'` 96 | ); 97 | // filename without the extension independently from the OS 98 | const actualFilename = path.basename( 99 | actual, 100 | path.extname(actual) 101 | ); 102 | assert.strictEqual(actualFilename, expectedToolFilenames[boardId]); 103 | const stdout = await run(actual, ['--version'], { 104 | silent: true, 105 | silentError: true, 106 | }); 107 | // TODO: assert GDB version? 108 | assert.strictEqual( 109 | stdout.includes('GNU gdb'), 110 | true, 111 | `output does not contain 'GNU gdb': ${stdout}` 112 | ); 113 | }) 114 | ); 115 | }); 116 | } 117 | 118 | interface CreateArduinoStateParams { 119 | testEnv: TestEnv; 120 | fqbn: string; 121 | sketchPath: string; 122 | } 123 | 124 | async function createArduinoState( 125 | params: CreateArduinoStateParams 126 | ): Promise { 127 | const { testEnv, fqbn, sketchPath } = params; 128 | const [boardDetails, compileSummary] = await Promise.all([ 129 | getBoardDetails(testEnv.cliContext, testEnv.toolsEnvs.cli, fqbn), 130 | compileSketch(testEnv.cliContext, testEnv.toolsEnvs.cli, fqbn, sketchPath), 131 | ]); 132 | 133 | return { 134 | fqbn, 135 | sketchPath, 136 | boardDetails, 137 | compileSummary, 138 | dataDirPath: testEnv.toolsEnvs['cli'].dataDirPath, 139 | userDirPath: testEnv.toolsEnvs['cli'].userDirPath, 140 | port: undefined, 141 | }; 142 | } 143 | 144 | function describeDecodeSuite(params: DecodeTestParams): Suite { 145 | const { input, fqbn, sketchPath, expected, skip } = params; 146 | let testEnv: TestEnv; 147 | let arduinoState: ArduinoState; 148 | 149 | return describe(`decode '${path.basename( 150 | sketchPath 151 | )}' sketch on '${fqbn}'`, () => { 152 | before(async function () { 153 | if (skip) { 154 | console.info(`[TEST SKIP] ${skip}`); 155 | return this.skip(); 156 | } 157 | testEnv = this.currentTest?.ctx?.['testEnv']; 158 | assert.notEqual(testEnv, undefined); 159 | arduinoState = await createArduinoState({ 160 | testEnv, 161 | fqbn, 162 | sketchPath, 163 | }); 164 | }); 165 | 166 | it('should decode', async function () { 167 | if (skip) { 168 | return this.skip(); 169 | } 170 | this.slow(10_000); 171 | const params = await createDecodeParams(arduinoState); 172 | const actual = await decode(params, input); 173 | 174 | assertDecodeResultEquals(actual, expected); 175 | }); 176 | }); 177 | } 178 | 179 | // To fix the path case issue on Windows: 180 | // - "file": "D:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1/riscv_1.ino" 181 | // + "file": "d:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1\\riscv_1.ino" 182 | function driveLetterToLowerCaseIfWin32(str: string) { 183 | if (process.platform === 'win32' && /^[a-zA-Z]:\\/.test(str)) { 184 | return str.charAt(0).toLowerCase() + str.slice(1); 185 | } 186 | return str; 187 | } 188 | 189 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 190 | function assertObjectContains(actual: any, expected: any) { 191 | for (const key of Object.keys(expected)) { 192 | assert.deepStrictEqual( 193 | actual[key], 194 | expected[key], 195 | `Mismatch on key: ${key}, expected: ${expected[key]}, actual: ${actual[key]}` 196 | ); 197 | } 198 | } 199 | 200 | function assertLocationEquals(actual: Location, expected: LocationMatcher) { 201 | if (typeof expected === 'string' || !('file' in expected)) { 202 | assert.deepStrictEqual(actual, expected); 203 | return; 204 | } 205 | 206 | assertObjectContains(actual, { 207 | method: expected.method, 208 | address: expected.address, 209 | line: expected.line, 210 | args: expected.args, 211 | }); 212 | 213 | if (typeof expected.file === 'function') { 214 | const assertFile = expected.file; 215 | const actualFile = (actual).file; 216 | assert.ok( 217 | assertFile(actualFile), 218 | `${actualFile} did not pass the assertion` 219 | ); 220 | } else { 221 | assert.strictEqual( 222 | driveLetterToLowerCaseIfWin32((actual).file), 223 | driveLetterToLowerCaseIfWin32(expected.file) 224 | ); 225 | } 226 | } 227 | 228 | function assertDecodeResultEquals( 229 | actual: DecodeResult, 230 | expected: DecodeResultMatcher 231 | ) { 232 | assert.deepStrictEqual(actual.exception, expected.exception); 233 | 234 | assert.strictEqual( 235 | Object.keys(actual.registerLocations).length, 236 | Object.keys(expected.registerLocations).length 237 | ); 238 | for (const [key, actualValue] of Object.entries(actual.registerLocations)) { 239 | const expectedValue = expected.registerLocations[key]; 240 | assertLocationEquals(actualValue, expectedValue); 241 | } 242 | 243 | assert.strictEqual( 244 | actual.stacktraceLines.length, 245 | expected.stacktraceLines.length 246 | ); 247 | for (let i = 0; i < actual.stacktraceLines.length; i++) { 248 | const actualLine = actual.stacktraceLines[i]; 249 | const expectedLine = expected.stacktraceLines[i]; 250 | assertLocationEquals(actualLine, expectedLine); 251 | } 252 | } 253 | 254 | type GDBLineMatcher = Omit & { 255 | file: (actualFile: string) => boolean; 256 | }; 257 | type LocationMatcher = Location | GDBLineMatcher; 258 | type DecodeResultMatcher = Omit< 259 | DecodeResult, 260 | 'stacktraceLines' | 'registerLocations' 261 | > & { 262 | stacktraceLines: (GDBLine | ParsedGDBLine | GDBLineMatcher)[]; 263 | registerLocations: Record; 264 | }; 265 | 266 | interface DecodeTestParams extends Omit { 267 | input: string; 268 | expected: DecodeResultMatcher; 269 | skip?: boolean | string; 270 | } 271 | 272 | const esp32h2Input = `Guru Meditation Error: Core 0 panic'ed (Breakpoint). Exception was unhandled. 273 | 274 | Core 0 register dump: 275 | MEPC : 0x42000054 RA : 0x42000054 SP : 0x40816af0 GP : 0x4080bcc4 276 | TP : 0x40816b40 T0 : 0x400184be T1 : 0x4080e000 T2 : 0x00000000 277 | S0/FP : 0x420001bc S1 : 0x4080e000 A0 : 0x00000001 A1 : 0x00000001 278 | A2 : 0x4080e000 A3 : 0x4080e000 A4 : 0x00000000 A5 : 0x600c5090 279 | A6 : 0xfa000000 A7 : 0x00000014 S2 : 0x00000000 S3 : 0x00000000 280 | S4 : 0x00000000 S5 : 0x00000000 S6 : 0x00000000 S7 : 0x00000000 281 | S8 : 0x00000000 S9 : 0x00000000 S10 : 0x00000000 S11 : 0x00000000 282 | T3 : 0x4080e000 T4 : 0x00000001 T5 : 0x4080e000 T6 : 0x00000001 283 | MSTATUS : 0x00001881 MTVEC : 0x40800001 MCAUSE : 0x00000003 MTVAL : 0x00009002 284 | MHARTID : 0x00000000 285 | 286 | Stack memory: 287 | 40816af0: 0x00000000 0x00000000 0x00000000 0x42001b6c 0x00000000 0x00000000 0x00000000 0x4080670a 288 | 40816b10: 0x00000000 0x00000000 0ESP-ROM:esp32h2-20221101 289 | Build:Nov 1 2022 290 | `; 291 | 292 | const esp32WroomDaInput = `Guru Meditation Error: Core 1 panic'ed (StoreProhibited). Exception was unhandled. 293 | 294 | Core 1 register dump: 295 | PC : 0x400d15f1 PS : 0x00060b30 A0 : 0x800d1609 A1 : 0x3ffb21d0 296 | A2 : 0x0000002a A3 : 0x3f40018f A4 : 0x00000020 A5 : 0x0000ff00 297 | A6 : 0x00ff0000 A7 : 0x00000022 A8 : 0x00000000 A9 : 0x3ffb21b0 298 | A10 : 0x0000002c A11 : 0x3f400164 A12 : 0x00000022 A13 : 0x0000ff00 299 | A14 : 0x00ff0000 A15 : 0x0000002a SAR : 0x0000000c EXCCAUSE: 0x0000001d 300 | EXCVADDR: 0x00000000 LBEG : 0x40086161 LEND : 0x40086171 LCOUNT : 0xfffffff5 301 | 302 | 303 | Backtrace: 0x400d15ee:0x3ffb21d0 0x400d1606:0x3ffb21f0 0x400d15da:0x3ffb2210 0x400d15c1:0x3ffb2240 0x400d302a:0x3ffb2270 0x40088be9:0x3ffb2290`; 304 | 305 | const esp8266Input = `Exception (28): 306 | epc1=0x4020107b epc2=0x00000000 epc3=0x00000000 excvaddr=0x00000000 depc=0x00000000 307 | 308 | >>>stack>>> 309 | 310 | ctx: cont 311 | sp: 3ffffe60 end: 3fffffd0 offset: 0150 312 | 3fffffb0: feefeffe 00000000 3ffee55c 4020195c 313 | 3fffffc0: feefeffe feefeffe 3fffdab0 40100d19 314 | << 342 | driveLetterToLowerCaseIfWin32(actualFile) === 343 | driveLetterToLowerCaseIfWin32( 344 | path.join(sketchesPath, 'riscv_1/riscv_1.ino') 345 | ), 346 | }, 347 | { 348 | method: 'loop', 349 | address: '??', 350 | line: '21', 351 | args: {}, 352 | file: (actualFile) => 353 | driveLetterToLowerCaseIfWin32(actualFile) === 354 | driveLetterToLowerCaseIfWin32( 355 | path.join(sketchesPath, 'riscv_1/riscv_1.ino') 356 | ), 357 | }, 358 | { 359 | address: '0x4c1c0042', 360 | line: '??', 361 | }, 362 | ], 363 | allocLocation: undefined, 364 | }, 365 | }, 366 | { 367 | skip, 368 | input: esp32h2Input, 369 | fqbn: 'esp32:esp32:esp32h2', 370 | sketchPath: path.join(sketchesPath, 'AE'), 371 | expected: { 372 | exception: ['Breakpoint', 3], 373 | registerLocations: { 374 | MEPC: '0x42000054', 375 | MTVAL: '0x00009002', 376 | }, 377 | stacktraceLines: [ 378 | { 379 | method: 'loop', 380 | address: '??', 381 | line: '7', 382 | args: {}, 383 | file: (actualFile) => 384 | driveLetterToLowerCaseIfWin32(actualFile) === 385 | driveLetterToLowerCaseIfWin32(path.join(sketchesPath, 'AE/AE.ino')), 386 | }, 387 | { 388 | address: '0x6c1b0042', 389 | line: '??', 390 | }, 391 | ], 392 | allocLocation: undefined, 393 | }, 394 | }, 395 | { 396 | input: esp32WroomDaInput, 397 | fqbn: 'esp32:esp32:esp32da', 398 | expected: { 399 | exception: undefined, 400 | registerLocations: { 401 | PC: { 402 | address: '0x400d15f1', 403 | method: 'functionC(int)', 404 | file: (actualFile) => 405 | driveLetterToLowerCaseIfWin32(actualFile) === 406 | driveLetterToLowerCaseIfWin32( 407 | path.join(sketchesPath, 'esp32backtracetest/module2.cpp') 408 | ), 409 | line: '9', 410 | }, 411 | EXCVADDR: '0x00000000', 412 | }, 413 | stacktraceLines: [ 414 | { 415 | address: '0x400d15ee', 416 | method: 'functionC(int)', 417 | file: (actualFile) => 418 | driveLetterToLowerCaseIfWin32(actualFile) === 419 | driveLetterToLowerCaseIfWin32( 420 | path.join(sketchesPath, 'esp32backtracetest/module2.cpp') 421 | ), 422 | line: '9', 423 | }, 424 | { 425 | address: '0x400d1606', 426 | method: 'functionB(int*)', 427 | file: (actualFile) => 428 | driveLetterToLowerCaseIfWin32(actualFile) === 429 | driveLetterToLowerCaseIfWin32( 430 | path.join(sketchesPath, 'esp32backtracetest/module2.cpp') 431 | ), 432 | line: '14', 433 | }, 434 | { 435 | address: '0x400d15da', 436 | method: 'functionA(int)', 437 | file: (actualFile) => 438 | driveLetterToLowerCaseIfWin32(actualFile) === 439 | driveLetterToLowerCaseIfWin32( 440 | path.join(sketchesPath, 'esp32backtracetest/module1.cpp') 441 | ), 442 | line: '7', 443 | }, 444 | { 445 | address: '0x400d15c1', 446 | method: 'setup()', 447 | file: (actualFile) => 448 | driveLetterToLowerCaseIfWin32(actualFile) === 449 | driveLetterToLowerCaseIfWin32( 450 | path.join( 451 | sketchesPath, 452 | 'esp32backtracetest/esp32backtracetest.ino' 453 | ) 454 | ), 455 | line: '8', 456 | }, 457 | { 458 | address: '0x400d302a', 459 | method: 'loopTask(void*)', 460 | file: (actualFile) => actualFile.endsWith('main.cpp'), 461 | line: '59', 462 | }, 463 | { 464 | address: '0x40088be9', 465 | method: 'vPortTaskWrapper', 466 | file: (actualFile) => actualFile.endsWith('port.c'), 467 | line: '139', 468 | }, 469 | ], 470 | allocLocation: undefined, 471 | }, 472 | sketchPath: path.join(sketchesPath, 'esp32backtracetest'), 473 | }, 474 | { 475 | skip, 476 | fqbn: 'esp8266:esp8266:generic', 477 | input: esp8266Input, 478 | sketchPath: path.join(sketchesPath, 'AE'), 479 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 480 | expected: { 481 | exception: [ 482 | 'LoadProhibited: A load referenced a page mapped with an attribute that does not permit loads', 483 | 28, 484 | ], 485 | registerLocations: { 486 | PC: '0x4020107b', 487 | EXCVADDR: '0x00000000', 488 | }, 489 | stacktraceLines: [ 490 | { 491 | address: '0x4020195c', 492 | method: 'user_init()', 493 | file: (actualFile) => actualFile.endsWith('core_esp8266_main.cpp'), 494 | line: '676', 495 | }, 496 | ], 497 | allocLocation: undefined, 498 | }, 499 | }, 500 | ]; 501 | 502 | async function getBoardDetails( 503 | cliContext: CliContext, 504 | toolsEnv: ToolsEnv, 505 | fqbn: string 506 | ): Promise { 507 | const { cliPath } = cliContext; 508 | const { cliConfigPath } = toolsEnv; 509 | const stdout = await run(cliPath, [ 510 | 'board', 511 | 'details', 512 | '-b', 513 | fqbn, 514 | '--config-file', 515 | cliConfigPath, 516 | '--format', 517 | 'json', 518 | ]); 519 | const buildProperties = JSON.parse(stdout).build_properties; 520 | return createBoardDetails(fqbn, buildProperties); 521 | } 522 | 523 | async function compileSketch( 524 | cliContext: CliContext, 525 | toolsEnv: ToolsEnv, 526 | fqbn: string, 527 | sketchPath: string 528 | ): Promise { 529 | const { cliPath } = cliContext; 530 | const { cliConfigPath } = toolsEnv; 531 | const stdout = await run(cliPath, [ 532 | 'compile', 533 | sketchPath, 534 | '-b', 535 | fqbn, 536 | '--config-file', 537 | cliConfigPath, 538 | '--format', 539 | 'json', 540 | ]); 541 | 542 | const cliCompileSummary = JSON.parse(stdout); 543 | return { 544 | buildPath: cliCompileSummary.builder_result.build_path, 545 | buildProperties: {}, 546 | usedLibraries: [], 547 | executableSectionsSize: [], 548 | boardPlatform: undefined, 549 | buildPlatform: undefined, 550 | }; 551 | } 552 | 553 | function createBoardDetails( 554 | fqbn: string, 555 | buildProperties: string[] | Record = {} 556 | ): BoardDetails { 557 | return { 558 | fqbn, 559 | buildProperties: Array.isArray(buildProperties) 560 | ? parseBuildProperties(buildProperties) 561 | : buildProperties, 562 | configOptions: [], 563 | programmers: [], 564 | toolsDependencies: [], 565 | }; 566 | } 567 | 568 | export function parseBuildProperties(properties: string[]): BuildProperties { 569 | return properties.reduce((acc, curr) => { 570 | const entry = parseProperty(curr); 571 | if (entry) { 572 | const [key, value] = entry; 573 | acc[key] = value; 574 | } 575 | return acc; 576 | }, >{}); 577 | } 578 | 579 | const propertySep = '='; 580 | function parseProperty( 581 | property: string 582 | ): [key: string, value: string] | undefined { 583 | const segments = property.split(propertySep); 584 | if (segments.length < 2) { 585 | console.warn(`Could not parse build property: ${property}.`); 586 | return undefined; 587 | } 588 | const [key, ...rest] = segments; 589 | if (!key) { 590 | console.warn(`Could not determine property key from raw: ${property}.`); 591 | return undefined; 592 | } 593 | const value = rest.join(propertySep); 594 | return [key, value]; 595 | } 596 | 597 | describe('decoder (slow)', () => { 598 | findToolTestParams.map(describeFindToolSuite); 599 | decodeTestParams.map(describeDecodeSuite); 600 | 601 | it(`should throw an error when the board's architecture is unsupported`, async () => { 602 | await assert.rejects( 603 | () => findToolPath(createBoardDetails('a:b:c')), 604 | /Unsupported board architecture: 'a:b:c'/ 605 | ); 606 | }); 607 | }); 608 | -------------------------------------------------------------------------------- /src/test/suite/decoder.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import { promises as fs } from 'node:fs'; 3 | import path from 'node:path'; 4 | import { FQBN } from 'fqbn'; 5 | import temp from 'temp'; 6 | import { 7 | DecodeParams, 8 | DecodeParamsError, 9 | __tests, 10 | createDecodeParams, 11 | decode, 12 | } from '../../decoder'; 13 | import { isWindows } from '../../utils'; 14 | import { mockArduinoState, mockBoardDetails, mockCompileSummary } from './mock'; 15 | 16 | const { 17 | buildCommandFlags, 18 | findElfPath, 19 | parseException, 20 | parseStacktrace, 21 | parseInstructionAddresses, 22 | parseGDBOutput, 23 | parseAlloc, 24 | parseRegisters, 25 | exceptions, 26 | fixWindowsPath, 27 | fixWindowsPaths, 28 | } = __tests; 29 | 30 | const esp8266Input = `--------------- CUT HERE FOR EXCEPTION DECODER --------------- 31 | $�K��5�z�͎���� 32 | User exception (panic/abort/assert) 33 | --------------- CUT HERE FOR EXCEPTION DECODER --------------- 34 | 35 | Abort called 36 | 37 | >>>stack>>> 38 | 39 | ctx: cont 40 | sp: 3fffff90 end: 3fffffd0 offset: 0010 41 | 3fffffa0: 00002580 00000000 3ffee54c 4020104e 42 | 3fffffb0: 3fffdad0 00000000 3ffee54c 402018ac 43 | 3fffffc0: feefeffe feefeffe 3fffdab0 40100d19 44 | <<>>stack>>> 54 | 55 | ctx: cont 56 | sp: 3fffff90 end: 3fffffd0 offset: 0011 57 | 3fffffa0: 00002580 00000000 3ffee54c 4020104e 58 | 3fffffb0: 3fffdad0 00000000 3ffee54c 402018ac 59 | 3fffffc0: feefeffe feefeffe 3fffdab0 40100d19 60 | << { 128 | let tracked: typeof temp; 129 | before(() => (tracked = temp.track())); 130 | after(() => tracked.cleanupSync()); 131 | 132 | describe('createDecodeParams', () => { 133 | it("should error with missing 'sketchPath' when all missing", async () => { 134 | await assert.rejects( 135 | createDecodeParams(mockArduinoState()), 136 | /Sketch path is not set/ 137 | ); 138 | }); 139 | 140 | it("should error with missing 'fqbn' when no board is selected", async () => { 141 | const sketchPath = tracked.mkdirSync(); 142 | await assert.rejects( 143 | createDecodeParams(mockArduinoState({ sketchPath })), 144 | /No board selected/ 145 | ); 146 | }); 147 | 148 | it("should error with not installed platform when 'boardDetails' is missing", async () => { 149 | const sketchPath = tracked.mkdirSync(); 150 | await assert.rejects( 151 | createDecodeParams( 152 | mockArduinoState({ sketchPath, fqbn: 'esp8266:esp8266:generic' }) 153 | ), 154 | (reason) => 155 | reason instanceof DecodeParamsError && 156 | /Platform 'esp8266:esp8266' is not installed/.test(reason.message) 157 | ); 158 | }); 159 | 160 | it("should error due to unsupported board when the arch is neither 'esp32' nor 'esp8266'", async () => { 161 | const sketchPath = tracked.mkdirSync(); 162 | const fqbn = 'a:b:c'; 163 | await assert.rejects( 164 | createDecodeParams( 165 | mockArduinoState({ 166 | sketchPath, 167 | fqbn, 168 | boardDetails: mockBoardDetails(fqbn), 169 | }) 170 | ), 171 | (reason) => 172 | reason instanceof DecodeParamsError && 173 | /Unsupported board: 'a:b:c'/.test(reason.message) 174 | ); 175 | }); 176 | 177 | it("should error when the sketch has not been compiled and the 'compileSummary' is undefined", async () => { 178 | const sketchPath = tracked.mkdirSync(); 179 | const fqbn = 'esp8266:esp8266:generic'; 180 | await assert.rejects( 181 | createDecodeParams( 182 | mockArduinoState({ 183 | sketchPath, 184 | fqbn, 185 | boardDetails: mockBoardDetails(fqbn), 186 | }) 187 | ), 188 | (reason) => 189 | reason instanceof DecodeParamsError && 190 | /The summary of the previous compilation is unavailable. Compile the sketch/.test( 191 | reason.message 192 | ) 193 | ); 194 | }); 195 | 196 | it("should error when the '.elf' file not found", async () => { 197 | const sketchPath = tracked.mkdirSync(); 198 | const buildPath = tracked.mkdirSync(); 199 | const fqbn = 'esp8266:esp8266:generic'; 200 | let elfPathFallbackCounter = 0; 201 | const fallbackParams = { 202 | async elfPath() { 203 | elfPathFallbackCounter++; 204 | return undefined; 205 | }, 206 | }; 207 | await assert.rejects( 208 | createDecodeParams( 209 | mockArduinoState({ 210 | sketchPath, 211 | fqbn, 212 | boardDetails: mockBoardDetails(fqbn), 213 | compileSummary: mockCompileSummary(buildPath), 214 | }), 215 | fallbackParams 216 | ), 217 | (reason) => 218 | reason instanceof DecodeParamsError && 219 | /Could not detect the '.elf' file in the build folder/.test( 220 | reason.message 221 | ) 222 | ); 223 | assert.strictEqual(elfPathFallbackCounter, 1); 224 | }); 225 | 226 | it('should error when the GDB tool not found', async () => { 227 | const tempPath = tracked.mkdirSync(); 228 | const sketchPath = path.join(tempPath, 'my_sketch'); 229 | await fs.mkdir(sketchPath, { recursive: true }); 230 | const buildPath = tracked.mkdirSync(); 231 | await fs.writeFile( 232 | path.join(buildPath, `${path.basename(sketchPath)}.ino.elf`), 233 | '' 234 | ); 235 | const fqbn = 'esp8266:esp8266:generic'; 236 | await assert.rejects( 237 | createDecodeParams( 238 | mockArduinoState({ 239 | sketchPath, 240 | fqbn, 241 | boardDetails: mockBoardDetails(fqbn), 242 | compileSummary: mockCompileSummary(buildPath), 243 | }) 244 | ), 245 | (reason) => 246 | reason instanceof DecodeParamsError && 247 | /Could not detect the GDB tool path/.test(reason.message) 248 | ); 249 | }); 250 | 251 | it('should create the decode params', async () => { 252 | const tempPath = tracked.mkdirSync(); 253 | const sketchPath = path.join(tempPath, 'my_sketch'); 254 | await fs.mkdir(sketchPath, { recursive: true }); 255 | const buildPath = tracked.mkdirSync(); 256 | const elfPath = path.join( 257 | buildPath, 258 | `${path.basename(sketchPath)}.ino.elf` 259 | ); 260 | await fs.writeFile(elfPath, ''); 261 | const mockToolDirPath = tracked.mkdirSync(); 262 | await fs.mkdir(path.join(mockToolDirPath, 'bin'), { recursive: true }); 263 | const toolPath = path.join( 264 | mockToolDirPath, 265 | 'bin', 266 | `xtensa-lx106-elf-gdb${isWindows ? '.exe' : ''}` 267 | ); 268 | await fs.writeFile(toolPath, ''); 269 | await fs.chmod(toolPath, 0o755); 270 | const fqbn = 'esp8266:esp8266:generic'; 271 | const actual = await createDecodeParams( 272 | mockArduinoState({ 273 | sketchPath, 274 | fqbn, 275 | boardDetails: mockBoardDetails(fqbn, { 276 | 'runtime.tools.xtensa-esp-elf-gdb.path': mockToolDirPath, 277 | }), 278 | compileSummary: mockCompileSummary(buildPath), 279 | }) 280 | ); 281 | assert.strictEqual(actual.elfPath, elfPath); 282 | assert.strictEqual(actual.toolPath, toolPath); 283 | assert.strictEqual(actual.sketchPath, sketchPath); 284 | assert.strictEqual(actual.fqbn.toString(), fqbn); 285 | }); 286 | }); 287 | 288 | describe('parseStacktrace', () => { 289 | it('should parse multiline ESP8266 content', () => { 290 | const actual = parseStacktrace(esp8266Input); 291 | assert.strictEqual(actual, esp8266Content); 292 | }); 293 | 294 | it('should parse single-line ESP32 content', () => { 295 | [ 296 | [esp32AbortInput, esp32AbortContent], 297 | [esp32PanicInput, esp32PanicContent], 298 | ].forEach(([input, expected]) => { 299 | const actual = parseStacktrace(input); 300 | assert.strictEqual(actual, expected); 301 | }); 302 | }); 303 | }); 304 | 305 | describe('parseInstructionAddresses', () => { 306 | it('should parse instruction addresses in stripped ESP8266 content', () => { 307 | const expected = ['4020104e', '402018ac', '40100d19']; 308 | const actual = parseInstructionAddresses(esp8266Content); 309 | assert.deepStrictEqual(actual, expected); 310 | }); 311 | 312 | it('should parse instruction addresses in stripped ESP32 content', () => { 313 | const expected = [ 314 | '400833dd', 315 | '40087f2d', 316 | '4008d17d', 317 | '400d129d', 318 | '400d2305', 319 | ]; 320 | const actual = parseInstructionAddresses(esp32AbortContent); 321 | assert.deepStrictEqual(actual, expected); 322 | }); 323 | }); 324 | 325 | describe('buildCommand', () => { 326 | it('should build command with flags from instruction addresses', () => { 327 | const elfPath = 'path/to/elf'; 328 | const actualFlags = buildCommandFlags( 329 | ['4020104e', '402018ac', '40100d19'], 330 | elfPath 331 | ); 332 | assert.deepStrictEqual(actualFlags, [ 333 | '--batch', 334 | elfPath, 335 | '-ex', 336 | 'set listsize 1', 337 | '-ex', 338 | 'list *0x4020104e', 339 | '-ex', 340 | 'list *0x402018ac', 341 | '-ex', 342 | 'list *0x40100d19', 343 | '-ex', 344 | 'q', 345 | ]); 346 | }); 347 | 348 | it("should throw when 'addresses' is empty", () => { 349 | assert.throws( 350 | () => buildCommandFlags([], 'never'), 351 | /Invalid argument: addresses.length <= 0/ 352 | ); 353 | }); 354 | }); 355 | 356 | describe('parseException', () => { 357 | it('should parse the exception', () => { 358 | const expectedCode = 29; 359 | const actual = parseException(esp8266exceptionInput); 360 | assert.deepStrictEqual(actual, [exceptions[expectedCode], expectedCode]); 361 | }); 362 | }); 363 | 364 | describe('parseRegister', () => { 365 | it('should not parse register address from invalid input', () => { 366 | const actual = parseRegisters('blabla'); 367 | assert.deepStrictEqual(actual, [undefined, undefined]); 368 | }); 369 | 370 | it("should parse ESP32 'PC' register address", () => { 371 | const actual = parseRegisters('PC : 0x400d129d'); 372 | assert.deepStrictEqual(actual, ['400d129d', undefined]); 373 | }); 374 | 375 | it("should parse ESP32 'EXCVADDR' register address", () => { 376 | const actual = parseRegisters('EXCVADDR: 0x00000001'); 377 | assert.deepStrictEqual(actual, [undefined, '00000001']); 378 | }); 379 | 380 | it("should parse ESP8266 'PC' register address", () => { 381 | const actual = parseRegisters('epc1=0x4000dfd9'); 382 | assert.deepStrictEqual(actual, ['4000dfd9', undefined]); 383 | }); 384 | 385 | it("should parse ESP8266 'EXCVADDR' register address", () => { 386 | const actual = parseRegisters('excvaddr=0x00000001'); 387 | assert.deepStrictEqual(actual, [undefined, '00000001']); 388 | }); 389 | 390 | it('should parse ESP32 register addresses', () => { 391 | const actual = parseRegisters(esp32PanicInput); 392 | assert.deepStrictEqual(actual, ['400d129d', '00000000']); 393 | }); 394 | 395 | it('should parse ESP8266 register addresses', () => { 396 | const actual = parseRegisters(esp8266exceptionInput); 397 | assert.deepStrictEqual(actual, ['4000dfd9', '00000000']); 398 | }); 399 | }); 400 | 401 | describe('parseAlloc', () => { 402 | it('should not parse alloc from invalid input', () => { 403 | assert.deepStrictEqual(parseAlloc('invalid'), undefined); 404 | }); 405 | 406 | it('should not parse alloc when address is not instruction address', () => { 407 | assert.deepStrictEqual( 408 | parseAlloc('last failed alloc call: 3022D552(1480)'), 409 | undefined 410 | ); 411 | }); 412 | 413 | it('should parse alloc', () => { 414 | assert.deepStrictEqual( 415 | parseAlloc('last failed alloc call: 4022D552(1480)'), 416 | ['4022D552', 1480] 417 | ); 418 | }); 419 | }); 420 | 421 | describe('filterLines', () => { 422 | it('should filter irrelevant lines from the stdout', () => { 423 | const actual = parseGDBOutput(esp8266Stdout); 424 | assert.strictEqual(actual.length, 1); 425 | assert.deepStrictEqual(actual[0], { 426 | address: '0x402018ac', 427 | file: '/Users/dankeboy36/Library/Arduino15/packages/esp8266/hardware/esp8266/3.1.2/cores/esp8266/core_esp8266_main.cpp', 428 | line: '258', 429 | method: 'loop_wrapper()', 430 | }); 431 | }); 432 | 433 | it('should handle () in the file path', () => { 434 | const actual = parseGDBOutput(esp32Stdout); 435 | assert.strictEqual(actual.length, 5); 436 | assert.deepStrictEqual(actual[3], { 437 | address: '0x400d129d', 438 | file: '/Users/dankeboy36/Documents/Arduino/folder with space/(here)/AE/AE.ino', 439 | line: '8', 440 | method: 'loop()', 441 | }); 442 | }); 443 | }); 444 | 445 | describe('findElfPath', () => { 446 | it('should not find the elf path when the sketch folder name does not match', async () => { 447 | const buildPath = tracked.mkdirSync(); 448 | const sketchName = 'my_sketch'; 449 | const invalidName = 'MySketch'; 450 | await fs.writeFile(path.join(buildPath, `${invalidName}.ino.elf`), ''); 451 | await fs.writeFile(path.join(buildPath, `${invalidName}.cpp.elf`), ''); 452 | const actual = await findElfPath(sketchName, buildPath); 453 | assert.strictEqual(actual, undefined); 454 | }); 455 | 456 | it("should not find the elf path when neither 'ino.elf' nor 'cpp.elf' exist", async () => { 457 | const buildPath = tracked.mkdirSync(); 458 | const sketchName = 'my_sketch'; 459 | const actual = await findElfPath(sketchName, buildPath); 460 | assert.strictEqual(actual, undefined); 461 | }); 462 | 463 | it("should find 'ino.elf' path", async () => { 464 | const buildPath = tracked.mkdirSync(); 465 | const sketchName = 'my_sketch'; 466 | const expected = path.join(buildPath, `${sketchName}.ino.elf`); 467 | await fs.writeFile(expected, ''); 468 | const actual = await findElfPath(sketchName, buildPath); 469 | assert.strictEqual(actual, expected); 470 | }); 471 | 472 | it("should find 'cpp.elf' path", async () => { 473 | const buildPath = tracked.mkdirSync(); 474 | const sketchName = 'my_sketch'; 475 | const expected = path.join(buildPath, `${sketchName}.cpp.elf`); 476 | await fs.writeFile(expected, ''); 477 | const actual = await findElfPath(sketchName, buildPath); 478 | assert.strictEqual(actual, expected); 479 | }); 480 | 481 | it("should prefer 'ino.elf' over 'cpp.elf' when both paths exist", async () => { 482 | const buildPath = tracked.mkdirSync(); 483 | const sketchName = 'my_sketch'; 484 | const expected = path.join(buildPath, `${sketchName}.ino.elf`); 485 | await fs.writeFile(expected, ''); 486 | await fs.writeFile(path.join(buildPath, `${sketchName}.cpp.elf`), ''); 487 | const actual = await findElfPath(sketchName, buildPath); 488 | assert.strictEqual(actual, expected); 489 | }); 490 | }); 491 | 492 | describe('decode', () => { 493 | it('should support cancellation', async function () { 494 | this.slow(5_000); 495 | this.timeout(5_000); 496 | const buildPath = tracked.mkdirSync(); 497 | const sketchName = 'my_sketch'; 498 | const tempPath = tracked.mkdirSync(); 499 | const sketchPath = path.join(tempPath, sketchName); 500 | const elfPath = path.join(buildPath, `${sketchName}.ino.elf`); 501 | const toolPath = path.join( 502 | __dirname, 503 | `../../../src/test/tools/fake-tool${isWindows ? '.bat' : ''}` 504 | ); 505 | await Promise.all([ 506 | fs.writeFile(elfPath, ''), 507 | fs.mkdir(sketchPath, { recursive: true }), 508 | ]); 509 | const params: DecodeParams = { 510 | elfPath, 511 | toolPath, 512 | fqbn: new FQBN('esp8266:esp8266:generic'), 513 | sketchPath, 514 | }; 515 | const abortController = new AbortController(); 516 | try { 517 | await Promise.all([ 518 | new Promise((resolve) => 519 | setTimeout(() => { 520 | abortController.abort(); 521 | resolve(); 522 | }, 2_000) 523 | ), 524 | decode(params, esp8266Input, { signal: abortController.signal }), 525 | ]); 526 | assert.fail('Expected an abort error'); 527 | } catch (err) { 528 | assert.strictEqual(err instanceof Error, true); 529 | assert.strictEqual('code' in err, true); 530 | assert.strictEqual((<{ code: string }>err).code, 'ABORT_ERR'); 531 | assert.strictEqual(abortController.signal.aborted, true); 532 | } 533 | }); 534 | }); 535 | 536 | describe('fixWindowsPath', () => { 537 | it('should fix the path', () => { 538 | assert.strictEqual( 539 | fixWindowsPath( 540 | 'D:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1/riscv_1.ino', 541 | true 542 | ), 543 | 'D:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1\\riscv_1.ino' 544 | ); 545 | }); 546 | 547 | it('should be noop if not on windows', () => { 548 | assert.strictEqual( 549 | fixWindowsPath( 550 | 'D:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1/riscv_1.ino', 551 | false 552 | ), 553 | 'D:\\a\\esp-exception-decoder\\esp-exception-decoder\\src\\test\\sketches\\riscv_1/riscv_1.ino' 554 | ); 555 | }); 556 | }); 557 | 558 | describe('fixWindowsPaths', () => { 559 | it('should fix the paths', () => { 560 | const actual = fixWindowsPaths( 561 | { 562 | exception: undefined, 563 | registerLocations: { 564 | PC: { 565 | address: '0x400d15af', 566 | method: 'loop()', 567 | file: 'C:\\Users\\kittaakos\\dev\\esp-exception-decoder\\src\\test\\sketches\\AE/AE.ino', 568 | line: '7', 569 | }, 570 | EXCVADDR: '0x00000000', 571 | }, 572 | stacktraceLines: [ 573 | { 574 | address: '0x400d15ac', 575 | method: 'loop()', 576 | file: 'C:\\Users\\kittaakos\\dev\\esp-exception-decoder\\src\\test\\sketches\\AE/AE.ino', 577 | line: '6', 578 | }, 579 | { 580 | address: '0x400d2f98', 581 | method: 'loopTask(void*)', 582 | file: 'C:\\Users\\kittaakos\\dev\\esp-exception-decoder\\test-resources\\envs\\cli\\Arduino15\\packages\\esp32\\hardware\\esp32\\3.1.1\\cores\\esp32\\main.cpp', 583 | line: '74', 584 | }, 585 | { 586 | address: '0x40088be9', 587 | method: 'vPortTaskWrapper', 588 | file: '/home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/FreeRTOS-Kernel/portable/xtensa/port.c', 589 | line: '139', 590 | }, 591 | ], 592 | allocLocation: undefined, 593 | }, 594 | true 595 | ); 596 | assert.deepStrictEqual(actual, { 597 | exception: undefined, 598 | registerLocations: { 599 | PC: { 600 | address: '0x400d15af', 601 | method: 'loop()', 602 | file: 'C:\\Users\\kittaakos\\dev\\esp-exception-decoder\\src\\test\\sketches\\AE\\AE.ino', 603 | line: '7', 604 | }, 605 | EXCVADDR: '0x00000000', 606 | }, 607 | stacktraceLines: [ 608 | { 609 | address: '0x400d15ac', 610 | method: 'loop()', 611 | file: 'C:\\Users\\kittaakos\\dev\\esp-exception-decoder\\src\\test\\sketches\\AE\\AE.ino', 612 | line: '6', 613 | }, 614 | { 615 | address: '0x400d2f98', 616 | method: 'loopTask(void*)', 617 | file: 'C:\\Users\\kittaakos\\dev\\esp-exception-decoder\\test-resources\\envs\\cli\\Arduino15\\packages\\esp32\\hardware\\esp32\\3.1.1\\cores\\esp32\\main.cpp', 618 | line: '74', 619 | }, 620 | { 621 | address: '0x40088be9', 622 | method: 'vPortTaskWrapper', 623 | file: '/home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/FreeRTOS-Kernel/portable/xtensa/port.c', 624 | line: '139', 625 | }, 626 | ], 627 | allocLocation: undefined, 628 | }); 629 | }); 630 | }); 631 | }); 632 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | // Enable `debug` coloring in VS Code _Debug Console_ 2 | // https://github.com/debug-js/debug/issues/641#issuecomment-490706752 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | (process as any).browser = true; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | (global as any).window = { process: { type: 'renderer' } }; 7 | 8 | import debug from 'debug'; 9 | import glob from 'glob'; 10 | import Mocha, { MochaOptions } from 'mocha'; 11 | import path from 'node:path'; 12 | import { TestEnv, setupTestEnv } from '../testEnv'; 13 | import { promisify } from 'node:util'; 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-var-requires 16 | const NYC = require('nyc'); 17 | // eslint-disable-next-line @typescript-eslint/no-var-requires 18 | const baseConfig = require('@istanbuljs/nyc-config-typescript'); 19 | // eslint-disable-next-line @typescript-eslint/no-var-requires 20 | const tty = require('node:tty'); 21 | if (!tty.getWindowSize) { 22 | tty.getWindowSize = (): number[] => { 23 | return [80, 75]; 24 | }; 25 | } 26 | 27 | export async function run(): Promise { 28 | // nyc setup 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | let nyc: any | undefined = undefined; 31 | if (runTestCoverage()) { 32 | nyc = new NYC({ 33 | ...baseConfig, 34 | cwd: path.join(__dirname, '..', '..', '..'), 35 | reporter: ['text'], 36 | all: true, 37 | silent: false, 38 | instrument: true, 39 | hookRequire: true, 40 | hookRunInContext: true, 41 | hookRunInThisContext: true, 42 | include: ['out/**/*.js'], 43 | exclude: ['out/test/**'], 44 | }); 45 | 46 | await nyc.reset(); 47 | await nyc.wrap(); 48 | Object.keys(require.cache) 49 | .filter((f) => nyc.exclude.shouldInstrument(f)) 50 | .forEach((m) => { 51 | console.warn('Module loaded before NYC, invalidating:', m); 52 | delete require.cache[m]; 53 | require(m); 54 | }); 55 | } 56 | 57 | // mocha setup 58 | const options: MochaOptions = { 59 | ui: 'bdd', 60 | color: true, 61 | }; 62 | // The debugger cannot be enabled with the `DEBUG` env variable. 63 | // https://github.com/microsoft/vscode/blob/c248f9ec0cf272351175ccf934054b18ffbf18c6/src/vs/base/common/processes.ts#L141 64 | if (typeof process.env['TEST_DEBUG'] === 'string') { 65 | debug.enable(process.env['TEST_DEBUG']); 66 | debug.selectColor(process.env['TEST_DEBUG']); 67 | } 68 | const context = testContext(); 69 | let testEnv: TestEnv | undefined = undefined; 70 | if (context) { 71 | options.timeout = noTestTimeout() ? 0 : 60_000; 72 | // Download Arduino CLI, unzip it and make it available for the tests 73 | // Initializes a CLI config, configures with ESP32 and ESP8266 and installs the platforms. 74 | testEnv = await setupTestEnv(); 75 | } else { 76 | options.timeout = noTestTimeout() ? 0 : 2_000; 77 | } 78 | const mocha = new Mocha(options); 79 | const testsRoot = path.resolve(__dirname, '..'); 80 | if (testEnv) { 81 | mocha.suite.ctx['testEnv'] = testEnv; 82 | } 83 | 84 | let testsPattern: string; 85 | if (context === 'all') { 86 | testsPattern = '**/*test.js'; 87 | } else if (context === 'slow') { 88 | testsPattern = '**/*.slow-test.js'; 89 | } else { 90 | testsPattern = '**/*.test.js'; 91 | } 92 | 93 | const files = await promisify(glob)(testsPattern, { cwd: testsRoot }); 94 | files.forEach((file) => mocha.addFile(path.resolve(testsRoot, file))); 95 | const failures = await new Promise((resolve) => mocha.run(resolve)); 96 | 97 | if (nyc) { 98 | // write coverage 99 | await nyc.writeCoverageFile(); 100 | console.log(await captureStdout(nyc.report.bind(nyc))); 101 | } 102 | 103 | if (failures > 0) { 104 | throw new Error(`${failures} tests failed.`); 105 | } 106 | } 107 | 108 | function testContext(): 'slow' | 'all' | undefined { 109 | if (typeof process.env.CLI_TEST_CONTEXT === 'string') { 110 | const value = process.env.CLI_TEST_CONTEXT; 111 | if (/all/i.test(value)) { 112 | return 'all'; 113 | } 114 | if (/slow/i.test(value)) { 115 | return 'slow'; 116 | } 117 | } 118 | return undefined; 119 | } 120 | 121 | function noTestTimeout(): boolean { 122 | return ( 123 | typeof process.env.NO_TEST_TIMEOUT === 'string' && 124 | /true/i.test(process.env.NO_TEST_TIMEOUT) 125 | ); 126 | } 127 | 128 | function runTestCoverage(): boolean { 129 | return !( 130 | typeof process.env.NO_TEST_COVERAGE === 'string' && 131 | /true/i.test(process.env.NO_TEST_COVERAGE) 132 | ); 133 | } 134 | 135 | async function captureStdout(task: () => Promise): Promise { 136 | const originalWrite = process.stdout.write; 137 | let buffer = ''; 138 | process.stdout.write = (s) => { 139 | buffer = buffer + s; 140 | return true; 141 | }; 142 | try { 143 | await task(); 144 | } finally { 145 | process.stdout.write = originalWrite; 146 | } 147 | return buffer; 148 | } 149 | -------------------------------------------------------------------------------- /src/test/suite/mock.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import type { 3 | ArduinoContext, 4 | ArduinoState, 5 | BoardDetails, 6 | BuildProperties, 7 | CompileSummary, 8 | } from 'vscode-arduino-api'; 9 | 10 | const never = new vscode.EventEmitter().event; 11 | const neverDidChange = () => 12 | never as vscode.Event as vscode.Event; 13 | 14 | export function mockArduinoContext( 15 | state?: Partial, 16 | onDidChange?: ArduinoContext['onDidChange'] 17 | ): ArduinoContext { 18 | const mock = mockArduinoState(state); 19 | return { 20 | ...mock, 21 | onDidChange: onDidChange ?? neverDidChange, 22 | }; 23 | } 24 | 25 | export function mockArduinoState(state?: Partial): ArduinoState { 26 | return { 27 | fqbn: undefined, 28 | boardDetails: undefined, 29 | compileSummary: undefined, 30 | dataDirPath: undefined, 31 | port: undefined, 32 | sketchPath: undefined, 33 | userDirPath: undefined, 34 | ...state, 35 | }; 36 | } 37 | 38 | export function mockBoardDetails( 39 | fqbn: string, 40 | buildProperties: BuildProperties = {} 41 | ): BoardDetails { 42 | return { 43 | fqbn, 44 | buildProperties, 45 | configOptions: [], 46 | programmers: [], 47 | toolsDependencies: [], 48 | }; 49 | } 50 | 51 | export function mockCompileSummary(buildPath: string): CompileSummary { 52 | return { 53 | buildPath, 54 | boardPlatform: undefined, 55 | buildPlatform: undefined, 56 | buildProperties: {}, 57 | executableSectionsSize: [], 58 | usedLibraries: [], 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/test/suite/riscv.test.ts: -------------------------------------------------------------------------------- 1 | import { FQBN } from 'fqbn'; 2 | import assert from 'node:assert/strict'; 3 | import net from 'node:net'; 4 | import { 5 | __tests, 6 | decodeRiscv, 7 | GdbServer, 8 | InvalidTargetError, 9 | } from '../../riscv'; 10 | 11 | const { 12 | createRegNameValidator, 13 | isTarget, 14 | parsePanicOutput, 15 | buildPanicServerArgs, 16 | getStackAddrAndData, 17 | parseGDBOutput, 18 | toHexString, 19 | gdbRegsInfo, 20 | gdbRegsInfoRiscvIlp32, 21 | createDecodeResult, 22 | } = __tests; 23 | 24 | export const esp32c3Input = `Core 0 panic'ed (Load access fault). Exception was unhandled. 25 | 26 | Core 0 register dump: 27 | MEPC : 0x4200007e RA : 0x4200007e SP : 0x3fc98300 GP : 0x3fc8d000 28 | TP : 0x3fc98350 T0 : 0x4005890e T1 : 0x3fc8f000 T2 : 0x00000000 29 | S0/FP : 0x420001ea S1 : 0x3fc8f000 A0 : 0x00000001 A1 : 0x00000001 30 | A2 : 0x3fc8f000 A3 : 0x3fc8f000 A4 : 0x00000000 A5 : 0x600c0028 31 | A6 : 0xfa000000 A7 : 0x00000014 S2 : 0x00000000 S3 : 0x00000000 32 | S4 : 0x00000000 S5 : 0x00000000 S6 : 0x00000000 S7 : 0x00000000 33 | S8 : 0x00000000 S9 : 0x00000000 S10 : 0x00000000 S11 : 0x00000000 34 | T3 : 0x3fc8f000 T4 : 0x00000001 T5 : 0x3fc8f000 T6 : 0x00000001 35 | MSTATUS : 0x00001801 MTVEC : 0x40380001 MCAUSE : 0x00000005 MTVAL : 0x00000000 36 | MHARTID : 0x00000000 37 | 38 | Stack memory: 39 | 3fc98300: 0x00000000 0x00000000 0x00000000 0x42001c4c 0x00000000 0x00000000 0x00000000 0x40385d20 40 | 3fc98320: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 41 | 3fc98340: 0x00000000 0xa5a5a5a5 0xa5a5a5a5 0xa5a5a5a5 0xa5a5a5a5 0xbaad5678 0x00000168 0xabba1234 42 | 3fc98360: 0x0000015c 0x3fc98270 0x000007d7 0x3fc8e308 0x3fc8e308 0x3fc98364 0x3fc8e300 0x00000018 43 | 3fc98380: 0x00000000 0x00000000 0x3fc98364 0x00000000 0x00000001 0x3fc96354 0x706f6f6c 0x6b736154 44 | 3fc983a0: 0x00000000 0x00000000 0x3fc98350 0x00000005 0x00000000 0x00000001 0x00000000 0x00000000 45 | 3fc983c0: 0x00000000 0x00000262 0x00000000 0x3fc8fe64 0x3fc8fecc 0x3fc8ff34 0x00000000 0x00000000 46 | 3fc983e0: 0x00000001 0x00000000 0x00000000 0x00000000 0x4200917a 0x00000000 0x00000000 0x00000000 47 | 3fc98400: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 48 | 3fc98420: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 49 | 3fc98440: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 50 | 3fc98460: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 51 | 3fc98480: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 52 | 3fc984a0: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 53 | 3fc984c0: 0xbaad5678 0x00000068 0xabba1234 0x0000005c 0x00000000 0x3fc984d0 0x00000000 0x00000000 54 | 3fc984e0: 0x00000000 0x3fc984e8 0xffffffff 0x3fc984e8 0x3fc984e8 0x00000000 0x3fc984fc 0xffffffff 55 | 3fc98500: 0x3fc984fc 0x3fc984fc 0x00000001 0x00000001 0x00000000 0x7700ffff 0x00000000 0x036f2206 56 | 3fc98520: 0x51c34501 0x8957fe96 0xdc2f3bf2 0xbaad5678 0x00000088 0xabba1234 0x0000007c 0x00000000 57 | 3fc98540: 0x00000014 0x3fc98d94 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x3fc985c8 58 | 3fc98560: 0x00000000 0x00000101 0x00000000 0x00000000 0x0000000a 0x3fc98cf0 0x00000000 0x00000000 59 | 3fc98580: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x3fc987d8 0x3fc98944 60 | 3fc985a0: 0x00000000 0x3fc98b40 0x3fc98ad4 0x3fc98c84 0x3fc98c18 0x3fc98bac 0xbaad5678 0x0000020c 61 | 3fc985c0: 0xabba1234 0x00000200 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 62 | 3fc985e0: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 63 | 3fc98600: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 64 | 3fc98620: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 65 | 3fc98640: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 66 | 3fc98660: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 67 | 3fc98680: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 68 | 3fc986a0: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 69 | 3fc986c0: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 70 | 3fc986e0: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 71 | `; 72 | 73 | const esp32c3Stdout = `a::geta (this=0x0) at /Users/kittaakos/Documents/Arduino/riscv_1/riscv_1.ino:11 74 | 11 return a; 75 | #0 a::geta (this=0x0) at /Users/kittaakos/Documents/Arduino/riscv_1/riscv_1.ino:11 76 | #1 loop () at /Users/kittaakos/Documents/Arduino/riscv_1/riscv_1.ino:21 77 | #2 0x4c1c0042 in ?? () 78 | Backtrace stopped: frame did not save the PC`; 79 | 80 | describe('riscv', () => { 81 | describe('createRegNameValidator', () => { 82 | it('should validate the register name', () => { 83 | Object.keys(gdbRegsInfo).forEach((target) => { 84 | const validator = createRegNameValidator( 85 | target as keyof typeof gdbRegsInfo 86 | ); 87 | gdbRegsInfoRiscvIlp32.forEach((reg) => { 88 | assert.strictEqual(validator(reg), true); 89 | }); 90 | }); 91 | }); 92 | 93 | it('should fail for invalid target', () => { 94 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 95 | assert.throws(() => createRegNameValidator('foo' as any)); 96 | }); 97 | 98 | it('should detect invalid', () => { 99 | const actual = createRegNameValidator('esp32c3')('foo'); 100 | assert.strictEqual(actual, false); 101 | }); 102 | }); 103 | 104 | describe('createDecodeResult', () => { 105 | it('should create a decode result', () => { 106 | const panicInfo = parsePanicOutput({ 107 | input: esp32c3Input, 108 | target: 'esp32c3', 109 | }); 110 | const result = createDecodeResult(panicInfo, esp32c3Stdout); 111 | assert.deepStrictEqual(result, { 112 | exception: ['Load access fault', 5], 113 | allocLocation: undefined, 114 | registerLocations: { 115 | MEPC: '0x4200007e', 116 | MTVAL: '0x00000000', 117 | }, 118 | stacktraceLines: [ 119 | { 120 | method: 'a::geta', 121 | address: 'this=0x0', 122 | file: '/Users/kittaakos/Documents/Arduino/riscv_1/riscv_1.ino', 123 | line: '11', 124 | args: { 125 | this: '0x0', 126 | }, 127 | }, 128 | { 129 | method: 'loop', 130 | address: '??', 131 | file: '/Users/kittaakos/Documents/Arduino/riscv_1/riscv_1.ino', 132 | line: '21', 133 | args: {}, 134 | }, 135 | { 136 | address: '0x4c1c0042', 137 | line: '??', 138 | }, 139 | ], 140 | }); 141 | }); 142 | }); 143 | 144 | describe('GdbServer', () => { 145 | const panicInfo = parsePanicOutput({ 146 | input: esp32c3Input, 147 | target: 'esp32c3', 148 | }); 149 | const params = { panicInfo } as const; 150 | let server: GdbServer; 151 | let client: net.Socket; 152 | 153 | beforeEach(async () => { 154 | server = new GdbServer(params); 155 | const address = await server.start(); 156 | client = await new Promise((resolve) => { 157 | const socket: net.Socket = net.createConnection( 158 | { port: address.port }, 159 | () => resolve(socket) 160 | ); 161 | }); 162 | }); 163 | 164 | afterEach(() => { 165 | server.close(); 166 | client.destroy(); 167 | }); 168 | 169 | it('should fail when the server is already started', async () => { 170 | await assert.rejects(() => server.start(), /server already started/gi); 171 | }); 172 | 173 | it('should end the connection when message starts with minus', async () => { 174 | const message = '-'; 175 | client.write(message); 176 | let received = ''; 177 | await new Promise((resolve) => { 178 | client.on('end', resolve); 179 | client.on('data', (data) => { 180 | received += data.toString(); 181 | }); 182 | }); 183 | assert.strictEqual(received, '-'); 184 | }); 185 | 186 | [ 187 | ['+$?#3f', '+$T05#b9'], 188 | [ 189 | '+$qSupported:multiprocess+;swbreak+;hwbreak+;qRelocInsn+;fork-events+;vfork-events+;exec-events+;vContSupported+;QThreadEvents+;no-resumed+;memory-tagging+#ec', // unhandled 190 | '+$#00', 191 | ], 192 | ['+$Hg0#df', '+$OK#9a'], 193 | ['+$Hc0#df', '+$OK#9a'], 194 | ['+$qfThreadInfo#bb', '+$m1#9e'], 195 | ['+$qC#b4', '+$QC1#c5'], 196 | [ 197 | '+$g#67', 198 | '+$000000007e0000420083c93f00d0c83f5083c93f0e89054000f0c83f00000000ea01004200f0c83f010000000100000000f0c83f00f0c83f0000000028000c60000000fa140000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f0c83f0100000000f0c83f010000007e000042#1c', 199 | ], 200 | [ 201 | '+$m3fc98300,40#fd', 202 | '+$00000000000000000000000042001c4c00000000000000000000000040385d200000000000000000000000000000000000000000000000000000000000000000#bb', 203 | ], 204 | ['+$k#33', '+$OK#9a'], 205 | ['+$vKill;a410#33', '+$OK#9a'], 206 | ].map(([message, expected]) => 207 | it(`should respond with ${expected} to ${message}`, async () => { 208 | client.write(message); 209 | client.end(); 210 | let received = ''; 211 | await new Promise((resolve) => { 212 | client.on('end', resolve); 213 | client.on('data', (data) => { 214 | received += data.toString(); 215 | }); 216 | }); 217 | assert.strictEqual(received, expected); 218 | }) 219 | ); 220 | 221 | describe('abort signal', () => { 222 | let otherServer: GdbServer | undefined; 223 | let abortController: AbortController; 224 | let signal: AbortSignal; 225 | 226 | beforeEach(() => { 227 | abortController = new AbortController(); 228 | signal = abortController.signal; 229 | }); 230 | 231 | afterEach(() => { 232 | otherServer?.close(); 233 | }); 234 | 235 | it('after start', async () => { 236 | otherServer = new GdbServer(params); 237 | const startPromise = otherServer.start({ signal }); 238 | abortController.abort(); 239 | await assert.rejects(startPromise, /AbortError/); 240 | }); 241 | 242 | it('before start', async () => { 243 | otherServer = new GdbServer(params); 244 | abortController.abort(); 245 | const startPromise = otherServer.start({ signal }); 246 | await assert.rejects(startPromise, /AbortError/); 247 | }); 248 | }); 249 | }); 250 | 251 | describe('isTarget', () => { 252 | it('should be a valid target', () => { 253 | Object.keys(gdbRegsInfo).forEach((target) => { 254 | assert.strictEqual(isTarget(target), true); 255 | }); 256 | }); 257 | 258 | it('should not be a valid target', () => { 259 | ['riscv32', 'trash'].forEach((target) => { 260 | assert.strictEqual(isTarget(target), false); 261 | }); 262 | }); 263 | }); 264 | 265 | describe('decodeRiscv', () => { 266 | it('should error on invalid target', async () => { 267 | await assert.rejects( 268 | () => 269 | decodeRiscv( 270 | { 271 | elfPath: '', 272 | sketchPath: '', 273 | toolPath: '', 274 | fqbn: new FQBN('a:b:c'), 275 | }, 276 | '' 277 | ), 278 | (reason) => reason instanceof InvalidTargetError 279 | ); 280 | }); 281 | }); 282 | 283 | describe('parsePanicOutput', () => { 284 | it('multi-code is not yet supported', () => { 285 | assert.throws(() => 286 | parsePanicOutput({ 287 | input: `Core 0 register dump: 288 | MEPC : 0x42000074 RA : 0x42000072 SP : 0x3fc94f70 GP : 0x3fc8c000 289 | 290 | Stack memory: 291 | 3fc94f70: 0x00000000 0x00000000 0x00000000 0x4200360a 0x00000000 0x00000000 0x00000000 0x403872d8 292 | 293 | Core 1 register dump: 294 | MEPC : 0x42000074 RA : 0x42000072 SP : 0x3fc94f70 GP : 0x3fc8c000 295 | 296 | Stack memory: 297 | 3fc94f70: 0x00000000 0x00000000 0x00000000 0x4200360a 0x00000000 0x00000000 0x00000000 0x403872d8`, 298 | target: 'esp32c3', 299 | }) 300 | ); 301 | }); 302 | 303 | it('should handle incomplete panic info (for example, __attribute__((noinline)))', () => { 304 | assert.throws( 305 | () => 306 | parsePanicOutput({ 307 | input: `MSTATUS : 0x00001881 MTVEC : 0x40800001 MCAUSE : 0x00000007 MTVAL : 0x00000000 308 | MHARTID : 0x00000000 309 | 310 | Stack memory: 311 | 40816ac0: 0x00000000 0x00000000 0xa0000000 0x420000cc 0x00000000 0x20001090 0x00000000 0x42000088`, 312 | target: 'esp32h2', 313 | }), 314 | /no register dumps found/gi 315 | ); 316 | }); 317 | 318 | it('should parse the panic output', () => { 319 | const result = parsePanicOutput({ 320 | input: esp32c3Input, 321 | target: 'esp32c3', 322 | }); 323 | assert.strictEqual(result.coreId, 0); 324 | assert.deepStrictEqual(result.regs, { 325 | MEPC: 0x4200007e, 326 | RA: 0x4200007e, 327 | SP: 0x3fc98300, 328 | GP: 0x3fc8d000, 329 | TP: 0x3fc98350, 330 | T0: 0x4005890e, 331 | T1: 0x3fc8f000, 332 | 'S0/FP': 0x420001ea, 333 | S1: 0x3fc8f000, 334 | A0: 0x00000001, 335 | A1: 0x00000001, 336 | A2: 0x3fc8f000, 337 | A3: 0x3fc8f000, 338 | A5: 0x600c0028, 339 | A6: 0xfa000000, 340 | A7: 0x00000014, 341 | T3: 0x3fc8f000, 342 | T4: 0x00000001, 343 | T5: 0x3fc8f000, 344 | T6: 0x00000001, 345 | }); 346 | assert.strictEqual(result.stackBaseAddr, 0x3fc98300); 347 | }); 348 | }); 349 | 350 | describe('buildPanicServerArgs', () => { 351 | it('should build the panic server args', () => { 352 | assert.deepStrictEqual(buildPanicServerArgs('path/to/elf', 36), [ 353 | '--batch', 354 | '-n', 355 | 'path/to/elf', 356 | '-ex', 357 | 'target remote :36', 358 | '-ex', 359 | 'bt', 360 | ]); 361 | }); 362 | }); 363 | 364 | describe('getStackAddrAndData', () => { 365 | assert.throws( 366 | () => 367 | getStackAddrAndData({ 368 | stackDump: [ 369 | { baseAddr: 0x1000, data: [] }, 370 | { baseAddr: 0x3000, data: [1, 2] }, 371 | ], 372 | }), 373 | /invalid base address/gi 374 | ); 375 | }); 376 | 377 | describe('parseGDBOutput', () => { 378 | it('should parse the GDB output', () => { 379 | const lines = parseGDBOutput(esp32c3Stdout); 380 | assert.deepStrictEqual(lines, [ 381 | { 382 | method: 'a::geta', 383 | address: 'this=0x0', 384 | file: '/Users/kittaakos/Documents/Arduino/riscv_1/riscv_1.ino', 385 | line: '11', 386 | args: { 387 | this: '0x0', 388 | }, 389 | }, 390 | { 391 | method: 'loop', 392 | address: '??', 393 | file: '/Users/kittaakos/Documents/Arduino/riscv_1/riscv_1.ino', 394 | line: '21', 395 | args: {}, 396 | }, 397 | { 398 | address: '0x4c1c0042', 399 | line: '??', 400 | }, 401 | ]); 402 | }); 403 | }); 404 | 405 | describe('toHexString', () => { 406 | it('should convert to hex string', () => { 407 | assert.strictEqual(toHexString(0x12345678), '0x12345678'); 408 | }); 409 | it('should pad 0', () => { 410 | assert.strictEqual(toHexString(0), '0x00000000'); 411 | }); 412 | }); 413 | }); 414 | -------------------------------------------------------------------------------- /src/test/suite/terminal.test.ts: -------------------------------------------------------------------------------- 1 | import { FQBN } from 'fqbn'; 2 | import assert from 'node:assert/strict'; 3 | import path from 'node:path'; 4 | import vscode from 'vscode'; 5 | import { DecodeParamsError, ParsedGDBLine } from '../../decoder'; 6 | import { __tests } from '../../terminal'; 7 | import { mockArduinoContext } from './mock'; 8 | 9 | const { 10 | openTerminal, 11 | decodeTerminalTitle, 12 | stringifyLines, 13 | stringifyTerminalState, 14 | DecoderTerminal, 15 | red, 16 | green, 17 | blue, 18 | bold, 19 | } = __tests; 20 | 21 | describe('terminal', () => { 22 | const arduinoContext = mockArduinoContext(); 23 | 24 | describe('openTerminal', () => { 25 | it('should open a terminal', async () => { 26 | const beforeOpenTerminals = vscode.window.terminals; 27 | const terminal = openTerminal(arduinoContext); 28 | assert.strictEqual( 29 | vscode.window.terminals.length, 30 | beforeOpenTerminals.length + 1 31 | ); 32 | assert.strictEqual(vscode.window.terminals.includes(terminal), true); 33 | assert.strictEqual(beforeOpenTerminals.includes(terminal), false); 34 | }); 35 | 36 | it('should not open a new terminal if already opened', async () => { 37 | const arduinoContext = mockArduinoContext(); 38 | const first = openTerminal(arduinoContext); 39 | const beforeSecondOpenTerminals = vscode.window.terminals; 40 | const second = openTerminal(arduinoContext); 41 | const afterSecondOpenTerminals = vscode.window.terminals; 42 | assert.strictEqual(first, second); 43 | assert.strictEqual( 44 | beforeSecondOpenTerminals.length, 45 | afterSecondOpenTerminals.length 46 | ); 47 | }); 48 | }); 49 | 50 | describe('DecoderTerminal', () => { 51 | const toDisposeBeforeEach: vscode.Disposable[] = []; 52 | 53 | beforeEach(() => { 54 | vscode.Disposable.from(...toDisposeBeforeEach).dispose(); 55 | toDisposeBeforeEach.length = 0; 56 | }); 57 | 58 | it('should discard the user input and decoder result on params error', () => { 59 | const fqbn = new FQBN('esp8266:esp8266:generic'); 60 | const terminal = new DecoderTerminal(arduinoContext); 61 | toDisposeBeforeEach.push(new vscode.Disposable(() => terminal.close())); 62 | terminal['state'] = { 63 | params: { 64 | sketchPath: '/path/to/sketch', 65 | fqbn, 66 | elfPath: '/path/to/elf', 67 | toolPath: '/path/to/tool', 68 | }, 69 | userInput: 'some user input', 70 | decoderResult: { 71 | registerLocations: {}, 72 | exception: undefined, 73 | allocLocation: undefined, 74 | stacktraceLines: [{ address: '0x00002710', line: 'bla bla' }], 75 | }, 76 | }; 77 | terminal['updateState']({ params: new Error('boom') }); 78 | assert.strictEqual(terminal['state'].userInput, undefined); 79 | assert.strictEqual(terminal['state'].decoderResult, undefined); 80 | }); 81 | 82 | it('should discard the decoder result before decoding', async function () { 83 | this.slow(200); 84 | const fqbn = new FQBN('esp8266:esp8266:generic'); 85 | const terminal = new DecoderTerminal(arduinoContext); 86 | toDisposeBeforeEach.push(new vscode.Disposable(() => terminal.close())); 87 | terminal['state'] = { 88 | params: { 89 | sketchPath: '/path/to/sketch', 90 | fqbn, 91 | elfPath: '/path/to/elf', 92 | toolPath: '/path/to/tool', 93 | }, 94 | userInput: 'some user input', 95 | decoderResult: { 96 | registerLocations: {}, 97 | exception: undefined, 98 | allocLocation: undefined, 99 | stacktraceLines: [{ address: '0x00002710', line: 'bla bla' }], 100 | }, 101 | statusMessage: 'idle', 102 | }; 103 | assert.notStrictEqual(terminal['state'].decoderResult, undefined); 104 | terminal.handleInput('some invalid thing it does not matter'); 105 | assert.strictEqual(terminal['state'].decoderResult, undefined); 106 | assert.notStrictEqual(terminal['state'].statusMessage, 'idle'); 107 | assert.notStrictEqual(terminal['state'].statusMessage, undefined); 108 | assert.strictEqual( 109 | (<{ statusMessage: string }>terminal['state']).statusMessage.length > 0, 110 | true 111 | ); 112 | await new Promise((resolve) => setTimeout(resolve, 100)); // TODO: listen on state did change event 113 | assert.strictEqual( 114 | terminal['state'].decoderResult instanceof Error, 115 | true 116 | ); 117 | assert.strictEqual( 118 | ((terminal['state'].decoderResult)).message, 119 | 'Could not recognize stack trace/backtrace' 120 | ); 121 | assert.strictEqual( 122 | terminal['state'].userInput, 123 | 'some invalid thing it does not matter' 124 | ); 125 | }); 126 | 127 | it("should gracefully handle all kind of line endings (including the bogus '\\r')", async function () { 128 | this.slow(200); 129 | const fqbn = new FQBN('esp8266:esp8266:generic'); 130 | const terminal = new DecoderTerminal(arduinoContext); 131 | toDisposeBeforeEach.push(new vscode.Disposable(() => terminal.close())); 132 | terminal['state'] = { 133 | params: { 134 | sketchPath: '/path/to/sketch', 135 | fqbn, 136 | elfPath: '/path/to/elf', 137 | toolPath: '/path/to/tool', 138 | }, 139 | }; 140 | terminal.handleInput('line1\rline2\r\nline3\rline4\nline5'); 141 | await new Promise((resolve) => setTimeout(resolve, 100)); // TODO: listen on state did change event 142 | assert.strictEqual( 143 | terminal['state'].decoderResult instanceof Error, 144 | true 145 | ); 146 | assert.strictEqual( 147 | (terminal['state'].decoderResult).message, 148 | 'Could not recognize stack trace/backtrace' 149 | ); 150 | assert.strictEqual( 151 | terminal['state'].userInput, 152 | 'line1\r\nline2\r\nline3\r\nline4\r\nline5' 153 | ); 154 | }); 155 | }); 156 | 157 | describe('stringifyLines', () => { 158 | it('should build terminal output from lines', () => { 159 | assert.strictEqual(stringifyLines([]), ''); 160 | assert.strictEqual(stringifyLines(['a', 'b']), 'a\r\nb'); 161 | assert.strictEqual(stringifyLines(['a', '', 'c']), 'a\r\n\r\nc'); 162 | }); 163 | }); 164 | 165 | describe('stringifyTerminalState', () => { 166 | it('should show the title when the state is empty', () => { 167 | const actual = stringifyTerminalState({ 168 | params: new Error('alma'), 169 | }); 170 | const expected = stringifyLines([decodeTerminalTitle, `${red('alma')}`]); 171 | assert.strictEqual(actual, expected); 172 | }); 173 | 174 | it('should show FQBN and sketch name when the error contains context traces', () => { 175 | const fqbn = 'a:b:c'; 176 | const sketchPath = 'my_sketch'; 177 | const actual = stringifyTerminalState({ 178 | params: new DecodeParamsError('the error message', { 179 | fqbn: new FQBN(fqbn), 180 | sketchPath, 181 | }), 182 | statusMessage: 'this should be ignored', 183 | }); 184 | const expected = stringifyLines([ 185 | decodeTerminalTitle, 186 | `Sketch: ${green(sketchPath)} FQBN: ${green(fqbn)}`, 187 | '', 188 | red('the error message'), 189 | '', 190 | ]); 191 | assert.strictEqual(actual, expected); 192 | }); 193 | 194 | it('should show the idle prompt', () => { 195 | const fqbn = 'a:b:c'; 196 | const sketchPath = 'my_sketch'; 197 | const statusMessage = 'this is the status message'; 198 | const actual = stringifyTerminalState({ 199 | params: { 200 | fqbn: new FQBN(fqbn), 201 | sketchPath, 202 | toolPath: 'this does not matter', 203 | elfPath: 'irrelevant', 204 | }, 205 | statusMessage, 206 | }); 207 | const expected = stringifyLines([ 208 | decodeTerminalTitle, 209 | `Sketch: ${green(sketchPath)} FQBN: ${green(fqbn)}`, 210 | '', 211 | statusMessage, 212 | '', 213 | ]); 214 | assert.strictEqual(actual, expected); 215 | }); 216 | 217 | it('should show the users input', () => { 218 | const fqbn = 'a:b:c'; 219 | const sketchPath = 'my_sketch'; 220 | const statusMessage = 'decoding'; 221 | const actual = stringifyTerminalState({ 222 | params: { 223 | fqbn: new FQBN(fqbn), 224 | sketchPath, 225 | toolPath: 'this does not matter', 226 | elfPath: 'irrelevant', 227 | }, 228 | userInput: 'alma\nkorte\nszilva', 229 | statusMessage, 230 | }); 231 | const expected = stringifyLines([ 232 | decodeTerminalTitle, 233 | `Sketch: ${green(sketchPath)} FQBN: ${green(fqbn)}`, 234 | '', 235 | 'alma', 236 | 'korte', 237 | 'szilva', 238 | '', 239 | statusMessage, 240 | '', 241 | ]); 242 | assert.strictEqual(actual, expected); 243 | }); 244 | 245 | it('should handle a decode error as result', () => { 246 | const fqbn = 'a:b:c'; 247 | const sketchPath = 'my_sketch'; 248 | const statusMessage = 'paste to decode'; 249 | const actual = stringifyTerminalState({ 250 | params: { 251 | fqbn: new FQBN(fqbn), 252 | sketchPath, 253 | toolPath: 'this does not matter', 254 | elfPath: 'irrelevant', 255 | }, 256 | userInput: 'alma\nkorte\nszilva', 257 | statusMessage, 258 | decoderResult: new Error('boom!'), 259 | }); 260 | const expected = stringifyLines([ 261 | decodeTerminalTitle, 262 | `Sketch: ${green(sketchPath)} FQBN: ${green(fqbn)}`, 263 | '', 264 | 'alma', 265 | 'korte', 266 | 'szilva', 267 | '', 268 | red('boom!'), 269 | '', 270 | statusMessage, 271 | '', 272 | ]); 273 | assert.strictEqual(actual, expected); 274 | }); 275 | it('should show decode output', () => { 276 | const fqbn = 'a:b:c'; 277 | const sketchPath = 'my_sketch'; 278 | const statusMessage = 'paste to decode'; 279 | const libPath = path.join(__dirname, 'path/to/lib.cpp'); 280 | const headerPath = path.join(__dirname, 'path/to/header.h'); 281 | const mainSketchFilePath = path.join( 282 | __dirname, 283 | 'path/to/main_sketch.ino' 284 | ); 285 | const actual = stringifyTerminalState({ 286 | params: { 287 | fqbn: new FQBN(fqbn), 288 | sketchPath, 289 | toolPath: 'this does not matter', 290 | elfPath: 'irrelevant', 291 | }, 292 | userInput: 'alma\nkorte\nszilva', 293 | statusMessage, 294 | decoderResult: { 295 | allocLocation: [ 296 | { 297 | address: '0x400d200d', 298 | line: '12', 299 | file: libPath, 300 | method: 'myMethod()', 301 | }, 302 | 100, 303 | ], 304 | exception: ['error message', 1], 305 | stacktraceLines: [ 306 | { 307 | address: '0x400d100d', 308 | line: 'stacktrace line', 309 | }, 310 | { 311 | address: '0x400d400d', 312 | line: '123', 313 | file: mainSketchFilePath, 314 | method: 'otherMethod()', 315 | }, 316 | ], 317 | registerLocations: { 318 | BAR: { 319 | address: '0x400d129d', 320 | line: '36', 321 | file: headerPath, 322 | method: 'loop()', 323 | }, 324 | FOO: '0x00000000', 325 | }, 326 | }, 327 | }); 328 | const location = (file: string) => 329 | `${path.dirname(file)}${path.sep}${bold(path.basename(file))}`; 330 | const expected = stringifyLines([ 331 | decodeTerminalTitle, 332 | `Sketch: ${green(sketchPath)} FQBN: ${green(fqbn)}`, 333 | '', 334 | 'alma', 335 | 'korte', 336 | 'szilva', 337 | '', 338 | red('Exception 1: error message'), 339 | `${red('BAR')}: ${green('0x400d129d')}: ${blue( 340 | 'loop()', 341 | true 342 | )} at ${location(headerPath)}:${bold('36')}`, 343 | `${red('FOO')}: ${green('0x00000000')}`, 344 | '', 345 | 'Decoding stack results', 346 | `${green('0x400d100d')}: stacktrace line`, 347 | `${green('0x400d400d')}: ${blue('otherMethod()', true)} at ${location( 348 | mainSketchFilePath 349 | )}:${bold('123')}`, 350 | '', 351 | `${red('Memory allocation of 100 bytes failed at')} ${green( 352 | '0x400d200d' 353 | )}: ${blue('myMethod()', true)} at ${location(libPath)}:${bold('12')}`, 354 | '', 355 | statusMessage, 356 | '', 357 | ]); 358 | assert.strictEqual(actual, expected); 359 | }); 360 | }); 361 | }); 362 | -------------------------------------------------------------------------------- /src/test/testEnv.ts: -------------------------------------------------------------------------------- 1 | // TODO: this code must go to the future vscode-arduino-api-dev package 2 | // TODO: vscode-arduino-api-dev should contribute commands to mock the 3 | // Arduino context so that developers can implement their tools in 4 | // VS Code without bundling every change to a VSIX and trying it out in Arduino IDE 5 | 6 | import debug from 'debug'; 7 | import { getTool } from 'get-arduino-tools'; 8 | import assert from 'node:assert/strict'; 9 | import { constants, promises as fs } from 'node:fs'; 10 | import path from 'node:path'; 11 | import { rimraf } from 'rimraf'; 12 | import { SemVer, gte } from 'semver'; 13 | import { access, isWindows, run } from '../utils'; 14 | import { cliVersion } from './cliContext.json'; 15 | import cliEnv from './envs.cli.json'; 16 | import gitEnv from './envs.git.json'; 17 | 18 | const testEnvDebug = debug('espExceptionDecoder:testEnv'); 19 | 20 | const rootPath = path.resolve(__dirname, '../../test-resources/'); 21 | const storagePath = path.resolve(rootPath, 'cli-releases'); 22 | 23 | const getUserDirPath = (type: ToolsInstallType) => 24 | path.resolve(rootPath, 'envs', type, 'Arduino'); 25 | const getDataDirPath = (type: ToolsInstallType) => 26 | path.resolve(rootPath, 'envs', type, 'Arduino15'); 27 | const getCliConfigPath = (type: ToolsInstallType) => 28 | path.resolve(rootPath, 'envs', type, 'arduino-cli.yaml'); 29 | 30 | export interface CliContext { 31 | readonly cliPath: string; 32 | readonly cliVersion: string; 33 | } 34 | 35 | /* TODO: add 'profile'? */ 36 | export type ToolsInstallType = 37 | /** 38 | * Installed via the CLI using the _Boards Manager_. 39 | */ 40 | | 'cli' 41 | /** 42 | * Installing the tools from Git. [Here](https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html#windows-manual-installation) is an example. 43 | */ 44 | | 'git'; 45 | 46 | export interface ToolsEnv { 47 | readonly cliConfigPath: string; 48 | readonly dataDirPath: string; 49 | readonly userDirPath: string; 50 | } 51 | 52 | export interface TestEnv { 53 | readonly cliContext: CliContext; 54 | readonly toolsEnvs: Readonly>; 55 | } 56 | 57 | async function installToolsViaGit( 58 | _: CliContext, 59 | toolsEnv: ToolsEnv 60 | ): Promise { 61 | const { userDirPath } = toolsEnv; 62 | const { gitUrl, branchOrTagName, folderName } = gitEnv; 63 | const checkoutPath = path.join(userDirPath, 'hardware', folderName); 64 | await fs.mkdir(checkoutPath, { recursive: true }); 65 | const toolsPath = path.join(checkoutPath, 'esp32/tools'); 66 | const getPy = path.join(toolsPath, 'get.py'); 67 | if ( 68 | !(await access(getPy, { 69 | mode: constants.F_OK | constants.X_OK, 70 | debug: testEnvDebug, 71 | })) 72 | ) { 73 | let tempToolsPath: string | undefined; 74 | try { 75 | // `--branch` can be a branch name or a tag 76 | await run( 77 | 'git', 78 | ['clone', gitUrl, '--depth', '1', '--branch', branchOrTagName, 'esp32'], 79 | { 80 | silent: false, 81 | cwd: checkoutPath, 82 | } 83 | ); 84 | // Instead of running the core installation python script in the esp32/tools `cwd`, 85 | // this code extracts the tools into a "temp" folder inside the `./test-resources` folder, 86 | // then moves the tools to esp32/tools. Extracting the files to temp might not work, because 87 | // the tests can run on D:\ and the temp folder is on C:\ and moving the files will result in EXDEV error. 88 | // Running both `python get.py` and `get.exe` have failed on Windows from Node.js. it was fine from CMD.EXE. 89 | tempToolsPath = await fs.mkdtemp(path.join(rootPath, 'esp32-temp-tool')); 90 | if (isWindows) { 91 | //https://github.com/espressif/arduino-esp32/blob/72c41d09538663ebef80d29eb986cd5bc3395c2d/tools/get.py#L35-L36 92 | await run('pip', ['install', 'requests', '-q']); 93 | } 94 | try { 95 | await run('python', [getPy], { silent: false, cwd: tempToolsPath }); 96 | } catch (err) { 97 | if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { 98 | // python has been renamed to python3 on some systems 99 | await run('python3', [getPy], { silent: false, cwd: tempToolsPath }); 100 | } else { 101 | throw err; 102 | } 103 | } 104 | const tools = await fs.readdir(tempToolsPath); 105 | for (const tool of tools) { 106 | await fs.rename( 107 | path.join(tempToolsPath, tool), 108 | path.join(toolsPath, tool) 109 | ); 110 | } 111 | } catch (err) { 112 | await rimraf(checkoutPath, { maxRetries: 5 }); // Cleanup local git clone 113 | throw err; 114 | } finally { 115 | if (tempToolsPath) { 116 | await rimraf(tempToolsPath, { maxRetries: 5 }); 117 | } 118 | } 119 | } 120 | return toolsEnv; 121 | } 122 | 123 | async function installToolsViaCLI( 124 | cliContext: CliContext, 125 | toolsEnv: ToolsEnv 126 | ): Promise { 127 | const { cliPath } = cliContext; 128 | const { cliConfigPath } = toolsEnv; 129 | const additionalUrls = cliEnv.map(({ url }) => url); 130 | await ensureConfigSet( 131 | cliPath, 132 | cliConfigPath, 133 | 'board_manager.additional_urls', 134 | ...additionalUrls 135 | ); 136 | for (const requirePlatform of cliEnv) { 137 | const { vendor, arch, version } = requirePlatform; 138 | await ensurePlatformExists(cliPath, cliConfigPath, [vendor, arch], version); 139 | } 140 | await Promise.all( 141 | cliEnv.map(({ vendor, arch }) => 142 | assertPlatformExists([vendor, arch], cliContext, toolsEnv) 143 | ) 144 | ); 145 | return toolsEnv; 146 | } 147 | 148 | async function setupToolsEnv( 149 | cliContext: CliContext, 150 | type: ToolsInstallType, 151 | postSetup: ( 152 | cliContext: CliContext, 153 | toolsEnv: ToolsEnv 154 | ) => Promise = (_, toolsEnv) => Promise.resolve(toolsEnv) 155 | ): Promise { 156 | const { cliPath } = cliContext; 157 | const cliConfigPath = getCliConfigPath(type); 158 | const dataDirPath = getDataDirPath(type); 159 | const userDirPath = getUserDirPath(type); 160 | const toolsEnv = { 161 | cliConfigPath, 162 | dataDirPath, 163 | userDirPath, 164 | }; 165 | await Promise.all([ 166 | ensureCliConfigExists(cliPath, toolsEnv), 167 | fs.mkdir(userDirPath, { recursive: true }), 168 | fs.mkdir(dataDirPath, { recursive: true }), 169 | ]); 170 | await ensureConfigSet( 171 | cliPath, 172 | cliConfigPath, 173 | 'directories.data', 174 | dataDirPath 175 | ); 176 | await ensureConfigSet( 177 | cliPath, 178 | cliConfigPath, 179 | 'directories.user', 180 | userDirPath 181 | ); 182 | await ensureIndexUpdated(cliPath, cliConfigPath); 183 | await postSetup(cliContext, toolsEnv); 184 | return toolsEnv; 185 | } 186 | 187 | async function assertCli(cliContext: CliContext): Promise { 188 | const { cliPath, cliVersion } = cliContext; 189 | assert.ok(cliPath); 190 | assert.ok(cliPath.length); 191 | const stdout = await run(cliPath, ['version', '--format', 'json']); 192 | assert.ok(stdout); 193 | assert.ok(stdout.length); 194 | const actualVersion = JSON.parse(stdout).VersionString; 195 | let expectedVersion = cliVersion; 196 | // Drop the `v` prefix from the CLI GitHub release name. 197 | // https://github.com/arduino/arduino-cli/pull/2374 198 | if (gte(expectedVersion, '0.35.0-rc.1')) { 199 | expectedVersion = new SemVer(expectedVersion).version; 200 | } 201 | assert.strictEqual(actualVersion, expectedVersion); 202 | return cliPath; 203 | } 204 | 205 | async function assertPlatformExists( 206 | [vendor, arch]: [string, string], 207 | cliContext: CliContext, 208 | toolsEnv: ToolsEnv 209 | ): Promise { 210 | const id = `${vendor}:${arch}`; 211 | const { cliPath } = cliContext; 212 | const { cliConfigPath } = toolsEnv; 213 | const stdout = await run(cliPath, [ 214 | 'core', 215 | 'list', 216 | '--config-file', 217 | cliConfigPath, 218 | '--format', 219 | 'json', 220 | ]); 221 | assert.ok(stdout); 222 | assert.ok(stdout.length); 223 | const { platforms } = JSON.parse(stdout); 224 | assert.ok(Array.isArray(platforms)); 225 | const platform = (>>platforms).find( 226 | (p) => p.id === id 227 | ); 228 | assert.ok(platform, `Could not find installed platform: '${id}'`); 229 | } 230 | 231 | export async function setupTestEnv(): Promise { 232 | const cliPath = await ensureCliExists(cliVersion); 233 | if (!cliPath) { 234 | throw new Error(`Could not find the Arduino CLI executable.`); 235 | } 236 | const cliContext = { 237 | cliPath, 238 | cliVersion, 239 | }; 240 | await assertCli(cliContext); 241 | 242 | const [cliToolsEnv, gitToolsEnv] = await Promise.all([ 243 | setupToolsEnv(cliContext, 'cli', installToolsViaCLI), 244 | setupToolsEnv(cliContext, 'git', installToolsViaGit), 245 | ]); 246 | return { 247 | cliContext, 248 | toolsEnvs: { 249 | cli: cliToolsEnv, 250 | git: gitToolsEnv, 251 | }, 252 | }; 253 | } 254 | 255 | async function ensureCliExists( 256 | version: string = cliVersion 257 | ): Promise { 258 | await fs.mkdir(storagePath, { recursive: true }); 259 | const tool = 'arduino-cli'; 260 | try { 261 | const { toolPath } = await getTool({ 262 | tool, 263 | version, 264 | destinationFolderPath: storagePath, 265 | }); 266 | return toolPath; 267 | } catch (err) { 268 | if (err instanceof Error && 'code' in err && err.code === 'EEXIST') { 269 | // this is expected when the CLI is already downloaded. A specific error would be great though 270 | return path.join(storagePath, `${tool}${isWindows ? '.exe' : ''}`); 271 | } else { 272 | throw err; 273 | } 274 | } 275 | } 276 | 277 | async function ensureIndexUpdated( 278 | cliPath: string, 279 | cliConfigPath: string 280 | ): Promise { 281 | await runCli(cliPath, ['core', 'update-index'], cliConfigPath); 282 | } 283 | 284 | async function ensurePlatformExists( 285 | cliPath: string, 286 | cliConfigPath: string, 287 | [vendor, arch]: [string, string], 288 | version?: string 289 | ): Promise { 290 | await ensureIndexUpdated(cliPath, cliConfigPath); 291 | await runCli( 292 | cliPath, 293 | [ 294 | 'core', 295 | 'install', 296 | `${vendor}:${arch}${version ? `@${version}` : ''}`, 297 | '--skip-post-install', 298 | ], 299 | cliConfigPath 300 | ); 301 | } 302 | 303 | async function ensureCliConfigExists( 304 | cliPath: string, 305 | toolsEnv: ToolsEnv 306 | ): Promise { 307 | const { cliConfigPath } = toolsEnv; 308 | if (!(await access(cliConfigPath))) { 309 | await runCli(cliPath, ['config', 'init', '--dest-file', cliConfigPath]); 310 | } 311 | } 312 | 313 | async function ensureConfigSet( 314 | cliPath: string, 315 | cliConfigPath: string, 316 | configKey: string, 317 | ...configValue: string[] 318 | ): Promise { 319 | await runCli( 320 | cliPath, 321 | ['config', 'set', configKey, ...configValue], 322 | cliConfigPath 323 | ); 324 | } 325 | 326 | async function runCli( 327 | cliPath: string, 328 | args: string[], 329 | cliConfigPath: string | undefined = undefined 330 | ): Promise { 331 | if (cliConfigPath) { 332 | args.push('--config-file', cliConfigPath); 333 | } 334 | return run(cliPath, args /*, { silent: false }*/); 335 | } 336 | -------------------------------------------------------------------------------- /src/test/tools/fake-tool: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for i in {1..300} 4 | do 5 | sleep 1 6 | echo "decoding... $i" 7 | done 8 | -------------------------------------------------------------------------------- /src/test/tools/fake-tool.bat: -------------------------------------------------------------------------------- 1 | :: Inspired by https://github.com/eclipse-theia/theia/blob/828b899cdc430aeeddf7765afaae0c7591aa16fc/packages/task/test-resources/task-long-running.bat 2 | :: https://stackoverflow.com/questions/735285/how-to-wait-in-a-batch-script 3 | @echo off 4 | for /l %%x in (1,1,300) do ( 5 | echo decoding... %%x 6 | ping 192.0.2.2 -n 1 -w 1000> nul 7 | ) 8 | echo "done" 9 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import { promises as fs } from 'node:fs'; 3 | import { platform } from 'node:os'; 4 | // @ts-expect-error see https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1319854183 5 | import type { Options } from 'execa'; 6 | 7 | const utilsDebug: Debug = debug('espExceptionDecoder:utils'); 8 | 9 | export interface Debug { 10 | (message: string): void; 11 | } 12 | 13 | export const isWindows = platform() === 'win32'; 14 | export const neverSignal = new AbortController().signal; 15 | 16 | export async function run( 17 | execPath: string, 18 | args: string[], 19 | options: Options & { 20 | silent?: boolean; 21 | silentError?: boolean; 22 | debug?: Debug; 23 | } = { 24 | silent: true, 25 | silentError: false, 26 | debug: utilsDebug, 27 | } 28 | ): Promise { 29 | const { execa } = await import(/* webpackMode: "eager" */ 'execa'); 30 | debug(`run: ${execPath} args: ${JSON.stringify(args)}`); 31 | const cp = execa(execPath, args, options); 32 | if (!options.silent) { 33 | cp.pipeStdout?.(process.stdout); 34 | } 35 | if (!options.silentError) { 36 | cp.pipeStderr?.(process.stderr); 37 | } 38 | try { 39 | const { stdout } = await cp; 40 | return stdout; 41 | } catch (err) { 42 | if ( 43 | err instanceof Error && 44 | 'code' in err && 45 | err.code === 'ABORT_ERR' && 46 | 'isCanceled' in err && 47 | err.isCanceled === true 48 | ) { 49 | // ignore 50 | } else { 51 | console.error( 52 | `Failed to execute: ${execPath} ${JSON.stringify(args)}`, 53 | err 54 | ); 55 | } 56 | throw err; 57 | } 58 | } 59 | 60 | /** 61 | * Returns with the argument if accessible. Does not ensure that the resource will be accessible when actually accessed. 62 | */ 63 | export async function access( 64 | path: string, 65 | options: { mode?: number; debug?: Debug } = { debug: utilsDebug } 66 | ): Promise { 67 | const { mode, debug } = options; 68 | try { 69 | await fs.access(path, mode); 70 | debug?.(`access: ${path}, mode: ${mode}. OK`); 71 | return path; 72 | } catch (err) { 73 | debug?.(`access: ${path}, mode: ${mode}. Fail`); 74 | return undefined; 75 | } 76 | } 77 | 78 | export class AbortError extends Error { 79 | constructor() { 80 | super('User abort'); 81 | this.name = 'AbortError'; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "moduleResolution": "node16", 5 | "target": "ES2020", 6 | "outDir": "out", 7 | "lib": ["ES2020"], 8 | "sourceMap": true, 9 | "rootDir": "src", 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "noImplicitOverride": true, 14 | "resolveJsonModule": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 'use strict'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const path = require('path'); 6 | 7 | /** @type {import('webpack').Configuration} */ 8 | const extensionConfig = { 9 | target: 'node', 10 | mode: 'none', 11 | entry: './src/extension.ts', 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: 'extension.js', 15 | libraryTarget: 'commonjs2', 16 | }, 17 | externals: { 18 | vscode: 'commonjs vscode', 19 | }, 20 | resolve: { 21 | extensions: ['.ts', '.js'], 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.ts$/, 27 | exclude: /node_modules/, 28 | use: [ 29 | { 30 | loader: 'ts-loader', 31 | }, 32 | ], 33 | }, 34 | ], 35 | }, 36 | devtool: 'nosources-source-map', 37 | infrastructureLogging: { 38 | level: 'log', 39 | }, 40 | }; 41 | module.exports = [extensionConfig]; 42 | --------------------------------------------------------------------------------