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
This commit is contained in:
@@ -1,5 +1,27 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- [x] Add a non-reboot abort path for a hung DOS command: inject `Ctrl+C` through QEMU's monitor and wait for the serial agent to recover.
|
## Done
|
||||||
- [x] Apply a configurable timeout to `dos_exec` and invoke the non-reboot abort path on timeout.
|
|
||||||
- [x] Expose `dos_abort` for an immediate user-requested command interruption.
|
- [x] Add a non-reboot abort path for a hung DOS command: inject `Ctrl+C` through
|
||||||
|
QEMU's monitor and wait for the agent to recover.
|
||||||
|
- [x] Apply a configurable timeout to `exec` and invoke the non-reboot abort path
|
||||||
|
on timeout.
|
||||||
|
- [x] Expose `abort` for an immediate user-requested command interruption.
|
||||||
|
|
||||||
|
The three above were first built for the COM1 serial agent, lost in the rewrite
|
||||||
|
to the resident TCP agent, and rebuilt on the QEMU monitor in `ferro-vm exec`.
|
||||||
|
The TCP version supervises with guest disk liveness (`info blockstats`) rather
|
||||||
|
than a fixed stopwatch, so a slow compile is no longer mistaken for a hang.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- [ ] Consider `BREAK=ON` in `C:\FDCONFIG.SYS`. Ctrl+C only takes effect at a DOS
|
||||||
|
break check, and with the FreeDOS default of `BREAK=OFF` a compute-bound
|
||||||
|
child whose output is redirected to a file may never reach one, so `abort`
|
||||||
|
cannot always stop it. `BREAK=ON` checks on every DOS call and makes the
|
||||||
|
abort reliable, at a small cost to every DOS call. Needs a VM reboot; back
|
||||||
|
up `FDCONFIG.SYS` first.
|
||||||
|
- [ ] `TCPAGENT.EXE` connects from a fixed source port (`LOCAL_PORT 2058`). After
|
||||||
|
the host end closes, a reconnect reuses the same 4-tuple and can flap until
|
||||||
|
the old state ages out. Observed as a ~10s connect/disconnect cycle after
|
||||||
|
the daemon is killed mid-connection.
|
||||||
|
|||||||
+18
-2
@@ -70,6 +70,8 @@ EPILOG = r"""examples:
|
|||||||
uv run ferro-vm exec 'dir C:\FEC' run a DOS command, print exit code and output
|
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 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 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
|
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
|
The authoritative workspace is C:\FEC inside the VM. Never build on D: (the vvfat
|
||||||
@@ -84,6 +86,7 @@ SIMPLE_COMMANDS = {
|
|||||||
"screenshot": "Capture the VGA console to a PPM/PNG under .qemu/.",
|
"screenshot": "Capture the VGA console to a PPM/PNG under .qemu/.",
|
||||||
"ocr": "Capture the console and print recognized text (RapidOCR).",
|
"ocr": "Capture the console and print recognized text (RapidOCR).",
|
||||||
"logs": "Follow the append-only daemon log. Uses lnav when available.",
|
"logs": "Follow the append-only daemon log. Uses lnav when available.",
|
||||||
|
"abort": "Interrupt the DOS command currently running (Ctrl+C via QEMU).",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -112,9 +115,19 @@ def main() -> int:
|
|||||||
execute = commands.add_parser(
|
execute = commands.add_parser(
|
||||||
"exec", help=exec_help,
|
"exec", help=exec_help,
|
||||||
description=exec_help + " Quote the command so the host shell does not eat"
|
description=exec_help + " Quote the command so the host shell does not eat"
|
||||||
r" backslashes: exec 'wcl386 -q HELLO.C'.")
|
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",
|
execute.add_argument("command", metavar="DOS_COMMAND",
|
||||||
help=r"command line to hand to COMMAND.COM, e.g. 'dir C:\FEC'")
|
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_help = "Copy a host file into the VM."
|
||||||
put = commands.add_parser("put", help=put_help, description=put_help)
|
put = commands.add_parser("put", help=put_help, description=put_help)
|
||||||
@@ -161,7 +174,10 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
raise RuntimeError("TCPAGENT did not become ready")
|
raise RuntimeError("TCPAGENT did not become ready")
|
||||||
payload: dict[str, object] = {"op": args.op}
|
payload: dict[str, object] = {"op": args.op}
|
||||||
if args.op == "exec": payload["command"] = args.command
|
if args.op == "exec":
|
||||||
|
payload["command"] = args.command
|
||||||
|
payload["idle_timeout"] = args.idle_timeout
|
||||||
|
payload["hard_timeout"] = args.hard_timeout
|
||||||
if args.op == "put":
|
if args.op == "put":
|
||||||
payload["source"] = str(args.source.resolve())
|
payload["source"] = str(args.source.resolve())
|
||||||
payload["destination"] = args.destination
|
payload["destination"] = args.destination
|
||||||
|
|||||||
+192
-32
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import select
|
import select
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
@@ -26,6 +27,22 @@ AGENT_ADDRESS = ("127.0.0.1", 5558)
|
|||||||
MONITOR_ADDRESS = ("127.0.0.1", 4444)
|
MONITOR_ADDRESS = ("127.0.0.1", 4444)
|
||||||
LOG_PATH = QEMU / "ferro-vm.log"
|
LOG_PATH = QEMU / "ferro-vm.log"
|
||||||
|
|
||||||
|
# Short commands answer promptly, so a plain socket timeout is the right guard.
|
||||||
|
REQUEST_TIMEOUT = 30
|
||||||
|
# EXEC is different: TCPAGENT is frozen inside system() for the whole command
|
||||||
|
# and cannot answer, so silence proves nothing. Wake up often, decide with
|
||||||
|
# guest liveness instead of a stopwatch, and never discard the connection just
|
||||||
|
# because a compile is slow.
|
||||||
|
EXEC_POLL_SECONDS = 2
|
||||||
|
DEFAULT_IDLE_TIMEOUT = 60
|
||||||
|
DEFAULT_HARD_TIMEOUT = 900
|
||||||
|
# Budget for collecting the result after Ctrl+C, before giving up on the stream.
|
||||||
|
INTERRUPT_GRACE_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
|
class ExecInterrupted(RuntimeError):
|
||||||
|
"""The supervisor decided to stop the running DOS command."""
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
QEMU.mkdir(exist_ok=True)
|
QEMU.mkdir(exist_ok=True)
|
||||||
@@ -46,16 +63,37 @@ class Host:
|
|||||||
self.agent_lock = threading.Lock()
|
self.agent_lock = threading.Lock()
|
||||||
self.agent_ready = threading.Event()
|
self.agent_ready = threading.Event()
|
||||||
self.qemu: subprocess.Popen[bytes] | None = None
|
self.qemu: subprocess.Popen[bytes] | None = None
|
||||||
|
# QEMU accepts one monitor connection at a time, and the EXEC
|
||||||
|
# supervisor polls it while other control requests run concurrently.
|
||||||
|
self.monitor_lock = threading.Lock()
|
||||||
|
self.abort_requested = threading.Event()
|
||||||
|
self.exec_active = threading.Event()
|
||||||
|
|
||||||
def accept_agents(self) -> None:
|
@staticmethod
|
||||||
|
def bind_agent_listener() -> socket.socket:
|
||||||
|
"""Bind 5558 exclusively so a second daemon fails loudly.
|
||||||
|
|
||||||
|
SO_REUSEADDR means something different on Windows than on Unix: it lets
|
||||||
|
another process bind an already-bound port and quietly take over new
|
||||||
|
connections, so a duplicate daemon would be silently half-working
|
||||||
|
instead of refusing to start. SO_EXCLUSIVEADDRUSE is the Windows way to
|
||||||
|
say "only me".
|
||||||
|
"""
|
||||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None)
|
||||||
|
if exclusive is not None:
|
||||||
|
server.setsockopt(socket.SOL_SOCKET, exclusive, 1)
|
||||||
|
else:
|
||||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
server.bind(AGENT_ADDRESS)
|
server.bind(AGENT_ADDRESS)
|
||||||
server.listen(1)
|
server.listen(1)
|
||||||
|
return server
|
||||||
|
|
||||||
|
def accept_agents(self, server: socket.socket) -> None:
|
||||||
log_event(logging.INFO, "agent listener ready", address="127.0.0.1:5558")
|
log_event(logging.INFO, "agent listener ready", address="127.0.0.1:5558")
|
||||||
while True:
|
while True:
|
||||||
sock, peer = server.accept()
|
sock, peer = server.accept()
|
||||||
sock.settimeout(30)
|
sock.settimeout(REQUEST_TIMEOUT)
|
||||||
with self.agent_lock:
|
with self.agent_lock:
|
||||||
if self.agent is not None:
|
if self.agent is not None:
|
||||||
sock.close()
|
sock.close()
|
||||||
@@ -94,15 +132,62 @@ class Host:
|
|||||||
log_event(logging.INFO, "agent disconnected", peer=f"{peer[0]}:{peer[1]}")
|
log_event(logging.INFO, "agent disconnected", peer=f"{peer[0]}:{peer[1]}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_line(sock: socket.socket) -> bytes:
|
def _read_line(sock: socket.socket, supervise=None) -> bytes:
|
||||||
|
# Partial input is kept across timeouts, so supervise() may fire in the
|
||||||
|
# middle of a line without losing what has already arrived.
|
||||||
out = bytearray()
|
out = bytearray()
|
||||||
while not out.endswith(b"\n"):
|
while not out.endswith(b"\n"):
|
||||||
|
try:
|
||||||
part = sock.recv(1)
|
part = sock.recv(1)
|
||||||
|
except TimeoutError:
|
||||||
|
if supervise is None:
|
||||||
|
raise
|
||||||
|
supervise()
|
||||||
|
continue
|
||||||
if not part:
|
if not part:
|
||||||
raise ConnectionError("TCP agent closed connection")
|
raise ConnectionError("TCP agent closed connection")
|
||||||
out.extend(part)
|
out.extend(part)
|
||||||
return bytes(out).rstrip(b"\r\n")
|
return bytes(out).rstrip(b"\r\n")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_exactly(sock: socket.socket, count: int, supervise=None) -> bytes:
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
while count:
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(min(65536, count))
|
||||||
|
except TimeoutError:
|
||||||
|
if supervise is None:
|
||||||
|
raise
|
||||||
|
supervise()
|
||||||
|
continue
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("TCP agent closed connection")
|
||||||
|
chunks.append(chunk)
|
||||||
|
count -= len(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
def guest_idle_seconds(self) -> float | None:
|
||||||
|
"""Seconds since the guest last touched a disk, or None if unknown.
|
||||||
|
|
||||||
|
QEMU keeps counting while TCPAGENT is frozen inside system(), so this
|
||||||
|
is the one progress signal available during a long DOS command. A
|
||||||
|
purely CPU-bound command looks idle here, which is what the hard
|
||||||
|
timeout is for.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = self.monitor("info blockstats")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
idle: float | None = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
operations = re.search(r"rd_operations=(\d+)", line)
|
||||||
|
elapsed = re.search(r"idle_time_ns=(\d+)", line)
|
||||||
|
if not operations or not elapsed or int(operations.group(1)) == 0:
|
||||||
|
continue
|
||||||
|
seconds = int(elapsed.group(1)) / 1e9
|
||||||
|
idle = seconds if idle is None else min(idle, seconds)
|
||||||
|
return idle
|
||||||
|
|
||||||
def request(self, command: str, payload: bytes = b"") -> str:
|
def request(self, command: str, payload: bytes = b"") -> str:
|
||||||
with self.agent_lock:
|
with self.agent_lock:
|
||||||
if self.agent is None:
|
if self.agent is None:
|
||||||
@@ -125,45 +210,107 @@ class Host:
|
|||||||
def ping(self) -> dict[str, object]:
|
def ping(self) -> dict[str, object]:
|
||||||
return {"response": self.request("PING")}
|
return {"response": self.request("PING")}
|
||||||
|
|
||||||
def exec(self, command: str) -> dict[str, object]:
|
def _read_exec_result(self, sock: socket.socket, supervise=None) -> tuple[int, int, bytes]:
|
||||||
log_event(logging.INFO, "exec start", command=command)
|
header = self._read_line(sock, supervise).decode("ascii", "replace")
|
||||||
|
fields = header.split()
|
||||||
|
if len(fields) == 4 and fields[0] == "RESULT":
|
||||||
|
code, length, flags = int(fields[1]), int(fields[2]), int(fields[3])
|
||||||
|
return code, flags, self._read_exactly(sock, length, supervise)
|
||||||
|
if fields and fields[0] == "OK":
|
||||||
|
# Compatibility with an installed pre-RESULT agent.
|
||||||
|
return int(fields[1]), 1, bytes.fromhex(fields[2]) if len(fields) > 2 else b""
|
||||||
|
raise RuntimeError("malformed EXEC response: " + header)
|
||||||
|
|
||||||
|
def exec(self, command: str, idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
|
||||||
|
hard_timeout: float = DEFAULT_HARD_TIMEOUT) -> dict[str, object]:
|
||||||
|
log_event(logging.INFO, "exec start", command=command,
|
||||||
|
idle_timeout=idle_timeout, hard_timeout=hard_timeout)
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
self.abort_requested.clear()
|
||||||
with self.agent_lock:
|
with self.agent_lock:
|
||||||
if self.agent is None:
|
if self.agent is None:
|
||||||
raise RuntimeError("TCPAGENT is not connected")
|
raise RuntimeError("TCPAGENT is not connected")
|
||||||
sock = self.agent
|
sock = self.agent
|
||||||
|
self.exec_active.set()
|
||||||
|
reported = started
|
||||||
|
interrupt_at: float | None = None
|
||||||
|
interrupted = ""
|
||||||
|
answered = False
|
||||||
|
|
||||||
|
def supervise() -> None:
|
||||||
|
"""Called every EXEC_POLL_SECONDS while the agent stays silent."""
|
||||||
|
nonlocal reported, interrupt_at, interrupted, answered
|
||||||
|
now = time.monotonic()
|
||||||
|
idle = self.guest_idle_seconds()
|
||||||
|
if now - reported >= 15:
|
||||||
|
reported = now
|
||||||
|
log_event(logging.INFO, "exec running", elapsed_s=round(now-started, 1),
|
||||||
|
guest_idle_s=None if idle is None else round(idle, 1))
|
||||||
|
if interrupt_at is None:
|
||||||
|
if self.abort_requested.is_set():
|
||||||
|
interrupted = "aborted by request"
|
||||||
|
elif now - started > hard_timeout:
|
||||||
|
interrupted = f"hard timeout after {hard_timeout:.0f}s"
|
||||||
|
elif idle is not None and idle > idle_timeout:
|
||||||
|
interrupted = f"guest idle {idle:.0f}s exceeds {idle_timeout:.0f}s"
|
||||||
|
if interrupted:
|
||||||
|
interrupt_at = now
|
||||||
|
log_event(logging.WARNING, "exec interrupting", reason=interrupted)
|
||||||
|
self.monitor("sendkey ctrl-c")
|
||||||
|
return
|
||||||
|
waited = now - interrupt_at
|
||||||
|
if not answered and waited > 4:
|
||||||
|
# COMMAND.COM asks "Terminate batch file (Y/N/A)?" for .BAT
|
||||||
|
# targets and sits at that prompt until it is answered.
|
||||||
|
answered = True
|
||||||
|
self.monitor("sendkey y")
|
||||||
|
self.monitor("sendkey ret")
|
||||||
|
# Ctrl+C only lands at a DOS break check. With BREAK=OFF (the
|
||||||
|
# FreeDOS default) a compute-bound child whose output we
|
||||||
|
# redirected to a file may never reach one, so the command runs
|
||||||
|
# to completion regardless. Keep collecting its result rather
|
||||||
|
# than abandoning a stream that still owes us one -- give up
|
||||||
|
# only once the guest has gone quiet too.
|
||||||
|
if waited > INTERRUPT_GRACE_SECONDS and (idle is None or idle > 5):
|
||||||
|
raise ExecInterrupted(interrupted + "; command did not stop")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
encoded = command.encode("ascii", "replace").hex().upper()
|
encoded = command.encode("ascii", "replace").hex().upper()
|
||||||
|
sock.settimeout(EXEC_POLL_SECONDS)
|
||||||
sock.sendall(f"EXEC {encoded}\n".encode("ascii"))
|
sock.sendall(f"EXEC {encoded}\n".encode("ascii"))
|
||||||
header = self._read_line(sock).decode("ascii", "replace")
|
code, flags, raw = self._read_exec_result(sock, supervise)
|
||||||
fields = header.split()
|
except (OSError, ExecInterrupted) as exc:
|
||||||
if len(fields) == 4 and fields[0] == "RESULT":
|
# Only now is the stream beyond repair; drop it so the agent
|
||||||
code, remaining, flags = int(fields[1]), int(fields[2]), int(fields[3])
|
# reconnects with a clean protocol state.
|
||||||
chunks: list[bytes] = []
|
|
||||||
while remaining:
|
|
||||||
chunk = sock.recv(min(65536, remaining))
|
|
||||||
if not chunk:
|
|
||||||
raise ConnectionError("TCP agent closed during EXEC result")
|
|
||||||
chunks.append(chunk)
|
|
||||||
remaining -= len(chunk)
|
|
||||||
raw = b"".join(chunks)
|
|
||||||
elif fields and fields[0] == "OK":
|
|
||||||
# Compatibility with an installed pre-RESULT agent.
|
|
||||||
code = int(fields[1]); flags = 1
|
|
||||||
raw = bytes.fromhex(fields[2]) if len(fields) > 2 else b""
|
|
||||||
else:
|
|
||||||
raise RuntimeError("malformed EXEC response: " + header)
|
|
||||||
except OSError as exc:
|
|
||||||
if self.agent is sock:
|
if self.agent is sock:
|
||||||
self.agent = None
|
self.agent = None
|
||||||
self.agent_ready.clear()
|
self.agent_ready.clear()
|
||||||
|
sock.close()
|
||||||
raise RuntimeError(f"TCPAGENT EXEC failed: {exc}") from exc
|
raise RuntimeError(f"TCPAGENT EXEC failed: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
self.exec_active.clear()
|
||||||
|
self.abort_requested.clear()
|
||||||
|
try:
|
||||||
|
sock.settimeout(REQUEST_TIMEOUT)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
output = raw.decode("cp437", "replace")
|
output = raw.decode("cp437", "replace")
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
log_event(logging.INFO, "dos output", line=line)
|
log_event(logging.INFO, "dos output", line=line)
|
||||||
log_event(logging.INFO, "exec finish", exit=code, bytes=len(raw), flags=flags,
|
log_event(logging.INFO, "exec finish", exit=code, bytes=len(raw), flags=flags,
|
||||||
|
interrupted=interrupted or None,
|
||||||
elapsed_ms=round((time.monotonic()-started)*1000))
|
elapsed_ms=round((time.monotonic()-started)*1000))
|
||||||
return {"exit": code, "output": output, "bytes": len(raw), "flags": flags}
|
result = {"exit": code, "output": output, "bytes": len(raw), "flags": flags}
|
||||||
|
if interrupted:
|
||||||
|
result["interrupted"] = interrupted
|
||||||
|
return result
|
||||||
|
|
||||||
|
def abort(self) -> dict[str, object]:
|
||||||
|
if not self.exec_active.is_set():
|
||||||
|
return {"aborted": False, "reason": "no command is running"}
|
||||||
|
self.abort_requested.set()
|
||||||
|
log_event(logging.INFO, "abort requested")
|
||||||
|
return {"aborted": True}
|
||||||
|
|
||||||
def put(self, source: str, destination: str) -> dict[str, object]:
|
def put(self, source: str, destination: str) -> dict[str, object]:
|
||||||
data = Path(source).read_bytes()
|
data = Path(source).read_bytes()
|
||||||
@@ -198,9 +345,8 @@ class Host:
|
|||||||
log_event(logging.INFO, "get", path=source, bytes=len(data), destination=str(target))
|
log_event(logging.INFO, "get", path=source, bytes=len(data), destination=str(target))
|
||||||
return {"path": source, "destination": str(target), "bytes": len(data)}
|
return {"path": source, "destination": str(target), "bytes": len(data)}
|
||||||
|
|
||||||
@staticmethod
|
def monitor(self, command: str) -> str:
|
||||||
def monitor(command: str) -> str:
|
with self.monitor_lock, socket.create_connection(MONITOR_ADDRESS, timeout=3) as sock:
|
||||||
with socket.create_connection(MONITOR_ADDRESS, timeout=3) as sock:
|
|
||||||
sock.settimeout(1)
|
sock.settimeout(1)
|
||||||
time.sleep(.1)
|
time.sleep(.1)
|
||||||
try:
|
try:
|
||||||
@@ -269,7 +415,11 @@ class Host:
|
|||||||
if op == "start": return self.start()
|
if op == "start": return self.start()
|
||||||
if op == "stop": return self.stop()
|
if op == "stop": return self.stop()
|
||||||
if op == "ping": return self.ping()
|
if op == "ping": return self.ping()
|
||||||
if op == "exec": return self.exec(str(request["command"]))
|
if op == "abort": return self.abort()
|
||||||
|
if op == "exec":
|
||||||
|
return self.exec(str(request["command"]),
|
||||||
|
float(request.get("idle_timeout", DEFAULT_IDLE_TIMEOUT)),
|
||||||
|
float(request.get("hard_timeout", DEFAULT_HARD_TIMEOUT)))
|
||||||
if op == "put": return self.put(str(request["source"]), str(request["destination"]))
|
if op == "put": return self.put(str(request["source"]), str(request["destination"]))
|
||||||
if op == "get": return self.get(str(request["source"]), str(request["destination"]))
|
if op == "get": return self.get(str(request["source"]), str(request["destination"]))
|
||||||
if op == "screenshot": return self.screenshot()
|
if op == "screenshot": return self.screenshot()
|
||||||
@@ -280,8 +430,8 @@ class Host:
|
|||||||
def serve_pipe(host: Host) -> None:
|
def serve_pipe(host: Host) -> None:
|
||||||
listener = Listener(PIPE, family="AF_PIPE")
|
listener = Listener(PIPE, family="AF_PIPE")
|
||||||
log_event(logging.INFO, "control pipe ready", pipe=PIPE)
|
log_event(logging.INFO, "control pipe ready", pipe=PIPE)
|
||||||
while True:
|
|
||||||
conn = listener.accept()
|
def handle(conn) -> None:
|
||||||
try:
|
try:
|
||||||
request = conn.recv()
|
request = conn.recv()
|
||||||
try:
|
try:
|
||||||
@@ -292,13 +442,23 @@ def serve_pipe(host: Host) -> None:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# One thread per request: `abort` has to be answerable while a long
|
||||||
|
# `exec` is still holding the agent.
|
||||||
|
threading.Thread(target=handle, args=(listener.accept(),), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
raise SystemExit("ferro-vm currently supports Windows only")
|
raise SystemExit("ferro-vm currently supports Windows only")
|
||||||
configure_logging()
|
configure_logging()
|
||||||
host = Host()
|
host = Host()
|
||||||
threading.Thread(target=host.accept_agents, daemon=True).start()
|
try:
|
||||||
|
server = host.bind_agent_listener()
|
||||||
|
except OSError as exc:
|
||||||
|
log_event(logging.ERROR, "agent listener bind failed", address="127.0.0.1:5558", error=str(exc))
|
||||||
|
raise SystemExit(f"another ferro-vm daemon already owns 127.0.0.1:5558 ({exc})")
|
||||||
|
threading.Thread(target=host.accept_agents, args=(server,), daemon=True).start()
|
||||||
serve_pipe(host)
|
serve_pipe(host)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,28 @@ are not interpreted on this FreeDOS console, so neither of the usual routes
|
|||||||
works. Elapsed times come from the BIOS tick counter at 18.2065 Hz (~55 ms
|
works. Elapsed times come from the BIOS tick counter at 18.2065 Hz (~55 ms
|
||||||
resolution).
|
resolution).
|
||||||
|
|
||||||
Note that mTCP is not driven while `system()` runs a child, so a DOS command
|
## Long commands
|
||||||
lasting tens of seconds can drop the TCP connection. The agent logs `link lost`
|
|
||||||
and reconnects on its own, but the host loses that command's result.
|
mTCP is only driven when the agent calls it, and `system()` freezes the agent
|
||||||
|
for the entire child command. So during a long `EXEC` the DOS side is mute: it
|
||||||
|
cannot answer, cannot acknowledge, cannot report progress. Silence therefore
|
||||||
|
proves nothing about whether the command is healthy.
|
||||||
|
|
||||||
|
The host must not read that silence as failure. `ferro-vm exec` waits on
|
||||||
|
QEMU's own view of the guest instead: `info blockstats` keeps counting while
|
||||||
|
the agent is frozen, and `idle_time_ns` distinguishes a slow command from a
|
||||||
|
stuck one. See `--idle-timeout` and `--hard-timeout` in `ferro-vm exec --help`.
|
||||||
|
|
||||||
|
When the host does decide to stop a command it injects Ctrl+C through the QEMU
|
||||||
|
monitor, then answers COMMAND.COM's `Terminate batch file (Y/N/A)?` prompt.
|
||||||
|
That is a request, not a guarantee: Ctrl+C only lands at a DOS break check, and
|
||||||
|
with `BREAK=OFF` (the FreeDOS default in `C:\FDCONFIG.SYS`) a compute-bound
|
||||||
|
child whose output we redirected to a file may never reach one. The host keeps
|
||||||
|
collecting the result either way rather than abandoning a stream that still
|
||||||
|
owes it a `RESULT`.
|
||||||
|
|
||||||
|
Adding `BREAK=ON` to `C:\FDCONFIG.SYS` would make DOS check on every system
|
||||||
|
call and so make Ctrl+C reliable, at a small cost to every DOS call.
|
||||||
|
|
||||||
## Rebuilding inside the VM
|
## Rebuilding inside the VM
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user