Files
doslang-mirror/src/ferrolang_vm/cli.py
T
coolguyandClaude Opus 5 ec48a60e3e feat: supervise EXEC with guest liveness instead of a stopwatch
TCPAGENT는 system()이 도는 동안 통째로 얼어 있어 응답도 진행 보고도 못 한다.
그런데 호스트는 소켓에 30초 고정 타임아웃을 걸고, 만료되면 연결 자체를 버렸다.
그래서 35초짜리 컴파일이 "느린 명령"이 아니라 "죽은 에이전트"로 취급됐다.

QEMU는 게스트가 얼어 있어도 계속 돈다. info blockstats의 idle_time_ns로
"작업 중"과 "멈춤"을 구분한다. 실측으로 확인했다: 에이전트가 완전히 벙어리인
동안에도 rd_operations가 7초당 47000씩 증가하고 idle은 0.00s를 유지한다.

- EXEC은 짧은 간격으로 깨어나 감시만 하고 소켓은 절대 안 버린다.
- --idle-timeout(기본 60s)과 --hard-timeout(기본 900s). 후자는 디스크를
  안 쓰는 CPU 바운드 멈춤용 백스톱이다.
- 중단은 QEMU 모니터로 Ctrl+C를 주입하고 COMMAND.COM의
  "Terminate batch file (Y/N/A)?" 프롬프트에 답한다.
- Ctrl+C는 DOS break check에서만 먹는다. FreeDOS 기본값 BREAK=OFF에서
  출력을 파일로 돌린 CPU 바운드 자식은 거기 도달 안 할 수 있다. 그래서
  중단은 보장이 아니라 요청으로 다루고, 명령이 안 멈춰도 RESULT를 끝까지
  수거해 스트림을 깨뜨리지 않는다.
- ferro-vm abort 추가. 실행 중에도 응답해야 하므로 파이프 서버를 요청당
  스레드로 바꿨다.
- 5558 바인딩을 SO_EXCLUSIVEADDRUSE로. Windows의 SO_REUSEADDR는 다른
  프로세스가 같은 포트를 잡아 조용히 반쯤 동작하게 만든다.

검증 (QEMU FreeDOS 실측):
- 32.4초 명령 정상 완료 (이전에는 30초에 실패)
- 실행 중 abort가 0.1초에 응답, exit=95로 종료, 부분 출력 1805B 수거,
  연결 유지
- pause처럼 디스크를 안 쓰는 명령을 idle 15s로 검출해 중단

시리얼 시절에 있다가 TCP 전환에서 사라진 TODO 3건을 복구한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PQm6oAvWX4Lp3iSN5AHGT
2026-08-16 17:42:43 +09:00

196 lines
8.5 KiB
Python

