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. What changes is 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 is registered with the event loop as a permanent
descriptor, 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 and writable (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.

A candidate is declined too unless its parent directory is a directory,
owned by our own uid, with no group or other permissions. mkdir() is best
effort and its result discarded, so pre-creating that directory is
otherwise enough to take the console over: `ln -s /etc /tmp/meshcore-0`
aims a root daemon's symlink() and unlink() wherever the owner of the link
likes, and a 0777 directory lets them substitute their own PTY for the
console and read whatever the operator types at it -- `get prv.key`
included. For the same reason the slave device is chmod'd 0600 before
unlockpt() rather than after: open() on a locked pts fails, so there is no
longer a window in which the 0620 root:tty node devpts creates is open to
everyone in group tty.

stdin is a second door onto the same CLI when meshcored runs in the
foreground of a terminal: raw, no kernel echo (the CLI echoes), and
VMIN=0/VTIME=0 so a read never blocks the mesh loop. The foreground only
-- isatty() is just as true for `meshcored &`, and tcsetattr() from a
process in a background process group of its controlling terminal raises
SIGTTOU at that whole group, whose default action is to stop it, so the
daemon would report "Stopped" from inside setup() and never boot; a read()
would do the same via SIGTTIN. Both signals are ignored as well, so a
session backgrounded after startup gets an error return rather than a
stop. 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. A terminal that
hangs up (nohup ... & over an ssh session that then drops) is
unregistered, instead of staying POLLHUP forever and reinstating the busy
loop the previous commit removed.

The terminal is handed back on the way out. end() restores it, and so does
a SIGINT/SIGTERM/SIGHUP handler installed alongside the raw mode: loop()
never returns and nothing calls exit(), so those signals -- not the
destructor -- are how this process really ends, and Ctrl-C used to leave
the operator at a shell with ECHO and ICANON off, typing blind until they
thought to run `reset`. The handler does nothing that is not
async-signal-safe: restore, then re-raise with the default disposition so
the exit status still reports the signal. fd 0 keeps the flags it came
with, too -- O_NONBLOCK belongs to the open file description, which fd 0
shares with the invoking shell's terminal, so setting it there outlived
this process ("bash: read error: 0: Resource temporarily unavailable"),
and with VMIN=0 it was never needed.

Nothing was line-buffering stdout. The C library gives stdout line
buffering only when it is a terminal; under StandardOutput=journal it is a
socket and fully buffered, so a reply or a log line waited for 4 KB to
pile up behind it before journald saw any of it. setup() now asks for
_IOLBF before the first write, which is what makes the README's claim that
no stdbuf wrapper is needed true, and the console's per-byte fflush() is
unconditional instead of skipped in exactly the case that needs it.

The PTY engine joins the native test build under PIO_UNIT_TESTING, which
is what l5yth#25 wanted, and the suite runs on Linux and macOS. It 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 candidate
rules (live, stale, not a symlink, parent directory open to others), the
XDG default, stdin routing with a real terminal on fd 0, a background job
left neither stopped nor in raw mode, a hung-up stdin dropped rather than
polled forever, Ctrl-C putting the terminal back, and end()/begin() across
a simulated reboot.
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