diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 16:16:15 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 16:17:41 -0300 |
| commit | 0b11540923d5ec2de57fc199c4c9309f838133f5 (patch) | |
| tree | 81c93e1c34b41fba5d20b7706a8ccb05ee1d0c4e | |
| parent | 6b176c598e55a48572e5e508a213491eec122bf4 (diff) | |
| download | esp32p4-0b11540923d5ec2de57fc199c4c9309f838133f5.tar.gz esp32p4-0b11540923d5ec2de57fc199c4c9309f838133f5.zip | |
Move the console into its own binary; the step was fighting the progress display
`zig build interact` shredded the editor's screen with fragments of
`[11/13] steps +- console`. The cause is structural, not cosmetic: an interactive
step lives for as long as the human does, and `std.Progress` redraws the build
runner's step tree on stderr every 80 ms for all of it.
std already solves this, and the solution is a lock rather than a flag.
`Step.Run` with `stdio == .inherit` holds `io.lockStderr()` for the entire
lifetime of the child (std/Build/Step/Run.zig:1588-1592) - the same lock
`Progress` must take to draw. A hand-rolled step gets none of that unless it
takes the lock itself, and `ConsoleStep` did not. So the console is now a real
host program, `tools/console_main.zig`, and `console`/`interact` are `Run` steps
on it. `--color off` is not needed and is no longer suggested anywhere.
Measured under a real pty (a pipe hides the bug, because progress only draws to
a terminal - which is why every earlier end-to-end test here looked clean):
zero bytes of progress output across an 11-second session, 3 frames, the editor's
`^` modified-marker landing at row 2 after two keystrokes.
The second reason for a program is that "just connect" should not imply a build.
Once the firmware is in flash the board runs it across resets, so the common case
during use is to open the port and nothing else:
zig-out/bin/p4-console # the board is already programmed
zig-out/bin/p4-console --no-reset # ...and leave a live session running
`--no-reset` is the interesting one and it is proven on the die: attaching to the
running editor produced 309 bytes with no ESP-ROM banner and zero `boot:` lines,
then a `^` frame in response to typing. The session survived a detach and
reattach with no repaint, which is exactly what a 11.9 KB/s link wants.
`zig build console` is 4 steps and builds no image, no app and no object. It
installs `p4-console` itself rather than going through `installArtifact`, so
`zig build` alone still lands exactly one file in `zig-out` - verified from an
empty tree.
Also here:
* `tools/console.zig`'s `ModeWatch` tests were reachable by nothing. `zig build
test` runs them now; the matcher has to resynchronise when the byte that broke
a match is the next match's ESC, which is worth a regression test.
* The port-permission advice existed twice, in `failPort` and in the new program.
It now lives once, in `tools/serial.zig` beside the code that opens ports, and
says nothing about the path so each caller can name its own on the first line.
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | build.zig | 105 | ||||
| -rw-r--r-- | tools/console_main.zig | 108 | ||||
| -rw-r--r-- | tools/serial.zig | 21 |
4 files changed, 193 insertions, 57 deletions
@@ -5,7 +5,7 @@ zig build # compile, link, and emit a flashable image zig build flash # ...then write it to the chip and run it zig build run # flash, then print the console (ordered; `flash monitor` is not) zig build monitor # reset the board and print its console -zig build console # attach an interactive terminal: keystrokes in, screen out (Ctrl-] detaches) +zig build console # attach a terminal to whatever is already on the board (Ctrl-] detaches) zig build interact # flash, then attach that terminal (ordered, like `run`) zig build reset # just pulse the reset line zig build size # where every byte of the image went @@ -19,6 +19,20 @@ against a linker script this `build.zig` generates; the image builder and the se ordinary Zig code in `tools/`, imported straight into `build.zig`, so they leave no artefacts of their own. What lands in `zig-out` is one file: the image. +One exception, and it earns it: `zig build console` also installs `zig-out/bin/p4-console`, a host +binary that opens the port and nothing else. Run it directly and there is no build runner in the +picture — which matters, because an interactive step lives for as long as the human does, and +`std.Progress` would otherwise redraw the build tree over the screen every 80 ms. std solves that +for child processes by holding `io.lockStderr()` for the child's whole lifetime +(`std/Build/Step/Run.zig:1588-1592`), which is the same lock `Progress` needs to draw, so both +spellings are clean; the binary is simply the one that assumes the board is already flashed. + +``` +zig-out/bin/p4-console # the board is already programmed; just connect +zig-out/bin/p4-console --no-reset # ...and do not pulse reset, so a live session survives +zig-out/bin/p4-console --port /dev/ttyUSB1 --baud 115200 +``` + ## Why it exists | | ESP-IDF blink | earlier Zig proof-of-concept | this | @@ -15,7 +15,6 @@ const std = @import("std"); const image = @import("tools/image.zig"); const serial = @import("tools/serial.zig"); const rom = @import("tools/rom.zig"); -const console = @import("tools/console.zig"); pub fn build(b: *std.Build) void { // ---------------------------------------------------------------- board and target knobs @@ -358,14 +357,45 @@ pub fn build(b: *std.Build) void { "console-baud", "interactive console baud (default 115200, the rate the bootloader leaves UART0 at)", ) orelse .b115200; - const con = ConsoleStep.create(b, port_path, console_baud); - b.step("console", "attach an interactive terminal to the running application").dependOn(&con.step); + + // A real host binary, and not an in-process step like every other tool here, for two reasons. + // It runs with no build runner at all when the board is already flashed; and as a child process + // under `Step.Run` with inherited stdio it gets std's stderr lock held for its whole lifetime + // (std/Build/Step/Run.zig:1588-1592), which is what stops `std.Progress` repainting the step + // tree over an interactive session. The in-process step this replaced never took that lock, and + // shredded the editor's screen with fragments of `[11/13] steps`. + const con_exe = b.addExecutable(.{ + .name = "p4-console", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/console_main.zig"), + .target = b.graph.host, + .optimize = .ReleaseSafe, + }), + }); + // Installed by the console steps, and deliberately NOT by `install`: the default build still + // lands exactly one file in zig-out, the flashable image. Anyone who has attached once has + // zig-out/bin/p4-console afterwards, which is the copy to run when the board is already + // flashed and no build is wanted. + const con_install = b.addInstallArtifact(con_exe, .{}); + + const con_args: []const []const u8 = &.{ "--port", port_path, "--baud", b.fmt("{d}", .{console_baud.rate()}) }; + + const con = b.addRunArtifact(con_exe); + con.addArgs(con_args); + // Inherited stdio is the whole point: the board's bytes and the user's keystrokes pass through + // untouched, and the terminal the child sees is the real one, so its ioctls answer. + con.stdio = .inherit; + con.step.dependOn(&con_install.step); + b.step("console", "attach a terminal to the application already on the board").dependOn(&con.step); // Ordered, for the same reason `run` is: an unordered `flash console` lets the console reset // the board out from under the writer. - const run_con = ConsoleStep.create(b, port_path, console_baud); + const run_con = b.addRunArtifact(con_exe); + run_con.addArgs(con_args); + run_con.stdio = .inherit; + run_con.step.dependOn(&con_install.step); run_con.step.dependOn(&flash.step); - b.step("interact", "flash the image, then attach an interactive terminal").dependOn(&run_con.step); + b.step("interact", "flash the image, then attach a terminal").dependOn(&run_con.step); const reset = ResetStep.create(b, port_path); b.step("reset", "reset the board and let the flashed application run").dependOn(&reset.step); @@ -413,6 +443,19 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(mmio_tests).step); + // The console bridge's escape-sequence matcher. `ModeWatch` has to recognise `\x1b[?2048h` + // split across arbitrary read boundaries, and the naive reset-on-mismatch loses a sequence + // whose own ESC is the byte that broke the previous match - a bug that would show up on the + // wire as an editor that never learns the window size. Host-testable, so tested here. + const console_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/console.zig"), + .target = b.graph.host, + .optimize = .Debug, + }), + }); + test_step.dependOn(&b.addRunArtifact(console_tests).step); + // The radio path's host-testable parts. A wrong checksum, a wrong snprintf, or a scheduler that // loses a task is far cheaper to find here than on a board whose only output is a serial line. // @@ -1371,22 +1414,7 @@ fn readImage(step: *std.Build.Step, gpa: std.mem.Allocator, path: std.Build.Lazy /// "fixed" it, which is a genuinely confusing place to be. fn failPort(step: *std.Build.Step, port: []const u8, err: anyerror) anyerror { if (err != error.AccessDenied) return step.fail("cannot open {s}: {s}", .{ port, @errorName(err) }); - return step.fail( - \\cannot open {s}: AccessDenied - \\ - \\ {s} is owned by a group your session is not in (uucp on Arch, dialout on Debian): - \\ ls -l {s} - \\ - \\ If you are NOT in that group yet, join it and log in again: - \\ sudo usermod -aG uucp $USER - \\ - \\ If `id -nG` already lists it, this shell simply predates the change - group membership - \\ is captured at login. Either log out and back in, or take it in this shell: - \\ newgrp uucp - \\ - \\ Or run one command with the group, without touching this shell at all: - \\ sg uucp -c 'zig build ... ' - , .{ port, port, port }); + return step.fail("cannot open {s}: AccessDenied\n\n" ++ serial.access_denied_help, .{port}); } const FlashStep = struct { @@ -1522,41 +1550,6 @@ const MonitorStep = struct { } }; -/// `monitor`, but the wire runs both ways. See tools/console.zig for the size handshake, which is -/// the only part of this that is not a straight byte copy. -/// -/// Takes the same `port_lock` as every other step that opens the port, so `zig build flash console` -/// cannot have the console pull the board out of download mode mid-write. It then HOLDS that lock -/// for the whole interactive session, which is correct and worth saying out loud: the session ends -/// when the user detaches, so any other port step named on the same command line waits for the -/// human rather than racing them. -const ConsoleStep = struct { - step: std.Build.Step, - port: []const u8, - baud: serial.Baud, - - fn create(b: *std.Build, port: []const u8, baud: serial.Baud) *ConsoleStep { - const self = b.allocator.create(ConsoleStep) catch @panic("OOM"); - self.* = .{ - .step = std.Build.Step.init(.{ .id = .custom, .name = "console", .owner = b, .makeFn = make }), - .port = port, - .baud = baud, - }; - return self; - } - - fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void { - const self: *ConsoleStep = @fieldParentPtr("step", step); - const b = step.owner; - - port_lock.lockUncancelable(b.graph.io); - defer port_lock.unlock(b.graph.io); - - console.attach(self.port, self.baud, .{}) catch |err| - return failPort(step, self.port, err); - } -}; - /// Pulse the reset line and leave. One ioctl pair, but it is the difference between "did my app /// hang or did I forget to reset it" during development. const ResetStep = struct { diff --git a/tools/console_main.zig b/tools/console_main.zig new file mode 100644 index 0000000..0afe81e --- /dev/null +++ b/tools/console_main.zig @@ -0,0 +1,108 @@ +//! `p4-console`: attach a terminal to whatever is already running on the board. +//! +//! **"Just connect" should not imply a build.** Once the firmware is in flash the board runs it +//! across resets and power cycles, so the common case during *use* - as opposed to during +//! development - is to open the port and nothing else. This program compiles nothing, reads no +//! image, and does not care which application is on the chip. `zig build console` runs this same +//! binary and installs it, so `zig-out/bin/p4-console` is the copy to reach for when no build is +//! wanted at all. +//! +//! The other reason it is a program and not an in-process step is a bug this replaced. An +//! interactive step runs for as long as the human is there, and `std.Progress` redraws the build +//! runner's step tree on stderr every 80 ms for all of it - so the editor's screen arrives shredded +//! by fragments of `[11/13] steps └─ console`. std already solves this for child processes, and the +//! solution is a lock rather than a flag: `Step.Run` with `stdio == .inherit` holds +//! `io.lockStderr()` for the entire lifetime of the child (`std/Build/Step/Run.zig:1588-1592`), +//! which is the same lock `Progress` must take to draw. A hand-rolled step gets no such treatment +//! unless it takes that lock itself, and the one this replaced did not. Measured on this board +//! under a real pty: zero bytes of progress output across an 11-second session. + +const std = @import("std"); +const serial = @import("serial.zig"); +const console = @import("console.zig"); + +const usage = + \\p4-console - attach a terminal to the application already running on the board + \\ + \\ p4-console [--port <path>] [--baud <rate>] [--no-reset] + \\ + \\ --port <path> serial port (default /dev/ttyUSB0) + \\ --baud <rate> line rate (default 115200; what the bootloader leaves UART0 at) + \\ --no-reset attach without pulsing reset, leaving the application mid-session + \\ -h, --help this + \\ + \\Ctrl-] detaches. The board's output goes to stdout and your keystrokes go to the board; + \\your terminal emulator answers the application's capability queries itself, so what runs + \\on the chip sees a real terminal. + \\ +; + +/// `Init.Minimal` rather than the full `std.process.Init`: this needs argv and nothing else, and +/// the POSIX arg iterator walks the real argv without allocating, so there is no allocator here at +/// all. Linux-only is not a new restriction - `tools/serial.zig` speaks `termios2` directly. +pub fn main(init: std.process.Init.Minimal) void { + var it: std.process.Args.Iterator = .init(init.args); + _ = it.skip(); // argv[0] + + var port: []const u8 = "/dev/ttyUSB0"; + var baud: serial.Baud = .b115200; + var reset = true; + + while (it.next()) |a| { + if (eql(a, "-h") or eql(a, "--help")) { + stdout.writeStreamingAll(io, usage) catch {}; + return; + } else if (eql(a, "--no-reset")) { + reset = false; + } else if (eql(a, "--port")) { + port = it.next() orelse fail("--port needs a path"); + } else if (eql(a, "--baud")) { + const text = it.next() orelse fail("--baud needs a rate"); + const rate = std.fmt.parseInt(u32, text, 10) catch fail("--baud must be a number"); + baud = std.enums.fromInt(serial.Baud, rate) orelse + fail("unsupported baud: this tool offers only the rates in serial.Baud"); + } else { + fail("unrecognised argument; try --help"); + } + } + + console.attach(port, baud, .{ .reset = reset }) catch |err| switch (err) { + // The one failure everybody hits, and the message the raw error name does not give. Shared + // with the build steps, so the two cannot drift. + error.AccessDenied => { + var buf: [256]u8 = undefined; + const head = std.fmt.bufPrint(&buf, "cannot open {s}: AccessDenied\n\n", .{port}) catch + "cannot open the port: AccessDenied\n\n"; + // Two writes rather than one buffer, because the help text is a comptime constant and + // copying it into a stack buffer only to print it would be work for nothing. + stderr.writeStreamingAll(io, "p4-console: ") catch {}; + stderr.writeStreamingAll(io, head) catch {}; + stderr.writeStreamingAll(io, serial.access_denied_help ++ "\n") catch {}; + std.process.exit(1); + }, + // Unplugging the CH340 mid-session is ordinary, not a crash: say so and leave. + error.PortDisconnected => fail("the port disappeared - the adapter was unplugged"), + else => { + var buf: [256]u8 = undefined; + fail(std.fmt.bufPrint(&buf, "console failed: {s}", .{@errorName(err)}) catch "console failed"); + }, + }; +} + +fn eql(a: []const u8, b: []const u8) bool { + return std.mem.eql(u8, a, b); +} + +const io = std.Io.Threaded.global_single_threaded.io(); +const stdout: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } }; +const stderr: std.Io.File = .{ .handle = 2, .flags = .{ .nonblocking = false } }; + +/// Report and leave. `noreturn` rather than an error union because every caller here is a usage +/// mistake with nothing above it to recover: returning an error would add std's "error: Reported" +/// and a stack trace on top of a message written for a human. +fn fail(msg: []const u8) noreturn { + var buf: [512]u8 = undefined; + const line = std.fmt.bufPrint(&buf, "p4-console: {s}\n", .{msg}) catch "p4-console: error\n"; + stderr.writeStreamingAll(io, line) catch {}; + std.process.exit(1); +} diff --git a/tools/serial.zig b/tools/serial.zig index da225fd..3398a11 100644 --- a/tools/serial.zig +++ b/tools/serial.zig @@ -26,6 +26,27 @@ pub const Baud = enum(u32) { } }; +/// The remedy for the one error every first run hits, in the module that owns port opening so the +/// build steps and `p4-console` cannot drift apart. Deliberately says nothing about the port's +/// path: the caller names that on its own first line, which is the only part that differs. +/// +/// The last case is the one worth spelling out, because it is the one that looks like the fix did +/// not work: a shell started before `usermod` never sees the new group, since credentials are +/// captured at login and not re-read. +pub const access_denied_help = + \\ It is owned by a group your session is not in (uucp on Arch, dialout on Debian). + \\ + \\ If you are NOT in that group yet, join it and log in again: + \\ sudo usermod -aG uucp $USER + \\ + \\ If `id -nG` already lists it, this shell simply predates the change - group membership is + \\ captured at login. Either log out and back in, or take it in this shell: + \\ newgrp uucp + \\ + \\ Or run one command with the group, without touching this shell at all: + \\ sg uucp -c '...' +; + /// `struct termios2` as the Linux kernel defines it: 19 control characters, then the two integer /// baud fields. std's `linux.termios2` uses NCCS = 32, which makes it 60 bytes instead of 44 and /// therefore encodes TCGETS2/TCSETS2 with the wrong size field - the ioctl then fails with ENOTTY. |
