diff --git a/CHANGELOG.md b/CHANGELOG.md index f479ddf..f2482f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- `run --follow --timeout SECONDS` waits longer for a script that works quietly, and `--timeout 0` waits until it ends. The default is still 10 s, and the timeout error now names the option (#172) + ## v0.1.5rc1 (2026-10-28) - Run the CLI's own sidecar, and only write to a drive proven to be the board's (#173) diff --git a/cli/src/mpftp/cli.py b/cli/src/mpftp/cli.py index c6519b0..6b42465 100755 --- a/cli/src/mpftp/cli.py +++ b/cli/src/mpftp/cli.py @@ -288,6 +288,11 @@ def close(self) -> None: class TcpClient(RpcClient): + #: How long a call may go without a byte from the session, in seconds. + #: ``None`` waits for the reply however long it takes; ``run --follow + #: --timeout`` uses that, since the sidecar's own limit bounds the call. + call_timeout: Optional[float] = 120 + def __init__(self, host: str, port: int) -> None: self.host = host self.port = port @@ -296,7 +301,7 @@ def __init__(self, host: str, port: int) -> None: def call(self, method: str, params: Optional[dict] = None) -> Any: self._id += 1 req = {"id": self._id, "method": method, "params": params or {}} - with socket.create_connection((self.host, self.port), timeout=120) as s: + with socket.create_connection((self.host, self.port), timeout=self.call_timeout) as s: s.sendall((json.dumps(req) + "\n").encode("utf-8")) buf = b"" while True: @@ -1388,14 +1393,36 @@ def cmd_probe(ns: argparse.Namespace) -> None: client.close() +def _follow_timeout_arg(text: str) -> float: + """``run --timeout``: seconds, 0 or more.""" + try: + secs = float(text) + except ValueError: + raise argparse.ArgumentTypeError(f"not a number of seconds: {text!r}") from None + if secs < 0 or secs != secs: + raise argparse.ArgumentTypeError("must be 0 (wait until the script ends) or more seconds") + return secs + + def cmd_run(ns: argparse.Namespace) -> None: + if getattr(ns, "timeout", None) is not None and not getattr(ns, "follow", False): + raise ValueError( + "--timeout only applies with --follow: without it, run returns as soon " + "as the script starts" + ) source = Path(ns.file).read_text(encoding="utf-8") client, mode = get_client() try: ensure_device(client, ns.device, ns.baud) # Default no-follow so UI apps do not wedge the COM handle (mpftp#3). follow = bool(getattr(ns, "follow", False)) - out(client.call("run_script", {"source": source, "follow": follow})) + params: dict[str, Any] = {"source": source, "follow": follow} + timeout = getattr(ns, "timeout", None) + if timeout is not None: + params["timeout"] = timeout + if isinstance(client, TcpClient): + client.call_timeout = None + out(client.call("run_script", params)) finally: if mode.startswith("sidecar"): client.close() @@ -2526,6 +2553,15 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Wait for the script to finish (default is no-follow for UI apps)", ) + run.add_argument( + "--timeout", + type=_follow_timeout_arg, + metavar="SECONDS", + help=( + "With --follow, how long the script may print nothing before mpftp " + "stops waiting (default 10); 0 waits until the script ends" + ), + ) run.set_defaults(func=cmd_run) pb = sub.add_parser( diff --git a/cli/src/mpftp/sidecar.py b/cli/src/mpftp/sidecar.py index ec3bea3..2a874b2 100644 --- a/cli/src/mpftp/sidecar.py +++ b/cli/src/mpftp/sidecar.py @@ -211,6 +211,32 @@ def is_eof_timeout_error(exc: BaseException) -> bool: ) +#: How long ``run --follow`` waits while the script prints nothing, in seconds: +#: mpremote's own limit for ``exec``, kept as the default so callers that don't +#: ask for another see no change. +FOLLOW_TIMEOUT_SECS = 10.0 + + +def friendly_follow_timeout_message(detail: str, timeout: Optional[float]) -> str: + """The error for a ``run --follow`` that stopped waiting on a quiet script. + + A script that prints a line and then works quietly for longer than the + limit is still running fine, so this leads with the option that lets the + follow wait for it (mpftp#172). + """ + secs = FOLLOW_TIMEOUT_SECS if timeout is None else timeout + return ( + f"{detail}\n" + f"The script printed nothing for {secs:g} s, so run --follow stopped " + "waiting; the board is still running it. If it's working quietly, give " + "it longer with --timeout SECONDS, or --timeout 0 to wait until it ends. " + "For a UI app or a loop, leave out --follow. To watch one that is " + "already running, use `mpftp monitor`. To stop it: interrupt, " + "soft-reset, or hard-reset. " + "mpftp released the COM handle so Connect/Resume can reclaim the port." + ) + + def friendly_exec_timeout_message(detail: str) -> str: return ( f"{detail}\n" @@ -243,25 +269,57 @@ class FollowTimeoutError(RuntimeError): lines looked identical to one that never started (mpftp#25). """ - def __init__(self, message: str, partial_output: str = "") -> None: + def __init__( + self, + message: str, + partial_output: str = "", + timeout: Optional[float] = FOLLOW_TIMEOUT_SECS, + ) -> None: super().__init__(message) self.partial_output = partial_output + self.timeout = timeout + +def follow_timeout(value: Any) -> Optional[float]: + """A ``timeout`` RPC param as the follow's quiet limit, in seconds. -def exec_follow(transport: Any, source: str) -> str: + Absent (``None``) is the default; ``0`` waits until the script ends, which + the result spells ``None`` because that is how mpremote's read loop says + "no limit". + """ + if value is None: + return FOLLOW_TIMEOUT_SECS + secs = float(value) + if secs < 0: + raise ValueError(f"timeout must be 0 or more seconds, not {value!r}") + return None if secs == 0 else secs + + +def exec_follow( + transport: Any, source: str, timeout: Optional[float] = FOLLOW_TIMEOUT_SECS +) -> str: """``transport.exec(source)`` that keeps the output when the follow times out. Streams the board's bytes through mpremote's ``data_consumer`` hook rather than letting ``follow`` accumulate them in a local its timeout discards; a timeout then raises :class:`FollowTimeoutError` carrying the partial text. + + ``timeout`` is how long the script may print nothing before the follow + gives up; ``None`` waits until it ends. mpremote's ``exec`` has no way to + pass one (it always waits 10 s), so this calls ``exec_raw`` and raises + the script's error the way ``exec`` would. """ chunks: list[bytes] = [] try: - transport.exec(source, data_consumer=chunks.append) + _, err = transport.exec_raw(source, timeout=timeout, data_consumer=chunks.append) except Exception as e: if is_eof_timeout_error(e): - raise FollowTimeoutError(str(e), _consumed_text(chunks)) from e + raise FollowTimeoutError(str(e), _consumed_text(chunks), timeout) from e raise + if err: + from mpremote.transport import TransportExecError + + raise TransportExecError(b"", err.decode()) return _consumed_text(chunks) @@ -2598,9 +2656,18 @@ def op(t): return self.with_raw(op) def _run_after_clean( - self, source: str, *, follow: bool, path: Optional[str] = None + self, + source: str, + *, + follow: bool, + path: Optional[str] = None, + timeout: Optional[float] = FOLLOW_TIMEOUT_SECS, ) -> dict[str, Any]: - """Interrupt + raw soft-reset (skip main), then exec source.""" + """Interrupt + raw soft-reset (skip main), then exec source. + + With ``follow``, ``timeout`` is how long the script may print nothing + before the follow gives up; ``None`` waits until it ends. + """ with self._lock: was_repl = self._repl_mode t = self._require() @@ -2608,7 +2675,10 @@ def _run_after_clean( try: t = self._take_control_resilient(t, clean=True, timeout_overall=20.0) if follow: - result: dict[str, Any] = {"output": exec_follow(t, source), "followed": True} + result: dict[str, Any] = { + "output": exec_follow(t, source, timeout), + "followed": True, + } else: t.exec_raw_no_follow(source.encode()) try: @@ -2623,10 +2693,10 @@ def _run_after_clean( except Exception as e: if is_eof_timeout_error(e) or is_dead_serial_error(e): self._release_dead_transport(str(e)) - message = friendly_exec_timeout_message(str(e)) if isinstance(e, FollowTimeoutError): - raise FollowTimeoutError(message, e.partial_output) from e - raise RuntimeError(message) from e + message = friendly_follow_timeout_message(str(e), e.timeout) + raise FollowTimeoutError(message, e.partial_output, e.timeout) from e + raise RuntimeError(friendly_exec_timeout_message(str(e))) from e raise finally: if self.transport and (was_repl or self._repl_mode): @@ -2635,15 +2705,21 @@ def _run_after_clean( elif not self.transport: self._repl_mode = False - def run_script(self, source: str, follow: bool = False) -> dict[str, Any]: + def run_script( + self, source: str, follow: bool = False, timeout: Optional[float] = FOLLOW_TIMEOUT_SECS + ) -> dict[str, Any]: """Run source after interrupt + raw soft-reset (skip main.py). Default ``follow=False`` so UI apps do not block the sidecar (same as - ``run_path``). Pass ``follow=True`` only for short scripts that exit. + ``run_path``). Pass ``follow=True`` only for scripts that exit; + ``timeout`` is how long a followed script may print nothing (``None`` + waits until it ends). """ - return self._run_after_clean(source, follow=follow) + return self._run_after_clean(source, follow=follow, timeout=timeout) - def run_path(self, path: str, follow: bool = False) -> dict[str, Any]: + def run_path( + self, path: str, follow: bool = False, timeout: Optional[float] = FOLLOW_TIMEOUT_SECS + ) -> dict[str, Any]: """ Run a .py file already on the board. @@ -2659,7 +2735,7 @@ def run_path(self, path: str, follow: bool = False) -> dict[str, Any]: "exec(compile(open(_p).read(), _p, \"exec\"), " "{\"__name__\": \"__main__\", \"__file__\": _p})\n" ) - return self._run_after_clean(code, follow=follow, path=path) + return self._run_after_clean(code, follow=follow, path=path, timeout=timeout) #: How long a board gets to say anything after Ctrl-C over WebREPL. NETWORK_INTERRUPT_WAIT = 2.5 @@ -4632,9 +4708,11 @@ def _debug_tee_loop( "exec": lambda p: SESSION.exec(p["code"], bool(p.get("follow", True))), "eval": lambda p: SESSION.eval(p["expr"]), "run_script": lambda p: SESSION.run_script( - p["source"], bool(p.get("follow", False)) + p["source"], bool(p.get("follow", False)), follow_timeout(p.get("timeout")) + ), + "run_path": lambda p: SESSION.run_path( + p["path"], bool(p.get("follow", False)), follow_timeout(p.get("timeout")) ), - "run_path": lambda p: SESSION.run_path(p["path"], bool(p.get("follow", False))), "interrupt": lambda _p: SESSION.interrupt(), "soft_reset": lambda _p: SESSION.soft_reset(), "soft_reboot": lambda _p: SESSION.soft_reboot(), diff --git a/cli/tests/test_follow_timeout.py b/cli/tests/test_follow_timeout.py index cd1acc6..7bb9a19 100644 --- a/cli/tests/test_follow_timeout.py +++ b/cli/tests/test_follow_timeout.py @@ -12,7 +12,7 @@ import socket import threading import unittest -from contextlib import redirect_stdout +from contextlib import redirect_stderr, redirect_stdout from unittest import mock from mpftp.cli import TcpClient, _emit_error_envelope @@ -37,10 +37,11 @@ class HangingBoard: """Fake raw-REPL transport whose script prints ``printed`` and then never sends the EOF that ends a follow (a UI loop, a blocking read, a hang). - Mirrors ``mpremote.transport_serial.SerialTransport.exec``: with a + Mirrors ``mpremote.transport_serial.SerialTransport``: with a ``data_consumer`` every byte is handed over as it arrives, the EOF byte included, and the return value carries nothing; without one the bytes are - accumulated in a local that the timeout throws away. + accumulated in a local that the timeout throws away. ``exec`` is + ``exec_raw`` at its fixed 10 s, as in mpremote. """ in_raw_repl = True @@ -49,14 +50,68 @@ def __init__(self, printed: bytes, *, finishes: bool = False) -> None: self.printed = printed self.finishes = finishes - def exec(self, command, data_consumer=None): + def exec_raw(self, command, timeout=10, data_consumer=None): stream = self.printed + (b"\x04" if self.finishes else b"") if data_consumer is not None: for i in range(len(stream)): data_consumer(stream[i : i + 1]) if not self.finishes: raise TransportError("timeout waiting for first EOF reception") - return b"" if data_consumer is not None else self.printed + return (b"" if data_consumer is not None else self.printed), b"" + + def exec(self, command, data_consumer=None): + ret, _ = self.exec_raw(command, data_consumer=data_consumer) + return ret + + +class QuietBoard: + """Fake raw-REPL transport whose script prints ``before``, works quietly + for ``quiet`` seconds, then prints ``after`` and ends (mpftp#172). + + Time is modelled rather than slept: mpremote's follow gives up when no + byte arrives for ``timeout`` seconds (``None`` waits forever), so the + follow times out exactly when ``quiet`` reaches it. ``timeouts`` records + the limit each follow was given. ``exec`` is mpremote's: ``exec_raw`` at + its fixed 10 s, raising when the script wrote to stderr. + """ + + in_raw_repl = True + + def __init__(self, before: bytes, quiet: float, after: bytes, err: bytes = b"") -> None: + self.before = before + self.quiet = quiet + self.after = after + self.err = err + self.timeouts: list = [] + + def exec_raw(self, command, timeout=10, data_consumer=None): + self.timeouts.append(timeout) + out = b"" + + def feed(data): + nonlocal out + if data_consumer is not None: + for i in range(len(data)): + data_consumer(data[i : i + 1]) + else: + out += data + + feed(self.before) + if timeout is not None and self.quiet >= timeout: + raise TransportError("timeout waiting for first EOF reception") + feed(self.after + b"\x04") + return (b"" if data_consumer is not None else out[:-1]), self.err + + def exec(self, command, data_consumer=None): + ret, err = self.exec_raw(command, data_consumer=data_consumer) + if err: + from mpremote.transport import TransportExecError + + raise TransportExecError(ret, err.decode()) + return ret + + +QUIET_SCRIPT = 'import time\nprint("start")\ntime.sleep(20)\nprint("done")\n' class RunFollowTimeoutTests(unittest.TestCase): @@ -96,6 +151,248 @@ def test_finished_script_output_is_unchanged(self): self.assertEqual(result, {"output": PRINTED.decode(), "followed": True}) +class RunFollowTimeoutOptionTests(unittest.TestCase): + """``run --follow --timeout SECONDS`` through the sidecar's RPC (mpftp#172). + + The board prints "start", works quietly for 20 s, then prints "done": the + script from the issue, which used to fail at mpremote's fixed 10 s. + """ + + @classmethod + def setUpClass(cls): + cls.mod = _load_sidecar() + + def _call(self, board, method="run_script", **params): + session = self.mod.Session() + session.transport = board + session.device = session.last_device = "COM99" + self.session = session + params.setdefault("source", QUIET_SCRIPT) + if method == "run_path": + params = {k: v for k, v in params.items() if k != "source"} + params["path"] = "/quiet.py" + with mock.patch.object(self.mod, "SESSION", session), mock.patch.object( + self.mod, "_notify" + ), mock.patch.object(session, "_take_control_resilient", return_value=board): + return self.mod.METHODS[method]({"follow": True, **params}) + + def test_quiet_script_longer_than_the_old_limit_finishes(self): + board = QuietBoard(b"start\r\n", 20, b"done\r\n") + result = self._call(board, timeout=30) + self.assertEqual(result, {"output": "start\r\ndone\r\n", "followed": True}) + self.assertEqual(board.timeouts, [30.0]) + + def test_run_path_takes_the_timeout_too(self): + board = QuietBoard(b"start\r\n", 20, b"done\r\n") + result = self._call(board, method="run_path", timeout=30) + self.assertEqual(result["output"], "start\r\ndone\r\n") + self.assertEqual(result["path"], "/quiet.py") + + def test_timeout_zero_waits_until_the_script_ends(self): + board = QuietBoard(b"start\r\n", 3600, b"done\r\n") + result = self._call(board, timeout=0) + self.assertEqual(result["output"], "start\r\ndone\r\n") + # None is mpremote's "no limit" for its read loop. + self.assertEqual(board.timeouts, [None]) + + def test_default_is_still_ten_seconds(self): + board = QuietBoard(b"start\r\n", 20, b"done\r\n") + with self.assertRaises(self.mod.FollowTimeoutError) as ctx: + self._call(board) + self.assertEqual(board.timeouts, [10.0]) + self.assertEqual(ctx.exception.partial_output, "start\r\n") + # A script that finishes inside the default is unaffected. + board = QuietBoard(b"start\r\n", 2, b"done\r\n") + self.assertEqual(self._call(board)["output"], "start\r\ndone\r\n") + + def test_timeout_error_names_the_option(self): + board = QuietBoard(b"start\r\n", 20, b"done\r\n") + with self.assertRaises(self.mod.FollowTimeoutError) as ctx: + self._call(board, timeout=15) + msg = str(ctx.exception) + self.assertIn("timeout waiting for first EOF", msg) + self.assertIn("--timeout SECONDS", msg) + self.assertIn("--timeout 0", msg) + self.assertIn("printed nothing for 15 s", msg) + self.assertEqual(ctx.exception.partial_output, "start\r\n") + # The wedged handle is still released so Connect/Resume can reclaim it. + self.assertIsNone(self.session.transport) + + def test_default_timeout_error_says_ten_seconds(self): + board = QuietBoard(b"start\r\n", 20, b"done\r\n") + with self.assertRaises(self.mod.FollowTimeoutError) as ctx: + self._call(board) + self.assertIn("printed nothing for 10 s", str(ctx.exception)) + self.assertIn("--timeout", str(ctx.exception)) + + def test_script_error_is_raised_as_before(self): + from mpremote.transport import TransportExecError + + board = QuietBoard(b"start\r\n", 0, b"", err=b"ZeroDivisionError: divide by zero\r\n") + with self.assertRaises(TransportExecError) as ctx: + self._call(board, timeout=30) + self.assertIn("ZeroDivisionError", str(ctx.exception)) + + def test_negative_timeout_is_refused(self): + with self.assertRaises(ValueError): + self.mod.follow_timeout(-1) + self.assertEqual(self.mod.follow_timeout(None), 10.0) + self.assertIsNone(self.mod.follow_timeout(0)) + self.assertEqual(self.mod.follow_timeout("2.5"), 2.5) + + +class QuietPort: + """A serial port whose raw REPL runs one script: print ``start``, go quiet + for ``gap`` real seconds, print ``done``. Enough of pyserial for mpftp's + transport, so the follow runs through mpremote's real read loop.""" + + def __init__(self, gap: float) -> None: + self.gap = gap + self._lock = threading.Lock() + self._out: list = [(0.0, b">")] # (time it is sent, bytes) + self._in = b"" + + def _ready(self) -> bytes: + import time + + now = time.monotonic() + return b"".join(data for at, data in self._out if at <= now) + + def inWaiting(self) -> int: + with self._lock: + return len(self._ready()) + + def read(self, n: int = 1) -> bytes: + import time + + deadline = time.monotonic() + 1.0 + while True: + with self._lock: + now = time.monotonic() + ready = [(at, d) for at, d in self._out if at <= now] + if ready or now >= deadline: + break + time.sleep(0.005) + with self._lock: + got = b"" + while self._out and self._out[0][0] <= now and len(got) < n: + at, data = self._out.pop(0) + take = data[: n - len(got)] + got += take + if len(take) < len(data): + self._out.insert(0, (at, data[len(take) :])) + return got + + def write(self, data: bytes) -> int: + import time + + with self._lock: + self._in += data + if self._in.endswith(b"\x04"): + now = time.monotonic() + self._out += [ + (now, b"OK"), + (now, b"start\r\n"), + (now + self.gap, b"done\r\n\x04\x04>"), + ] + self._in = b"" + return len(data) + + def flushInput(self) -> None: + pass + + def close(self) -> None: + pass + + +class RealReadLoopTests(unittest.TestCase): + """The limit reaches the transport's own read loop, not just the call.""" + + def _transport(self, gap): + from mpftp import rawpaste + + cls = rawpaste._transport_class() + t = cls.__new__(cls) + t.in_raw_repl = True + t.use_raw_paste = False + t.device_name = "COM98" + t.mounted = False + t.is_pty = False + t.paced = None + t.serial = QuietPort(gap) + return t + + def test_short_limit_times_out_and_keeps_output(self): + sidecar = _load_sidecar() + with self.assertRaises(sidecar.FollowTimeoutError) as ctx: + sidecar.exec_follow(self._transport(1.0), "x", 0.3) + self.assertEqual(ctx.exception.partial_output, "start\r\n") + + def test_longer_limit_and_zero_wait_for_done(self): + sidecar = _load_sidecar() + self.assertEqual(sidecar.exec_follow(self._transport(1.0), "x", 3.0), "start\r\ndone\r\n") + self.assertEqual(sidecar.exec_follow(self._transport(1.0), "x", None), "start\r\ndone\r\n") + + +class RunTimeoutCliTests(unittest.TestCase): + """``mpftp run --timeout`` on the command line.""" + + def _run(self, argv, client=None): + from mpftp import cli + + client = client or mock.Mock(spec=["call", "close"]) + client.call.return_value = {"output": "", "followed": True} + import tempfile + + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + script = f"{tmp.name}/quiet.py" + with open(script, "w", encoding="utf-8") as f: + f.write(QUIET_SCRIPT) + buf = io.StringIO() + with mock.patch.object(cli, "get_client", return_value=(client, "sidecar")), mock.patch.object( + cli, "ensure_device" + ), redirect_stdout(buf): + try: + cli.main(["run", script, "-d", "COM99", *argv]) + except SystemExit as e: + return client, buf.getvalue(), e.code + return client, buf.getvalue(), 0 + + def test_timeout_is_sent_with_follow(self): + client, _, code = self._run(["--follow", "--timeout", "30"]) + self.assertEqual(code, 0) + client.call.assert_called_once_with( + "run_script", {"source": QUIET_SCRIPT, "follow": True, "timeout": 30.0} + ) + + def test_zero_is_sent_as_zero(self): + client, _, _ = self._run(["--follow", "--timeout", "0"]) + self.assertEqual(client.call.call_args.args[1]["timeout"], 0.0) + + def test_no_timeout_sends_what_it_always_did(self): + client, _, _ = self._run(["--follow"]) + client.call.assert_called_once_with("run_script", {"source": QUIET_SCRIPT, "follow": True}) + + def test_timeout_without_follow_is_an_error(self): + client, text, code = self._run(["--timeout", "30"]) + self.assertEqual(code, 1) + self.assertIn("--timeout only applies with --follow", json.loads(text)["error"]) + client.call.assert_not_called() + + def test_negative_timeout_is_refused_by_the_parser(self): + with redirect_stderr(io.StringIO()): + _, _, code = self._run(["--follow", "--timeout", "-1"]) + self.assertEqual(code, 2) + + def test_tcp_session_waits_as_long_as_the_sidecar_does(self): + client = TcpClient("127.0.0.1", 9) + client.call = mock.Mock(return_value={"output": "", "followed": True}) + self._run(["--follow", "--timeout", "0"], client=client) + self.assertIsNone(client.call_timeout) + self.assertEqual(TcpClient.call_timeout, 120) + + class SidecarErrorReplyTests(unittest.TestCase): """The sidecar's ``{"type": "error"}`` reply for a handler exception.""" diff --git a/docs/guides/run-code-and-use-the-repl.md b/docs/guides/run-code-and-use-the-repl.md index 552f6c9..64c8a96 100644 --- a/docs/guides/run-code-and-use-the-repl.md +++ b/docs/guides/run-code-and-use-the-repl.md @@ -15,6 +15,7 @@ mpftp eval '1+1' mpftp exec 'print(1)' # waits for the program to end mpftp run ./app.py # starts it and returns mpftp run ./short.py --follow # waits for it to finish +mpftp run ./encode.py --follow --timeout 0 # waits however long it's quiet ``` `exec` waits for the program's output to end, so use `--no-follow` for a @@ -24,6 +25,10 @@ your program: use `run` without `--follow`, then `interrupt` or `soft-reset`. When a `run --follow` times out, the error carries `partialOutput`, everything printed up to that point. +`run --follow` gives up when the script prints nothing for 10 s. A script that +works quietly for longer, a long encode or a stream, needs `--timeout SECONDS`, +or `--timeout 0` to wait until it ends. + ## Interrupt and reset | Command | MicroPython | CircuitPython | diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index b11be82..c2409f7 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -10,7 +10,7 @@ | `Access is denied` or `transport_dead` after a hung `exec` or `run` | The session releases the port handle on its own. `disconnect`, then `resume` or `connect`. If it is still busy, reload the editor window, and replug USB last | | `busy or locked` on a board's native USB port with no tool holding it | The board's program is stuck in a callback that never returns, so its USB stops answering. A killed mpftp command lets go of the port after a few seconds. If it stays busy, reset the board through its UART port (`hard-reset -d `) or its RESET button | | `get` failed with "source code string cannot contain null bytes" | An older mpftp. The board's native USB sent extra bytes after a chunk, sometimes holding a `\x04` that ends the reply early. A current `get` and `cp` read each chunk with an explicit `seek`, check its length and ask again; a chunk that never comes right fails with its byte offset | -| `timeout waiting for first EOF` | The board is still running your program. Use `run` without `--follow` or `exec --no-follow`, then `interrupt` or `soft-reset` | +| `timeout waiting for first EOF` | The board is still running your program. If it's working quietly, give `run --follow` longer with `--timeout SECONDS`, or `--timeout 0` to wait until it ends. For a loop, use `run` without `--follow` or `exec --no-follow`, then `interrupt` or `soft-reset` | | Board is dead after deploy and `soft-reset` | Expected: soft-reset skips `main.py`. Use `soft-reboot` or `hard-reset` | | Two USB ports (UART and native CDC) | `mpftp ports` shows `role` (`repl` or `cdc_debug`). Control on the UART and `monitor` on the CDC | | A crash or reboot with no Python traceback | The panic backtrace goes to the console UART only: [Capture the console](capture-the-console.md) | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4175f96..11f18f9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -36,7 +36,7 @@ and exits non-zero. | Command | What it does | |---|---| | `eval`, `exec` | Evaluate an expression, or run code; `exec --no-follow` for loops | -| `run FILE` | Run a local script; `--follow` waits for it to end | +| `run FILE` | Run a local script; `--follow` waits for it to end, giving up after 10 s with no output. `--timeout SECONDS` changes that, and `--timeout 0` waits until the script ends | | `probe FILE` | Run, wait, and read a result file (`--reboot-first`, `--capture`, `--wait`) | | `watch-repl` | Tail the board's output without interrupting it | | `interrupt`, `soft-reset`, `soft-reboot`, `hard-reset` | See [Run code and use the REPL](../guides/run-code-and-use-the-repl.md#interrupt-and-reset). `hard-reset --monitor SECONDS [--log-path FILE]` captures the boot | diff --git a/integrations/claude-code-plugin/skills/board-tools/SKILL.md b/integrations/claude-code-plugin/skills/board-tools/SKILL.md index 2778290..e577b79 100644 --- a/integrations/claude-code-plugin/skills/board-tools/SKILL.md +++ b/integrations/claude-code-plugin/skills/board-tools/SKILL.md @@ -16,6 +16,7 @@ mpftp status # who holds the session, which device mpftp put main.py /main.py # also get, cp, ls, tree, rm, mkdir, hash mpftp exec "import gc; print(gc.mem_free())" mpftp run app.py --follow # run a local script, wait for its output +mpftp run app.py --follow --timeout 0 # ...however long it stays quiet (default 10 s) ``` Pass `-d DEVICE` after the subcommand when no editor session is connected.