diff --git a/Cargo.lock b/Cargo.lock index aa1749e..e70c550 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3106,7 +3106,7 @@ dependencies = [ [[package]] name = "senamby" -version = "0.1.0" +version = "0.1.1" dependencies = [ "chrono", "dirs 5.0.1", @@ -3123,7 +3123,7 @@ dependencies = [ [[package]] name = "senamby-supervisor" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "pretty_assertions", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4105b28..108d6a9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "senamby", - "version": "0.1.0", + "version": "0.1.1", "description": "Senamby desktop plotter workspace", "type": "module", "packageManager": "pnpm@10.27.0", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 982d50f..f22fc72 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "senamby" -version = "0.1.0" +version = "0.1.1" description = "Senamby desktop runtime and workspace application" authors = ["Senamby"] edition = "2021" diff --git a/apps/desktop/src-tauri/runtime/python/runner.py b/apps/desktop/src-tauri/runtime/python/runner.py index 048b471..d7a10f9 100644 --- a/apps/desktop/src-tauri/runtime/python/runner.py +++ b/apps/desktop/src-tauri/runtime/python/runner.py @@ -6,6 +6,7 @@ import importlib.util import inspect import json +import os import queue import sys import threading @@ -21,8 +22,9 @@ SensorPayload: TypeAlias = Dict[str, float] ActuatorPayload: TypeAlias = Dict[str, float] ControllerOutputPayload: TypeAlias = Dict[str, float] -PROTOCOL_STDOUT = sys.stdout +PROTOCOL_STDOUT: Optional[int] = None +PROTOCOL_STDOUT_LOCK = threading.Lock() DRIVER_REQUIRED_METHODS = ("connect", "stop", "read") DRIVER_WRITE_METHOD = "write" CONTROLLER_REQUIRED_METHODS = ("compute",) @@ -653,12 +655,101 @@ def _load_controllers_async( ) +def _require_stream_fd(stream: Any, name: str) -> int: + if stream is None or not hasattr(stream, "fileno"): + raise RuntimeError(f"{name} não expõe um descritor de arquivo") + try: + return int(stream.fileno()) + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"Falha ao obter descritor de arquivo de {name}: {exc}") from exc + + +def _sync_windows_stdout_handle_to_stderr() -> None: + if os.name != "nt": + return + + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + invalid_handle = ctypes.c_void_p(-1).value + stdout_handle_id = -11 + stderr_handle_id = -12 + + stderr_handle = kernel32.GetStdHandle(stderr_handle_id) + if stderr_handle in (0, invalid_handle): + raise ctypes.WinError() + + if not kernel32.SetStdHandle(stdout_handle_id, stderr_handle): + raise ctypes.WinError() + except Exception as exc: # noqa: BLE001 + log_error(f"Aviso: não foi possível sincronizar stdout do Windows com stderr: {exc}") + + +def bootstrap_protocol_stdout() -> None: + global PROTOCOL_STDOUT + + if PROTOCOL_STDOUT is not None: + return + + stdout_stream = sys.__stdout__ + stderr_stream = sys.__stderr__ + stdout_fd = _require_stream_fd(stdout_stream, "stdout") + stderr_fd = _require_stream_fd(stderr_stream, "stderr") + + stdout_stream.flush() + stderr_stream.flush() + + try: + protocol_stdout_fd = os.dup(stdout_fd) + except OSError as exc: + raise RuntimeError(f"Falha ao duplicar stdout do protocolo: {exc}") from exc + + try: + try: + os.set_inheritable(protocol_stdout_fd, False) + except OSError: + pass + + PROTOCOL_STDOUT = protocol_stdout_fd + os.dup2(stderr_fd, stdout_fd) + sys.stdout = sys.stderr + _sync_windows_stdout_handle_to_stderr() + except Exception: # noqa: BLE001 + PROTOCOL_STDOUT = None + try: + os.close(protocol_stdout_fd) + except OSError: + pass + raise + + +def _resolve_protocol_stdout_fd() -> int: + if PROTOCOL_STDOUT is not None: + return PROTOCOL_STDOUT + return _require_stream_fd(sys.__stdout__, "stdout") + + def emit(msg_type: str, payload: Optional[Dict[str, Any]] = None) -> None: envelope: Dict[str, Any] = {"type": msg_type} if payload is not None: envelope["payload"] = payload - PROTOCOL_STDOUT.write(json.dumps(envelope, ensure_ascii=False) + "\n") - PROTOCOL_STDOUT.flush() + data = ( + json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + b"\n" + ) + protocol_stdout_fd = _resolve_protocol_stdout_fd() + total_written = 0 + + with PROTOCOL_STDOUT_LOCK: + while total_written < len(data): + try: + written = os.write(protocol_stdout_fd, data[total_written:]) + except InterruptedError: + continue + if written <= 0: + raise RuntimeError("Falha ao escrever envelope no stdout do protocolo") + total_written += written def log_error(message: str) -> None: @@ -1291,9 +1382,7 @@ def run() -> int: runtime_dir = Path(args.runtime_dir) bootstrap_path = Path(args.bootstrap) - # Keep stdout reserved for the JSON protocol. Any plugin/library print() - # should flow to stderr so it never corrupts the IPC stream. - sys.stdout = sys.stderr + bootstrap_protocol_stdout() if not bootstrap_path.exists(): emit("error", {"message": f"bootstrap.json não encontrado em '{bootstrap_path}'"}) diff --git a/apps/desktop/src-tauri/runtime/python/test_runner_contract.py b/apps/desktop/src-tauri/runtime/python/test_runner_contract.py index 42bb3ce..83b6710 100644 --- a/apps/desktop/src-tauri/runtime/python/test_runner_contract.py +++ b/apps/desktop/src-tauri/runtime/python/test_runner_contract.py @@ -1,6 +1,9 @@ from __future__ import annotations import importlib.util +import json +import os +import subprocess import sys import tempfile import textwrap @@ -25,6 +28,40 @@ def load_runner_module() -> ModuleType: runner = load_runner_module() +def run_runner_subprocess(snippet: str) -> subprocess.CompletedProcess[str]: + runner_path = Path(__file__).with_name("runner.py") + script = "\n".join( + [ + "import importlib.util", + "import sys", + "from pathlib import Path", + "", + f"runner_path = Path({str(runner_path)!r})", + 'spec = importlib.util.spec_from_file_location("senamby_runtime_runner_subprocess", runner_path)', + "if spec is None or spec.loader is None:", + ' raise RuntimeError("Falha ao carregar runner.py no subprocesso")', + "", + "runner = importlib.util.module_from_spec(spec)", + "sys.modules[spec.name] = runner", + "spec.loader.exec_module(runner)", + "", + textwrap.dedent(snippet).strip(), + "", + ] + ) + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + + +def parse_protocol_lines(raw_stdout: str) -> list[dict[str, Any]]: + return [json.loads(line) for line in raw_stdout.splitlines() if line.strip()] + + class RunnerContractTests(unittest.TestCase): def build_bootstrap(self, root: Path) -> Any: plant_variables = [ @@ -342,5 +379,89 @@ def capture_emit(msg_type: str, payload: dict[str, Any] | None = None) -> None: self.assertAlmostEqual(telemetry_payloads[2]["uptime_s"], 2.0, places=6) +class RunnerProtocolStreamTests(unittest.TestCase): + def test_emit_keeps_json_on_stdout_after_bootstrap(self) -> None: + completed = run_runner_subprocess( + """ + runner.bootstrap_protocol_stdout() + runner.emit("ready", {"ok": True}) + """ + ) + + self.assertEqual(completed.returncode, 0, msg=completed.stderr) + self.assertEqual( + parse_protocol_lines(completed.stdout), + [{"type": "ready", "payload": {"ok": True}}], + ) + self.assertEqual(completed.stderr.strip(), "") + + def test_python_print_is_redirected_to_stderr(self) -> None: + completed = run_runner_subprocess( + """ + runner.bootstrap_protocol_stdout() + print("python-log") + runner.emit("ready", {"ok": True}) + """ + ) + + self.assertEqual(completed.returncode, 0, msg=completed.stderr) + self.assertEqual( + parse_protocol_lines(completed.stdout), + [{"type": "ready", "payload": {"ok": True}}], + ) + self.assertIn("python-log", completed.stderr) + + @unittest.skipUnless(os.name == "posix", "Teste de libc disponível apenas em POSIX") + def test_libc_printf_is_redirected_to_stderr(self) -> None: + completed = run_runner_subprocess( + """ + import ctypes + + runner.bootstrap_protocol_stdout() + libc = ctypes.CDLL(None) + libc.printf.argtypes = [ctypes.c_char_p] + libc.printf.restype = ctypes.c_int + libc.fflush.argtypes = [ctypes.c_void_p] + libc.fflush.restype = ctypes.c_int + libc.printf(b"native-log\\\\n") + libc.fflush(None) + runner.emit("ready", {"ok": True}) + """ + ) + + self.assertEqual(completed.returncode, 0, msg=completed.stderr) + self.assertEqual( + parse_protocol_lines(completed.stdout), + [{"type": "ready", "payload": {"ok": True}}], + ) + self.assertIn("native-log", completed.stderr) + + def test_emit_stays_atomic_across_threads(self) -> None: + completed = run_runner_subprocess( + """ + import threading + + runner.bootstrap_protocol_stdout() + + def worker(worker_id: int) -> None: + for sequence in range(200): + runner.emit("telemetry", {"worker": worker_id, "sequence": sequence}) + + threads = [threading.Thread(target=worker, args=(worker_id,)) for worker_id in range(6)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + """ + ) + + self.assertEqual(completed.returncode, 0, msg=completed.stderr) + lines = parse_protocol_lines(completed.stdout) + + self.assertEqual(len(lines), 1200) + self.assertTrue(all(line["type"] == "telemetry" for line in lines)) + self.assertEqual(completed.stderr.strip(), "") + + if __name__ == "__main__": unittest.main() diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 0e53281..cdcbed1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "senamby", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.senamby.plotter", "build": { "beforeDevCommand": "pnpm dev", diff --git a/crates/supervisor/Cargo.toml b/crates/supervisor/Cargo.toml index 418ce47..1025216 100644 --- a/crates/supervisor/Cargo.toml +++ b/crates/supervisor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "senamby-supervisor" -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT" publish = false