Skip to content

usb: a console helper for headless Linux hosts (script, helper disk, console test) - #76

Merged
dormancygrace merged 14 commits into
mainfrom
claude/serial-console-helper
Oct 11, 2026
Merged

dormancygrace merged 14 commits into
mainfrom
claude/serial-console-helper

Conversation

@dormancygrace

@dormancygrace dormancygrace commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

USB serial gave the managed computer a CDC ACM port, but the login on it was left to the administrator: a udev rule and a getty by hand, and docs/usb-composition.md said the Linux side was never tried. This adds a helper that sets the login up on the host, two ways to get it there (the NanoKVM's web server, and a read-only helper disk next to the virtual drive), a console test from the USB settings page, and the same helper wrapped for the Proxmox VE Helper-Scripts.

The script: nanokvm-serial-console.sh

POSIX sh (dash, busybox ash, bash), runs on the managed Linux host, about 560 lines, nothing beyond coreutils.

  • plan (default) and status only read. They report the distribution (os-release), the init system (systemd, sysvinit or busybox init with /etc/inittab, runit, OpenRC), whether this is a container, an immutable or read-only system (OSTree, NixOS), the cdc_acm driver for the running kernel (loaded, built in, or a module file), the getty, and the NanoKVM on the USB bus (by 3346:1009, with its serial number and ttyACM name). Then they print what apply would change.
  • apply asks for confirmation (--yes skips it; a non-terminal without --yes changes nothing) and:
    • on systemd hosts writes /etc/udev/rules.d/90-nanokvm-serial-console.rules, two rules bound to the vendor, product and serial number: on the USB device ENV{ID_MM_DEVICE_IGNORE}="1" (ModemManager stays away, old and new ModemManager), and on the tty ACTION=="add", SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", the same mark plus ID_MM_PORT_IGNORE, TAG+="systemd", ENV{SYSTEMD_WANTS}+="serial-getty@%k.service", SYMLINK+="nanokvm-console". The login starts for whichever ttyACMn the NanoKVM becomes, only while it is present. Then udevadm control --reload and, when the port is present, udevadm trigger --subsystem-match=tty --sysname-match=<port> --action=add (flags that old udevadm has too), and it checks serial-getty@<port> is active; when the trigger alone did not start it (systemd 247, Debian 11, after an undo), it starts the unit and checks again. restorecon on the rule when SELinux enforces.
    • on busybox init (Alpine) appends ttyACMn::respawn:getty … to /etc/inittab, on sysvinit nkac:2345:respawn:… (backup kept), and sends init SIGHUP.
    • Everything goes into a manifest with backups under /var/backups/nanokvm-serial-console/<timestamp>/. A second apply changes nothing and leaves no record. undo reverts the last change, reloads udev and stops the getty.
  • Refuses (exit 2, or 3 for an unhandled system) and prints the manual steps: in a container (/.dockerenv, container= in PID 1's environment, cgroup), on OSTree/NixOS/read-only root, without cdc_acm for the running kernel (with the known reasons: Ubuntu cloud images without linux-modules-extra, Debian's cloud kernel), without serial-getty@.service or agetty/getty, and on runit or OpenRC as PID 1.
  • When a login for this NanoKVM is already set up another way (a udev rule with serial-getty@ for its serial number, or a running serial-getty@<port> that the rule did not start), apply says so and adds no second rule.
  • --serial SERIAL binds the rule when the NanoKVM is not plugged in yet (the USB page's command includes it); a connected NanoKVM wins over the option.
  • The bootloader's console= is out of scope: early boot cannot go through CDC ACM anyway.

Getting it onto a headless host

  • Over the network: GET /nanokvm-serial-console.sh on the NanoKVM, public (the host has no credentials, and the script is firmware source), with X-Checksum-Sha256. The USB page shows curl -fsS -k https://<nanokvm>/nanokvm-serial-console.sh -o nanokvm-serial-console.sh && sh nanokvm-serial-console.sh --serial <serial> (-k only when the page itself is HTTPS), the same for the NanoKVM's USB network address when the USB network is up (a host with no other network), and a wget form for busybox hosts.
  • Without a network, the helper disk: with USB serial and the virtual disk enabled, S03usbdev adds a second LUN to mass_storage.disk0, read-only, removable, inquiry_string "NanoKVM Setup Disk", backed by /kvmapp/system/share/serial-console/nanokvm-serial-console.img: a 1440 KiB FAT12 image (volume label NANOKVM) with the script as setup.sh and a readme.txt. mount -o ro /dev/disk/by-label/NANOKVM /mnt && sh /mnt/setup.sh. The LUN is removed again when serial is off, so the drives match the composition. A LUN can only be added or removed while the function is not linked into the configuration (the kernel answers "Resource busy"), so the script settles it before the ln -s.
    • The image is built by scripts/build-serial-console-disk.py, in Python, byte-for-byte reproducible (fixed timestamps and volume id), no mtools needed on the build host; platform/build.sh tools builds it and packages.list ships both files under /kvmapp/system/share/serial-console/. Its boot sector carries the stub mkfs.fat writes ("This is not a bootable disk", wait for a key, next device), so a BIOS with a USB drive first in its boot order does not run zeros.
    • Why a disk and not a CD-ROM: auto-mounting is a desktop (udisks) matter and applies to both; a headless host mounts either by hand, /dev/disk/by-label/NANOKVM names both. A CD-ROM LUN would need an ISO 9660 image (FAT cannot be read through a 2048-byte-sector device); that can follow if /dev/sr0 naming is wanted.
    • Separate from the virtual drive: it does not replace a mounted image and costs no endpoints.
    • /etc/init.d/S03usbdev on a device is a symlink to the base package's copy (packages.list:46, /usr/libexec/nanokvm/legacy/S03usbdev), so the helper disk needs nanokvm-base rebuilt, not only the app package.
  • Proxmox VE Helper-Scripts: firmware/services/community-scripts/nanokvm-serial-console.sh is a separate bash script for Proxmox VE and Proxmox Backup Server hosts only (Debian, systemd), built on that project's Core as their host tools are: it sources core.func, tools.func, error_handler.func and api.func from COMMUNITY_SCRIPTS_CORE_URL (so it needs that reachable, or a local copy), and uses load_functions, catch_errors, header_info (APP_TYPE=tools), root_check, on_pve_host, the prompt_* helpers for the menu, and plan|status|apply|undo [--yes] with NANOKVM_SERIAL for use without prompts. PVE 8.x/9.x and PBS on bookworm/trixie; pve_check is not called because it requires /etc/pve to be mounted. It writes the same rule file with the same content and records to the same backup directory as the POSIX helper, so a host set up by one is "already set up" for the other and undo works across them. Submitted as New Script: NanoKVM USB serial console (tools/pve) community-scripts/DevScripts#2414.

Server and web

  • GET /api/vm/serial-console (admin): the gadget's USB serial number, the script's path, size and SHA-256, and whether the helper disk is shipped and attached (LUN 1 backed by it). When a package upgrade replaces the image while the LUN holds the old one, the kernel shows the path as deleted: reported as attached and stale, and the page says the computer sees the new disk once the USB devices reconnect (the next composition change or boot).
  • POST /api/vm/serial-console/test (admin): sends one carriage return on /dev/ttyGS0, raw 8N1, and reads the answer for up to five seconds, as the browser terminal's first Enter does. Reports login, password, shell (the prompt ends the text), text, silent, busy (picocom's lock file, or any other process holding the port, found through /proc/*/fd), unavailable (no USB serial), error. ANSI sequences are stripped. Some gettys (seen on Arch and Ubuntu 26.04) reset the terminal and ask for the cursor position before the prompt, and wait for the answer: the probe replies as the browser terminal does, so the prompt can follow.
  • USB settings page, while USB serial is enabled: the fetch commands (copyable), the SHA-256, the helper disk state (attached, pending after reconnect, or "enable the virtual disk as well"), the disk command, Test the console, and a download link. en and ru.
  • docs/usb-composition.md describes the helper, and now says the terminal's 9600 baud is nominal (it said 115200 while the server always opens ttyGS at 9600). README.md points to the helper.

Checked

  • firmware/services/test_serial_console.py (in CI): fake roots for Debian 12 and 8, Devuan (sysvinit), Rocky with SELinux, Alpine (busybox init with inittab), runit, unknown init, no os-release; every refusal (three container signals, OSTree, NixOS, read-only root, no cdc_acm with the Ubuntu and Debian cloud hints, no unit, no agetty); the foreign rule and the foreign getty; plan and status change no file and run no command, for every case, under dash, busybox sh and bash; apply writes the expected rules or inittab line and runs exactly udevadm control --reload, the targeted trigger and the is-active check, and starts the unit when the trigger did not; a second apply changes nothing; undo restores files and stops the getty; --serial and the connected device; a rule for another serial is replaced with a backup; --help and a bad argument. shellcheck clean.
  • scripts/test-serial-console-disk.py (in CI): layout, both FATs, the root directory entries with lower-case names, reproducibility, and mtools (mdir, mtype) reading the files back.
  • Go: classifySerialResponse (prompts, ANSI, the captured terminal setup); the probe against a pty with a fake getty (login prompt, a getty that waits for the cursor position reply, ANSI shell prompt, silent host within the timeout, missing device, a lock file, another process holding the port); the state and the handlers. go vet, go test -tags teststub ./....
  • Web: tsc --noEmit, eslint, prettier, test:i18n. scripts/test-platform-build.py, test-platform-config.py.
  • The Proxmox script: shellcheck at default severity with that project's disables; on the Proxmox host (NanoKVM passed through to Debian 13, 12 and 11 VMs, a stub pveversion, and once a stub proxmox-backup-manager for the PBS branch; Core from its main branch): status and plan without the device and with NANOKVM_SERIAL, apply without --yes and without a terminal changes nothing, apply --yes → nkvm-test-debianNN login: read on the NanoKVM, apply again (nothing to do), undo (inactive, port silent), apply after undo, undo again, undo with nothing left, --help, a bad argument. PBS is exercised through the stub only. Read-only status and plan on the real PVE 9.2 host, with a local Core copy, report the hand-made setup and change nothing.
  • On the stand (Windows host): the script URL answers 200 with the checksum header; GET /api/vm/serial-console reports the serial d19506b31359c372; applying the console preset creates lun.1 with file = the image, ro=1, removable=1, inquiry "NanoKVM Setup Disk"; turning serial off removes the LUN and attached goes false, turning it on brings it back; the console test reports silent, right for a Windows host without a login on the COM port, and busy while another process holds /dev/ttyGS0.

Linux hosts end to end

A second NanoKVM (serial-only composition, NanoKVM OS 1.0.0-beta.11) on a Proxmox VE 9.2 host (pve-manager 9.2.21, kernel 7.0.14-19-pve, Debian 13.7), passed through (qm set --usb0 host=3346:1009, hot-plugged into running q35 VMs) to one VM per distribution, one at a time. For each VM the driver ran plan without the device and with it, apply, read the getty state, sent Enter from the NanoKVM side and captured what came back, ran apply again (nothing changed), undo (getty gone, port silent), apply again, undo again, then detached the device and checked the host's own getty was back on /dev/ttyACM0.

Host Kernel Init apply → login prompt on the NanoKVM undo → silent apply again, undo again
Debian 13 6.12.111+deb13 systemd yes yes yes
Debian 12 6.1.0-53 systemd yes yes yes
Debian 11 (systemd 247, what PVE 7 runs) 5.10.0-46 systemd yes yes yes: after undo the trigger alone did not restart the unit there, the script's explicit systemctl start did
Ubuntu 26.04 7.0.0-38-generic systemd terminal setup, prompt after the capture window (see below) yes yes
Ubuntu 24.04 6.8.0-146-generic systemd yes yes yes
Ubuntu 22.04 5.15.0-198-generic systemd yes yes yes
Fedora 42 6.14.0-63 systemd yes yes yes
Rocky 9.8 5.14.0-687 systemd yes yes yes
openSUSE Leap 15.6 6.4.0-150600.23.103 systemd yes yes yes
Arch (2026-10) 7.2.9-arch1 systemd terminal setup, prompt after the capture window (see below) yes yes
Alpine 3.21 6.12.112-0-lts busybox init, inittab yes (ttyACM0::respawn:/sbin/getty -L 115200 ttyACM0 vt220) yes (inittab restored, getty gone) yes (line appended again, getty running)
  • On Arch and Ubuntu 26.04 the getty first sends a terminal reset and a cursor position query (CSI 6 n) and waits for the reply before the prompt; the passive capture ended before that. The console test now answers the query (commit c859e2b); the browser terminal always did.
  • Refusals, as designed (exit 2, nothing written, the manual steps printed): an LXC container (Debian 13 template) on the same host; and every cloud image whose kernel has no cdc_acm: Debian genericcloud (cloud kernel), Ubuntu cloud without linux-modules-extra (on 26.04 the running kernel's package had already left the archive: the hint now says linux-generic and reboot), Alpine cloud linux-virt, openSUSE Minimal-VM kernel-default-base. The table rows are after the ordinary kernel was installed (Debian generic image; linux-modules-extra/linux-generic; linux-lts; kernel-default), which is what an installed host runs.
  • Read-only on the Proxmox host itself, where the console was set up by hand before this script existed: status and plan report "already set up by /etc/udev/rules.d/70-nanokvm-serial-console.rules" and would change nothing. No apply was run there.

Not covered

  • The web terminal on ttyGS0 fails on current builds with picocom: executable file not found: scripts/build-picocom.sh exists but nothing builds or packages it, although the docs say it is bundled. Found while checking the busy state; terminal: bridge serial ports in the server, without picocom #78 replaces picocom with a native session.
  • The optional resize bridge (docs/USB-SERIAL.md) is not installed by the script.
  • platform/expected.sha256 lists the two tools/serial-console/ outputs (the script byte for byte, the reproducible FAT image); the web assets still need the platform build, as for any web change.
  • The cursor position reply in the console test is verified against the captured sequence in the pty test; a live Arch or Ubuntu 26.04 host with this server build is still to run.
  • Devuan and Linux Mint have no cloud images; Devuan's sysvinit path is covered by the fake-root test only, Mint by Ubuntu.

🤖 Generated with Claude Code

dormancygrace and others added 3 commits October 11, 2026 16:55
USB serial gave the managed computer a CDC ACM port, but setting up a
login on it was left to the administrator: a udev rule and a getty by
hand, with the docs admitting the Linux path was never tried.

nanokvm-serial-console.sh (POSIX sh) runs on the managed host. plan and
status only read: the distribution, the init system, the cdc_acm driver
for the running kernel, the getty, and the NanoKVM on the USB bus by
3346:1009 with its serial number. apply, with a confirmation, writes a
udev rule on systemd hosts that starts serial-getty@<port> for the
NanoKVM's port only, bound to its serial number, with ModemManager told
to ignore it; on sysvinit and busybox init it appends one inittab line.
Every change is recorded with backups, a second apply changes nothing,
and undo reverts. It refuses in containers, on immutable or read-only
systems, without cdc_acm or a getty, and on init systems it does not
handle, and prints the manual steps. The bootloader is out of scope.

The NanoKVM serves the script at /nanokvm-serial-console.sh (public, the
host has no credentials) and, with USB serial and the virtual disk
enabled, S03usbdev attaches it as a read-only second LUN of the virtual
drive: a 1440 KiB FAT12 image built reproducibly in Python, with the
script as setup.sh and a readme. The USB settings page shows the fetch
command with the serial number, the helper disk state, and a console
test that sends Enter on ttyGS0 and reports the answer (login prompt,
password, shell, text, silence, busy, unavailable).

Tests: the script against fake roots under dash, busybox sh and bash
(detection, every refusal, plan and status write nothing and run
nothing, apply and undo, idempotence); the disk image (layout, mtools
reads it, reproducible); the probe against a pty with a fake getty; the
handlers. The docs now say the terminal's 9600 baud is nominal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The FAT boot sector carried the boot signature with no code after the
BPB. A BIOS with a USB drive first in its boot order would run those
zeros. Write the stub mkfs.fat uses: it prints that the disk is not
bootable, waits for a key and goes on with the next device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dormancygrace and others added 5 commits October 11, 2026 18:12
A host can have the login already, set up after the docs: a udev rule of
its own that starts serial-getty@ for this NanoKVM, or the getty running
on its port. The Proxmox host of 2026-10-11 is one. plan and apply now
find such a rule (any udev rules file naming the vendor, product and this
serial number with serial-getty@) and the running unit (the invocation
link systemd keeps), report them, and change nothing: this script adds
no second rule. A rule for another NanoKVM does not count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Seen on the Proxmox matrix: Ubuntu cloud images ship without
linux-modules-extra, and Debian genericcloud images run a cloud kernel
without USB drivers. The refusal now names the package to install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On Debian 11 (systemd 247) a repeated add event for a device that is
already present does not start the unit named in SYSTEMD_WANTS, so an
apply right after an undo left the login off until the next plug.
When the unit is not active after the trigger, apply starts it; the
rule still covers the next plug and boot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A bash script in the style of community-scripts/ProxmoxVE tools/pve:
header, root and PVE version check (8 and 9; 7 only warns), whiptail
menu for plan, apply, undo and the serial number, or the helper's own
arguments without dialogs. The POSIX helper is embedded in a quoted
heredoc, so the published script downloads nothing; scripts/
build-community-script.py assembles it from the source, and CI checks
the committed copy matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…le test

On the Proxmox matrix the getty on Arch and Ubuntu 26.04 resets the terminal
and asks where the cursor is (CSI 6 n) before it prints the login prompt, and
waits for the answer. The console test only listened, so on those hosts it
reported "text" with the leftovers of ESC 7 / ESC 8 ("7 8") instead of the
prompt. The probe now replies to each query the way the browser terminal
does, and the classifier drops the two-byte ESC sequences too, so the setup
alone reads as silence and the prompt that follows as login.

The Ubuntu hint for a missing cdc_acm adds the case seen on 26.04 at first
boot: the running kernel's linux-modules-extra is already gone from the
archive, so linux-generic and a reboot are the way.

Tests: the captured setup sequence, a fake host that waits for the reply
before the prompt, and the extra hint line. The community-scripts copy is
rebuilt from the source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dormancygrace
dormancygrace marked this pull request as ready for review October 11, 2026 18:31
…pped helper

The community-scripts copy was the whole POSIX helper in a bash heredoc, with
its Alpine, sysvinit, runit and OSTree branches that no Proxmox host has. It
is now a short bash script of its own for Proxmox VE and Proxmox Backup
Server (Debian, systemd), in that project's form: the product gate, the
Debian release check, the container refusal, the whiptail menu and the
argument mode. It writes the same rule file with the same content and
records to the same backup directory as the helper, so either entry point
reads the other's setup as "already set up" and undo works across them.

The template, the assembling script and its CI step go; the global shellcheck
step covers the new file.

Checked on the Proxmox host with the NanoKVM passed through: Debian 12 and
Debian 11 VMs with a stub pveversion (status, plan, apply with the login
prompt read on the NanoKVM, apply again, undo with the port silent, apply
after undo, undo again), once with a stub proxmox-backup-manager for the PBS
branch; read-only status and plan on the real PVE 9.2 host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The maintainers' review of the DevScripts submission: the script carried its
own colours, messages, checks and whiptail menu instead of the project's. It
now sources Core (core.func, tools.func, error_handler.func, api.func) the way
the current host tools do, and uses load_functions, catch_errors, the
telemetry hook, header_info with APP_TYPE=tools (the banner comes from their
generator), root_check, on_pve_host and _pve_version, and prompt_select,
prompt_confirm and prompt_input for the menu. pve_check is not called: it
needs /etc/pve mounted, and a console for a host in trouble must not depend
on that; its version policy (PVE 8.x and 9.x) is kept, so PVE 7 is out, as
it is for the rest of that project. Under catch_errors every command must
succeed or be tested, so the reads are written that way.

The Source line points at NanoKVM OS: the stock firmware has no USB serial
function. Arguments: plan|status|apply|undo and --yes; the serial number of
a NanoKVM that is not plugged in comes from NANOKVM_SERIAL.

Checked on the Proxmox host with the NanoKVM passed through, Core from its
main branch: Debian 13, 12 and 11 VMs with a stub pveversion, and the PBS
branch with a stub proxmox-backup-manager on Debian 12: status and plan,
apply without --yes and without a terminal changes nothing, apply --yes with
the login prompt read on the NanoKVM, apply again, undo with the port
silent, apply after undo, undo again, --help, a bad argument. Read-only
status and plan on the real PVE 9.2 host with a local Core copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dormancygrace

Copy link
Copy Markdown
Owner Author

Review of 41ab2ae8aafec682e81b4400821c618521a3dd9f against current main 1a4723e5.

Two fixes are needed before merging:

  1. [P2] Acquire exclusive ownership for the whole console probe. In server/service/vm/serial_console.go:167–193, the busy check is only an observation, and deviceInUse deliberately skips this process. Two requests to /vm/serial-console/test therefore both open/read the same tty and save/restore its termios. Reproduced with the existing fakeHost(t, ""): start a 600 ms probe, wait for received() (the first CR), then run a second 200 ms probe. The second returns silent, not busy. They can consume each other's responses and restore stale tty settings. Use an atomic per-device lease covering open, read/write and termios restoration; share it with terminal sessions when terminal: bridge serial ports in the server, without picocom #78 is integrated. Test overlapping probes and both probe/terminal start orders.

  2. [P2] Register the new build outputs in the checksum manifest. platform/build.sh:579–586 now creates tools/serial-console/nanokvm-serial-console.sh and tools/serial-console/nanokvm-serial-console.img, but neither is in platform/expected.sha256. verify() explicitly rejects every unlisted output (lines 982–989), so even after accounting for intended changed binaries, the documented build verification cannot succeed. Generate both artifacts reproducibly and add their verified hashes; do not weaken the verifier.

Integration note: current main also introduces a content conflict in .github/workflows/ci.yml. Keep both the new serial-helper tests and main's platform-library tests when resolving it. Confirmed with git merge-tree --write-tree 1a4723e5 HEAD.

Validation: all 18 host-helper tests and all 5 FAT-image tests pass. The existing targeted Go serial tests pass; the added overlapping-probe regression fails as described. Tests ran on local PTYs/fake roots; the physical NanoKVM was not touched.

dormancygrace and others added 3 commits October 11, 2026 23:19
…-helper

# Conflicts:
#	.github/workflows/ci.yml
Review: the busy check was an observation only, and deviceInUse skips this
process, so two console tests at once both opened the port, read each
other's answer and restored each other's line settings. The probe now
takes a per-device lease before anything else and gives it back after the
device is closed and the settings are restored; a second probe meanwhile
reports busy. The lease registry (serial_lease.go) is the one the terminal
session in #78 will share, so either holder keeps the other out in both
start orders.

Test: a 600 ms probe on a silent host, a 200 ms probe started while the
first waits for its answer is busy, the lease is gone when the first ends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: build.sh tools writes tools/serial-console/nanokvm-serial-console.sh
and .img, and verify rejects every unlisted output. The script copy is the
source file byte for byte; the FAT image comes from
scripts/build-serial-console-disk.py, which is reproducible (two builds
give the same bytes, as its test checks). Both hashes are from those.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dormancygrace

Copy link
Copy Markdown
Owner Author

Both fixed, and main merged in (dc2d44e, the ci.yml conflict resolved with both test lists: mmf-light-link from main, serial-console-disk and test_serial_console.py from here).

  1. Lease for the whole probe — 3d61c10. New serial_lease.go holds the per-device registry (claimSerialPort / serialPortOpen); probeSerialConsole claims it before anything else and releases it after the device is closed and the termios restored, so an overlapping probe reports busy instead of reading the first one's answer. terminal: bridge serial ports in the server, without picocom #78's terminal session will use the same registry, which gives the exclusion in both start orders; that part lands there. Regression: TestProbeSerialConsoleHoldsTheDeviceForItsWholeRun (600 ms probe on a silent host, a 200 ms probe started after the first CR is busy, the lease is gone when the first ends). go test -race on the probe tests is clean.

  2. Manifest — 9b0c91f. tools/serial-console/nanokvm-serial-console.sh (the source file byte for byte) and nanokvm-serial-console.img (from scripts/build-serial-console-disk.py, two builds give the same bytes) are in platform/expected.sha256 with their hashes. The web assets still need the platform build, as for any web change; the verifier is untouched.

Tests after the merge: the 18 helper tests, the 5 FAT-image tests, test-platform-build.py, test-platform-config.py, go vet, go test -tags teststub ./service/vm/, shellcheck. PTYs and fake roots only; the devices were not touched.

dormancygrace added a commit that referenced this pull request Oct 11, 2026
… lease

Review of the serial session, three findings.

One writer. The device side and the input side both wrote bytes and
notices to the WebSocket, and gorilla allows one data writer at a time:
closing the device while a large browser write was blocked panicked with
"concurrent write to websocket connection", in a goroutine the HTTP
recovery does not cover. Every data write and its deadline now go through
one mutex (wsWriter); the pings are control frames and stay outside.

Cancellable writes. writeAllSerial waited for room in the tty for ever,
and it ran in the goroutine that reads the WebSocket, so a browser that
closed while the line did not take data was never seen and the device
stayed claimed. The browser's messages now go through a bounded queue to
a writer goroutine that waits in 200 ms slices and stops with the
session; the WebSocket reader never waits on the tty. The ping goroutine
is the watch on the connection's life: a ping to a browser that has gone
fails, and that ends the session. No write timeout: a slow line still
gets its bytes. The descriptor is closed after every goroutine has
returned, and the lease released after that.

The lease. The probe's busy check saw a terminal, but a terminal started
while the probe ran opened the device and later got the probe's restored
canonical settings over its raw line. #76 now holds a per-device lease
for the probe's whole run (serial_lease.go); the session claims the same
one, so either keeps the other out in both start orders.

Tests: a disconnect during traffic in both directions (20 runs under
-race), the browser closing during a blocked write releases the device
within the ping interval, a terminal refused while the probe holds the
device and admitted afterwards; the existing exclusivity test with the
generic notice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dormancygrace

Copy link
Copy Markdown
Owner Author

Re-review complete on 81414b234c22fe8b7e5417adf1ac38622b7311f9. The previous findings are fixed. The shared probe lease test passes under -race; all 18 helper and 5 FAT-image tests pass, and both generated output hashes match expected.sha256. I merged current main and resolved the USB-menu conflict by retaining SerialConsole plus #77's conditional MouseJiggler. TypeScript and all 7 USB preset tests pass. No device changes. Waiting for CI on the integrated head before merging, as authorized.

@dormancygrace
dormancygrace merged commit 77f8d34 into main Oct 11, 2026
13 checks passed
@dormancygrace
dormancygrace deleted the claude/serial-console-helper branch October 11, 2026 20:35
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