Repository navigation
JNode Serial Skill
OpenCode skill for the JNode serial transport: agent console, boot log, and KDB over serial for QEMU and VirtualBox VMs, with per-instance socket paths.
The jnode-serial skill is the transport layer shared by the jnode-qemu and jnode-vbox lifecycle skills (and usable standalone). It provides agent-driven interaction with JNode's implicit serial console (SerialConsolePlugin) over a per-VM unix socket/pipe, a persistent mux that solves the VirtualBox single-client pipe stall problem, boot-log and readiness helpers, and KDB-over-serial tooling.
Everything is keyed by an instance id so several VMs (QEMU/VirtualBox, local/GitHub-Actions) run side by side without path collisions. Instance resolution: --instance flag > $JNODE_INSTANCE env > auto (default when free, else qemu-1, qemu-2, ...).
| resource |
default instance (legacy, fixed) |
any other id |
|---|---|---|
| agent socket | /tmp/jnode.serial2 |
/tmp/jnode/<id>/agent.sock |
| boot log | /tmp/qemu_serial.log |
/tmp/jnode/<id>/boot.log |
| KDB socket | /tmp/jnode.kdb |
/tmp/jnode/<id>/kdb.sock |
| QEMU monitor | /tmp/qemu_monitor.sock |
/tmp/jnode/<id>/monitor.sock |
| mux files | /tmp/jnode_serial_* |
/tmp/jnode/<id>/mux/ |
| registry | /tmp/jnode/instances/<id>.json |
same |
The legacy default paths keep existing tooling (tests/jdwp, local/*.sh, older skills) working. JNODE_STATE_DIR relocates the state root.
| File | Purpose |
|---|---|
.opencode/skills/jnode-serial/SKILL.md |
Skill definition and usage instructions |
.opencode/skills/jnode-serial/scripts/jnode_paths.py |
Instance model: paths, registry, --list/--env/--alloc/--probe/--is-live CLI |
.opencode/skills/jnode-serial/scripts/serial_cmd.py |
Agent CLI client: batches, --wait, --oneshot, --status/--stop/--restart/--interrupt
|
.opencode/skills/jnode-serial/scripts/serial_mux.py |
Persistent single-client proxy daemon (auto-started) |
.opencode/skills/jnode-serial/scripts/jnode_kdb_cmd.py |
One-shot KDB command sender (p t T q r w W v l h) |
.opencode/skills/jnode-serial/scripts/kdb_mux.py |
Persistent KDB holder + cmd.fifo multiplexer |
VirtualBox's named pipe server (--uartmode2 server <path>) supports only one client at a time. The legacy pattern (one connection per command) eventually wedges the pipe server: new attaches get an immediate FIN/EOF while the guest stays healthy. VBox.log shows NamedPipe0: only single connection supported. Only a power cycle used to clear it.
serial_mux.py holds one pipe client for the entire session, never disconnecting, so the stuck state is never entered. It:
- Connects to the instance's agent socket/pipe once
- Waits for the
[JNODE_AGENT_READY]prompt (\n[JNODE_AGENT_READY]\n, CRLF output) - Accepts batches of commands from
serial_cmd.py - Sends commands and streams output back
- Self-heals: reconnect with backoff while the guest has not opened its COM port yet; runtime
changeuartmode2 replugif the VBox pipe server wedges
Before the guest opens COM2 (VBox entry "all plugins" can take 3-6 minutes) the status shows link=down; it flips to link=up on its own.
If the pipe server wedges (VBox FINs new attaches), the mux resets it without a VM reboot:
vboxmanage controlvm "JNode" changeuartmode2 disconnected
sleep 1
vboxmanage controlvm "JNode" changeuartmode2 server /tmp/jnode.serial2The wedge detector fires after WEDGE_SILENCE = 180.0 s of RX silence, waits REPLUG_AFTER_FINS = 3 input-finish events, then replugs — throttled to REPLUG_MIN_INTERVAL = 15.0 s.
Three resend paths existed, none of them capped: the wait_begin prompt resend (a prompt seen without the marker echo), the reconnect resync, and the wedge-replug. A client SIGKILLed mid-batch left its request in flight, and any error flood that drowned the __JSM_E_<cid>__ END marker kept the command uncapturable — so the same command re-executed on the guest ~10 times per second for up to the 20-minute DEFAULT_REQ_TIMEOUT. Each execution spawns a proclet with streams plus a logged stack trace, exhausting the guest's fixed heap into a GC spiral.
Every send — first send and resend alike — now goes through one funnel:
MAX_RESENDS_PER_CMD = 3 # bound every resend path
MIN_RESEND_INTERVAL = 2.0 # minimum seconds between resends of the same command
MAX_CAPTURE_BYTES = 512 * 1024
def guarded_send(req):
if not client_alive(req.get("pid")):
raise Abandon("client %s died" % req.get("pid"))
if req["sends_this_cmd"] > 0: # this is a resend
now = time.time()
if now - req["last_resend"] < MIN_RESEND_INTERVAL:
return # silently drop, not an error
req["resends"] += 1
if req["resends"] > MAX_RESENDS_PER_CMD:
raise Abandon("abandoned after %d resends" % MAX_RESENDS_PER_CMD)
req["last_resend"] = now
req["sends_this_cmd"] += 1
start_next_cmd(req)-
resends/last_resendare per request (the whole batch shares the budget of 3), so a multi-command batch cannot multiply it.sends_this_cmdandcap_totalreset to 0 per command. -
client_alive(cpid)returnsTruefor a request with no PID (or a non-numeric one), so old clients behave exactly as before; only a request carrying a dead PID is abandoned.serial_cmd.pynow includes"pid": os.getpid()in the request JSON. -
Abandondrops the whole request:abandon_cursends ETX to the guest, logs, finalises the.outfile with anERROR <reason>line and clearslink.buf.
Output is also bounded: cap_total accumulates both the complete lines about to be emitted and the bytes past the partial-marker retain window, and exceeding MAX_CAPTURE_BYTES raises Abandon("output overflow (> 524288 bytes)"). That is what stops a cat of a 4.3 MB jar from filling the guest log.
Validated on a real VM: a SIGKILLed client mid-batch abandons in 8 seconds with no resend and a healthy shell; the 4.3 MB cat hits the flood cap in 14 seconds, ETX reaches the guest and the shell survives; ordinary single- and multi-command batches are unaffected.
Ctrl-C (ETX, 0x03) and Ctrl-Z (SUB, 0x1A) are delivered through the held connection and translated by the guest into keyboard events via RawKeyboardReader and AbstractConsole.enqueueKeyboardEvent(). This gives shell job control (background, interrupt) over serial just like on VGA.
S=.opencode/skills/jnode-serial/scripts
# Wait for the shell, then run commands in one batch
python3 $S/serial_cmd.py --wait 300 "date" "echo hello" "pwd"
# Second VM
python3 $S/serial_cmd.py --instance qemu-2 --wait 60 "date"
# Mux management
python3 $S/serial_cmd.py --status
python3 $S/serial_cmd.py --stop
python3 $S/serial_cmd.py --restart
python3 $S/serial_cmd.py --interrupt
python3 $S/serial_cmd.py --suspend
# Instance model
python3 $S/jnode_paths.py --list
python3 $S/jnode_paths.py --env --instance qemu-2
# KDB over serial (UART1; QEMU full mode, VirtualBox with --kdb)
python3 $S/jnode_kdb_cmd.py --instance qemu-kdb p
python3 $S/jnode_kdb_cmd.py --instance qemu-kdb T WReadiness: the boot log contains Serial console available on serial1 at 115200 baud when the agent console is up; serial_cmd.py --wait N retries until a command round-trips.
JNode's RedirectingInterpreter only handles <, >, | (no heredoc, no >>). Multi-line files are written via a single echo command with shell escape sequences (\n, \t, \r). The --write mode on serial_cmd.py handles escaping and line-boundary chunking for byte-exact large file writes.
- Single client: Only the mux may hold the pipe/socket. No concurrent raw connections (one per VM instance).
- Instance exclusivity: two VMs never share an instance; bind a VM to its instance at config/launch time.
- No silence timeout: The mux waits for the prompt regardless of command duration.
-
No
&&: JNode shell does not support&&. Send multiple commands as separate arguments. -
State persists:
cdandclasspath --addsurvive across calls (same shell process). -
Implicit console:
SerialConsolePluginstarts automatically. Do NOT runserialconsolemanually.
-
Pipe recreation: If the socket is deleted while the VM is running, VirtualBox does NOT recreate it. Reattach at runtime via
changeuartmode2. -
Plugin unload: Unloading
org.jnode.shell.command.driver.consoleviaplugin --unloadbreaks the serial console. Reload is broken; restart the VM. -
Thread.stop() limitation: Ctrl-C delivers the keystroke but
Thread.stop()is ineffective against timer-sleeping proclet workers (JVM-level issue). -
__JSM_B_/__JSM_E_framing echoes are visible to anything reading the console. A JNode program holding the serial console as its REPL (see NanoCode-Agent) sees them as input lines and must filter them;AgentLoop.isSerialAgentMarkerdrops__JSM_B_,__JSM_E_,echo __JSM_B_andecho __JSM_E_prefixes. - An ETX is sent on abandonment so a wedged guest command cannot pin the shell holding the rest of the batch hostage.
-
UART1 boot log vs KDB: QEMU full mode tees UART1 into a socket and a logfile at once (
logfile=chardev). VirtualBox cannot -- the default is--uartmode1 file <boot.log>(VBox is the permanent reader, so the guest never wedges on an unread pipe and the log is complete from the first byte); KDB needs the pipe layout:jnode_vbox.py config --kdb, or at runtimeVBoxManage controlvm <vm> changeuartmode1 server <kdb_sock>. In pipe mode, connecting to the KDB socket early and reading yields full boot diagnostics; KDB replies arrive once the kernel debugger is triggered.
- Serial-Port-Driver -- Serial port hardware driver and serial console architecture
- Boot-Log -- Serial debug output configuration
- Shell-Commands -- Command framework and job control
- GitHub-Actions-Workflows -- CI/CD automation that uses this skill