Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
101 changes: 95 additions & 6 deletions apps/desktop/src-tauri/runtime/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import importlib.util
import inspect
import json
import os
import queue
import sys
import threading
Expand All @@ -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",)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}'"})
Expand Down
121 changes: 121 additions & 0 deletions apps/desktop/src-tauri/runtime/python/test_runner_contract.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from __future__ import annotations

import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import textwrap
Expand All @@ -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 = [
Expand Down Expand Up @@ -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()
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion crates/supervisor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "senamby-supervisor"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
license = "MIT"
publish = false
Expand Down
Loading