variants/linux: harden the PTY console and add stdin input - #10
Open
mmmorks wants to merge 1 commit into
Open
Conversation
mmmorks
force-pushed
the
pr/05-pty-console
branch
from
September 7, 2026 22:57
9e63079 to
bb016db
Compare
meshcorectl
mmmorks
force-pushed
the
pr/05-pty-console
branch
from
September 8, 2026 04:11
bb016db to
b6620be
Compare
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.
mmmorks
force-pushed
the
pr/05-pty-console
branch
from
September 12, 2026 21:21
b6620be to
0b58165
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/consoleand 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.)POLLHUPand readsEIOuntil the next client opens. With thepoll()-based main loop from the previous PR that is a level condition: one detachedmeshcore-clicost 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.reboot. Every descriptor is close-on-exec, andreboot()unpublishes the console before re-exec, so the new image never inherits a master (which would keep the old/dev/pts/Nalive with nobody reading it) or a symlink pointing at a number the exec just freed.console_path, then/run/meshcored/consolewhen the unit'sRuntimeDirectoryis 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.meshcoreddirectly (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.PtyConsole/LinuxConsolecompile underPIO_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 status1(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 globalConsole(defined in the.cpp) soLinuxBoardcan reach it.examples/simple_repeater/main.cpp: usesConsole; logs the PTY path or the reason there is none.variants/linux/LinuxBoard.{h,cpp}:reboot()callsConsole.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/ptsnodes in a temp dir, including a real terminal on fd 0 for the routing test.meshcored.ini,meshcored.service: a new## The control CLIsection (path order, permissions and the decline rules, other serial tools, stdin); the "uncommentconsole_path" instruction is gone because the default finds/run/meshcoredon its own.How it was tested
pio test -e native) passes on macOS and in the arm64 bookworm container. The holder test fails without the holder on Linux (poll()returnsPOLLHUPimmediately) and the close-on-exec test fails without thefcntlcalls.linux_repeaterbuilds 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 theconsole_pathkey 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)