Skip to content

variants/linux: harden the PTY console and add stdin input - #10

Open
mmmorks wants to merge 1 commit into
pr/05-basefrom
pr/05-pty-console
Open

variants/linux: harden the PTY console and add stdin input#10
mmmorks wants to merge 1 commit into
pr/05-basefrom
pr/05-pty-console

Conversation

@mmmorks

@mmmorks mmmorks commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

Built on top of l5yth#25 and keeps its design: the CLI is served on a pseudo-terminal published as a stable symlink, so meshcore-cli -r -s /run/meshcored/console and any serial tool attach directly, and CLI traffic is mirrored to stdout so journald records what was run. This PR changes what is underneath it. (A dependency-free client, meshcorectl, follows as a separate PR stacked on this one.)

  • No wake-up storm after a client detaches. A PTY master whose slave has closed reports POLLHUP and reads EIO until the next client opens. With the poll()-based main loop from the previous PR that is a level condition: one detached meshcore-cli cost a wake-up per millisecond for the rest of the run. The daemon now holds a descriptor of its own on the slave, so a detached console is idle, and the master is registered with the event loop so the daemon wakes on console input.
  • Survives reboot. Every descriptor is close-on-exec, and reboot() unpublishes the console before re-exec, so the new image never inherits a master (which would keep the old /dev/pts/N alive with nobody reading it) or a symlink pointing at a number the exec just freed.
  • Path resolution without an INI edit. console_path, then /run/meshcored/console when the unit's RuntimeDirectory is present, then $XDG_RUNTIME_DIR/meshcore/console, then /tmp/meshcore-<uid>/console. A candidate held by a live console (its symlink resolves to an existing device) is declined rather than taken over; a regular file at the path is refused rather than unlinked; a dangling symlink from a crash is reclaimed. Each decision is logged.
  • stdin as a second door. In a foreground terminal you can type at meshcored directly (raw, non-blocking, no kernel echo, restored on exit). Replies follow the command to its source: always to stdout, and to the PTY only when the command arrived there, so a foreground session's output does not queue in the PTY to greet the next client.
  • Tests in the native build. PtyConsole/LinuxConsole compile under PIO_UNIT_TESTING, which is what variants/linux: local CLI console over a PTY l5yth/meshcore-linux#25 wanted and reverted. The "SIGHUP" its author saw is PlatformIO's native runner mapping a plain exit status 1 (one failed assertion) to signal 1; nothing tty-related. The suite runs on macOS and Linux.

What changed

  • src/helpers/PtyConsole.{h,cpp}: holder descriptor, close-on-exec, candidate order and the live/stale/non-symlink rules, fd() accessor.
  • src/helpers/LinuxConsole.{h,cpp}: stdin input, per-source output routing, end() restoring the terminal, ptyFd()/stdinFd(); the console is now the global Console (defined in the .cpp) so LinuxBoard can reach it.
  • examples/simple_repeater/main.cpp: uses Console; logs the PTY path or the reason there is none.
  • variants/linux/LinuxBoard.{h,cpp}: reboot() calls Console.end(); idleUntilEvent() registers the console's descriptors.
  • platformio.ini: the two sources join the native build.
  • test/test_linux_console: 13 tests against real /dev/pts nodes in a temp dir, including a real terminal on fd 0 for the routing test.
  • README, meshcored.ini, meshcored.service: a new ## The control CLI section (path order, permissions and the decline rules, other serial tools, stdin); the "uncomment console_path" instruction is gone because the default finds /run/meshcored on its own.

How it was tested

  • Native suite (pio test -e native) passes on macOS and in the arm64 bookworm container. The holder test fails without the holder on Linux (poll() returns POLLHUP immediately) and the close-on-exec test fails without the fcntl calls.
  • linux_repeater builds for arm64 in the container.

Dependencies

Stacked on pr/04-event-loop-cad, with l5yth#25 replayed on top of it as the base commit (authored by @rgrizzell, unchanged apart from the console_path key going through the validated INI loader). Review the last commit only. If l5yth#25 lands first, this rebases onto it with no change in content.

Shared code touched

  • src/helpers/PtyConsole.{h,cpp}, src/helpers/LinuxConsole.{h,cpp} (Linux-only by guard; also compiled in the native test build)
  • examples/simple_repeater/main.cpp (Linux-only block; no change for other targets)
  • platformio.ini (native test env only)

@mmmorks mmmorks changed the title variants/linux: harden the PTY console, add stdin input and meshcorectl variants/linux: harden the PTY console and add stdin input Sep 7, 2026
Builds on l5yth#25's PTY console (PtyConsole/LinuxConsole) and keeps its shape:
a pseudo-terminal published as a stable symlink, so meshcore-cli -r -s and
any serial tool attach directly, and the CLI's traffic mirrored to stdout
so journald records what was run. Four things change underneath it.

The daemon holds a descriptor of its own on the slave. Without one the
master reports POLLHUP and read() fails with EIO from the moment the last
client closes until the next one opens -- a level condition the poll()
loop introduced in the previous commit could only throttle, not clear, so
one detached client cost a wake-up per millisecond for the rest of the
run. With it a detached console is idle: poll() sleeps, read() says
"nothing yet", and the next client attaches as if the first had never
left. The master and holder are registered with the event loop as
permanent descriptors, so the daemon wakes on console input.

Every descriptor is close-on-exec and reboot() unpublishes the console
before re-exec. An inherited master would keep the old /dev/pts/N alive
with nobody reading it; a symlink left behind would point at a number the
exec has just freed, which the next image can only guess about. begin()
now tries candidates in order -- console_path, /run/meshcored/console when
the unit's RuntimeDirectory is present (so the packaged service needs no
INI edit), $XDG_RUNTIME_DIR/meshcore/console, /tmp/meshcore-<uid>/console
-- and declines one that a live console holds (its symlink resolves to an
existing device) or that is not a symlink at all, since the path is
operator input and unlink() does not care what it removes. A dangling
symlink from a crashed daemon is reclaimed.

stdin is a second door onto the same CLI when meshcored runs in a
terminal: raw, non-blocking, no kernel echo (the CLI echoes), restored on
end(). Replies follow the command to its source -- always to stdout, and
to the PTY only when the command arrived there -- so a foreground session's
output does not queue in the PTY to greet the next client.

The PTY engine joins the native test build under PIO_UNIT_TESTING, which
is what l5yth#25 wanted and reverted: the "SIGHUP" its author saw is
PlatformIO's runner mapping a plain exit status 1 (a failed assertion) to
signal 1. The suite covers the symlink and its mode, both directions and
the newline map, the holder (a detached client leaves poll() sleeping and
the event loop timing out cleanly), close-on-exec for every descriptor
begin() opens, the live/stale/non-symlink candidate rules, the XDG
default, stdin routing with a real terminal on fd 0, and end()/begin()
across a simulated reboot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant