|
| 1 | +# Phase 3 Design Document — Build, Flash & Debug |
| 2 | + |
| 3 | +**Status:** Active |
| 4 | +**Scope:** `east build`, `east flash`, `east debug`, `east attach`, `east reset`; `east-build` crate, `east-runner` crate, `.east/state.toml`. |
| 5 | + |
| 6 | +## 1. Goal |
| 7 | + |
| 8 | +Deliver the first end-to-end "build and run firmware on real hardware" experience: |
| 9 | + |
| 10 | +1. **`east-build` crate** — CMake wrapper with preset support, pristine detection, build artifact tracking. |
| 11 | +2. **`east-runner` crate** — Runner trait, two runner kinds (External, OpenOCD), `DebugSession` RAII guard. |
| 12 | +3. **`.east/state.toml`** — persistent workspace state tracking build products, default runner, and preferences. |
| 13 | + |
| 14 | +**Hard acceptance criterion:** the author must run `east build -p my-preset && east flash && east debug` on a real RISC-V dev board and hit a breakpoint in gdb. |
| 15 | + |
| 16 | +### Explicit Non-Goals |
| 17 | + |
| 18 | +- No serial ISP protocols / `SerialRunner` (Phase 4). |
| 19 | +- No `probe-rs` integration. |
| 20 | +- No GDB frontend / IDE integration. |
| 21 | +- No non-CMake build systems. |
| 22 | +- No sysbuild / multi-image builds. |
| 23 | +- No automatic port / device discovery. |
| 24 | +- No `east run` or `east test`. |
| 25 | + |
| 26 | +## 2. `east build` Semantics |
| 27 | + |
| 28 | +### 2.1 CMake Version |
| 29 | + |
| 30 | +Minimum: **3.21** (Preset schema v3). Detected at CLI startup; lower versions produce a hard error. |
| 31 | + |
| 32 | +### 2.2 Build Directory |
| 33 | + |
| 34 | +Default: `<workspace_root>/build/<name>`, where `<name>` is the preset name (from `-p/--preset`) or `default`. Override: `-d/--build-dir <path>`. |
| 35 | + |
| 36 | +### 2.3 Source Directory Precedence |
| 37 | + |
| 38 | +1. `--source-dir` CLI flag. |
| 39 | +2. `build.source_dir` in config. |
| 40 | +3. `<workspace_root>/app` if that directory exists. |
| 41 | +4. `<workspace_root>`. |
| 42 | + |
| 43 | +### 2.4 Pristine Strategies |
| 44 | + |
| 45 | +| Strategy | Behavior | |
| 46 | +|---|---| |
| 47 | +| `always` | Remove build dir before configure. | |
| 48 | +| `never` | Never remove. | |
| 49 | +| `auto` (default) | Remove if: source dir changed, preset changed, toolchain file changed, or `CMakeCache.txt` is unreadable. | |
| 50 | + |
| 51 | +### 2.5 Argument Passthrough |
| 52 | + |
| 53 | +- First `--`: appended to configure step (`east build -- -DFOO=bar`). |
| 54 | +- Second `--`: appended to build step (`east build -- -DFOO=bar -- -v -j4`). |
| 55 | + |
| 56 | +### 2.6 Success Criteria |
| 57 | + |
| 58 | +`cmake --build` exits 0 AND at least one artifact matching `**/*.elf`, `**/*.bin`, or `**/*.hex` is found under the build directory. No artifact = warning (not error). Override pattern via `build.elf_pattern` config. |
| 59 | + |
| 60 | +### 2.7 State Update |
| 61 | + |
| 62 | +On success, write to `.east/state.toml`: |
| 63 | + |
| 64 | +```toml |
| 65 | +[build] |
| 66 | +last_build_dir = "build/default" |
| 67 | +last_preset = "default" |
| 68 | +last_source_dir = "/abs/path/to/source" |
| 69 | +last_elf = "build/default/app/firmware.elf" |
| 70 | +last_bin = "build/default/app/firmware.bin" |
| 71 | +last_hex = "" |
| 72 | +last_configured_at = "2026-04-09T12:34:56Z" |
| 73 | +``` |
| 74 | + |
| 75 | +On failure, state.toml is not touched. |
| 76 | + |
| 77 | +## 3. Runner Trait |
| 78 | + |
| 79 | +```rust |
| 80 | +#[async_trait] |
| 81 | +pub trait Runner: Send + Sync { |
| 82 | + fn name(&self) -> &str; |
| 83 | + fn kind(&self) -> RunnerKind; |
| 84 | + fn capabilities(&self) -> RunnerCapabilities; |
| 85 | + async fn flash(&self, ctx: &RunCtx, opts: &FlashOpts) -> Result<(), RunnerError>; |
| 86 | + async fn debug(&self, ctx: &RunCtx, opts: &DebugOpts) -> Result<DebugSession, RunnerError>; |
| 87 | + async fn attach(&self, ctx: &RunCtx, opts: &AttachOpts) -> Result<DebugSession, RunnerError>; |
| 88 | + async fn reset(&self, ctx: &RunCtx, opts: &ResetOpts) -> Result<(), RunnerError>; |
| 89 | +} |
| 90 | + |
| 91 | +pub enum RunnerKind { |
| 92 | + External, |
| 93 | + OpenOcd, |
| 94 | + // Serial reserved for Phase 4 |
| 95 | +} |
| 96 | + |
| 97 | +pub struct RunnerCapabilities { |
| 98 | + pub flash: bool, |
| 99 | + pub debug: bool, |
| 100 | + pub attach: bool, |
| 101 | + pub reset: bool, |
| 102 | + pub erase: bool, // always false in Phase 3 |
| 103 | +} |
| 104 | +``` |
| 105 | + |
| 106 | +**Key decisions:** |
| 107 | + |
| 108 | +- `capabilities()` is synchronous. CLI checks before dispatch with a clean error message. |
| 109 | +- `debug()`/`attach()` return `DebugSession` (RAII guard holding OpenOCD child process). |
| 110 | +- `DebugSession` Drop: SIGTERM → 2s wait → SIGKILL (Unix); `taskkill /T /F` (Windows). |
| 111 | +- `RunCtx` is populated by CLI from state.toml and manifest. Runners do not search for artifacts. |
| 112 | + |
| 113 | +```rust |
| 114 | +pub struct RunCtx<'a> { |
| 115 | + pub workspace: &'a Workspace, |
| 116 | + pub manifest: &'a Manifest, |
| 117 | + pub config: &'a Config, |
| 118 | + pub state: &'a State, |
| 119 | + pub elf_path: Option<&'a Path>, |
| 120 | + pub bin_path: Option<&'a Path>, |
| 121 | + pub hex_path: Option<&'a Path>, |
| 122 | + pub openocd_binary: &'a Path, |
| 123 | + pub gdb_binary: &'a Path, |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +## 4. OpenOCD Runner |
| 128 | + |
| 129 | +### 4.1 Commands |
| 130 | + |
| 131 | +| Operation | Command | |
| 132 | +|---|---| |
| 133 | +| flash | `openocd -f <cfg> -c "program <artifact> verify reset exit"` | |
| 134 | +| reset | `openocd -f <cfg> -c "init; reset; exit"` | |
| 135 | +| debug | Background: `openocd -f <cfg>`, then foreground: `<gdb> <elf> -ex "target remote :<port>" -ex "load"` | |
| 136 | +| attach | Same as debug but gdb omits `load` | |
| 137 | + |
| 138 | +Flash artifact precedence: elf > hex > bin (first present wins). |
| 139 | + |
| 140 | +### 4.2 Config Path |
| 141 | + |
| 142 | +Uses `ManifestRelativePath` from Phase 2.5. The `runners:` declaration carries `declared_in`, same as `CommandDecl`. |
| 143 | + |
| 144 | +### 4.3 Binary Resolution |
| 145 | + |
| 146 | +**gdb:** `--gdb` CLI flag > `runner.openocd.gdb` config > `runner.<name>.gdb` config > `riscv64-unknown-elf-gdb` PATH lookup. |
| 147 | + |
| 148 | +**OpenOCD:** Same pattern with `runner.openocd.binary`, default `openocd`. |
| 149 | + |
| 150 | +### 4.4 Port Defaults |
| 151 | + |
| 152 | +gdb: 3333, telnet: 4444, TCL: 6666. Configurable per runner via `runner.<name>.gdb_port` etc. |
| 153 | + |
| 154 | +## 5. OpenOCD Output Classification |
| 155 | + |
| 156 | +OpenOCD writes almost everything to stderr, including success. **Never** use "stderr non-empty" as a failure signal. |
| 157 | + |
| 158 | +- Capture both streams line-by-line via `tokio::io::BufReader`. |
| 159 | +- Classify each line: |
| 160 | + - **Ready:** matches `Listening on port \d+ for gdb connections`. |
| 161 | + - **Error:** matches `^Error:` or contains `failed` at end with non-zero exit. |
| 162 | + - **Progress:** everything else (logged at `tracing::debug!`). |
| 163 | +- On failure, include last 20 lines of captured output in the error. |
| 164 | +- Implemented in `OpenOcdOutputClassifier` module with unit tests on real captured fixtures. |
| 165 | + |
| 166 | +## 6. External Runner |
| 167 | + |
| 168 | +```yaml |
| 169 | +runners: |
| 170 | + - name: custom-tool |
| 171 | + type: external |
| 172 | + flash: |
| 173 | + command: "my-flasher --elf ${runner.elf} --port ${config.runner.custom-tool.port}" |
| 174 | + reset: |
| 175 | + command: "my-flasher --reset" |
| 176 | +``` |
| 177 | +
|
| 178 | +- Each capability (`flash`/`reset`/`debug`/`attach`) is an optional object with a `command` field. |
| 179 | +- Command rendered by template engine with `runner.*` namespace. |
| 180 | +- Shell execution reuses Phase 2 `exec:` code path (`sh -c` / `cmd /C`). |
| 181 | + |
| 182 | +## 7. Template Engine Extension |
| 183 | + |
| 184 | +New namespace: `runner.*`. |
| 185 | + |
| 186 | +| Binding | Description | |
| 187 | +|---|---| |
| 188 | +| `runner.elf` | Absolute path to elf (or empty) | |
| 189 | +| `runner.bin` | Absolute path to bin (or empty) | |
| 190 | +| `runner.hex` | Absolute path to hex (or empty) | |
| 191 | +| `runner.workspace_root` | Workspace root absolute path | |
| 192 | +| `runner.build_dir` | Build directory from state.toml | |
| 193 | +| `runner.name` | Name of the invoked runner | |
| 194 | + |
| 195 | +Missing key remains a hard error. Must not break existing template tests. |
| 196 | + |
| 197 | +## 8. `.east/state.toml` Schema v1 |
| 198 | + |
| 199 | +```toml |
| 200 | +schema_version = 1 |
| 201 | +
|
| 202 | +[build] |
| 203 | +last_build_dir = "build/default" |
| 204 | +last_preset = "default" |
| 205 | +last_source_dir = "/abs/path/to/source" |
| 206 | +last_elf = "build/default/app/firmware.elf" |
| 207 | +last_bin = "" |
| 208 | +last_hex = "" |
| 209 | +last_configured_at = "2026-04-09T12:34:56Z" |
| 210 | +
|
| 211 | +[runner] |
| 212 | +default = "wch-link" |
| 213 | +``` |
| 214 | + |
| 215 | +- **Location:** `<workspace_root>/.east/state.toml`. |
| 216 | +- **Ownership:** `east-workspace::state` module. |
| 217 | +- **Schema versioning:** checked on load; mismatch = error asking user to delete and rebuild. |
| 218 | +- **Atomic writes:** write to `.east/state.toml.tmp`, then rename. |
| 219 | +- **Missing file:** return `State::default()` with `schema_version = 1` and empty fields. |
| 220 | + |
| 221 | +## 9. Manifest Runner Declaration |
| 222 | + |
| 223 | +```yaml |
| 224 | +runners: |
| 225 | + - name: wch-link |
| 226 | + type: openocd |
| 227 | + config: openocd/wch-riscv.cfg |
| 228 | + gdb_port: 3333 |
| 229 | + - name: custom-tool |
| 230 | + type: external |
| 231 | + flash: |
| 232 | + command: "..." |
| 233 | +``` |
| 234 | + |
| 235 | +**Validation:** |
| 236 | + |
| 237 | +- `name` matches `[a-z][a-z0-9-]*`. |
| 238 | +- `type` is `openocd` or `external`. `serial` is reserved → "not yet implemented, reserved for Phase 4" error. |
| 239 | +- `openocd`: `config` field required. |
| 240 | +- `external`: at least one capability present. |
| 241 | +- Runner names unique in resolved manifest; collision = hard error. |
| 242 | + |
| 243 | +## 10. Error Model |
| 244 | + |
| 245 | +| Crate | Error Type | Key Variants | |
| 246 | +|---|---|---| |
| 247 | +| `east-build` | `BuildError` | `CmakeNotFound`, `CmakeVersionTooLow`, `SourceDirNotFound`, `ConfigureFailed`, `BuildFailed`, `NoArtifactsFound` | |
| 248 | +| `east-runner` | `RunnerError` | `ConfigFileNotFound`, `BinaryNotFound`, `SpawnFailed`, `NonZeroExit`, `StartupTimeout`, `ArtifactMissing`, `CapabilityUnsupported` | |
| 249 | +| `east-workspace` | `StateError` | `SchemaVersionMismatch`, `TomlParse`, `Io` | |
| 250 | + |
| 251 | +All derive `miette::Diagnostic`. |
| 252 | + |
| 253 | +## 11. Subprocess Lifecycle Rules |
| 254 | + |
| 255 | +- **Spawn:** `tokio::process::Command` with `stdin(null)`, `stdout(piped)`, `stderr(piped)` for background OpenOCD; `Stdio::inherit()` for foreground gdb. |
| 256 | +- **Termination:** SIGTERM → 2s wait → SIGKILL (Unix); `taskkill /T /F` (Windows). |
| 257 | +- **Zombie prevention:** always `Child::wait()`, including kill paths. |
| 258 | +- **Windows:** `CREATE_NEW_PROCESS_GROUP` flag; CLI Ctrl+C handler delegates to `DebugSession`. |
| 259 | +- **Testability:** `SubprocessHost` trait with `spawn`, `wait_ready`, `kill_gracefully`. Mock impl for tests; production uses `tokio::process`. |
| 260 | + |
| 261 | +## 12. Crate Dependency Graph |
| 262 | + |
| 263 | +``` |
| 264 | +east-cli ─┬─► east-command ─► east-manifest |
| 265 | + ├─► east-config |
| 266 | + ├─► east-workspace ─► east-manifest |
| 267 | + ├─► east-vcs |
| 268 | + ├─► east-build ─► east-workspace, east-config |
| 269 | + └─► east-runner ─► east-manifest, east-workspace, east-config, east-command |
| 270 | +``` |
| 271 | +
|
| 272 | +**Rule:** `east-runner` MUST NOT depend on `east-build`. Runners receive paths, not build semantics. |
| 273 | +
|
| 274 | +## 13. Testing Strategy |
| 275 | +
|
| 276 | +- **CMake fixture:** `tests/fixtures/phase3/hello-cmake/` — tiny project producing `.elf` via host compiler. |
| 277 | +- **Fake OpenOCD:** scripts with variants: `success-flash`, `success-listen`, `error-cfg-missing`, `slow-start`. |
| 278 | +- **Fake gdb:** script that connects, prints banner, exits 0. |
| 279 | +- **OpenOCD output fixtures:** captured from real hardware, one file per scenario. |
| 280 | +- **CI cannot test real hardware.** Manual validation required with hardware log in dev notes. |
| 281 | +
|
| 282 | +## 14. Performance Bars |
| 283 | +
|
| 284 | +- `east --version` < 30 ms (lazy loading of build/runner). |
| 285 | +- `east build` overhead < 50 ms before CMake invoked. |
| 286 | +- No zombie processes after any test path. |
| 287 | +- Zero `unsafe` continues. |
0 commit comments