Skip to content

JNode Serial Skill

Levente Santha edited this page Oct 16, 2026 · 5 revisions

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.

Overview

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, ...).

Instance Paths

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.

Key Components

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

How It Works

The Pipe Stall Problem (VirtualBox)

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.

The Mux Solution

serial_mux.py holds one pipe client for the entire session, never disconnecting, so the stuck state is never entered. It:

  1. Connects to the instance's agent socket/pipe once
  2. Waits for the [JNODE_AGENT_READY] prompt (\n[JNODE_AGENT_READY]\n, CRLF output)
  3. Accepts batches of commands from serial_cmd.py
  4. Sends commands and streams output back
  5. Self-heals: reconnect with backoff while the guest has not opened its COM port yet; runtime changeuartmode2 replug if 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.

Runtime Replug Recovery

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.serial2

The 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.

Bounded Resends and Dead Clients

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_resend are per request (the whole batch shares the budget of 3), so a multi-command batch cannot multiply it. sends_this_cmd and cap_total reset to 0 per command.
  • client_alive(cpid) returns True for 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.py now includes "pid": os.getpid() in the request JSON.
  • Abandon drops the whole request: abandon_cur sends ETX to the guest, logs, finalises the .out file with an ERROR <reason> line and clears link.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.

Job Control

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.

Usage

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 W

Readiness: 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.

Multi-Line File Writes

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.

Key Constraints

  • 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: cd and classpath --add survive across calls (same shell process).
  • Implicit console: SerialConsolePlugin starts automatically. Do NOT run serialconsole manually.

Gotchas

  • 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.console via plugin --unload breaks 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.isSerialAgentMarker drops __JSM_B_, __JSM_E_, echo __JSM_B_ and echo __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 runtime VBoxManage 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.

Related Pages

Clone this wiki locally