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
196 lines
8.5 KiB
Python
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())
|