"""Command line client for the Windows-only ferro-vm daemon."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import time
from multiprocessing.connection import Client
from pathlib import Path
from .daemon import PIPE, ROOT
def rpc(payload: dict[str, object], start_daemon: bool = False) -> object:
try:
conn = Client(PIPE, family="AF_PIPE")
except (FileNotFoundError, OSError):
if not start_daemon:
raise RuntimeError("ferro-vm daemon is not running; run `uv run ferro-vm start`")
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
subprocess.Popen([sys.executable, "-m", "ferrolang_vm.daemon"], cwd=ROOT, creationflags=flags, close_fds=True)
deadline = time.monotonic() + 5
while True:
try:
conn = Client(PIPE, family="AF_PIPE")
break
except (FileNotFoundError, OSError):
if time.monotonic() >= deadline:
raise RuntimeError("ferro-vm daemon did not create its control pipe")
time.sleep(.1)
with conn:
conn.send(payload)
response = conn.recv()
if not response["ok"]:
raise RuntimeError(response["error"])
return response["result"]
def wait_ready(timeout: int) -> bool:
"""Wait quietly; do not turn an expected boot gap into error-log spam."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
status = rpc({"op": "status"})
if status["agent_connected"] and str(rpc({"op": "ping"})["response"]).startswith("OK 504F4E47"):
return True
except RuntimeError:
pass
time.sleep(.5)
return False
def follow_logs() -> int:
log_path = ROOT / ".qemu" / "ferro-vm.log"
lnav = shutil.which("lnav.exe") or shutil.which("lnav")
if lnav:
return subprocess.run([lnav, str(log_path)]).returncode
print("lnav was not found; following the log with PowerShell.", file=sys.stderr)
return subprocess.run([
"powershell", "-NoProfile", "-Command",
f"Get-Content -LiteralPath '{log_path}' -Wait",
]).returncode
EPILOG = r"""examples:
uv run ferro-vm start boot the VM and start the daemon
uv run ferro-vm wait-ready block until TCPAGENT answers PING
uv run ferro-vm exec 'dir C:\FEC' run a DOS command, print exit code and output
uv run ferro-vm put fec/src/check.c 'C:\FEC\SRC\CHECK.C'
uv run ferro-vm get 'C:\FEC\TEST.OK' .qemu/TEST.OK
uv run ferro-vm exec --idle-timeout 180 'C:\FEC\BUILD-DOS.BAT'
uv run ferro-vm abort Ctrl+C the command running right now
uv run ferro-vm logs follow the structured daemon log
The authoritative workspace is C:\FEC inside the VM. Never build on D: (the vvfat
view is for exchange only). See AGENTS.md for verification rules and build traps.
"""
SIMPLE_COMMANDS = {
"start": "Start the daemon and boot QEMU. Safe to run when already up.",
"stop": "Quit QEMU cleanly and stop the daemon.",
"status": "Print daemon, QEMU, and TCPAGENT connection state as JSON.",
"ping": "Send PING to TCPAGENT. Expects 'OK 504F4E47' (PONG).",
"screenshot": "Capture the VGA console to a PPM/PNG under .qemu/.",
"ocr": "Capture the console and print recognized text (RapidOCR).",
"logs": "Follow the append-only daemon log. Uses lnav when available.",
"abort": "Interrupt the DOS command currently running (Ctrl+C via QEMU).",
}
def main() -> int:
parser = argparse.ArgumentParser(
prog="ferro-vm",
description="Windows-only QEMU/FreeDOS automation for the Ferro compiler.",
epilog=EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
commands = parser.add_subparsers(dest="op", required=True, metavar="COMMAND")
for name, blurb in SIMPLE_COMMANDS.items():
commands.add_parser(name, help=blurb, description=blurb)
wait_help = "Block until TCPAGENT is connected and answers PING."
wait = commands.add_parser("wait-ready", help=wait_help, description=wait_help)
wait.add_argument("--timeout", type=int, default=45, metavar="SECONDS",
help="give up after this many seconds (default: %(default)s)")
reset_help = "Quit QEMU cleanly, reboot it, and wait for TCPAGENT."
reset = commands.add_parser("reset", help=reset_help, description=reset_help)
reset.add_argument("--timeout", type=int, default=45, metavar="SECONDS",
help="give up after this many seconds (default: %(default)s)")
exec_help = "Run a DOS command inside the VM and print its exit code and output."
execute = commands.add_parser(
"exec", help=exec_help,
description=exec_help + " Quote the command so the host shell does not eat"
r" backslashes: exec 'wcl386 -q HELLO.C'."
" A slow command is not a failed one: the wait ends"
" when the guest stops touching its disk, not when a"
" stopwatch expires.")
execute.add_argument("command", metavar="DOS_COMMAND",
help=r"command line to hand to COMMAND.COM, e.g. 'dir C:\FEC'")
execute.add_argument("--idle-timeout", type=float, default=60, metavar="SECONDS",
help="interrupt once the guest has made no disk access for"
" this long (default: %(default)s)")
execute.add_argument("--hard-timeout", type=float, default=900, metavar="SECONDS",
help="interrupt after this much total time regardless of"
" activity; the backstop for a CPU-bound hang"
" (default: %(default)s)")
put_help = "Copy a host file into the VM."
put = commands.add_parser("put", help=put_help, description=put_help)
put.add_argument("source", type=Path, metavar="HOST_PATH",
help="file on this machine")
put.add_argument("destination", metavar="DOS_PATH",
help=r"target inside the VM, e.g. 'C:\FEC\SRC\CHECK.C'."
" DOS uses 8.3 names, so long fixtures must be shortened"
" explicitly (BAD-ARI.FE, TRY-FPR.FE)")
get_help = "Copy a file out of the VM onto the host."
get = commands.add_parser("get", help=get_help, description=get_help)
get.add_argument("source", metavar="DOS_PATH",
help=r"file inside the VM, e.g. 'C:\FEC\TEST.OK'")
get.add_argument("destination", type=Path, metavar="HOST_PATH",
help="target on this machine")
args = parser.parse_args()
try:
if args.op == "logs":
return follow_logs()
if args.op == "wait-ready":
if wait_ready(args.timeout):
print(json.dumps({"agent": "PONG"}))
return 0
raise RuntimeError("TCPAGENT did not become ready")
if args.op == "ocr":
result = rpc({"op": "screenshot"})
import logging
logging.disable(logging.INFO)
from rapidocr import RapidOCR
recognized = RapidOCR()(result["path"])
print("\n".join(recognized.txts or ()))
return 0
if args.op == "reset":
rpc({"op": "stop"}, start_daemon=True)
time.sleep(.5)
rpc({"op": "start"}, start_daemon=True)
time.sleep(2)
rpc({"op": "monitor", "command": "sendkey ret"})
if wait_ready(args.timeout):
print(json.dumps({"reset": "complete", "agent": "PONG"}))
return 0
raise RuntimeError("TCPAGENT did not become ready")
payload: dict[str, object] = {"op": args.op}
if args.op == "exec":
payload["command"] = args.command
payload["idle_timeout"] = args.idle_timeout
payload["hard_timeout"] = args.hard_timeout
if args.op == "put":
payload["source"] = str(args.source.resolve())
payload["destination"] = args.destination
if args.op == "get":
payload["source"] = args.source
payload["destination"] = str(args.destination.resolve())
print(json.dumps(rpc(payload, start_daemon=args.op == "start"), ensure_ascii=False, indent=2))
return 0
except RuntimeError as exc:
print(f"ferro-vm: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())