From 050cd6515b36ca7e19bdf25236386a48f28ffb5c Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sat, 21 Feb 2026 17:43:19 +0100 Subject: [PATCH 01/19] uapi,posix: initial POSIX support for UAPI --- uapi/src/arch/mod.rs | 4 - uapi/src/lib.rs | 25 ++++- uapi/src/posix.rs | 252 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 uapi/src/posix.rs diff --git a/uapi/src/arch/mod.rs b/uapi/src/arch/mod.rs index 27ee14c0..cf4f5496 100644 --- a/uapi/src/arch/mod.rs +++ b/uapi/src/arch/mod.rs @@ -5,10 +5,6 @@ #[macro_use] pub mod arm_cortex_m; -#[cfg(target_arch = "x86_64")] -#[macro_use] -pub mod x86_64; - #[cfg(target_arch = "riscv32")] #[macro_use] pub mod riscv32; diff --git a/uapi/src/lib.rs b/uapi/src/lib.rs index 0259af0a..62c3c99a 100644 --- a/uapi/src/lib.rs +++ b/uapi/src/lib.rs @@ -19,7 +19,14 @@ //! are implemented in the [`mod@syscall`] module, while the upper, easy interface //! is out of the scope of this very crate, and written in the shield crate instead. -#![cfg_attr(not(feature = "std"), no_std)] +#![cfg_attr( + not(all(target_arch = "x86_64", target_os = "linux")), + no_std +)] + +/// Support for std in POSIX mode, only for linux/x86_64 targets +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +extern crate std; #[macro_use] mod arch; @@ -76,7 +83,21 @@ mod exchange; /// but instead with an upper interface such as shield /// /// > **NOTE**: This module may not be kept public forever +// +#[cfg(not(all(target_arch = "x86_64", target_os = "linux")))] +pub mod syscall; + +/// POSIX-based syscall implementation +/// +/// # Usage +/// +/// This module is responsible for making uapi functional in POSIX mode, by providing +/// a syscall implementation that is based on the POSIX API and thus only available +/// for linux/x86_64 targets. /// +// +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +#[path = "posix.rs"] pub mod syscall; /// Sentry kernelspace/userspace shared types and values @@ -116,5 +137,5 @@ pub use self::exchange::SentryExchangeable; /// Re-export Sentry uapi length fonction for Kani tests pub use self::exchange::length; -#[cfg(not(feature = "std"))] +#[cfg(not(all(target_arch = "x86_64", target_os = "linux")))] mod panic; diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs new file mode 100644 index 00000000..39d80c0b --- /dev/null +++ b/uapi/src/posix.rs @@ -0,0 +1,252 @@ +// Copyright 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +/// +/// This module provides a POSIX-compatible implementation of the Sentry kernel +/// interface for Linux/x86_64 targets. +/// It serves as a compatibility layer, allowing applications to behave as if +/// they were interacting with the Sentry kernel, but using standard +/// POSIX system calls and conventions. +/// This implementation is intended for use in environments where the Sentry kernel +/// is not available, such as during development or testing on a standard Linux system. +/// +/// In order to support multi-tasking and inter-process communication (IPC) in a POSIX environment, +/// this module will interact with a local GNU/Linux server that simulates the Sentry kernel's behavior. +/// This server will handle the necessary system calls and manage the state of the simulated kernel, +/// allowing applications to communicate with external objects such as devices, shared memory, +/// and other task. +/// This server will be written as a separate component in this repository, in the tools directory. +/// Interactions with this server will be made through a local socket, and the protocol will be defined +/// in the documentation of the server itself, as well as in the documentation of this module. +/// +/// Each Except for local-only syscalls such as `sched_yield` or `sys_exit`, all the syscalls +/// will behave as a proxy to the server, forwarding the syscall number and arguments to the +/// server and returning the server's response as the syscall result. +/// + +use crate::systypes::*; + + +#[inline(always)] +pub fn exit(status: i32) -> Status { + std::process::exit(status); +} + +#[inline(always)] +pub fn sleep(_duration_ms: SleepDuration, _mode: SleepMode) -> Status { + todo!("Convert local sleep API to std::thread::sleep"); +} + +#[inline(always)] +pub fn sched_yield() -> Status { + std::thread::yield_now(); + Status::Ok +} + +#[inline(always)] +pub fn get_process_handle(_label: TaskLabel) -> Status { + // Pas d'équivalent POSIX direct + todo!("get_process_handle not implemented in POSIX mode"); +} +#[inline(always)] +pub fn get_current_process() -> Status { + // POSIX: getpid() + let _pid = std::process::id(); + Status::Ok +} +// ------------------------------------------------------------------------- +// IPC / Events +// ------------------------------------------------------------------------- +#[inline(always)] +pub fn send_ipc(_target: TaskHandle, _length: u8) -> Status { + todo!("send_ipc not implemented in POSIX mode"); +} +#[inline(always)] +pub fn wait_for_event(_mask: u8, _timeout: i32) -> Status { + todo!("wait_for_event not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn map_dev(_handle: DeviceHandle) -> Status { + todo!("map_dev not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn unmap_dev(_handle: DeviceHandle) -> Status { + todo!("map_dev not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn get_shm_handle(_shm: ShmLabel) -> Status { + todo!("get_shm_handle not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn get_device_handle(_devlabel: u8) -> Status { + todo!("get_device_handle not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn get_dma_stream_handle(_stream: StreamLabel) -> Status { + todo!("get_dma_stream_handle not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn start(_process: TaskLabel) -> Status { + todo!("start not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn map_shm(_shm: ShmHandle) -> Status { + todo!("map_shm not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn unmap_shm(_shm: ShmHandle) -> Status { + todo!("unmap_shm not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn shm_set_credential( + _shm: ShmHandle, + _id: TaskHandle, + _shm_perm: u32, +) -> Status { + todo!("shm_set_credential not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn send_signal(_target: u32, _sig: Signal) -> Status { + todo!("send_signal not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn gpio_get(_resource: u32, _io: u8) -> Status { + todo!("gpio_get not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn gpio_set(_resource: u32, _io: u8, _val: bool) -> Status { + todo!("gpio_set not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn gpio_reset(_resource: u32, _io: u8) -> Status { + todo!("gpio_reset not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn gpio_toggle(_resource: u32, _io: u8) -> Status { + todo!("gpio_toggle not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn gpio_configure(_resource: u32, _io: u8) -> Status { + todo!("gpio_configure not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn irq_acknowledge(_irq: u16) -> Status { + todo!("irq_acknowledge not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn irq_enable(_irq: u16) -> Status { + todo!("irq_enable not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn irq_disable(_irq: u16) -> Status { + todo!("irq_disable not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn pm_manage(_mode: CPUSleep) -> Status { + todo!("pm_manage not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn alarm(_timeout_ms: u32, _flag: AlarmFlag) -> Status { + todo!("alarm not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn log(_length: usize) -> Status { + todo!("log not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn get_random() -> Status { + todo!("get_random not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn get_cycle(_precision: Precision) -> Status { + todo!("get_cycle not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn pm_set_clock(_clk_reg: u32, _clkmsk: u32, _val: u32) -> Status { + todo!("pm_set_clock not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn dma_start_stream(_dmah: StreamHandle) -> Status { + todo!("dma_start_stream not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn dma_suspend_stream(_dmah: StreamHandle) -> Status { + todo!("dma_suspend_stream not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn dma_get_stream_status(_dmah: StreamHandle) -> Status { + todo!("dma_get_stream_status not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn shm_get_infos(_shm: ShmHandle) -> Status { + todo!("shm_get_infos not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn dma_assign_stream(_dmah: StreamHandle) -> Status { + todo!("dma_assign_stream not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn dma_unassign_stream(_dmah: StreamHandle) -> Status { + todo!("dma_unassign_stream not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn dma_get_stream_info(_dmah: StreamHandle) -> Status { + todo!("dma_get_stream_info not implemented in POSIX mode"); +} + +#[inline(always)] +pub fn dma_resume_stream(_dmah: StreamHandle) -> Status { + todo!("dma_resume_stream not implemented in POSIX mode"); +} + +// Autotest only +#[cfg(CONFIG_BUILD_TARGET_AUTOTEST)] +#[inline(always)] +pub fn autotest_set_capa(_capa: u32) -> Status { + todo!("autotest_set_capa not implemented in POSIX mode"); +} + +#[cfg(CONFIG_BUILD_TARGET_AUTOTEST)] +#[inline(always)] +pub fn autotest_clear_capa(_capa: u32) -> Status { + todo!("autotest_clear_capa not implemented in POSIX mode"); +} + +// ------------------------------------------------------------------------- +// Default fallback helper +// ------------------------------------------------------------------------- +#[inline(always)] +pub fn unsupported() -> Status { + todo!("Unsupported syscall in POSIX GNU/Linux mode"); +} From f2e31dd176050d394736764a374dfceeaf98eceb Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 22 Feb 2026 10:08:29 +0100 Subject: [PATCH 02/19] uapi,posix: adding sleep and println support --- uapi/src/posix.rs | 59 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index 39d80c0b..9b2cfec6 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -24,7 +24,7 @@ /// server and returning the server's response as the syscall result. /// -use crate::systypes::*; +use crate::{SentryExchangeable, systypes::*}; #[inline(always)] @@ -34,7 +34,36 @@ pub fn exit(status: i32) -> Status { #[inline(always)] pub fn sleep(_duration_ms: SleepDuration, _mode: SleepMode) -> Status { - todo!("Convert local sleep API to std::thread::sleep"); + match _duration_ms { + SleepDuration::D1ms => { + std::thread::sleep(std::time::Duration::from_millis(1)); + Status::Ok + } + SleepDuration::D2ms => { + std::thread::sleep(std::time::Duration::from_millis(2)); + Status::Ok + } + SleepDuration::D5ms => { + std::thread::sleep(std::time::Duration::from_millis(5)); + Status::Ok + } + SleepDuration::D10ms => { + std::thread::sleep(std::time::Duration::from_millis(10)); + Status::Ok + } + SleepDuration::D20ms => { + std::thread::sleep(std::time::Duration::from_millis(20)); + Status::Ok + } + SleepDuration::D50ms => { + std::thread::sleep(std::time::Duration::from_millis(50)); + Status::Ok + } + SleepDuration::ArbitraryMs(ms) => { + std::thread::sleep(std::time::Duration::from_millis(ms as u64)); + Status::Ok + } + } } #[inline(always)] @@ -48,15 +77,7 @@ pub fn get_process_handle(_label: TaskLabel) -> Status { // Pas d'équivalent POSIX direct todo!("get_process_handle not implemented in POSIX mode"); } -#[inline(always)] -pub fn get_current_process() -> Status { - // POSIX: getpid() - let _pid = std::process::id(); - Status::Ok -} -// ------------------------------------------------------------------------- -// IPC / Events -// ------------------------------------------------------------------------- + #[inline(always)] pub fn send_ipc(_target: TaskHandle, _length: u8) -> Status { todo!("send_ipc not implemented in POSIX mode"); @@ -172,7 +193,18 @@ pub fn alarm(_timeout_ms: u32, _flag: AlarmFlag) -> Status { #[inline(always)] pub fn log(_length: usize) -> Status { - todo!("log not implemented in POSIX mode"); + // usual model is to consecutively call: + // - str.to_kernel() to copy the log string into the exchange area + // - log(length) to trigger the log syscall, which will read the log string from the exchange area and print it + // + // In order to stay compatible with embedded use cases, we will keep the same model, by + // directly reading the log string from the exchange area and printing it, + // without any actual syscall, as this is a POSIX implementation and we can directly use std::println! + // Max log length is 128 by now, to be config-based set using CONFIG + let mut u8_slice: &mut [u8] = &mut [0u8; 128]; + let _ = u8_slice.from_kernel(); + std::println!("{}", String::from_utf8_lossy(u8_slice.as_ref())); + Status::Ok } #[inline(always)] @@ -243,9 +275,6 @@ pub fn autotest_clear_capa(_capa: u32) -> Status { todo!("autotest_clear_capa not implemented in POSIX mode"); } -// ------------------------------------------------------------------------- -// Default fallback helper -// ------------------------------------------------------------------------- #[inline(always)] pub fn unsupported() -> Status { todo!("Unsupported syscall in POSIX GNU/Linux mode"); From 1b80422fa57fd1ee38306a2f944d7287acfe4eea Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 10:49:52 +0200 Subject: [PATCH 03/19] simulator: initial sentry simulator for native testing of Camelot-OS applications --- meson.options | 1 + tools/meson.build | 4 + tools/sentry-emulator/.gitignore | 18 + tools/sentry-emulator/README.md | 40 + tools/sentry-emulator/meson.build | 74 ++ tools/sentry-emulator/proto/emulator.proto | 18 + tools/sentry-emulator/pyproject.toml | 60 ++ .../sample-rust-app/Cargo.lock | 898 ++++++++++++++++++ .../sample-rust-app/Cargo.toml | 11 + .../sample-rust-app/src/main.rs | 7 + tools/sentry-emulator/src/camelot/__init__.py | 1 + .../src/camelot/sentry_emulator/__init__.py | 20 + .../src/camelot/sentry_emulator/__main__.py | 6 + .../src/camelot/sentry_emulator/cli.py | 75 ++ .../src/camelot/sentry_emulator/dispatcher.py | 30 + .../camelot/sentry_emulator/grpc/__init__.py | 1 + .../sentry_emulator/grpc/emulator_pb2.py | 40 + .../sentry_emulator/grpc/emulator_pb2_grpc.py | 97 ++ .../src/camelot/sentry_emulator/protocol.py | 31 + .../src/camelot/sentry_emulator/server.py | 218 +++++ tools/sentry-emulator/tests/conftest.py | 12 + tools/sentry-emulator/tests/test_protocol.py | 29 + tools/sentry-emulator/tests/test_server.py | 88 ++ tools/sentry-emulator/tox.ini | 25 + uapi/Cargo.lock | 882 +++++++++++++++++ uapi/Cargo.toml | 5 + uapi/src/posix.rs | 273 ++++-- 27 files changed, 2891 insertions(+), 73 deletions(-) create mode 100644 tools/sentry-emulator/.gitignore create mode 100644 tools/sentry-emulator/README.md create mode 100644 tools/sentry-emulator/meson.build create mode 100644 tools/sentry-emulator/proto/emulator.proto create mode 100644 tools/sentry-emulator/pyproject.toml create mode 100644 tools/sentry-emulator/sample-rust-app/Cargo.lock create mode 100644 tools/sentry-emulator/sample-rust-app/Cargo.toml create mode 100644 tools/sentry-emulator/sample-rust-app/src/main.rs create mode 100644 tools/sentry-emulator/src/camelot/__init__.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/cli.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/server.py create mode 100644 tools/sentry-emulator/tests/conftest.py create mode 100644 tools/sentry-emulator/tests/test_protocol.py create mode 100644 tools/sentry-emulator/tests/test_server.py create mode 100644 tools/sentry-emulator/tox.ini diff --git a/meson.options b/meson.options index 8161bbf5..1a1b1a40 100644 --- a/meson.options +++ b/meson.options @@ -8,6 +8,7 @@ option('with_kernel', type: 'boolean', value: true, description: 'Sentry kernel option('with_uapi', type: 'boolean', value: true, description: 'Sentry UAPI library is built') option('with_idle', type: 'boolean', value: true, description: 'Sentry Idle task is built') option('with_tools', type: 'boolean', value: true, description: 'compile naitive tools') +option('with_emulator', type: 'boolean', value: false, description: 'build Python Sentry emulator daemon') option('with_sbom', type: 'boolean', value: false, description: 'generate SBOMs for python and subprojects') option('config', type: 'string', description: 'Configuration file to use', yield: true) option('dts', type: 'string', description: 'Top level DTS file', yield: true) diff --git a/tools/meson.build b/tools/meson.build index d35a8019..e32abd8e 100644 --- a/tools/meson.build +++ b/tools/meson.build @@ -7,3 +7,7 @@ endif subdir('robot') subdir('genmetadata') + +if get_option('with_emulator') + subdir('sentry-emulator') +endif diff --git a/tools/sentry-emulator/.gitignore b/tools/sentry-emulator/.gitignore new file mode 100644 index 00000000..e5bca5f1 --- /dev/null +++ b/tools/sentry-emulator/.gitignore @@ -0,0 +1,18 @@ +# Python caches and bytecode +__pycache__/ +*.py[cod] +*.pyo + +# Virtual environments and test tooling +.venv/ +.tox/ +.mypy_cache/ +.pytest_cache/ + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Rust sample app build artifacts +sample-rust-app/target/ diff --git a/tools/sentry-emulator/README.md b/tools/sentry-emulator/README.md new file mode 100644 index 00000000..9e9780c9 --- /dev/null +++ b/tools/sentry-emulator/README.md @@ -0,0 +1,40 @@ +# Camelot Sentry Emulator + +This package provides a gRPC daemon used by the POSIX implementation of the `sentry-uapi` crate. + +## Features + +- listens on gRPC (`127.0.0.1:44044` by default) +- receives syscall commands emitted by the POSIX Rust backend +- protobuf-based serialization/deserialization for syscall payloads +- traces each deserialized command to daemon stderr logs +- validates incoming commands and sorts them by syscall name +- starts applications with labeled contexts (`--start app.elf,label=`) +- assigns one unique context handle per started application + +## Run + +```sh +sentry-emulator --host 127.0.0.1 --port 44044 +``` + +To start applications and register their contexts: + +```sh +sentry-emulator --start ./build/my-app,label=7 --start ./build/my-other-app,label=12 +``` + +Each started app receives `SENTRY_APP_LABEL`, `SENTRY_EMULATOR_HOST`, and +`SENTRY_EMULATOR_PORT` in its environment. + +## Local build + +```sh +python -m build +``` + +## Tox workflow + +```sh +tox +``` diff --git a/tools/sentry-emulator/meson.build b/tools/sentry-emulator/meson.build new file mode 100644 index 00000000..2adc93db --- /dev/null +++ b/tools/sentry-emulator/meson.build @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 + +emulator_pyproject = files('pyproject.toml') +emulator_tox = files('tox.ini') + +emulator_build = custom_target( + 'sentry-emulator-build', + input: [emulator_pyproject, emulator_tox], + output: 'sentry-emulator-build.stamp', + command: [ + py3, + '-c', + 'import pathlib, subprocess, sys; out = pathlib.Path(sys.argv[1]); src = pathlib.Path(sys.argv[2]); dist = pathlib.Path(sys.argv[3]); dist.mkdir(parents=True, exist_ok=True); subprocess.run([sys.executable, "-m", "build", "--sdist", "--wheel", "--outdir", str(dist), str(src)], check=True); out.write_text("ok\n", encoding="utf-8")', + '@OUTPUT@', + meson.current_source_dir(), + meson.current_build_dir() / 'dist', + ], + build_by_default: true, +) + +cargo = find_program('cargo', required: true) + +sample_rust_manifest = files('sample-rust-app/Cargo.toml') + +sample_rust_build = custom_target( + 'sentry-emulator-sample-rust-app-build', + input: sample_rust_manifest, + output: 'sentry-emulator-sample-rust-app-build.stamp', + command: [ + py3, + '-c', + 'import pathlib, subprocess, sys; out = pathlib.Path(sys.argv[1]); manifest = pathlib.Path(sys.argv[2]); target = pathlib.Path(sys.argv[3]); subprocess.run(["cargo", "build", "--manifest-path", str(manifest), "--target-dir", str(target)], check=True); out.write_text("ok\\n", encoding="utf-8")', + '@OUTPUT@', + sample_rust_manifest[0].full_path(), + meson.current_build_dir() / 'sample-rust-target', + ], + build_by_default: true, +) + +if with_tests + test( + 'sentry-emulator-pytest', + py3, + args: [ + '-m', + 'pytest', + '-c', + meson.current_source_dir() / 'pyproject.toml', + meson.current_source_dir() / 'tests', + ], + suite: 'tools', + workdir: meson.current_source_dir(), + ) + + test( + 'sentry-emulator-mypy', + py3, + args: [ + '-m', + 'mypy', + meson.current_source_dir() / 'src', + ], + suite: 'tools', + workdir: meson.current_source_dir(), + ) +endif + +summary( + { + 'sentry emulator artifacts': meson.current_build_dir() / 'dist', + 'sentry emulator sample rust app': meson.current_build_dir() / 'sample-rust-target', + }, + section: 'Tools', +) diff --git a/tools/sentry-emulator/proto/emulator.proto b/tools/sentry-emulator/proto/emulator.proto new file mode 100644 index 00000000..fb5a2b78 --- /dev/null +++ b/tools/sentry-emulator/proto/emulator.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package camelot.sentry.emulator; + +service Emulator { + rpc Dispatch(DispatchRequest) returns (DispatchResponse); +} + +message DispatchRequest { + string syscall = 1; + repeated sint64 args = 2; + uint32 label = 3; +} + +message DispatchResponse { + sint32 status = 1; + string detail = 2; +} diff --git a/tools/sentry-emulator/pyproject.toml b/tools/sentry-emulator/pyproject.toml new file mode 100644 index 00000000..ccf0f681 --- /dev/null +++ b/tools/sentry-emulator/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = [ + "setuptools>=69", + "setuptools-scm[toml]>=8", + "wheel", +] +build-backend = "setuptools.build_meta" + +[project] +name = "camelot.sentry-emulator" +description = "gRPC daemon that emulates Sentry kernel syscalls in POSIX mode" +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +authors = [ + { name = "H2Lab Development Team" }, +] +keywords = ["sentry", "emulator", "grpc", "kernel", "camelot"] +dependencies = [ + "grpcio>=1.80", + "protobuf>=6.31", +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Embedded Systems", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/camelot-os/sentry-kernel" +Repository = "https://github.com/camelot-os/sentry-kernel" + +[project.scripts] +sentry-emulator = "camelot.sentry_emulator.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools_scm] +root = "../.." +fallback_version = "0.0.0+unknown" + +[tool.pytest.ini_options] +pythonpath = ["src"] +addopts = "-q" +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unused_configs = true +exclude = "(^build/|^dist/|src/camelot/sentry_emulator/grpc/)" +packages = ["camelot.sentry_emulator"] + +[[tool.mypy.overrides]] +module = ["camelot.sentry_emulator.grpc.*"] +ignore_errors = true diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.lock b/tools/sentry-emulator/sample-rust-app/Cargo.lock new file mode 100644 index 00000000..b1b1dbda --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/Cargo.lock @@ -0,0 +1,898 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "sentry-emulator-sample-rust-app" +version = "0.1.0" +dependencies = [ + "sentry-uapi", +] + +[[package]] +name = "sentry-uapi" +version = "0.4.2" +dependencies = [ + "prost", + "tokio", + "tonic", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.toml b/tools/sentry-emulator/sample-rust-app/Cargo.toml new file mode 100644 index 00000000..8f6dcf7f --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/Cargo.toml @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "sentry-emulator-sample-rust-app" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +sentry-uapi = { path = "../../../uapi", features = ["std"] } diff --git a/tools/sentry-emulator/sample-rust-app/src/main.rs b/tools/sentry-emulator/sample-rust-app/src/main.rs new file mode 100644 index 00000000..4390c346 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/src/main.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +fn main() { + let _ = sentry_uapi::syscall::sched_yield(); + let _ = sentry_uapi::syscall::get_random(); + let _ = sentry_uapi::syscall::unsupported(); +} diff --git a/tools/sentry-emulator/src/camelot/__init__.py b/tools/sentry-emulator/src/camelot/__init__.py new file mode 100644 index 00000000..98813136 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: Apache-2.0 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py new file mode 100644 index 00000000..0db967eb --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: Apache-2.0 + +from importlib.metadata import PackageNotFoundError, version + +from .dispatcher import SyscallStore +from .protocol import ProtocolError, SyscallMessage, deserialize_request +from .server import GrpcEmulatorDaemon + +try: + __version__ = version("camelot.sentry-emulator") +except PackageNotFoundError: + __version__ = "0.0.0+dev" + +__all__ = [ + "ProtocolError", + "SyscallMessage", + "SyscallStore", + "GrpcEmulatorDaemon", + "deserialize_request", +] diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py new file mode 100644 index 00000000..b0c99916 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py new file mode 100644 index 00000000..e062ba58 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import logging + +from .server import ( + DEFAULT_HOST, + DEFAULT_PORT, + GrpcEmulatorDaemon, + StartSpec, + parse_start_option, +) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="sentry-emulator", + description="Run the Camelot Sentry userspace emulator daemon.", + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"gRPC listening interface (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + default=DEFAULT_PORT, + type=int, + help=f"gRPC listening port (default: {DEFAULT_PORT})", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Daemon log level", + ) + parser.add_argument( + "--start", + action="append", + default=[], + metavar="APP,label=", + help="Start an application and register context for this label (repeatable)", + ) + return parser + + +def _parse_start_specs(raw_specs: list[str], parser: argparse.ArgumentParser) -> tuple[StartSpec, ...]: + parsed: list[StartSpec] = [] + for raw in raw_specs: + try: + parsed.append(parse_start_option(raw)) + except ValueError as exc: + parser.error(str(exc)) + return tuple(parsed) + + +def main() -> int: + parser = _build_parser() + args = parser.parse_args() + start_specs = _parse_start_specs(args.start, parser) + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + daemon = GrpcEmulatorDaemon(host=args.host, port=args.port, start_specs=start_specs) + try: + daemon.serve_forever() + except KeyboardInterrupt: + logging.getLogger("camelot.sentry_emulator").info("Shutdown requested") + + return 0 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py new file mode 100644 index 00000000..c924f256 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field + +from .protocol import SyscallMessage + + +@dataclass(slots=True) +class SyscallStore: + """In-memory store that groups incoming syscalls by syscall name.""" + + buckets: dict[str, list[SyscallMessage]] = field( + default_factory=lambda: defaultdict(list) + ) + invalid_packets: int = 0 + + def register(self, message: SyscallMessage) -> None: + self.buckets[message.syscall].append(message) + + def register_invalid(self) -> None: + self.invalid_packets += 1 + + def count_for(self, syscall_name: str) -> int: + return len(self.buckets.get(syscall_name, [])) + + def snapshot_counts(self) -> dict[str, int]: + return {name: len(entries) for name, entries in self.buckets.items()} diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py new file mode 100644 index 00000000..98813136 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: Apache-2.0 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py new file mode 100644 index 00000000..b5e1a19d --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: emulator.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'emulator.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x65mulator.proto\x12\x17\x63\x61melot.sentry.emulator\"?\n\x0f\x44ispatchRequest\x12\x0f\n\x07syscall\x18\x01 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x02 \x03(\x12\x12\r\n\x05label\x18\x03 \x01(\r\"2\n\x10\x44ispatchResponse\x12\x0e\n\x06status\x18\x01 \x01(\x11\x12\x0e\n\x06\x64\x65tail\x18\x02 \x01(\t2k\n\x08\x45mulator\x12_\n\x08\x44ispatch\x12(.camelot.sentry.emulator.DispatchRequest\x1a).camelot.sentry.emulator.DispatchResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulator_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_DISPATCHREQUEST']._serialized_start=43 + _globals['_DISPATCHREQUEST']._serialized_end=106 + _globals['_DISPATCHRESPONSE']._serialized_start=108 + _globals['_DISPATCHRESPONSE']._serialized_end=158 + _globals['_EMULATOR']._serialized_start=160 + _globals['_EMULATOR']._serialized_end=267 +# @@protoc_insertion_point(module_scope) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py new file mode 100644 index 00000000..75ede9d8 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py @@ -0,0 +1,97 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from . import emulator_pb2 as emulator__pb2 + +GRPC_GENERATED_VERSION = '1.80.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in emulator_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class EmulatorStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Dispatch = channel.unary_unary( + '/camelot.sentry.emulator.Emulator/Dispatch', + request_serializer=emulator__pb2.DispatchRequest.SerializeToString, + response_deserializer=emulator__pb2.DispatchResponse.FromString, + _registered_method=True) + + +class EmulatorServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Dispatch(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_EmulatorServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Dispatch': grpc.unary_unary_rpc_method_handler( + servicer.Dispatch, + request_deserializer=emulator__pb2.DispatchRequest.FromString, + response_serializer=emulator__pb2.DispatchResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'camelot.sentry.emulator.Emulator', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('camelot.sentry.emulator.Emulator', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class Emulator(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Dispatch(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/camelot.sentry.emulator.Emulator/Dispatch', + emulator__pb2.DispatchRequest.SerializeToString, + emulator__pb2.DispatchResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py new file mode 100644 index 00000000..adcecd3b --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .grpc import emulator_pb2 + + +class ProtocolError(ValueError): + """Raised when a gRPC request does not follow the emulator protocol.""" + + +@dataclass(frozen=True, slots=True) +class SyscallMessage: + syscall: str + args: tuple[int, ...] + label: int + + +def deserialize_request(request: Any) -> SyscallMessage: + syscall = request.syscall.strip() + if not syscall: + raise ProtocolError("'syscall' must be a non-empty string") + + return SyscallMessage( + syscall=syscall, + args=tuple(int(arg) for arg in request.args), + label=int(request.label), + ) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py new file mode 100644 index 00000000..b93207e5 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -0,0 +1,218 @@ +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from concurrent import futures +import logging +import os +from pathlib import Path +import subprocess +import threading +from dataclasses import dataclass, field +from typing import Any +from typing import Final + +import grpc + +from .dispatcher import SyscallStore +from .grpc import emulator_pb2, emulator_pb2_grpc +from .protocol import ProtocolError, deserialize_request + +DEFAULT_HOST: Final[str] = "127.0.0.1" +DEFAULT_PORT: Final[int] = 44044 +UINT32_MAX: Final[int] = (1 << 32) - 1 + + +@dataclass(frozen=True, slots=True) +class StartSpec: + app_path: Path + label: int + + +@dataclass(frozen=True, slots=True) +class AppContext: + label: int + handle: int + app_path: Path + process: subprocess.Popen[bytes] + + +def parse_start_option(value: str) -> StartSpec: + app_part, sep, label_part = value.partition(",") + if sep == "": + raise ValueError("--start expects 'app.elf,label='") + + app = app_part.strip() + if not app: + raise ValueError("--start application path cannot be empty") + + key, eq, raw_label = label_part.partition("=") + if eq == "" or key.strip() != "label": + raise ValueError("--start expects 'label='") + + try: + label = int(raw_label.strip(), 0) + except ValueError as exc: + raise ValueError("label must be an integer in [0, 4294967295]") from exc + + if label < 0 or label > UINT32_MAX: + raise ValueError("label must be an integer in [0, 4294967295]") + + return StartSpec(app_path=Path(app), label=label) + + +@dataclass(slots=True) +class GrpcEmulatorDaemon: + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + start_specs: tuple[StartSpec, ...] = () + store: SyscallStore = field(default_factory=SyscallStore) + logger: logging.Logger = field( + default_factory=lambda: logging.getLogger("camelot.sentry_emulator") + ) + + _bound_address: tuple[str, int] | None = field(default=None, init=False) + _contexts_by_label: dict[int, AppContext] = field(default_factory=dict, init=False) + _started_processes: list[subprocess.Popen[bytes]] = field(default_factory=list, init=False) + _next_handle: int = field(default=1, init=False) + + def _allocate_handle(self) -> int: + if self._next_handle > UINT32_MAX: + raise RuntimeError("app context handle overflow") + handle = self._next_handle + self._next_handle += 1 + return handle + + def _launch_start_spec(self, spec: StartSpec) -> AppContext: + if spec.label in self._contexts_by_label: + raise RuntimeError(f"duplicate app label: {spec.label}") + + app_path = spec.app_path.expanduser().resolve() + if not app_path.exists(): + raise RuntimeError(f"app does not exist: {app_path}") + + child_env = os.environ.copy() + child_env["SENTRY_APP_LABEL"] = str(spec.label) + child_env["SENTRY_EMULATOR_HOST"] = self.host + child_env["SENTRY_EMULATOR_PORT"] = str(self.port) + + process = subprocess.Popen([str(app_path)], env=child_env) + context = AppContext( + label=spec.label, + handle=self._allocate_handle(), + app_path=app_path, + process=process, + ) + self._contexts_by_label[spec.label] = context + self._started_processes.append(process) + return context + + def _startup_apps(self) -> None: + for spec in self.start_specs: + context = self._launch_start_spec(spec) + self.logger.info( + "Started app label=%d handle=%d pid=%d path=%s", + context.label, + context.handle, + context.process.pid, + context.app_path, + ) + + def _terminate_started_apps(self) -> None: + for process in self._started_processes: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + + def context_for_label(self, label: int) -> AppContext | None: + return self._contexts_by_label.get(label) + + @property + def bound_address(self) -> tuple[str, int]: + if self._bound_address is None: + raise RuntimeError("daemon is not bound yet") + return self._bound_address + + def serve_forever( + self, + stop_event: threading.Event | None = None, + ready_event: threading.Event | None = None, + poll_interval: float = 0.2, + ) -> None: + event = stop_event if stop_event is not None else threading.Event() + + self._startup_apps() + + grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) + emulator_pb2_grpc.add_EmulatorServicer_to_server( + _EmulatorServicer(daemon=self, store=self.store, logger=self.logger), grpc_server + ) # type: ignore[no-untyped-call] + + bound_port = grpc_server.add_insecure_port(f"{self.host}:{self.port}") + if bound_port == 0: + raise RuntimeError(f"cannot bind gRPC server on {self.host}:{self.port}") + + self._bound_address = (self.host, int(bound_port)) + grpc_server.start() + + if ready_event is not None: + ready_event.set() + + self.logger.info( + "Sentry emulator listening on grpc://%s:%d", + self._bound_address[0], + self._bound_address[1], + ) + + try: + while not event.wait(timeout=poll_interval): + continue + finally: + grpc_server.stop(grace=0).wait() + self._terminate_started_apps() + + +@dataclass(slots=True) +class _EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): + daemon: GrpcEmulatorDaemon + store: SyscallStore + logger: logging.Logger + + def Dispatch( + self, request: Any, context: grpc.ServicerContext + ) -> Any: + response_cls = getattr(emulator_pb2, "DispatchResponse") + + try: + message = deserialize_request(request) + except ProtocolError as exc: + self.store.register_invalid() + self.logger.warning("Rejected command from %s: %s", context.peer(), exc) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(exc)) + return response_cls(status=1, detail=str(exc)) + + app_context = self.daemon.context_for_label(message.label) + if app_context is None: + self.store.register_invalid() + detail = f"unknown app label: {message.label}" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(detail) + return response_cls(status=1, detail=detail) + + self.store.register(message) + # Keep this trace explicit for early integration/debug phases. + self.logger.info( + "grpc command label=%d handle=%d syscall=%s args=%s peer=%s", + message.label, + app_context.handle, + message.syscall, + list(message.args), + context.peer(), + ) + return response_cls(status=0, detail="ok") diff --git a/tools/sentry-emulator/tests/conftest.py b/tools/sentry-emulator/tests/conftest.py new file mode 100644 index 00000000..ed1d7eda --- /dev/null +++ b/tools/sentry-emulator/tests/conftest.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pathlib +import sys + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / "src" + +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) diff --git a/tools/sentry-emulator/tests/test_protocol.py b/tools/sentry-emulator/tests/test_protocol.py new file mode 100644 index 00000000..2c283b0b --- /dev/null +++ b/tools/sentry-emulator/tests/test_protocol.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 + +import pathlib +import sys + +import pytest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from camelot.sentry_emulator.grpc import emulator_pb2 +from camelot.sentry_emulator.protocol import ProtocolError, deserialize_request + + +def test_deserialize_request_ok() -> None: + message = deserialize_request( + emulator_pb2.DispatchRequest(syscall="map_dev", args=[1, 2, 3], label=17) + ) + + assert message.syscall == "map_dev" + assert message.args == (1, 2, 3) + assert message.label == 17 + + +def test_deserialize_request_rejects_empty_syscall() -> None: + with pytest.raises(ProtocolError): + deserialize_request(emulator_pb2.DispatchRequest(syscall=" ", args=[12], label=1)) diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py new file mode 100644 index 00000000..9c6469ed --- /dev/null +++ b/tools/sentry-emulator/tests/test_server.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 + +import pathlib +import importlib +import threading +import time + +import grpc +import pytest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] + +emulator_pb2 = importlib.import_module( + "camelot.sentry_emulator.grpc.emulator_pb2" +) +emulator_pb2_grpc = importlib.import_module( + "camelot.sentry_emulator.grpc.emulator_pb2_grpc" +) +GrpcEmulatorDaemon = importlib.import_module( + "camelot.sentry_emulator.server" +).GrpcEmulatorDaemon +StartSpec = importlib.import_module("camelot.sentry_emulator.server").StartSpec +parse_start_option = importlib.import_module("camelot.sentry_emulator.server").parse_start_option + + + +def test_parse_start_option_ok() -> None: + spec = parse_start_option("./app.elf,label=123") + + assert str(spec.app_path) == "app.elf" + assert spec.label == 123 + + +def test_parse_start_option_rejects_bad_label() -> None: + with pytest.raises(ValueError): + parse_start_option("./app.elf,label=50000000000") + + +def test_grpc_server_receives_and_sorts_messages(tmp_path: pathlib.Path) -> None: + app_path = tmp_path / "dummy_app.sh" + app_path.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=(StartSpec(app_path=app_path, label=7),), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[10], label=7)) + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[11], label=7)) + stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="gpio_set", args=[12, 3, 1], label=7) + ) + + with pytest.raises(grpc.RpcError): + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="", args=[1], label=7)) + + with pytest.raises(grpc.RpcError): + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[1], label=99)) + + deadline = time.time() + 2.0 + while time.time() < deadline: + if daemon.store.count_for("map_dev") == 2 and daemon.store.count_for("gpio_set") == 1: + break + time.sleep(0.02) + + stop_event.set() + thread.join(timeout=2.0) + + assert daemon.store.count_for("map_dev") == 2 + assert daemon.store.count_for("gpio_set") == 1 + assert daemon.store.invalid_packets == 2 diff --git a/tools/sentry-emulator/tox.ini b/tools/sentry-emulator/tox.ini new file mode 100644 index 00000000..8036afbf --- /dev/null +++ b/tools/sentry-emulator/tox.ini @@ -0,0 +1,25 @@ +[tox] +envlist = py,mypy,build +isolated_build = true + +[testenv] +deps = + pytest>=8 + grpcio>=1.80 + protobuf>=6.31 +commands = + pytest + +[testenv:mypy] +deps = + mypy>=1.10 + types-grpcio + types-protobuf +commands = + mypy src + +[testenv:build] +deps = + build>=1.2 +commands = + python -m build --sdist --wheel diff --git a/uapi/Cargo.lock b/uapi/Cargo.lock index 4eb9be77..681e05dd 100644 --- a/uapi/Cargo.lock +++ b/uapi/Cargo.lock @@ -2,9 +2,891 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "sentry-uapi" version = "0.4.2" dependencies = [ + "prost", "sentry-uapi", + "tokio", + "tonic", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] diff --git a/uapi/Cargo.toml b/uapi/Cargo.toml index 291f0323..63f82336 100644 --- a/uapi/Cargo.toml +++ b/uapi/Cargo.toml @@ -16,6 +16,11 @@ exclude = [ [dependencies] +[target.'cfg(all(target_arch = "x86_64", target_os = "linux"))'.dependencies] +prost = { version = "0.13", default-features = false, features = ["derive"] } +tokio = { version = "1.44", features = ["rt", "time", "net"] } +tonic = { version = "0.12", default-features = false, features = ["codegen", "prost", "transport"] } + [dev-dependencies] sentry-uapi = { path = '.', features = ['std'] } diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index 9b2cfec6..b634479b 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -25,6 +25,125 @@ /// use crate::{SentryExchangeable, systypes::*}; +use std::sync::OnceLock; +use tonic::codegen::http::uri::PathAndQuery; +use tonic::transport::Endpoint; +use tonic::{Request, Status as GrpcStatus}; + +const DEFAULT_EMULATOR_HOST: &str = "127.0.0.1"; +const DEFAULT_EMULATOR_PORT: u16 = 44044; +const DEFAULT_APP_LABEL: u32 = 0; + +static GRPC_RUNTIME: OnceLock> = OnceLock::new(); +static APP_LABEL: OnceLock = OnceLock::new(); + +#[derive(Clone, PartialEq, ::prost::Message)] +struct DispatchRequest { + #[prost(string, tag = "1")] + syscall: String, + #[prost(sint64, repeated, tag = "2")] + args: std::vec::Vec, + #[prost(uint32, tag = "3")] + label: u32, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +struct DispatchResponse { + #[prost(sint32, tag = "1")] + status: i32, + #[prost(string, tag = "2")] + detail: std::string::String, +} + +fn grpc_runtime() -> Option<&'static tokio::runtime::Runtime> { + GRPC_RUNTIME + .get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok() + }) + .as_ref() +} + +fn emulator_uri() -> std::string::String { + let host = std::env::var("SENTRY_EMULATOR_HOST") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_EMULATOR_HOST.to_string()); + let port = std::env::var("SENTRY_EMULATOR_PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_EMULATOR_PORT); + format!("http://{host}:{port}") +} + +fn app_label() -> u32 { + *APP_LABEL.get_or_init(|| { + std::env::var("SENTRY_APP_LABEL") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_APP_LABEL) + }) +} + +fn status_from_i32(raw_status: i32) -> Status { + match raw_status { + 0 => Status::Ok, + 1 => Status::Invalid, + 2 => Status::Denied, + 3 => Status::NoEntity, + 4 => Status::Busy, + 5 => Status::AlreadyMapped, + 6 => Status::Critical, + 7 => Status::Timeout, + 8 => Status::Again, + 9 => Status::Intr, + 10 => Status::Deadlk, + _ => Status::Invalid, + } +} + +async fn grpc_dispatch(request: DispatchRequest) -> Result { + let endpoint = Endpoint::from_shared(emulator_uri()) + .map_err(|_| GrpcStatus::unavailable("invalid endpoint"))?; + let channel = endpoint + .connect() + .await + .map_err(|_| GrpcStatus::unavailable("cannot connect to emulator"))?; + + let mut client = tonic::client::Grpc::new(channel); + let codec = tonic::codec::ProstCodec::default(); + let path = PathAndQuery::from_static("/camelot.sentry.emulator.Emulator/Dispatch"); + let response = client.unary(Request::new(request), path, codec).await?; + Ok(response.into_inner()) +} + +fn forward_syscall(syscall: &str, args: &[i128]) -> Status { + let grpc_args = args + .iter() + .map(|value| i64::try_from(*value).ok()) + .collect::>>(); + + let Some(grpc_args) = grpc_args else { + return Status::Invalid; + }; + + let request = DispatchRequest { + syscall: syscall.to_string(), + args: grpc_args, + label: app_label(), + }; + + let Some(runtime) = grpc_runtime() else { + return Status::NoEntity; + }; + + match runtime.block_on(grpc_dispatch(request)) { + Ok(response) => status_from_i32(response.status), + Err(_) => Status::NoEntity, + } +} #[inline(always)] @@ -73,122 +192,127 @@ pub fn sched_yield() -> Status { } #[inline(always)] -pub fn get_process_handle(_label: TaskLabel) -> Status { - // Pas d'équivalent POSIX direct - todo!("get_process_handle not implemented in POSIX mode"); +pub fn get_process_handle(label: TaskLabel) -> Status { + forward_syscall("get_process_handle", &[label as i128]) } #[inline(always)] -pub fn send_ipc(_target: TaskHandle, _length: u8) -> Status { - todo!("send_ipc not implemented in POSIX mode"); +pub fn send_ipc(target: TaskHandle, length: u8) -> Status { + forward_syscall("send_ipc", &[target as i128, length as i128]) } #[inline(always)] -pub fn wait_for_event(_mask: u8, _timeout: i32) -> Status { - todo!("wait_for_event not implemented in POSIX mode"); +pub fn wait_for_event(mask: u8, timeout: i32) -> Status { + forward_syscall("wait_for_event", &[mask as i128, timeout as i128]) } #[inline(always)] -pub fn map_dev(_handle: DeviceHandle) -> Status { - todo!("map_dev not implemented in POSIX mode"); +pub fn map_dev(handle: DeviceHandle) -> Status { + forward_syscall("map_dev", &[handle as i128]) } #[inline(always)] -pub fn unmap_dev(_handle: DeviceHandle) -> Status { - todo!("map_dev not implemented in POSIX mode"); +pub fn unmap_dev(handle: DeviceHandle) -> Status { + forward_syscall("unmap_dev", &[handle as i128]) } #[inline(always)] -pub fn get_shm_handle(_shm: ShmLabel) -> Status { - todo!("get_shm_handle not implemented in POSIX mode"); +pub fn get_shm_handle(shm: ShmLabel) -> Status { + forward_syscall("get_shm_handle", &[shm as i128]) } #[inline(always)] -pub fn get_device_handle(_devlabel: u8) -> Status { - todo!("get_device_handle not implemented in POSIX mode"); +pub fn get_device_handle(devlabel: u32) -> Status { + forward_syscall("get_device_handle", &[devlabel as i128]) } #[inline(always)] -pub fn get_dma_stream_handle(_stream: StreamLabel) -> Status { - todo!("get_dma_stream_handle not implemented in POSIX mode"); +pub fn get_dma_stream_handle(stream: StreamLabel) -> Status { + forward_syscall("get_dma_stream_handle", &[stream as i128]) } #[inline(always)] -pub fn start(_process: TaskLabel) -> Status { - todo!("start not implemented in POSIX mode"); +pub fn start(process: TaskLabel) -> Status { + forward_syscall("start", &[process as i128]) } #[inline(always)] -pub fn map_shm(_shm: ShmHandle) -> Status { - todo!("map_shm not implemented in POSIX mode"); +pub fn map_shm(shm: ShmHandle) -> Status { + forward_syscall("map_shm", &[shm as i128]) } #[inline(always)] -pub fn unmap_shm(_shm: ShmHandle) -> Status { - todo!("unmap_shm not implemented in POSIX mode"); +pub fn unmap_shm(shm: ShmHandle) -> Status { + forward_syscall("unmap_shm", &[shm as i128]) } #[inline(always)] pub fn shm_set_credential( - _shm: ShmHandle, - _id: TaskHandle, - _shm_perm: u32, + shm: ShmHandle, + id: TaskHandle, + shm_perm: u32, ) -> Status { - todo!("shm_set_credential not implemented in POSIX mode"); + forward_syscall( + "shm_set_credential", + &[shm as i128, id as i128, shm_perm as i128], + ) } #[inline(always)] -pub fn send_signal(_target: u32, _sig: Signal) -> Status { - todo!("send_signal not implemented in POSIX mode"); +pub fn send_signal(target: u32, sig: Signal) -> Status { + forward_syscall("send_signal", &[target as i128, sig as i128]) } #[inline(always)] -pub fn gpio_get(_resource: u32, _io: u8) -> Status { - todo!("gpio_get not implemented in POSIX mode"); +pub fn gpio_get(resource: u32, io: u8) -> Status { + forward_syscall("gpio_get", &[resource as i128, io as i128]) } #[inline(always)] -pub fn gpio_set(_resource: u32, _io: u8, _val: bool) -> Status { - todo!("gpio_set not implemented in POSIX mode"); +pub fn gpio_set(resource: u32, io: u8, val: bool) -> Status { + forward_syscall( + "gpio_set", + &[resource as i128, io as i128, i128::from(val as u8)], + ) } #[inline(always)] -pub fn gpio_reset(_resource: u32, _io: u8) -> Status { - todo!("gpio_reset not implemented in POSIX mode"); +pub fn gpio_reset(resource: u32, io: u8) -> Status { + forward_syscall("gpio_reset", &[resource as i128, io as i128]) } #[inline(always)] -pub fn gpio_toggle(_resource: u32, _io: u8) -> Status { - todo!("gpio_toggle not implemented in POSIX mode"); +pub fn gpio_toggle(resource: u32, io: u8) -> Status { + forward_syscall("gpio_toggle", &[resource as i128, io as i128]) } #[inline(always)] -pub fn gpio_configure(_resource: u32, _io: u8) -> Status { - todo!("gpio_configure not implemented in POSIX mode"); +pub fn gpio_configure(resource: u32, io: u8) -> Status { + forward_syscall("gpio_configure", &[resource as i128, io as i128]) } #[inline(always)] -pub fn irq_acknowledge(_irq: u16) -> Status { - todo!("irq_acknowledge not implemented in POSIX mode"); +pub fn irq_acknowledge(irq: u16) -> Status { + forward_syscall("irq_acknowledge", &[irq as i128]) } #[inline(always)] -pub fn irq_enable(_irq: u16) -> Status { - todo!("irq_enable not implemented in POSIX mode"); +pub fn irq_enable(irq: u16) -> Status { + forward_syscall("irq_enable", &[irq as i128]) } #[inline(always)] -pub fn irq_disable(_irq: u16) -> Status { - todo!("irq_disable not implemented in POSIX mode"); +pub fn irq_disable(irq: u16) -> Status { + forward_syscall("irq_disable", &[irq as i128]) } #[inline(always)] -pub fn pm_manage(_mode: CPUSleep) -> Status { - todo!("pm_manage not implemented in POSIX mode"); +pub fn pm_manage(mode: CPUSleep) -> Status { + forward_syscall("pm_manage", &[u32::from(mode) as i128]) } #[inline(always)] -pub fn alarm(_timeout_ms: u32, _flag: AlarmFlag) -> Status { - todo!("alarm not implemented in POSIX mode"); +pub fn alarm(timeout_ms: u32, flag: AlarmFlag) -> Status { + forward_syscall("alarm", &[timeout_ms as i128, u32::from(flag) as i128]) } #[inline(always)] @@ -209,73 +333,76 @@ pub fn log(_length: usize) -> Status { #[inline(always)] pub fn get_random() -> Status { - todo!("get_random not implemented in POSIX mode"); + forward_syscall("get_random", &[]) } #[inline(always)] -pub fn get_cycle(_precision: Precision) -> Status { - todo!("get_cycle not implemented in POSIX mode"); +pub fn get_cycle(precision: Precision) -> Status { + forward_syscall("get_cycle", &[precision as i128]) } #[inline(always)] -pub fn pm_set_clock(_clk_reg: u32, _clkmsk: u32, _val: u32) -> Status { - todo!("pm_set_clock not implemented in POSIX mode"); +pub fn pm_set_clock(clk_reg: u32, clkmsk: u32, val: u32) -> Status { + forward_syscall( + "pm_set_clock", + &[clk_reg as i128, clkmsk as i128, val as i128], + ) } #[inline(always)] -pub fn dma_start_stream(_dmah: StreamHandle) -> Status { - todo!("dma_start_stream not implemented in POSIX mode"); +pub fn dma_start_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_start_stream", &[dmah as i128]) } #[inline(always)] -pub fn dma_suspend_stream(_dmah: StreamHandle) -> Status { - todo!("dma_suspend_stream not implemented in POSIX mode"); +pub fn dma_suspend_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_suspend_stream", &[dmah as i128]) } #[inline(always)] -pub fn dma_get_stream_status(_dmah: StreamHandle) -> Status { - todo!("dma_get_stream_status not implemented in POSIX mode"); +pub fn dma_get_stream_status(dmah: StreamHandle) -> Status { + forward_syscall("dma_get_stream_status", &[dmah as i128]) } #[inline(always)] -pub fn shm_get_infos(_shm: ShmHandle) -> Status { - todo!("shm_get_infos not implemented in POSIX mode"); +pub fn shm_get_infos(shm: ShmHandle) -> Status { + forward_syscall("shm_get_infos", &[shm as i128]) } #[inline(always)] -pub fn dma_assign_stream(_dmah: StreamHandle) -> Status { - todo!("dma_assign_stream not implemented in POSIX mode"); +pub fn dma_assign_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_assign_stream", &[dmah as i128]) } #[inline(always)] -pub fn dma_unassign_stream(_dmah: StreamHandle) -> Status { - todo!("dma_unassign_stream not implemented in POSIX mode"); +pub fn dma_unassign_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_unassign_stream", &[dmah as i128]) } #[inline(always)] -pub fn dma_get_stream_info(_dmah: StreamHandle) -> Status { - todo!("dma_get_stream_info not implemented in POSIX mode"); +pub fn dma_get_stream_info(dmah: StreamHandle) -> Status { + forward_syscall("dma_get_stream_info", &[dmah as i128]) } #[inline(always)] -pub fn dma_resume_stream(_dmah: StreamHandle) -> Status { - todo!("dma_resume_stream not implemented in POSIX mode"); +pub fn dma_resume_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_resume_stream", &[dmah as i128]) } // Autotest only #[cfg(CONFIG_BUILD_TARGET_AUTOTEST)] #[inline(always)] pub fn autotest_set_capa(_capa: u32) -> Status { - todo!("autotest_set_capa not implemented in POSIX mode"); + forward_syscall("autotest_set_capa", &[_capa as i128]) } #[cfg(CONFIG_BUILD_TARGET_AUTOTEST)] #[inline(always)] pub fn autotest_clear_capa(_capa: u32) -> Status { - todo!("autotest_clear_capa not implemented in POSIX mode"); + forward_syscall("autotest_clear_capa", &[_capa as i128]) } #[inline(always)] pub fn unsupported() -> Status { - todo!("Unsupported syscall in POSIX GNU/Linux mode"); + forward_syscall("unsupported", &[]) } From 986617e122e2c91098ba9398b927aa5de4319762 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 11:04:29 +0200 Subject: [PATCH 04/19] fix: adding missing copyrights --- REUSE.toml | 33 +++++++++++++++++++ tools/sentry-emulator/.gitignore | 3 ++ tools/sentry-emulator/README.md | 3 ++ tools/sentry-emulator/meson.build | 2 ++ tools/sentry-emulator/proto/emulator.proto | 3 ++ tools/sentry-emulator/pyproject.toml | 3 ++ .../sample-rust-app/Cargo.lock | 3 ++ .../sample-rust-app/Cargo.toml | 2 ++ .../sample-rust-app/src/main.rs | 2 ++ tools/sentry-emulator/src/camelot/__init__.py | 2 ++ .../src/camelot/sentry_emulator/__init__.py | 2 ++ .../src/camelot/sentry_emulator/__main__.py | 2 ++ .../src/camelot/sentry_emulator/cli.py | 2 ++ .../src/camelot/sentry_emulator/dispatcher.py | 2 ++ .../camelot/sentry_emulator/grpc/__init__.py | 2 ++ .../sentry_emulator/grpc/emulator_pb2.py | 3 ++ .../sentry_emulator/grpc/emulator_pb2_grpc.py | 3 ++ .../src/camelot/sentry_emulator/protocol.py | 2 ++ .../src/camelot/sentry_emulator/server.py | 2 ++ tools/sentry-emulator/tests/conftest.py | 2 ++ tools/sentry-emulator/tests/test_protocol.py | 2 ++ tools/sentry-emulator/tests/test_server.py | 2 ++ tools/sentry-emulator/tox.ini | 3 ++ uapi/src/posix.rs | 2 +- 24 files changed, 86 insertions(+), 1 deletion(-) diff --git a/REUSE.toml b/REUSE.toml index 41465a30..8d5b6f65 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -32,3 +32,36 @@ path = ["uapi/Cargo.lock", "uapi/rust_target.in", "uapi/rustargs.in"] SPDX-FileCopyrightText = "2023-2024 Ledger SAS" SPDX-License-Identifier = "Apache-2.0" +# local build and analysis artifacts +[[annotations]] +path = [ + ".config", + ".config.*", + ".ivette", + ".vscode/settings.json", + ".sonar/**", + "bw_output/**", +] +SPDX-FileCopyrightText = "2026 H2Lab Development Team" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = [ + "config", + "coverage.xml", + "cscope.out", + "cscope.out.in", + "cscope.out.po", + "subprojects.toml", + "results/log.html", + "results/output.xml", + "results/report.html", + "kernel/proof/proof_composition/proof_zlib/.meson.build.swp", + "kernel/src/managers/memory/.memory_mpu.c.swp", + "kernel/proof/tools/gencoverage.py.2", + "kernel/proof/tools/gencoverage.py.metrics", + "kernel/proof/tools/sonarqube.tests.schema", +] +SPDX-FileCopyrightText = "2026 H2Lab Development Team" +SPDX-License-Identifier = "Apache-2.0" + diff --git a/tools/sentry-emulator/.gitignore b/tools/sentry-emulator/.gitignore index e5bca5f1..cf5c1478 100644 --- a/tools/sentry-emulator/.gitignore +++ b/tools/sentry-emulator/.gitignore @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + # Python caches and bytecode __pycache__/ *.py[cod] diff --git a/tools/sentry-emulator/README.md b/tools/sentry-emulator/README.md index 9e9780c9..52f54add 100644 --- a/tools/sentry-emulator/README.md +++ b/tools/sentry-emulator/README.md @@ -1,3 +1,6 @@ + + + # Camelot Sentry Emulator This package provides a gRPC daemon used by the POSIX implementation of the `sentry-uapi` crate. diff --git a/tools/sentry-emulator/meson.build b/tools/sentry-emulator/meson.build index 2adc93db..4f73399a 100644 --- a/tools/sentry-emulator/meson.build +++ b/tools/sentry-emulator/meson.build @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + emulator_pyproject = files('pyproject.toml') emulator_tox = files('tox.ini') diff --git a/tools/sentry-emulator/proto/emulator.proto b/tools/sentry-emulator/proto/emulator.proto index fb5a2b78..5c49ea73 100644 --- a/tools/sentry-emulator/proto/emulator.proto +++ b/tools/sentry-emulator/proto/emulator.proto @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + syntax = "proto3"; package camelot.sentry.emulator; diff --git a/tools/sentry-emulator/pyproject.toml b/tools/sentry-emulator/pyproject.toml index ccf0f681..29cd8430 100644 --- a/tools/sentry-emulator/pyproject.toml +++ b/tools/sentry-emulator/pyproject.toml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + [build-system] requires = [ "setuptools>=69", diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.lock b/tools/sentry-emulator/sample-rust-app/Cargo.lock index b1b1dbda..c9f4cdfa 100644 --- a/tools/sentry-emulator/sample-rust-app/Cargo.lock +++ b/tools/sentry-emulator/sample-rust-app/Cargo.lock @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.toml b/tools/sentry-emulator/sample-rust-app/Cargo.toml index 8f6dcf7f..2b674d26 100644 --- a/tools/sentry-emulator/sample-rust-app/Cargo.toml +++ b/tools/sentry-emulator/sample-rust-app/Cargo.toml @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + [package] name = "sentry-emulator-sample-rust-app" version = "0.1.0" diff --git a/tools/sentry-emulator/sample-rust-app/src/main.rs b/tools/sentry-emulator/sample-rust-app/src/main.rs index 4390c346..fa715f76 100644 --- a/tools/sentry-emulator/sample-rust-app/src/main.rs +++ b/tools/sentry-emulator/sample-rust-app/src/main.rs @@ -1,5 +1,7 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team // SPDX-License-Identifier: Apache-2.0 + fn main() { let _ = sentry_uapi::syscall::sched_yield(); let _ = sentry_uapi::syscall::get_random(); diff --git a/tools/sentry-emulator/src/camelot/__init__.py b/tools/sentry-emulator/src/camelot/__init__.py index 98813136..66066271 100644 --- a/tools/sentry-emulator/src/camelot/__init__.py +++ b/tools/sentry-emulator/src/camelot/__init__.py @@ -1 +1,3 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py index 0db967eb..9605b24d 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + from importlib.metadata import PackageNotFoundError, version from .dispatcher import SyscallStore diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py index b0c99916..a7bdbc5f 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + from .cli import main if __name__ == "__main__": diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py index e062ba58..877f79bf 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + from __future__ import annotations import argparse diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py index c924f256..01729629 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + from __future__ import annotations from collections import defaultdict diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py index 98813136..66066271 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py @@ -1 +1,3 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py index b5e1a19d..df1c150f 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py index 75ede9d8..de43cde3 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py index adcecd3b..bc1e8233 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + from __future__ import annotations from dataclasses import dataclass diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py index b93207e5..a627e42b 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + from __future__ import annotations from concurrent import futures diff --git a/tools/sentry-emulator/tests/conftest.py b/tools/sentry-emulator/tests/conftest.py index ed1d7eda..0a5398bd 100644 --- a/tools/sentry-emulator/tests/conftest.py +++ b/tools/sentry-emulator/tests/conftest.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + from __future__ import annotations import pathlib diff --git a/tools/sentry-emulator/tests/test_protocol.py b/tools/sentry-emulator/tests/test_protocol.py index 2c283b0b..7f76390d 100644 --- a/tools/sentry-emulator/tests/test_protocol.py +++ b/tools/sentry-emulator/tests/test_protocol.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + import pathlib import sys diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py index 9c6469ed..430ea343 100644 --- a/tools/sentry-emulator/tests/test_server.py +++ b/tools/sentry-emulator/tests/test_server.py @@ -1,5 +1,7 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 + import pathlib import importlib import threading diff --git a/tools/sentry-emulator/tox.ini b/tools/sentry-emulator/tox.ini index 8036afbf..5d3e8f93 100644 --- a/tools/sentry-emulator/tox.ini +++ b/tools/sentry-emulator/tox.ini @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + [tox] envlist = py,mypy,build isolated_build = true diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index b634479b..df3409fb 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -1,4 +1,4 @@ -// Copyright 2026 H2Lab Development Team +// SPDX-FileCopyrightText: 2026 H2Lab Development Team // SPDX-License-Identifier: Apache-2.0 /// From 283945f96770459bdd88f5d58e1bee78dc3df96b Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 11:15:52 +0200 Subject: [PATCH 05/19] emulator: adding clean numpy-compliant doc --- tools/sentry-emulator/src/camelot/__init__.py | 1 + .../src/camelot/sentry_emulator/__init__.py | 5 + .../src/camelot/sentry_emulator/__main__.py | 1 + .../src/camelot/sentry_emulator/cli.py | 34 +++- .../src/camelot/sentry_emulator/dispatcher.py | 42 ++++- .../src/camelot/sentry_emulator/protocol.py | 35 ++++- .../src/camelot/sentry_emulator/server.py | 145 +++++++++++++++++- tools/sentry-emulator/tests/conftest.py | 3 +- 8 files changed, 257 insertions(+), 9 deletions(-) diff --git a/tools/sentry-emulator/src/camelot/__init__.py b/tools/sentry-emulator/src/camelot/__init__.py index 66066271..ac93b158 100644 --- a/tools/sentry-emulator/src/camelot/__init__.py +++ b/tools/sentry-emulator/src/camelot/__init__.py @@ -1,3 +1,4 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""Top-level namespace for Camelot Python packages.""" diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py index 9605b24d..21467596 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py @@ -1,6 +1,11 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""Public API for the Camelot Sentry emulator package. + +This package exposes protocol helpers, in-memory dispatch storage, and daemon +runtime entrypoints for embedding or command-line usage. +""" from importlib.metadata import PackageNotFoundError, version diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py index a7bdbc5f..44fd01d1 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""Module entrypoint for ``python -m camelot.sentry_emulator``.""" from .cli import main diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py index 877f79bf..1d837fa6 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py @@ -1,8 +1,12 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""Command-line interface for the Sentry emulator daemon. -from __future__ import annotations +This module defines the public executable behavior used by the +``sentry-emulator`` console script. It validates CLI arguments, configures +logging, builds startup application specifications, and starts the gRPC daemon. +""" import argparse import logging @@ -17,6 +21,13 @@ def _build_parser() -> argparse.ArgumentParser: + """Create the command-line parser for the emulator daemon. + + Returns + ------- + argparse.ArgumentParser + Parser configured with network, logging, and startup options. + """ parser = argparse.ArgumentParser( prog="sentry-emulator", description="Run the Camelot Sentry userspace emulator daemon.", @@ -49,6 +60,20 @@ def _build_parser() -> argparse.ArgumentParser: def _parse_start_specs(raw_specs: list[str], parser: argparse.ArgumentParser) -> tuple[StartSpec, ...]: + """Parse and validate ``--start`` values into startup specifications. + + Parameters + ---------- + raw_specs : list[str] + Raw values provided by repeated ``--start`` arguments. + parser : argparse.ArgumentParser + Parser used to report user-facing argument errors. + + Returns + ------- + tuple[StartSpec, ...] + Validated startup specifications in input order. + """ parsed: list[StartSpec] = [] for raw in raw_specs: try: @@ -59,6 +84,13 @@ def _parse_start_specs(raw_specs: list[str], parser: argparse.ArgumentParser) -> def main() -> int: + """Run the daemon CLI entrypoint. + + Returns + ------- + int + Process return code (``0`` for graceful termination). + """ parser = _build_parser() args = parser.parse_args() start_specs = _parse_start_specs(args.start, parser) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py index 01729629..296cc99a 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py @@ -1,8 +1,11 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""In-memory syscall dispatch bookkeeping. -from __future__ import annotations +The daemon currently stores accepted syscall messages in memory for inspection +and tests. This module centralizes that behavior. +""" from collections import defaultdict from dataclasses import dataclass, field @@ -12,7 +15,15 @@ @dataclass(slots=True) class SyscallStore: - """In-memory store that groups incoming syscalls by syscall name.""" + """Store incoming syscall messages grouped by syscall name. + + Attributes + ---------- + buckets : dict[str, list[SyscallMessage]] + Mapping from syscall name to all corresponding messages. + invalid_packets : int + Number of rejected requests due to protocol or routing errors. + """ buckets: dict[str, list[SyscallMessage]] = field( default_factory=lambda: defaultdict(list) @@ -20,13 +31,40 @@ class SyscallStore: invalid_packets: int = 0 def register(self, message: SyscallMessage) -> None: + """Register a valid syscall message. + + Parameters + ---------- + message : SyscallMessage + Validated syscall payload to store. + """ self.buckets[message.syscall].append(message) def register_invalid(self) -> None: + """Increment the invalid packet counter.""" self.invalid_packets += 1 def count_for(self, syscall_name: str) -> int: + """Return the number of stored calls for one syscall name. + + Parameters + ---------- + syscall_name : str + Syscall name key in the store. + + Returns + ------- + int + Number of registered messages for ``syscall_name``. + """ return len(self.buckets.get(syscall_name, [])) def snapshot_counts(self) -> dict[str, int]: + """Return a compact count snapshot for all known syscalls. + + Returns + ------- + dict[str, int] + Mapping from syscall names to message counts. + """ return {name: len(entries) for name, entries in self.buckets.items()} diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py index bc1e8233..41617cba 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py @@ -1,8 +1,7 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations +"""Protocol decoding and validation utilities for emulator gRPC messages.""" from dataclasses import dataclass from typing import Any @@ -11,17 +10,47 @@ class ProtocolError(ValueError): - """Raised when a gRPC request does not follow the emulator protocol.""" + """Error raised when a request does not follow emulator protocol rules.""" @dataclass(frozen=True, slots=True) class SyscallMessage: + """Normalized syscall request extracted from gRPC payload. + + Attributes + ---------- + syscall : str + Name of the syscall to emulate. + args : tuple[int, ...] + Positional syscall arguments as signed integers. + label : int + Caller application label (expected to fit in ``u32``). + """ + syscall: str args: tuple[int, ...] label: int def deserialize_request(request: Any) -> SyscallMessage: + """Convert a gRPC ``DispatchRequest`` into a validated syscall message. + + Parameters + ---------- + request : Any + Incoming protobuf request object. It must expose ``syscall``, ``args`` + and ``label`` attributes. + + Returns + ------- + SyscallMessage + Immutable normalized representation used by the daemon core. + + Raises + ------ + ProtocolError + If ``syscall`` is empty or contains only whitespace. + """ syscall = request.syscall.strip() if not syscall: raise ProtocolError("'syscall' must be a non-empty string") diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py index a627e42b..a648f38f 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -1,8 +1,12 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""gRPC server implementation for the Sentry userspace emulator. -from __future__ import annotations +The daemon accepts gRPC syscall dispatch requests, validates payloads, +associates them with startup-managed application contexts, and records calls in +an in-memory store. +""" from concurrent import futures import logging @@ -27,12 +31,36 @@ @dataclass(frozen=True, slots=True) class StartSpec: + """Definition of one application to start with the daemon. + + Attributes + ---------- + app_path : Path + Executable path to start. + label : int + Application label used to route incoming syscall requests. + """ + app_path: Path label: int @dataclass(frozen=True, slots=True) class AppContext: + """Runtime context associated with one started application. + + Attributes + ---------- + label : int + Static application label used by requests. + handle : int + Unique ``u32`` handle allocated by the daemon. + app_path : Path + Resolved executable path used to spawn the process. + process : subprocess.Popen[bytes] + Spawned process object for lifecycle management. + """ + label: int handle: int app_path: Path @@ -40,6 +68,23 @@ class AppContext: def parse_start_option(value: str) -> StartSpec: + """Parse one ``--start`` argument value. + + Parameters + ---------- + value : str + Argument value formatted as ``APP_PATH,label=``. + + Returns + ------- + StartSpec + Parsed startup specification. + + Raises + ------ + ValueError + If the format is invalid or label is out of ``u32`` range. + """ app_part, sep, label_part = value.partition(",") if sep == "": raise ValueError("--start expects 'app.elf,label='") @@ -65,6 +110,22 @@ def parse_start_option(value: str) -> StartSpec: @dataclass(slots=True) class GrpcEmulatorDaemon: + """Lifecycle manager for the emulator gRPC daemon. + + Parameters + ---------- + host : str, optional + Interface to bind for gRPC service. + port : int, optional + Port to bind for gRPC service (``0`` allows dynamic allocation). + start_specs : tuple[StartSpec, ...], optional + Startup application specifications to spawn before serving. + store : SyscallStore, optional + In-memory syscall storage backend. + logger : logging.Logger, optional + Logger used for daemon diagnostics. + """ + host: str = DEFAULT_HOST port: int = DEFAULT_PORT start_specs: tuple[StartSpec, ...] = () @@ -79,6 +140,18 @@ class GrpcEmulatorDaemon: _next_handle: int = field(default=1, init=False) def _allocate_handle(self) -> int: + """Allocate the next unique context handle. + + Returns + ------- + int + Newly allocated ``u32`` handle. + + Raises + ------ + RuntimeError + If ``u32`` handle space is exhausted. + """ if self._next_handle > UINT32_MAX: raise RuntimeError("app context handle overflow") handle = self._next_handle @@ -86,6 +159,23 @@ def _allocate_handle(self) -> int: return handle def _launch_start_spec(self, spec: StartSpec) -> AppContext: + """Start one app and register its runtime context. + + Parameters + ---------- + spec : StartSpec + Startup specification to execute. + + Returns + ------- + AppContext + Registered context for the started app. + + Raises + ------ + RuntimeError + If the label is duplicated or executable path is invalid. + """ if spec.label in self._contexts_by_label: raise RuntimeError(f"duplicate app label: {spec.label}") @@ -110,6 +200,7 @@ def _launch_start_spec(self, spec: StartSpec) -> AppContext: return context def _startup_apps(self) -> None: + """Start and register all configured startup applications.""" for spec in self.start_specs: context = self._launch_start_spec(spec) self.logger.info( @@ -121,6 +212,7 @@ def _startup_apps(self) -> None: ) def _terminate_started_apps(self) -> None: + """Terminate all child processes started by the daemon.""" for process in self._started_processes: if process.poll() is None: process.terminate() @@ -131,10 +223,34 @@ def _terminate_started_apps(self) -> None: process.wait(timeout=2) def context_for_label(self, label: int) -> AppContext | None: + """Look up the runtime context bound to a label. + + Parameters + ---------- + label : int + Application label coming from request payload. + + Returns + ------- + AppContext | None + Matching context or ``None`` if the label is unknown. + """ return self._contexts_by_label.get(label) @property def bound_address(self) -> tuple[str, int]: + """Return the effective bind address once server is started. + + Returns + ------- + tuple[str, int] + Bound host and port. + + Raises + ------ + RuntimeError + If server has not been started yet. + """ if self._bound_address is None: raise RuntimeError("daemon is not bound yet") return self._bound_address @@ -145,6 +261,17 @@ def serve_forever( ready_event: threading.Event | None = None, poll_interval: float = 0.2, ) -> None: + """Start the daemon and serve requests until stop is requested. + + Parameters + ---------- + stop_event : threading.Event | None, optional + External event used to request server shutdown. + ready_event : threading.Event | None, optional + Event set once binding and server startup are complete. + poll_interval : float, optional + Poll period in seconds when waiting for ``stop_event``. + """ event = stop_event if stop_event is not None else threading.Event() self._startup_apps() @@ -180,6 +307,8 @@ def serve_forever( @dataclass(slots=True) class _EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): + """Internal gRPC service implementation for syscall dispatch.""" + daemon: GrpcEmulatorDaemon store: SyscallStore logger: logging.Logger @@ -187,6 +316,20 @@ class _EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): def Dispatch( self, request: Any, context: grpc.ServicerContext ) -> Any: + """Handle one syscall dispatch gRPC request. + + Parameters + ---------- + request : Any + Incoming protobuf request payload. + context : grpc.ServicerContext + gRPC context used to return detailed error status. + + Returns + ------- + Any + ``DispatchResponse`` protobuf instance. + """ response_cls = getattr(emulator_pb2, "DispatchResponse") try: diff --git a/tools/sentry-emulator/tests/conftest.py b/tools/sentry-emulator/tests/conftest.py index 0a5398bd..8c6bde51 100644 --- a/tools/sentry-emulator/tests/conftest.py +++ b/tools/sentry-emulator/tests/conftest.py @@ -1,8 +1,7 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations +"""Pytest bootstrap for local source-tree imports during tests.""" import pathlib import sys From 0bc7fd9beff8924aea3a3bbdc087f76deef2ec07 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 11:42:29 +0200 Subject: [PATCH 06/19] emulator: fixing races and adding log() support between app and daemon --- tools/sentry-emulator/proto/emulator.proto | 2 + .../sample-rust-app/Cargo.lock | 5 +- .../sample-rust-app/src/main.rs | 6 +- .../sentry_emulator/grpc/emulator_pb2.py | 12 ++-- .../src/camelot/sentry_emulator/protocol.py | 4 ++ .../src/camelot/sentry_emulator/server.py | 58 +++++++++++++++- tools/sentry-emulator/tests/test_protocol.py | 5 +- tools/sentry-emulator/tests/test_server.py | 23 ++++++- uapi/src/exchange.rs | 32 ++++++++- uapi/src/posix.rs | 66 ++++++++++++++----- 10 files changed, 179 insertions(+), 34 deletions(-) diff --git a/tools/sentry-emulator/proto/emulator.proto b/tools/sentry-emulator/proto/emulator.proto index 5c49ea73..07e32eb2 100644 --- a/tools/sentry-emulator/proto/emulator.proto +++ b/tools/sentry-emulator/proto/emulator.proto @@ -13,9 +13,11 @@ message DispatchRequest { string syscall = 1; repeated sint64 args = 2; uint32 label = 3; + bytes payload = 4; } message DispatchResponse { sint32 status = 1; string detail = 2; + bytes payload = 3; } diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.lock b/tools/sentry-emulator/sample-rust-app/Cargo.lock index c9f4cdfa..46a88fa5 100644 --- a/tools/sentry-emulator/sample-rust-app/Cargo.lock +++ b/tools/sentry-emulator/sample-rust-app/Cargo.lock @@ -1,8 +1,7 @@ -# SPDX-FileCopyrightText: 2026 H2Lab Development Team -# SPDX-License-Identifier: Apache-2.0 - # This file is automatically @generated by Cargo. # It is not intended for manual editing. +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 version = 4 [[package]] diff --git a/tools/sentry-emulator/sample-rust-app/src/main.rs b/tools/sentry-emulator/sample-rust-app/src/main.rs index fa715f76..bd148c65 100644 --- a/tools/sentry-emulator/sample-rust-app/src/main.rs +++ b/tools/sentry-emulator/sample-rust-app/src/main.rs @@ -3,7 +3,7 @@ fn main() { - let _ = sentry_uapi::syscall::sched_yield(); - let _ = sentry_uapi::syscall::get_random(); - let _ = sentry_uapi::syscall::unsupported(); + let msg = "hello from sample-rust-app"; + let _ = sentry_uapi::copy_to_kernel(&msg.as_bytes()); + let _ = sentry_uapi::syscall::log(msg.len()); } diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py index df1c150f..c4712104 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py @@ -27,7 +27,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x65mulator.proto\x12\x17\x63\x61melot.sentry.emulator\"?\n\x0f\x44ispatchRequest\x12\x0f\n\x07syscall\x18\x01 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x02 \x03(\x12\x12\r\n\x05label\x18\x03 \x01(\r\"2\n\x10\x44ispatchResponse\x12\x0e\n\x06status\x18\x01 \x01(\x11\x12\x0e\n\x06\x64\x65tail\x18\x02 \x01(\t2k\n\x08\x45mulator\x12_\n\x08\x44ispatch\x12(.camelot.sentry.emulator.DispatchRequest\x1a).camelot.sentry.emulator.DispatchResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x65mulator.proto\x12\x17\x63\x61melot.sentry.emulator\"P\n\x0f\x44ispatchRequest\x12\x0f\n\x07syscall\x18\x01 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x02 \x03(\x12\x12\r\n\x05label\x18\x03 \x01(\r\x12\x0f\n\x07payload\x18\x04 \x01(\x0c\"C\n\x10\x44ispatchResponse\x12\x0e\n\x06status\x18\x01 \x01(\x11\x12\x0e\n\x06\x64\x65tail\x18\x02 \x01(\t\x12\x0f\n\x07payload\x18\x03 \x01(\x0c\x32k\n\x08\x45mulator\x12_\n\x08\x44ispatch\x12(.camelot.sentry.emulator.DispatchRequest\x1a).camelot.sentry.emulator.DispatchResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -35,9 +35,9 @@ if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals['_DISPATCHREQUEST']._serialized_start=43 - _globals['_DISPATCHREQUEST']._serialized_end=106 - _globals['_DISPATCHRESPONSE']._serialized_start=108 - _globals['_DISPATCHRESPONSE']._serialized_end=158 - _globals['_EMULATOR']._serialized_start=160 - _globals['_EMULATOR']._serialized_end=267 + _globals['_DISPATCHREQUEST']._serialized_end=123 + _globals['_DISPATCHRESPONSE']._serialized_start=125 + _globals['_DISPATCHRESPONSE']._serialized_end=192 + _globals['_EMULATOR']._serialized_start=194 + _globals['_EMULATOR']._serialized_end=301 # @@protoc_insertion_point(module_scope) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py index 41617cba..9813d0e4 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py @@ -25,11 +25,14 @@ class SyscallMessage: Positional syscall arguments as signed integers. label : int Caller application label (expected to fit in ``u32``). + payload : bytes + Raw exchange payload transported with request. """ syscall: str args: tuple[int, ...] label: int + payload: bytes def deserialize_request(request: Any) -> SyscallMessage: @@ -59,4 +62,5 @@ def deserialize_request(request: Any) -> SyscallMessage: syscall=syscall, args=tuple(int(arg) for arg in request.args), label=int(request.label), + payload=bytes(request.payload), ) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py index a648f38f..0147be0f 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -27,6 +27,7 @@ DEFAULT_HOST: Final[str] = "127.0.0.1" DEFAULT_PORT: Final[int] = 44044 UINT32_MAX: Final[int] = (1 << 32) - 1 +EXCHANGE_BUFFER_LEN: Final[int] = 128 @dataclass(frozen=True, slots=True) @@ -59,12 +60,17 @@ class AppContext: Resolved executable path used to spawn the process. process : subprocess.Popen[bytes] Spawned process object for lifecycle management. + exchange_buffer : bytearray + Per-application exchange zone content. """ label: int handle: int app_path: Path process: subprocess.Popen[bytes] + exchange_buffer: bytearray = field( + default_factory=lambda: bytearray(EXCHANGE_BUFFER_LEN) + ) def parse_start_option(value: str) -> StartSpec: @@ -237,6 +243,35 @@ def context_for_label(self, label: int) -> AppContext | None: """ return self._contexts_by_label.get(label) + def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None: + """Write payload to the context exchange buffer. + + Parameters + ---------- + app_context : AppContext + Target context. + payload : bytes + Source bytes copied into the buffer and zero-padded. + """ + app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN + copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) + app_context.exchange_buffer[:copy_len] = payload[:copy_len] + + def read_exchange_buffer(self, app_context: AppContext) -> bytes: + """Read full exchange buffer for a context. + + Parameters + ---------- + app_context : AppContext + Source context. + + Returns + ------- + bytes + Full serialized exchange buffer. + """ + return bytes(app_context.exchange_buffer) + @property def bound_address(self) -> tuple[str, int]: """Return the effective bind address once server is started. @@ -274,8 +309,6 @@ def serve_forever( """ event = stop_event if stop_event is not None else threading.Event() - self._startup_apps() - grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) emulator_pb2_grpc.add_EmulatorServicer_to_server( _EmulatorServicer(daemon=self, store=self.store, logger=self.logger), grpc_server @@ -288,6 +321,8 @@ def serve_forever( self._bound_address = (self.host, int(bound_port)) grpc_server.start() + self._startup_apps() + if ready_event is not None: ready_event.set() @@ -350,6 +385,25 @@ def Dispatch( context.set_details(detail) return response_cls(status=1, detail=detail) + if message.syscall == "exchange_to_kernel": + self.daemon.write_exchange_buffer(app_context, message.payload) + return response_cls(status=0, detail="ok") + + if message.syscall == "exchange_from_kernel": + return response_cls( + status=0, + detail="ok", + payload=self.daemon.read_exchange_buffer(app_context), + ) + + if message.syscall == "log": + log_len = int(message.args[0]) if message.args else 0 + log_len = max(0, min(log_len, EXCHANGE_BUFFER_LEN)) + raw = bytes(app_context.exchange_buffer[:log_len]) + text = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + print(text, flush=True) + return response_cls(status=0, detail="ok") + self.store.register(message) # Keep this trace explicit for early integration/debug phases. self.logger.info( diff --git a/tools/sentry-emulator/tests/test_protocol.py b/tools/sentry-emulator/tests/test_protocol.py index 7f76390d..746332ed 100644 --- a/tools/sentry-emulator/tests/test_protocol.py +++ b/tools/sentry-emulator/tests/test_protocol.py @@ -18,12 +18,15 @@ def test_deserialize_request_ok() -> None: message = deserialize_request( - emulator_pb2.DispatchRequest(syscall="map_dev", args=[1, 2, 3], label=17) + emulator_pb2.DispatchRequest( + syscall="map_dev", args=[1, 2, 3], label=17, payload=b"abc" + ) ) assert message.syscall == "map_dev" assert message.args == (1, 2, 3) assert message.label == 17 + assert message.payload == b"abc" def test_deserialize_request_rejects_empty_syscall() -> None: diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py index 430ea343..9034439f 100644 --- a/tools/sentry-emulator/tests/test_server.py +++ b/tools/sentry-emulator/tests/test_server.py @@ -38,7 +38,9 @@ def test_parse_start_option_rejects_bad_label() -> None: parse_start_option("./app.elf,label=50000000000") -def test_grpc_server_receives_and_sorts_messages(tmp_path: pathlib.Path) -> None: +def test_grpc_server_receives_and_sorts_messages( + tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] +) -> None: app_path = tmp_path / "dummy_app.sh" app_path.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") app_path.chmod(0o755) @@ -76,6 +78,22 @@ def test_grpc_server_receives_and_sorts_messages(tmp_path: pathlib.Path) -> None with pytest.raises(grpc.RpcError): stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[1], label=99)) + stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="exchange_to_kernel", + label=7, + payload=b"hello from task", + ) + ) + exchange_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + assert exchange_reply.payload.startswith(b"hello from task") + + stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="log", args=[15], label=7) + ) + deadline = time.time() + 2.0 while time.time() < deadline: if daemon.store.count_for("map_dev") == 2 and daemon.store.count_for("gpio_set") == 1: @@ -88,3 +106,6 @@ def test_grpc_server_receives_and_sorts_messages(tmp_path: pathlib.Path) -> None assert daemon.store.count_for("map_dev") == 2 assert daemon.store.count_for("gpio_set") == 1 assert daemon.store.invalid_packets == 2 + + captured = capsys.readouterr() + assert "hello from task" in captured.out diff --git a/uapi/src/exchange.rs b/uapi/src/exchange.rs index a2bc31d9..10990db2 100644 --- a/uapi/src/exchange.rs +++ b/uapi/src/exchange.rs @@ -363,6 +363,24 @@ pub const fn length() -> usize { EXCHANGE_AREA_LEN } +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +#[allow(static_mut_refs)] +fn sync_exchange_to_daemon() -> Result { + match crate::syscall::exchange_to_daemon(unsafe { &EXCHANGE_AREA }) { + Status::Ok => Ok(Status::Ok), + status => Err(status), + } +} + +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +#[allow(static_mut_refs)] +fn sync_exchange_from_daemon() -> Result { + match crate::syscall::exchange_from_daemon(unsafe { &mut EXCHANGE_AREA }) { + Status::Ok => Ok(Status::Ok), + status => Err(status), + } +} + /// copy to kernel generic implementation /// /// This API is a generic implementation in order to allow userspace to kernelspace @@ -381,13 +399,25 @@ pub fn copy_to_kernel(from: &T) -> Result where T: SentryExchangeable + ?Sized, { - from.to_kernel() + let status = from.to_kernel()?; + + #[cfg(all(target_arch = "x86_64", target_os = "linux"))] + { + sync_exchange_to_daemon()?; + } + + Ok(status) } pub fn copy_from_kernel(to: &mut T) -> Result where T: SentryExchangeable + ?Sized, { + #[cfg(all(target_arch = "x86_64", target_os = "linux"))] + { + sync_exchange_from_daemon()?; + } + to.from_kernel() } diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index df3409fb..54e26409 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -24,7 +24,7 @@ /// server and returning the server's response as the syscall result. /// -use crate::{SentryExchangeable, systypes::*}; +use crate::systypes::*; use std::sync::OnceLock; use tonic::codegen::http::uri::PathAndQuery; use tonic::transport::Endpoint; @@ -45,6 +45,8 @@ struct DispatchRequest { args: std::vec::Vec, #[prost(uint32, tag = "3")] label: u32, + #[prost(bytes = "vec", tag = "4")] + payload: std::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] @@ -53,6 +55,8 @@ struct DispatchResponse { status: i32, #[prost(string, tag = "2")] detail: std::string::String, + #[prost(bytes = "vec", tag = "3")] + payload: std::vec::Vec, } fn grpc_runtime() -> Option<&'static tokio::runtime::Runtime> { @@ -113,35 +117,74 @@ async fn grpc_dispatch(request: DispatchRequest) -> Result Status { +fn dispatch_with_payload( + syscall: &str, + args: &[i128], + payload: &[u8], +) -> Result { let grpc_args = args .iter() .map(|value| i64::try_from(*value).ok()) .collect::>>(); let Some(grpc_args) = grpc_args else { - return Status::Invalid; + return Err(Status::Invalid); }; let request = DispatchRequest { syscall: syscall.to_string(), args: grpc_args, label: app_label(), + payload: payload.to_vec(), }; let Some(runtime) = grpc_runtime() else { - return Status::NoEntity; + return Err(Status::NoEntity); }; match runtime.block_on(grpc_dispatch(request)) { + Ok(response) => Ok(response), + Err(_) => Err(Status::NoEntity), + } +} + +fn forward_syscall(syscall: &str, args: &[i128]) -> Status { + match dispatch_with_payload(syscall, args, &[]) { Ok(response) => status_from_i32(response.status), - Err(_) => Status::NoEntity, + Err(status) => status, + } +} + +pub(crate) fn exchange_to_daemon(data: &[u8]) -> Status { + match dispatch_with_payload("exchange_to_kernel", &[], data) { + Ok(response) => status_from_i32(response.status), + Err(status) => status, + } +} + +pub(crate) fn exchange_from_daemon(data: &mut [u8]) -> Status { + match dispatch_with_payload("exchange_from_kernel", &[], &[]) { + Ok(response) => { + let status = status_from_i32(response.status); + if status != Status::Ok { + return status; + } + + let copy_len = core::cmp::min(data.len(), response.payload.len()); + data[..copy_len].copy_from_slice(&response.payload[..copy_len]); + Status::Ok + } + Err(status) => status, } } @@ -317,18 +360,7 @@ pub fn alarm(timeout_ms: u32, flag: AlarmFlag) -> Status { #[inline(always)] pub fn log(_length: usize) -> Status { - // usual model is to consecutively call: - // - str.to_kernel() to copy the log string into the exchange area - // - log(length) to trigger the log syscall, which will read the log string from the exchange area and print it - // - // In order to stay compatible with embedded use cases, we will keep the same model, by - // directly reading the log string from the exchange area and printing it, - // without any actual syscall, as this is a POSIX implementation and we can directly use std::println! - // Max log length is 128 by now, to be config-based set using CONFIG - let mut u8_slice: &mut [u8] = &mut [0u8; 128]; - let _ = u8_slice.from_kernel(); - std::println!("{}", String::from_utf8_lossy(u8_slice.as_ref())); - Status::Ok + forward_syscall("log", &[_length as i128]) } #[inline(always)] From 76fb0b6f753f41c46b5299ab73115ef484bbb1a8 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 11:52:37 +0200 Subject: [PATCH 07/19] emulator: support for log levels --- .../src/camelot/sentry_emulator/cli.py | 38 +++++++--- .../src/camelot/sentry_emulator/server.py | 72 ++++++++++++++++++- 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py index 1d837fa6..a61b94a7 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py @@ -47,7 +47,11 @@ def _build_parser() -> argparse.ArgumentParser: "--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], - help="Daemon log level", + help=( + "Daemon verbosity: INFO=start/stop lifecycle, " + "WARNING=invalid request content, ERROR=invalid args/start failures, " + "DEBUG=full request/response and task events" + ), ) parser.add_argument( "--start", @@ -59,27 +63,26 @@ def _build_parser() -> argparse.ArgumentParser: return parser -def _parse_start_specs(raw_specs: list[str], parser: argparse.ArgumentParser) -> tuple[StartSpec, ...]: +def _parse_start_specs(raw_specs: list[str]) -> tuple[StartSpec, ...]: """Parse and validate ``--start`` values into startup specifications. Parameters ---------- raw_specs : list[str] Raw values provided by repeated ``--start`` arguments. - parser : argparse.ArgumentParser - Parser used to report user-facing argument errors. - Returns ------- tuple[StartSpec, ...] Validated startup specifications in input order. + + Raises + ------ + ValueError + If any ``--start`` entry has invalid syntax or values. """ parsed: list[StartSpec] = [] for raw in raw_specs: - try: - parsed.append(parse_start_option(raw)) - except ValueError as exc: - parser.error(str(exc)) + parsed.append(parse_start_option(raw)) return tuple(parsed) @@ -93,17 +96,30 @@ def main() -> int: """ parser = _build_parser() args = parser.parse_args() - start_specs = _parse_start_specs(args.start, parser) logging.basicConfig( level=getattr(logging, args.log_level), format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) + logger = logging.getLogger("camelot.sentry_emulator") + + if args.port < 0 or args.port > 65535: + logger.error("Invalid --port value: %d (expected in [0, 65535])", args.port) + return 2 + + try: + start_specs = _parse_start_specs(args.start) + except ValueError as exc: + logger.error("Invalid startup argument: %s", exc) + return 2 daemon = GrpcEmulatorDaemon(host=args.host, port=args.port, start_specs=start_specs) try: daemon.serve_forever() except KeyboardInterrupt: - logging.getLogger("camelot.sentry_emulator").info("Shutdown requested") + logger.info("Shutdown requested") + except RuntimeError as exc: + logger.error("Daemon startup failed: %s", exc) + return 1 return 0 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py index 0147be0f..7b8586fa 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -183,10 +183,12 @@ def _launch_start_spec(self, spec: StartSpec) -> AppContext: If the label is duplicated or executable path is invalid. """ if spec.label in self._contexts_by_label: + self.logger.error("Duplicate startup label detected: %d", spec.label) raise RuntimeError(f"duplicate app label: {spec.label}") app_path = spec.app_path.expanduser().resolve() if not app_path.exists(): + self.logger.error("Startup executable does not exist: %s", app_path) raise RuntimeError(f"app does not exist: {app_path}") child_env = os.environ.copy() @@ -194,7 +196,12 @@ def _launch_start_spec(self, spec: StartSpec) -> AppContext: child_env["SENTRY_EMULATOR_HOST"] = self.host child_env["SENTRY_EMULATOR_PORT"] = str(self.port) - process = subprocess.Popen([str(app_path)], env=child_env) + try: + process = subprocess.Popen([str(app_path)], env=child_env) + except OSError as exc: + self.logger.error("Cannot start app label=%d path=%s: %s", spec.label, app_path, exc) + raise RuntimeError(f"cannot start app: {app_path}") from exc + context = AppContext( label=spec.label, handle=self._allocate_handle(), @@ -203,10 +210,18 @@ def _launch_start_spec(self, spec: StartSpec) -> AppContext: ) self._contexts_by_label[spec.label] = context self._started_processes.append(process) + self.logger.debug( + "Registered app context label=%d handle=%d pid=%d", + context.label, + context.handle, + context.process.pid, + ) return context def _startup_apps(self) -> None: """Start and register all configured startup applications.""" + if not self.start_specs: + self.logger.info("No startup tasks configured") for spec in self.start_specs: context = self._launch_start_spec(spec) self.logger.info( @@ -221,10 +236,15 @@ def _terminate_started_apps(self) -> None: """Terminate all child processes started by the daemon.""" for process in self._started_processes: if process.poll() is None: + self.logger.debug("Stopping child process pid=%d", process.pid) process.terminate() try: process.wait(timeout=2) except subprocess.TimeoutExpired: + self.logger.warning( + "Child process pid=%d did not stop gracefully, killing", + process.pid, + ) process.kill() process.wait(timeout=2) @@ -256,6 +276,12 @@ def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) app_context.exchange_buffer[:copy_len] = payload[:copy_len] + self.logger.debug( + "exchange_to_kernel label=%d handle=%d bytes=%d", + app_context.label, + app_context.handle, + copy_len, + ) def read_exchange_buffer(self, app_context: AppContext) -> bytes: """Read full exchange buffer for a context. @@ -270,7 +296,14 @@ def read_exchange_buffer(self, app_context: AppContext) -> bytes: bytes Full serialized exchange buffer. """ - return bytes(app_context.exchange_buffer) + payload = bytes(app_context.exchange_buffer) + self.logger.debug( + "exchange_from_kernel label=%d handle=%d bytes=%d", + app_context.label, + app_context.handle, + len(payload), + ) + return payload @property def bound_address(self) -> tuple[str, int]: @@ -316,6 +349,7 @@ def serve_forever( bound_port = grpc_server.add_insecure_port(f"{self.host}:{self.port}") if bound_port == 0: + self.logger.error("Cannot bind gRPC server on %s:%d", self.host, self.port) raise RuntimeError(f"cannot bind gRPC server on {self.host}:{self.port}") self._bound_address = (self.host, int(bound_port)) @@ -338,6 +372,7 @@ def serve_forever( finally: grpc_server.stop(grace=0).wait() self._terminate_started_apps() + self.logger.info("Sentry emulator stopped") @dataclass(slots=True) @@ -366,6 +401,14 @@ def Dispatch( ``DispatchResponse`` protobuf instance. """ response_cls = getattr(emulator_pb2, "DispatchResponse") + self.logger.debug( + "Received request peer=%s syscall=%s args=%s label=%s payload_len=%d", + context.peer(), + getattr(request, "syscall", ""), + list(getattr(request, "args", [])), + getattr(request, "label", ""), + len(getattr(request, "payload", b"")), + ) try: message = deserialize_request(request) @@ -387,13 +430,25 @@ def Dispatch( if message.syscall == "exchange_to_kernel": self.daemon.write_exchange_buffer(app_context, message.payload) + self.logger.debug( + "Responding syscall=%s label=%d status=0", + message.syscall, + message.label, + ) return response_cls(status=0, detail="ok") if message.syscall == "exchange_from_kernel": + payload = self.daemon.read_exchange_buffer(app_context) + self.logger.debug( + "Responding syscall=%s label=%d status=0 payload_len=%d", + message.syscall, + message.label, + len(payload), + ) return response_cls( status=0, detail="ok", - payload=self.daemon.read_exchange_buffer(app_context), + payload=payload, ) if message.syscall == "log": @@ -402,6 +457,12 @@ def Dispatch( raw = bytes(app_context.exchange_buffer[:log_len]) text = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") print(text, flush=True) + self.logger.debug( + "Responding syscall=%s label=%d status=0 printed_len=%d", + message.syscall, + message.label, + len(text), + ) return response_cls(status=0, detail="ok") self.store.register(message) @@ -414,4 +475,9 @@ def Dispatch( list(message.args), context.peer(), ) + self.logger.debug( + "Responding syscall=%s label=%d status=0", + message.syscall, + message.label, + ) return response_cls(status=0, detail="ok") From a940e2b3e20feb5ea6577b1c180455180036356e Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 12:19:07 +0200 Subject: [PATCH 08/19] emulator: support for basic syscalls adding send_signal(), get_handle(), get_random() and get_cycle() --- .../sample-rust-app/src/main.rs | 46 +++- .../src/camelot/sentry_emulator/server.py | 260 +++++++++++++++++- tools/sentry-emulator/tests/test_server.py | 68 ++++- 3 files changed, 358 insertions(+), 16 deletions(-) diff --git a/tools/sentry-emulator/sample-rust-app/src/main.rs b/tools/sentry-emulator/sample-rust-app/src/main.rs index bd148c65..3c06c7df 100644 --- a/tools/sentry-emulator/sample-rust-app/src/main.rs +++ b/tools/sentry-emulator/sample-rust-app/src/main.rs @@ -3,7 +3,47 @@ fn main() { - let msg = "hello from sample-rust-app"; - let _ = sentry_uapi::copy_to_kernel(&msg.as_bytes()); - let _ = sentry_uapi::syscall::log(msg.len()); + fn daemon_log(msg: &str) { + let _ = sentry_uapi::copy_to_kernel(&msg.as_bytes()); + let _ = sentry_uapi::syscall::log(msg.len()); + } + + let my_label = std::env::var("SENTRY_APP_LABEL") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(7); + + daemon_log("[1] get_process_handle(self)"); + let st_proc = sentry_uapi::syscall::get_process_handle(my_label); + let mut my_handle: u32 = 0; + let _ = sentry_uapi::copy_from_kernel(&mut my_handle); + daemon_log(&format!("status={st_proc:?} handle={my_handle}")); + + let peer_label = if my_label == 7 { 8 } else { 7 }; + daemon_log("[2] get_process_handle(peer) + send_signal(peer_handle, SIGUSR1)"); + let st_peer = sentry_uapi::syscall::get_process_handle(peer_label); + let mut peer_handle: u32 = 0; + let _ = sentry_uapi::copy_from_kernel(&mut peer_handle); + daemon_log(&format!("peer_lookup status={st_peer:?} handle={peer_handle}")); + if st_peer == sentry_uapi::systypes::Status::Ok { + let st_sig_peer = sentry_uapi::syscall::send_signal(peer_handle, sentry_uapi::systypes::Signal::Usr1); + daemon_log(&format!("send_signal status={st_sig_peer:?}")); + } + + daemon_log("[3] alarm(start then stop)"); + let st_alarm_start = sentry_uapi::syscall::alarm(50, sentry_uapi::systypes::AlarmFlag::AlarmStartPeriodic); + let st_alarm_stop = sentry_uapi::syscall::alarm(50, sentry_uapi::systypes::AlarmFlag::AlarmStop); + daemon_log(&format!("start={st_alarm_start:?} stop={st_alarm_stop:?}")); + + daemon_log("[4] get_random"); + let st_rng = sentry_uapi::syscall::get_random(); + let mut rng_value: u32 = 0; + let _ = sentry_uapi::copy_from_kernel(&mut rng_value); + daemon_log(&format!("status={st_rng:?} value={rng_value}")); + + daemon_log("[5] get_cycle(Milliseconds)"); + let st_cycle = sentry_uapi::syscall::get_cycle(sentry_uapi::systypes::Precision::Milliseconds); + let mut cycle_value: u64 = 0; + let _ = sentry_uapi::copy_from_kernel(&mut cycle_value); + daemon_log(&format!("status={st_cycle:?} value={cycle_value}")); } diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py index 7b8586fa..2f1a9732 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -12,8 +12,10 @@ import logging import os from pathlib import Path +import random import subprocess import threading +import time from dataclasses import dataclass, field from typing import Any from typing import Final @@ -28,6 +30,28 @@ DEFAULT_PORT: Final[int] = 44044 UINT32_MAX: Final[int] = (1 << 32) - 1 EXCHANGE_BUFFER_LEN: Final[int] = 128 +SIGNAL_ABORT: Final[int] = 1 +SIGNAL_USR2: Final[int] = 12 +SIGNAL_ALARM: Final[int] = 2 +PRECISION_CYCLE: Final[int] = 0 +PRECISION_NANOSECONDS: Final[int] = 1 +PRECISION_MICROSECONDS: Final[int] = 2 +PRECISION_MILLISECONDS: Final[int] = 3 + +STATUS_OK: Final[int] = 0 +STATUS_INVALID: Final[int] = 1 +STATUS_DENIED: Final[int] = 2 +STATUS_NO_ENTITY: Final[int] = 3 +STATUS_BUSY: Final[int] = 4 + +MAX_PENDING_SIGNALS: Final[int] = 32 + + +@dataclass(slots=True) +class AlarmRegistration: + delay_ms: int + periodic: bool + timer: threading.Timer @dataclass(frozen=True, slots=True) @@ -62,6 +86,10 @@ class AppContext: Spawned process object for lifecycle management. exchange_buffer : bytearray Per-application exchange zone content. + pending_signals : list[int] + Pending signals queued for this application. + alarms : dict[int, AlarmRegistration] + Alarm registrations keyed by timeout in milliseconds. """ label: int @@ -71,6 +99,8 @@ class AppContext: exchange_buffer: bytearray = field( default_factory=lambda: bytearray(EXCHANGE_BUFFER_LEN) ) + pending_signals: list[int] = field(default_factory=list) + alarms: dict[int, AlarmRegistration] = field(default_factory=dict) def parse_start_option(value: str) -> StartSpec: @@ -142,8 +172,12 @@ class GrpcEmulatorDaemon: _bound_address: tuple[str, int] | None = field(default=None, init=False) _contexts_by_label: dict[int, AppContext] = field(default_factory=dict, init=False) + _contexts_by_handle: dict[int, AppContext] = field(default_factory=dict, init=False) _started_processes: list[subprocess.Popen[bytes]] = field(default_factory=list, init=False) _next_handle: int = field(default=1, init=False) + _rng: random.Random = field(default_factory=random.Random, init=False) + _lock: threading.Lock = field(default_factory=threading.Lock, init=False) + _start_monotonic_ns: int = field(default_factory=time.monotonic_ns, init=False) def _allocate_handle(self) -> int: """Allocate the next unique context handle. @@ -208,8 +242,10 @@ def _launch_start_spec(self, spec: StartSpec) -> AppContext: app_path=app_path, process=process, ) - self._contexts_by_label[spec.label] = context - self._started_processes.append(process) + with self._lock: + self._contexts_by_label[spec.label] = context + self._contexts_by_handle[context.handle] = context + self._started_processes.append(process) self.logger.debug( "Registered app context label=%d handle=%d pid=%d", context.label, @@ -248,6 +284,13 @@ def _terminate_started_apps(self) -> None: process.kill() process.wait(timeout=2) + with self._lock: + for app_context in self._contexts_by_label.values(): + for registration in app_context.alarms.values(): + registration.timer.cancel() + self._contexts_by_label.clear() + self._contexts_by_handle.clear() + def context_for_label(self, label: int) -> AppContext | None: """Look up the runtime context bound to a label. @@ -261,7 +304,13 @@ def context_for_label(self, label: int) -> AppContext | None: AppContext | None Matching context or ``None`` if the label is unknown. """ - return self._contexts_by_label.get(label) + with self._lock: + return self._contexts_by_label.get(label) + + def context_for_handle(self, handle: int) -> AppContext | None: + """Look up the runtime context bound to a handle.""" + with self._lock: + return self._contexts_by_handle.get(handle) def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None: """Write payload to the context exchange buffer. @@ -273,9 +322,10 @@ def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None payload : bytes Source bytes copied into the buffer and zero-padded. """ - app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN - copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) - app_context.exchange_buffer[:copy_len] = payload[:copy_len] + with self._lock: + app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN + copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) + app_context.exchange_buffer[:copy_len] = payload[:copy_len] self.logger.debug( "exchange_to_kernel label=%d handle=%d bytes=%d", app_context.label, @@ -296,7 +346,8 @@ def read_exchange_buffer(self, app_context: AppContext) -> bytes: bytes Full serialized exchange buffer. """ - payload = bytes(app_context.exchange_buffer) + with self._lock: + payload = bytes(app_context.exchange_buffer) self.logger.debug( "exchange_from_kernel label=%d handle=%d bytes=%d", app_context.label, @@ -305,6 +356,116 @@ def read_exchange_buffer(self, app_context: AppContext) -> bytes: ) return payload + def write_u32_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + """Store one ``u32`` value into app exchange buffer.""" + self.write_exchange_buffer(app_context, int(value).to_bytes(4, "little", signed=False)) + + def write_u64_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + """Store one ``u64`` value into app exchange buffer.""" + self.write_exchange_buffer(app_context, int(value).to_bytes(8, "little", signed=False)) + + def queue_signal(self, target: AppContext, signal: int) -> int: + """Queue a signal for a target context. + + Returns + ------- + int + Emulator status code. + """ + with self._lock: + if len(target.pending_signals) >= MAX_PENDING_SIGNALS: + return STATUS_BUSY + target.pending_signals.append(signal) + self.logger.debug( + "Queued signal=%d for label=%d handle=%d", + signal, + target.label, + target.handle, + ) + return STATUS_OK + + def _alarm_fire(self, label: int, delay_ms: int) -> None: + with self._lock: + target = self._contexts_by_label.get(label) + if target is None: + return + status = self.queue_signal(target, SIGNAL_ALARM) + if status != STATUS_OK: + self.logger.warning( + "Alarm delivery failed label=%d delay_ms=%d status=%d", + label, + delay_ms, + status, + ) + + def _schedule_alarm(self, app_context: AppContext, delay_ms: int, periodic: bool) -> int: + with self._lock: + if delay_ms in app_context.alarms: + return STATUS_BUSY + + interval_s = max(0.0, delay_ms / 1000.0) + + def _callback() -> None: + self._alarm_fire(app_context.label, delay_ms) + if not periodic: + with self._lock: + app_context.alarms.pop(delay_ms, None) + return + + with self._lock: + still_registered = app_context.alarms.get(delay_ms) + if still_registered is None: + return + + next_timer = threading.Timer(interval_s, _callback) + app_context.alarms[delay_ms] = AlarmRegistration( + delay_ms=delay_ms, + periodic=True, + timer=next_timer, + ) + next_timer.start() + + timer = threading.Timer(interval_s, _callback) + registration = AlarmRegistration(delay_ms=delay_ms, periodic=periodic, timer=timer) + with self._lock: + app_context.alarms[delay_ms] = registration + timer.start() + self.logger.debug( + "Scheduled alarm label=%d handle=%d delay_ms=%d periodic=%s", + app_context.label, + app_context.handle, + delay_ms, + periodic, + ) + return STATUS_OK + + def _stop_alarm(self, app_context: AppContext, delay_ms: int) -> int: + with self._lock: + registration = app_context.alarms.pop(delay_ms, None) + if registration is None: + return STATUS_NO_ENTITY + registration.timer.cancel() + self.logger.debug( + "Stopped alarm label=%d handle=%d delay_ms=%d", + app_context.label, + app_context.handle, + delay_ms, + ) + return STATUS_OK + + def current_cycle_value(self, precision: int) -> int: + """Compute a synthetic cycle/timestamp value for requested precision.""" + elapsed_ns = max(0, time.monotonic_ns() - self._start_monotonic_ns) + if precision == PRECISION_CYCLE: + return elapsed_ns + if precision == PRECISION_NANOSECONDS: + return elapsed_ns + if precision == PRECISION_MICROSECONDS: + return elapsed_ns // 1_000 + if precision == PRECISION_MILLISECONDS: + return elapsed_ns // 1_000_000 + raise ValueError("invalid precision") + @property def bound_address(self) -> tuple[str, int]: """Return the effective bind address once server is started. @@ -456,7 +617,7 @@ def Dispatch( log_len = max(0, min(log_len, EXCHANGE_BUFFER_LEN)) raw = bytes(app_context.exchange_buffer[:log_len]) text = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") - print(text, flush=True) + print(f"[app:{message.label}] {text}", flush=True) self.logger.debug( "Responding syscall=%s label=%d status=0 printed_len=%d", message.syscall, @@ -465,6 +626,89 @@ def Dispatch( ) return response_cls(status=0, detail="ok") + if message.syscall == "get_process_handle": + if not message.args: + detail = "missing label argument" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_label = int(message.args[0]) + target_context = self.daemon.context_for_label(target_label) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown label") + + self.daemon.write_u32_to_exchange_buffer(app_context, target_context.handle) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "send_signal": + if len(message.args) < 2: + detail = "missing send_signal arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_handle = int(message.args[0]) + signal = int(message.args[1]) + if target_handle == app_context.handle: + return response_cls(status=STATUS_INVALID, detail="self signal forbidden") + if signal < SIGNAL_ABORT or signal > SIGNAL_USR2: + return response_cls(status=STATUS_INVALID, detail="invalid signal") + + target_context = self.daemon.context_for_handle(target_handle) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown target handle") + + status = self.daemon.queue_signal(target_context, signal) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + + if message.syscall == "alarm": + if len(message.args) < 2: + detail = "missing alarm arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + delay_ms = max(0, int(message.args[0])) + flag = int(message.args[1]) + if flag == 0: + status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=False) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if flag == 1: + status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=True) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if flag == 2: + status = self.daemon._stop_alarm(app_context, delay_ms) + return response_cls( + status=status, + detail="ok" if status == STATUS_OK else "no entity", + ) + return response_cls(status=STATUS_INVALID, detail="invalid alarm flag") + + if message.syscall == "get_random": + value = self.daemon._rng.getrandbits(32) + self.daemon.write_u32_to_exchange_buffer(app_context, value) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "get_cycle": + if not message.args: + detail = "missing precision argument" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + precision = int(message.args[0]) + if precision < PRECISION_CYCLE or precision > PRECISION_MILLISECONDS: + return response_cls(status=STATUS_INVALID, detail="invalid precision") + + cycle_value = self.daemon.current_cycle_value(precision) + self.daemon.write_u64_to_exchange_buffer(app_context, cycle_value) + return response_cls(status=STATUS_OK, detail="ok") + self.store.register(message) # Keep this trace explicit for early integration/debug phases. self.logger.info( diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py index 9034439f..fecdb093 100644 --- a/tools/sentry-emulator/tests/test_server.py +++ b/tools/sentry-emulator/tests/test_server.py @@ -41,14 +41,21 @@ def test_parse_start_option_rejects_bad_label() -> None: def test_grpc_server_receives_and_sorts_messages( tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: - app_path = tmp_path / "dummy_app.sh" - app_path.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") - app_path.chmod(0o755) + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) daemon = GrpcEmulatorDaemon( host="127.0.0.1", port=0, - start_specs=(StartSpec(app_path=app_path, label=7),), + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), ) stop_event = threading.Event() ready_event = threading.Event() @@ -94,6 +101,57 @@ def test_grpc_server_receives_and_sorts_messages( emulator_pb2.DispatchRequest(syscall="log", args=[15], label=7) ) + proc_self = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[7], label=7) + ) + assert proc_self.status == 0 + self_handle_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + self_handle = int.from_bytes(self_handle_reply.payload[:4], "little") + assert self_handle > 0 + + proc_other = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[8], label=7) + ) + assert proc_other.status == 0 + other_handle_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + other_handle = int.from_bytes(other_handle_reply.payload[:4], "little") + assert other_handle > 0 + assert other_handle != self_handle + + send_sig = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="send_signal", args=[other_handle, 11], label=7) + ) + assert send_sig.status == 0 + + alarm_start = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="alarm", args=[50, 1], label=7) + ) + assert alarm_start.status == 0 + alarm_stop = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="alarm", args=[50, 2], label=7) + ) + assert alarm_stop.status == 0 + + rnd = stub.Dispatch(emulator_pb2.DispatchRequest(syscall="get_random", label=7)) + assert rnd.status == 0 + rnd_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + assert len(rnd_reply.payload) >= 4 + + cyc = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_cycle", args=[3], label=7) + ) + assert cyc.status == 0 + cyc_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + assert len(cyc_reply.payload) >= 8 + deadline = time.time() + 2.0 while time.time() < deadline: if daemon.store.count_for("map_dev") == 2 and daemon.store.count_for("gpio_set") == 1: @@ -108,4 +166,4 @@ def test_grpc_server_receives_and_sorts_messages( assert daemon.store.invalid_packets == 2 captured = capsys.readouterr() - assert "hello from task" in captured.out + assert "[app:7] hello from task" in captured.out From a55bf22dccfa91b3d9877ca4b885444fb3e51a2b Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 12:19:49 +0200 Subject: [PATCH 09/19] emulator: adding cli test suite --- tools/sentry-emulator/tests/test_cli.py | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tools/sentry-emulator/tests/test_cli.py diff --git a/tools/sentry-emulator/tests/test_cli.py b/tools/sentry-emulator/tests/test_cli.py new file mode 100644 index 00000000..789e0b48 --- /dev/null +++ b/tools/sentry-emulator/tests/test_cli.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""CLI behavior tests for argument validation and error reporting.""" + +import logging +import sys + +from camelot.sentry_emulator import cli + + +def test_main_returns_non_zero_and_logs_error_on_invalid_port( + monkeypatch, caplog +) -> None: + """Invalid port should produce ERROR log and non-zero return code.""" + monkeypatch.setattr( + sys, + "argv", + ["sentry-emulator", "--log-level", "ERROR", "--port", "70000"], + ) + + caplog.set_level(logging.ERROR, logger="camelot.sentry_emulator") + rc = cli.main() + + assert rc != 0 + assert any( + rec.levelno == logging.ERROR and "Invalid --port value" in rec.getMessage() + for rec in caplog.records + ) + + +def test_main_returns_non_zero_and_logs_error_on_invalid_start_arg( + monkeypatch, caplog +) -> None: + """Malformed --start should produce ERROR log and non-zero return code.""" + monkeypatch.setattr( + sys, + "argv", + [ + "sentry-emulator", + "--log-level", + "ERROR", + "--start", + "sample-app-without-label", + ], + ) + + caplog.set_level(logging.ERROR, logger="camelot.sentry_emulator") + rc = cli.main() + + assert rc != 0 + assert any( + rec.levelno == logging.ERROR and "Invalid startup argument" in rec.getMessage() + for rec in caplog.records + ) From 5e9b9abb6f3fbcd6f06da81e54c680cbc8e37e0b Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 15:24:02 +0200 Subject: [PATCH 10/19] emulator: enhanced emulator with proper meson integration adding emulation test with sample apps as meson test suite --- tools/meson.build | 22 ++ tools/sentry-emulator/README.md | 10 + tools/sentry-emulator/meson.build | 49 +-- tools/sentry-emulator/pyproject.toml | 3 + .../sample-rust-app/Cargo.lock | 2 +- .../sample-rust-app/Cargo.toml | 11 +- .../sample-rust-app/src/bin/sample-app-one.rs | 7 + .../sample-rust-app/src/bin/sample-app-two.rs | 7 + .../sample-rust-app/src/lib.rs | 117 +++++++ .../sample-rust-app/src/main.rs | 49 --- .../src/camelot/sentry_emulator/server.py | 289 ++++++++++++++---- tools/sentry-emulator/tests/test_cli.py | 19 ++ tools/sentry-emulator/tests/test_emulator.py | 66 ++++ tools/sentry-emulator/tests/test_server.py | 203 ++++++++++++ tools/sentry-emulator/tox.ini | 10 +- uapi/src/posix.rs | 1 + 16 files changed, 713 insertions(+), 152 deletions(-) create mode 100644 tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs create mode 100644 tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs create mode 100644 tools/sentry-emulator/sample-rust-app/src/lib.rs delete mode 100644 tools/sentry-emulator/sample-rust-app/src/main.rs create mode 100644 tools/sentry-emulator/tests/test_emulator.py diff --git a/tools/meson.build b/tools/meson.build index e32abd8e..669849ed 100644 --- a/tools/meson.build +++ b/tools/meson.build @@ -10,4 +10,26 @@ subdir('genmetadata') if get_option('with_emulator') subdir('sentry-emulator') + + emulator_start_app_one = '@0@,label=7'.format(sample_rust_app_one_path) + emulator_start_app_two = '@0@,label=8'.format(sample_rust_app_two_path) + + test( + 'sentry-emulator-rust-apps-e2e', + py3, + args: [ + '-m', + 'camelot.sentry_emulator', + '--log-level', + 'INFO', + '--start', + emulator_start_app_one, + '--start', + emulator_start_app_two, + ], + env: {'PYTHONPATH': meson.project_source_root() / 'tools/sentry-emulator/src'}, + suite: 'emulator', + timeout: 30, + depends: [sample_rust_build], + ) endif diff --git a/tools/sentry-emulator/README.md b/tools/sentry-emulator/README.md index 52f54add..4b431c21 100644 --- a/tools/sentry-emulator/README.md +++ b/tools/sentry-emulator/README.md @@ -30,6 +30,16 @@ sentry-emulator --start ./build/my-app,label=7 --start ./build/my-other-app,labe Each started app receives `SENTRY_APP_LABEL`, `SENTRY_EMULATOR_HOST`, and `SENTRY_EMULATOR_PORT` in its environment. +## Sample Rust tasks + +The sample Rust project now builds two binaries with the same validation flow: + +- `sample-app-one` +- `sample-app-two` + +They are produced under `sample-rust-app/target/debug/` when building the sample +project. + ## Local build ```sh diff --git a/tools/sentry-emulator/meson.build b/tools/sentry-emulator/meson.build index 4f73399a..5bd74f36 100644 --- a/tools/sentry-emulator/meson.build +++ b/tools/sentry-emulator/meson.build @@ -5,6 +5,8 @@ emulator_pyproject = files('pyproject.toml') emulator_tox = files('tox.ini') +rustmod = import('rust') + emulator_build = custom_target( 'sentry-emulator-build', input: [emulator_pyproject, emulator_tox], @@ -12,7 +14,7 @@ emulator_build = custom_target( command: [ py3, '-c', - 'import pathlib, subprocess, sys; out = pathlib.Path(sys.argv[1]); src = pathlib.Path(sys.argv[2]); dist = pathlib.Path(sys.argv[3]); dist.mkdir(parents=True, exist_ok=True); subprocess.run([sys.executable, "-m", "build", "--sdist", "--wheel", "--outdir", str(dist), str(src)], check=True); out.write_text("ok\n", encoding="utf-8")', + 'import pathlib, subprocess, sys; out = pathlib.Path(sys.argv[1]); src = pathlib.Path(sys.argv[2]); dist = pathlib.Path(sys.argv[3]); dist.mkdir(parents=True, exist_ok=True); subprocess.run([sys.executable, "-m", "build", "--sdist", "--wheel", "--outdir", str(dist), str(src)], check=True); out.write_text("ok\\n", encoding="utf-8")', '@OUTPUT@', meson.current_source_dir(), meson.current_build_dir() / 'dist', @@ -23,54 +25,35 @@ emulator_build = custom_target( cargo = find_program('cargo', required: true) sample_rust_manifest = files('sample-rust-app/Cargo.toml') +sample_rust_sources = files([ + 'sample-rust-app/src/lib.rs', + 'sample-rust-app/src/bin/sample-app-one.rs', + 'sample-rust-app/src/bin/sample-app-two.rs', +]) +sample_rust_target_dir = meson.current_build_dir() / 'sample-rust-target' +sample_rust_app_one_path = sample_rust_target_dir / 'debug' / 'sample-app-one' +sample_rust_app_two_path = sample_rust_target_dir / 'debug' / 'sample-app-two' sample_rust_build = custom_target( 'sentry-emulator-sample-rust-app-build', - input: sample_rust_manifest, + input: [sample_rust_manifest, sample_rust_sources], output: 'sentry-emulator-sample-rust-app-build.stamp', command: [ py3, '-c', 'import pathlib, subprocess, sys; out = pathlib.Path(sys.argv[1]); manifest = pathlib.Path(sys.argv[2]); target = pathlib.Path(sys.argv[3]); subprocess.run(["cargo", "build", "--manifest-path", str(manifest), "--target-dir", str(target)], check=True); out.write_text("ok\\n", encoding="utf-8")', '@OUTPUT@', - sample_rust_manifest[0].full_path(), - meson.current_build_dir() / 'sample-rust-target', + sample_rust_manifest[0 ].full_path(), + sample_rust_target_dir, ], build_by_default: true, ) -if with_tests - test( - 'sentry-emulator-pytest', - py3, - args: [ - '-m', - 'pytest', - '-c', - meson.current_source_dir() / 'pyproject.toml', - meson.current_source_dir() / 'tests', - ], - suite: 'tools', - workdir: meson.current_source_dir(), - ) - - test( - 'sentry-emulator-mypy', - py3, - args: [ - '-m', - 'mypy', - meson.current_source_dir() / 'src', - ], - suite: 'tools', - workdir: meson.current_source_dir(), - ) -endif - summary( { 'sentry emulator artifacts': meson.current_build_dir() / 'dist', - 'sentry emulator sample rust app': meson.current_build_dir() / 'sample-rust-target', + 'sentry emulator sample rust app one': sample_rust_app_one_path, + 'sentry emulator sample rust app two': sample_rust_app_two_path, }, section: 'Tools', ) diff --git a/tools/sentry-emulator/pyproject.toml b/tools/sentry-emulator/pyproject.toml index 29cd8430..f90c940f 100644 --- a/tools/sentry-emulator/pyproject.toml +++ b/tools/sentry-emulator/pyproject.toml @@ -50,6 +50,9 @@ fallback_version = "0.0.0+unknown" pythonpath = ["src"] addopts = "-q" testpaths = ["tests"] +markers = [ + "emulator: end-to-end tests that launch the emulator with real startup apps", +] [tool.mypy] python_version = "3.11" diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.lock b/tools/sentry-emulator/sample-rust-app/Cargo.lock index 46a88fa5..955677a8 100644 --- a/tools/sentry-emulator/sample-rust-app/Cargo.lock +++ b/tools/sentry-emulator/sample-rust-app/Cargo.lock @@ -514,7 +514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "sentry-emulator-sample-rust-app" +name = "sentry-emulator-sample-rust-tasks" version = "0.1.0" dependencies = [ "sentry-uapi", diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.toml b/tools/sentry-emulator/sample-rust-app/Cargo.toml index 2b674d26..d7912038 100644 --- a/tools/sentry-emulator/sample-rust-app/Cargo.toml +++ b/tools/sentry-emulator/sample-rust-app/Cargo.toml @@ -3,11 +3,20 @@ [package] -name = "sentry-emulator-sample-rust-app" +name = "sentry-emulator-sample-rust-tasks" version = "0.1.0" edition = "2024" license = "Apache-2.0" publish = false +autobins = false [dependencies] sentry-uapi = { path = "../../../uapi", features = ["std"] } + +[[bin]] +name = "sample-app-one" +path = "src/bin/sample-app-one.rs" + +[[bin]] +name = "sample-app-two" +path = "src/bin/sample-app-two.rs" diff --git a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs new file mode 100644 index 00000000..d231ba32 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +fn main() { + sentry_emulator_sample_rust_tasks::run_sample_app_one(8); + let _ = sentry_uapi::syscall::exit(0); +} diff --git a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs new file mode 100644 index 00000000..b81d2fd8 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +fn main() { + sentry_emulator_sample_rust_tasks::run_sample_app_two(); + let _ = sentry_uapi::syscall::exit(0); +} diff --git a/tools/sentry-emulator/sample-rust-app/src/lib.rs b/tools/sentry-emulator/sample-rust-app/src/lib.rs new file mode 100644 index 00000000..b5dd52a6 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/src/lib.rs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +use sentry_uapi::systypes::{ + AlarmFlag, EventType, Precision, Signal, Status, +}; +use std::thread; +use std::time::Duration; + +fn report_status(context: &str, status: Status) { + eprintln!("[sample-rust-app] {context}: {status:?}"); +} + +const SIGNAL_EVENT_TYPE: u8 = 2; +const SIGNAL_EVENT_MAGIC: u16 = 0x4242; +const TARGET_APP_TWO_HANDLE: u32 = 2; + +fn read_signal_event_from_exchange() -> Option { + let mut raw_exchange = [0u8; 128]; + let mut raw_exchange_slice: &mut [u8] = &mut raw_exchange; + let st_copy_event = + sentry_uapi::copy_from_kernel(&mut raw_exchange_slice).unwrap_or(Status::Invalid); + report_status("copy_from_kernel(raw signal event)", st_copy_event); + if st_copy_event != Status::Ok { + return None; + } + + let event_type = raw_exchange[0]; + let event_len = raw_exchange[1]; + let event_magic = u16::from_le_bytes([raw_exchange[2], raw_exchange[3]]); + let event_peer = u32::from_le_bytes([ + raw_exchange[4], + raw_exchange[5], + raw_exchange[6], + raw_exchange[7], + ]); + let signal = u32::from_le_bytes([ + raw_exchange[8], + raw_exchange[9], + raw_exchange[10], + raw_exchange[11], + ]); + + if event_type != SIGNAL_EVENT_TYPE + || event_len != 4 + || event_magic != SIGNAL_EVENT_MAGIC + || event_peer == 0 + { + eprintln!( + "[sample-rust-app] invalid signal event header: type={event_type} len={event_len} magic=0x{event_magic:04x} peer={event_peer}" + ); + return None; + } + + eprintln!("[sample-rust-app] signal event received from peer={event_peer} value={signal}"); + Some(signal) +} + +fn run_alarm_random_cycle_checks() { + // Start periodic alarm to guarantee that an immediate stop targets a live registration. + let st_alarm_start = sentry_uapi::syscall::alarm(5000, AlarmFlag::AlarmStartPeriodic); + let st_alarm_stop = sentry_uapi::syscall::alarm(5000, AlarmFlag::AlarmStop); + report_status("alarm(start)", st_alarm_start); + if st_alarm_stop == Status::NoEntity { + eprintln!("[sample-rust-app] alarm(stop): Ok (already stopped)"); + } else { + report_status("alarm(stop)", st_alarm_stop); + } + + let st_rng = sentry_uapi::syscall::get_random(); + let mut rng_value: u32 = 0; + report_status("get_random", st_rng); + let st_copy_rng = sentry_uapi::copy_from_kernel(&mut rng_value).unwrap_or(Status::Invalid); + report_status("copy_from_kernel(random)", st_copy_rng); + + let st_cycle = sentry_uapi::syscall::get_cycle(Precision::Milliseconds); + let mut cycle_value: u64 = 0; + report_status("get_cycle", st_cycle); + let st_copy_cycle = + sentry_uapi::copy_from_kernel(&mut cycle_value).unwrap_or(Status::Invalid); + report_status("copy_from_kernel(cycle)", st_copy_cycle); +} + +pub fn run_sample_app_one(peer_label: u32) { + let _ = peer_label; + + // Let the receiver enter its blocking wait path before sending the signal. + thread::sleep(Duration::from_millis(100)); + + let st_sig_peer = sentry_uapi::syscall::send_signal(TARGET_APP_TWO_HANDLE, Signal::Usr1); + report_status("send_signal(peer, SIGUSR1)", st_sig_peer); + + // Emit a second signal to make the emulator e2e startup phase robust against transient ordering. + let st_sig_peer_retry = sentry_uapi::syscall::send_signal(TARGET_APP_TWO_HANDLE, Signal::Usr1); + report_status("send_signal(peer, SIGUSR1, retry)", st_sig_peer_retry); +} + +pub fn run_sample_app_two() { + // Wait without timeout and return only when SIGUSR1 has been serialized by daemon. + loop { + let st_wait_signal = sentry_uapi::syscall::wait_for_event(EventType::Signal.into(), 0); + report_status("wait_for_event(signal, no timeout)", st_wait_signal); + if st_wait_signal != Status::Ok { + continue; + } + let Some(signal) = read_signal_event_from_exchange() else { + continue; + }; + + if signal == Signal::Usr1 as u32 { + break; + } + + eprintln!("[sample-rust-app] ignoring other signal value={signal}"); + } + +} diff --git a/tools/sentry-emulator/sample-rust-app/src/main.rs b/tools/sentry-emulator/sample-rust-app/src/main.rs deleted file mode 100644 index 3c06c7df..00000000 --- a/tools/sentry-emulator/sample-rust-app/src/main.rs +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: 2026 H2Lab Development Team -// SPDX-License-Identifier: Apache-2.0 - - -fn main() { - fn daemon_log(msg: &str) { - let _ = sentry_uapi::copy_to_kernel(&msg.as_bytes()); - let _ = sentry_uapi::syscall::log(msg.len()); - } - - let my_label = std::env::var("SENTRY_APP_LABEL") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(7); - - daemon_log("[1] get_process_handle(self)"); - let st_proc = sentry_uapi::syscall::get_process_handle(my_label); - let mut my_handle: u32 = 0; - let _ = sentry_uapi::copy_from_kernel(&mut my_handle); - daemon_log(&format!("status={st_proc:?} handle={my_handle}")); - - let peer_label = if my_label == 7 { 8 } else { 7 }; - daemon_log("[2] get_process_handle(peer) + send_signal(peer_handle, SIGUSR1)"); - let st_peer = sentry_uapi::syscall::get_process_handle(peer_label); - let mut peer_handle: u32 = 0; - let _ = sentry_uapi::copy_from_kernel(&mut peer_handle); - daemon_log(&format!("peer_lookup status={st_peer:?} handle={peer_handle}")); - if st_peer == sentry_uapi::systypes::Status::Ok { - let st_sig_peer = sentry_uapi::syscall::send_signal(peer_handle, sentry_uapi::systypes::Signal::Usr1); - daemon_log(&format!("send_signal status={st_sig_peer:?}")); - } - - daemon_log("[3] alarm(start then stop)"); - let st_alarm_start = sentry_uapi::syscall::alarm(50, sentry_uapi::systypes::AlarmFlag::AlarmStartPeriodic); - let st_alarm_stop = sentry_uapi::syscall::alarm(50, sentry_uapi::systypes::AlarmFlag::AlarmStop); - daemon_log(&format!("start={st_alarm_start:?} stop={st_alarm_stop:?}")); - - daemon_log("[4] get_random"); - let st_rng = sentry_uapi::syscall::get_random(); - let mut rng_value: u32 = 0; - let _ = sentry_uapi::copy_from_kernel(&mut rng_value); - daemon_log(&format!("status={st_rng:?} value={rng_value}")); - - daemon_log("[5] get_cycle(Milliseconds)"); - let st_cycle = sentry_uapi::syscall::get_cycle(sentry_uapi::systypes::Precision::Milliseconds); - let mut cycle_value: u64 = 0; - let _ = sentry_uapi::copy_from_kernel(&mut cycle_value); - daemon_log(&format!("status={st_cycle:?} value={cycle_value}")); -} diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py index 2f1a9732..a27eb7d0 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -31,8 +31,10 @@ UINT32_MAX: Final[int] = (1 << 32) - 1 EXCHANGE_BUFFER_LEN: Final[int] = 128 SIGNAL_ABORT: Final[int] = 1 +EVENT_TYPE_SIGNAL: Final[int] = 2 SIGNAL_USR2: Final[int] = 12 SIGNAL_ALARM: Final[int] = 2 +EVENT_MAGIC: Final[int] = 0x4242 PRECISION_CYCLE: Final[int] = 0 PRECISION_NANOSECONDS: Final[int] = 1 PRECISION_MICROSECONDS: Final[int] = 2 @@ -43,6 +45,8 @@ STATUS_DENIED: Final[int] = 2 STATUS_NO_ENTITY: Final[int] = 3 STATUS_BUSY: Final[int] = 4 +STATUS_TIMEOUT: Final[int] = 7 +STATUS_AGAIN: Final[int] = 8 MAX_PENDING_SIGNALS: Final[int] = 32 @@ -70,7 +74,7 @@ class StartSpec: label: int -@dataclass(frozen=True, slots=True) +@dataclass(slots=True) class AppContext: """Runtime context associated with one started application. @@ -82,25 +86,29 @@ class AppContext: Unique ``u32`` handle allocated by the daemon. app_path : Path Resolved executable path used to spawn the process. - process : subprocess.Popen[bytes] + process : subprocess.Popen[bytes] | None Spawned process object for lifecycle management. exchange_buffer : bytearray Per-application exchange zone content. - pending_signals : list[int] - Pending signals queued for this application. + pending_signals : list[tuple[int, int]] + Pending signals queued as ``(signal, source_handle)`` tuples. alarms : dict[int, AlarmRegistration] Alarm registrations keyed by timeout in milliseconds. + event_condition : threading.Condition + Condition used to block ``wait_for_event`` until an event is available. """ label: int handle: int app_path: Path - process: subprocess.Popen[bytes] + process: subprocess.Popen[bytes] | None = None exchange_buffer: bytearray = field( default_factory=lambda: bytearray(EXCHANGE_BUFFER_LEN) ) - pending_signals: list[int] = field(default_factory=list) + pending_signals: list[tuple[int, int]] = field(default_factory=list) alarms: dict[int, AlarmRegistration] = field(default_factory=dict) + event_condition: threading.Condition = field(default_factory=threading.Condition) + exit_code: int | None = None def parse_start_option(value: str) -> StartSpec: @@ -179,6 +187,54 @@ class GrpcEmulatorDaemon: _lock: threading.Lock = field(default_factory=threading.Lock, init=False) _start_monotonic_ns: int = field(default_factory=time.monotonic_ns, init=False) + def _all_startup_contexts_stopped(self) -> bool: + """Return ``True`` when all startup contexts have been deactivated.""" + if not self.start_specs: + return False + with self._lock: + return not self._contexts_by_label + + def deactivate_context(self, app_context: AppContext, exit_code: int) -> bool: + """Deactivate one application context after an ``exit`` syscall. + + Parameters + ---------- + app_context : AppContext + Context to deactivate. + exit_code : int + Application-provided process return code. + + Returns + ------- + bool + ``True`` when this deactivation makes all startup contexts inactive. + """ + with self._lock: + removed_by_label = self._contexts_by_label.pop(app_context.label, None) + removed_by_handle = self._contexts_by_handle.pop(app_context.handle, None) + contexts_remaining = len(self._contexts_by_label) + + if removed_by_label is None or removed_by_handle is None: + return self._all_startup_contexts_stopped() + + app_context.exit_code = exit_code + with app_context.event_condition: + app_context.pending_signals.clear() + app_context.event_condition.notify_all() + + for registration in app_context.alarms.values(): + registration.timer.cancel() + app_context.alarms.clear() + + self.logger.info( + "App exited label=%d handle=%d code=%d remaining_contexts=%d", + app_context.label, + app_context.handle, + exit_code, + contexts_remaining, + ) + return contexts_remaining == 0 and bool(self.start_specs) + def _allocate_handle(self) -> int: """Allocate the next unique context handle. @@ -198,76 +254,97 @@ def _allocate_handle(self) -> int: self._next_handle += 1 return handle - def _launch_start_spec(self, spec: StartSpec) -> AppContext: - """Start one app and register its runtime context. + def _prepare_start_specs(self) -> None: + """Create and register all startup contexts before launching apps. Parameters ---------- - spec : StartSpec - Startup specification to execute. - - Returns - ------- - AppContext - Registered context for the started app. - Raises ------ RuntimeError - If the label is duplicated or executable path is invalid. + If labels are duplicated or one executable path is invalid. """ - if spec.label in self._contexts_by_label: - self.logger.error("Duplicate startup label detected: %d", spec.label) - raise RuntimeError(f"duplicate app label: {spec.label}") - - app_path = spec.app_path.expanduser().resolve() - if not app_path.exists(): - self.logger.error("Startup executable does not exist: %s", app_path) - raise RuntimeError(f"app does not exist: {app_path}") - - child_env = os.environ.copy() - child_env["SENTRY_APP_LABEL"] = str(spec.label) - child_env["SENTRY_EMULATOR_HOST"] = self.host - child_env["SENTRY_EMULATOR_PORT"] = str(self.port) + prepared_contexts: list[AppContext] = [] + labels: set[int] = set() + for spec in self.start_specs: + if spec.label in labels or spec.label in self._contexts_by_label: + self.logger.error("Duplicate startup label detected: %d", spec.label) + raise RuntimeError(f"duplicate app label: {spec.label}") + + app_path = spec.app_path.expanduser().resolve() + if not app_path.exists(): + self.logger.error("Startup executable does not exist: %s", app_path) + raise RuntimeError(f"app does not exist: {app_path}") + + prepared_contexts.append( + AppContext( + label=spec.label, + handle=self._allocate_handle(), + app_path=app_path, + ) + ) + labels.add(spec.label) - try: - process = subprocess.Popen([str(app_path)], env=child_env) - except OSError as exc: - self.logger.error("Cannot start app label=%d path=%s: %s", spec.label, app_path, exc) - raise RuntimeError(f"cannot start app: {app_path}") from exc - - context = AppContext( - label=spec.label, - handle=self._allocate_handle(), - app_path=app_path, - process=process, - ) with self._lock: - self._contexts_by_label[spec.label] = context - self._contexts_by_handle[context.handle] = context - self._started_processes.append(process) - self.logger.debug( - "Registered app context label=%d handle=%d pid=%d", - context.label, - context.handle, - context.process.pid, - ) - return context + for context in prepared_contexts: + self._contexts_by_label[context.label] = context + self._contexts_by_handle[context.handle] = context - def _startup_apps(self) -> None: - """Start and register all configured startup applications.""" - if not self.start_specs: - self.logger.info("No startup tasks configured") + for context in prepared_contexts: + self.logger.debug( + "Initialized app context label=%d handle=%d path=%s", + context.label, + context.handle, + context.app_path, + ) + + def _start_prepared_apps(self) -> None: + """Start all applications after contexts have been registered.""" for spec in self.start_specs: - context = self._launch_start_spec(spec) + with self._lock: + context = self._contexts_by_label.get(spec.label) + + if context is None: + self.logger.error("Missing initialized context for label=%d", spec.label) + raise RuntimeError(f"missing app context for label: {spec.label}") + + child_env = os.environ.copy() + child_env["SENTRY_APP_LABEL"] = str(spec.label) + child_env["SENTRY_EMULATOR_HOST"] = self.host + child_env["SENTRY_EMULATOR_PORT"] = str(self.port) + + try: + process = subprocess.Popen([str(context.app_path)], env=child_env) + except OSError as exc: + self.logger.error( + "Cannot start app label=%d path=%s: %s", + spec.label, + context.app_path, + exc, + ) + raise RuntimeError(f"cannot start app: {context.app_path}") from exc + + context.process = process + with self._lock: + self._started_processes.append(process) + self.logger.info( "Started app label=%d handle=%d pid=%d path=%s", context.label, context.handle, - context.process.pid, + process.pid, context.app_path, ) + def _startup_apps(self) -> None: + """Start and register all configured startup applications.""" + if not self.start_specs: + self.logger.info("No startup tasks configured") + return + + self._prepare_start_specs() + self._start_prepared_apps() + def _terminate_started_apps(self) -> None: """Terminate all child processes started by the daemon.""" for process in self._started_processes: @@ -288,6 +365,9 @@ def _terminate_started_apps(self) -> None: for app_context in self._contexts_by_label.values(): for registration in app_context.alarms.values(): registration.timer.cancel() + app_context.alarms.clear() + app_context.pending_signals.clear() + app_context.process = None self._contexts_by_label.clear() self._contexts_by_handle.clear() @@ -364,7 +444,7 @@ def write_u64_to_exchange_buffer(self, app_context: AppContext, value: int) -> N """Store one ``u64`` value into app exchange buffer.""" self.write_exchange_buffer(app_context, int(value).to_bytes(8, "little", signed=False)) - def queue_signal(self, target: AppContext, signal: int) -> int: + def queue_signal(self, target: AppContext, signal: int, source_handle: int) -> int: """Queue a signal for a target context. Returns @@ -372,13 +452,15 @@ def queue_signal(self, target: AppContext, signal: int) -> int: int Emulator status code. """ - with self._lock: + with target.event_condition: if len(target.pending_signals) >= MAX_PENDING_SIGNALS: return STATUS_BUSY - target.pending_signals.append(signal) + target.pending_signals.append((signal, source_handle)) + target.event_condition.notify_all() self.logger.debug( - "Queued signal=%d for label=%d handle=%d", + "Queued signal=%d source=%d for label=%d handle=%d", signal, + source_handle, target.label, target.handle, ) @@ -389,7 +471,7 @@ def _alarm_fire(self, label: int, delay_ms: int) -> None: target = self._contexts_by_label.get(label) if target is None: return - status = self.queue_signal(target, SIGNAL_ALARM) + status = self.queue_signal(target, SIGNAL_ALARM, target.handle) if status != STATUS_OK: self.logger.warning( "Alarm delivery failed label=%d delay_ms=%d status=%d", @@ -443,7 +525,13 @@ def _stop_alarm(self, app_context: AppContext, delay_ms: int) -> int: with self._lock: registration = app_context.alarms.pop(delay_ms, None) if registration is None: - return STATUS_NO_ENTITY + self.logger.debug( + "Alarm already stopped label=%d handle=%d delay_ms=%d", + app_context.label, + app_context.handle, + delay_ms, + ) + return STATUS_OK registration.timer.cancel() self.logger.debug( "Stopped alarm label=%d handle=%d delay_ms=%d", @@ -466,6 +554,28 @@ def current_cycle_value(self, precision: int) -> int: return elapsed_ns // 1_000_000 raise ValueError("invalid precision") + def _dequeue_matching_signal( + self, app_context: AppContext, mask: int + ) -> tuple[int, int] | None: + if not (mask & EVENT_TYPE_SIGNAL): + return None + + with app_context.event_condition: + if not app_context.pending_signals: + return None + return app_context.pending_signals.pop(0) + + def _serialize_signal_event( + self, app_context: AppContext, signal: int, source_handle: int + ) -> None: + header = bytes( + [EVENT_TYPE_SIGNAL, 4] + ) + EVENT_MAGIC.to_bytes(2, "little") + int(source_handle).to_bytes( + 4, "little", signed=False + ) + payload = int(signal).to_bytes(4, "little", signed=False) + self.write_exchange_buffer(app_context, header + payload) + @property def bound_address(self) -> tuple[str, int]: """Return the effective bind address once server is started. @@ -528,8 +638,14 @@ def serve_forever( ) try: - while not event.wait(timeout=poll_interval): - continue + while True: + if event.wait(timeout=poll_interval): + break + if self._all_startup_contexts_stopped(): + self.logger.info( + "All startup tasks have terminated, stopping emulator daemon" + ) + break finally: grpc_server.stop(grace=0).wait() self._terminate_started_apps() @@ -661,9 +777,42 @@ def Dispatch( if target_context is None: return response_cls(status=STATUS_INVALID, detail="unknown target handle") - status = self.daemon.queue_signal(target_context, signal) + status = self.daemon.queue_signal(target_context, signal, app_context.handle) return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if message.syscall == "wait_for_event": + if len(message.args) < 2: + detail = "missing wait_for_event arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + mask = int(message.args[0]) + timeout = int(message.args[1]) + + pending = self.daemon._dequeue_matching_signal(app_context, mask) + if pending is not None: + signal, source_handle = pending + self.daemon._serialize_signal_event(app_context, signal, source_handle) + return response_cls(status=STATUS_OK, detail="ok") + + if timeout == -1: + return response_cls(status=STATUS_AGAIN, detail="again") + + wait_timeout = None if timeout == 0 else max(0.0, timeout / 1000.0) + with app_context.event_condition: + has_event = app_context.event_condition.wait_for( + lambda: bool(app_context.pending_signals) and bool(mask & EVENT_TYPE_SIGNAL), + timeout=wait_timeout, + ) + if not has_event: + return response_cls(status=STATUS_TIMEOUT, detail="timeout") + signal, source_handle = app_context.pending_signals.pop(0) + + self.daemon._serialize_signal_event(app_context, signal, source_handle) + return response_cls(status=STATUS_OK, detail="ok") + if message.syscall == "alarm": if len(message.args) < 2: detail = "missing alarm arguments" @@ -709,6 +858,12 @@ def Dispatch( self.daemon.write_u64_to_exchange_buffer(app_context, cycle_value) return response_cls(status=STATUS_OK, detail="ok") + if message.syscall == "exit": + exit_code = int(message.args[0]) if message.args else 0 + all_done = self.daemon.deactivate_context(app_context, exit_code) + detail = "all tasks exited" if all_done else "context deactivated" + return response_cls(status=STATUS_OK, detail=detail) + self.store.register(message) # Keep this trace explicit for early integration/debug phases. self.logger.info( diff --git a/tools/sentry-emulator/tests/test_cli.py b/tools/sentry-emulator/tests/test_cli.py index 789e0b48..02e0acf0 100644 --- a/tools/sentry-emulator/tests/test_cli.py +++ b/tools/sentry-emulator/tests/test_cli.py @@ -9,6 +9,25 @@ from camelot.sentry_emulator import cli +def test_parser_accepts_multiple_start_arguments() -> None: + """Repeated --start options should be collected in order.""" + parser = cli._build_parser() + + args = parser.parse_args( + [ + "--start", + "sample-app-one,label=7", + "--start", + "sample-app-two,label=8", + ] + ) + + assert args.start == [ + "sample-app-one,label=7", + "sample-app-two,label=8", + ] + + def test_main_returns_non_zero_and_logs_error_on_invalid_port( monkeypatch, caplog ) -> None: diff --git a/tools/sentry-emulator/tests/test_emulator.py b/tools/sentry-emulator/tests/test_emulator.py new file mode 100644 index 00000000..95c4da60 --- /dev/null +++ b/tools/sentry-emulator/tests/test_emulator.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end emulator tests using the Rust sample applications.""" + +import os +import pathlib +import subprocess +import sys + +import pytest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = PROJECT_ROOT.parents[1] +SAMPLE_RUST_BIN_DIR = ( + REPO_ROOT / "builddir" / "tools" / "sentry-emulator" / "sample-rust-target" / "debug" +) + + +@pytest.mark.emulator +def test_cli_starts_sample_rust_apps_via_start() -> None: + app_one = SAMPLE_RUST_BIN_DIR / "sample-app-one" + app_two = SAMPLE_RUST_BIN_DIR / "sample-app-two" + + missing_binaries = [str(path) for path in (app_one, app_two) if not path.exists()] + if missing_binaries: + pytest.skip( + "sample Rust apps are not built in builddir: " + ", ".join(missing_binaries) + ) + + child_env = os.environ.copy() + pythonpath_entries = [str(PROJECT_ROOT / "src")] + existing_pythonpath = child_env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + child_env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + + completed = subprocess.run( + [ + sys.executable, + "-m", + "camelot.sentry_emulator", + "--log-level", + "INFO", + "--start", + f"{app_one},label=7", + "--start", + f"{app_two},label=8", + ], + cwd=PROJECT_ROOT, + env=child_env, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert completed.returncode == 0, ( + f"stdout:\n{completed.stdout}\n\nstderr:\n{completed.stderr}" + ) + assert "Started app label=7" in completed.stderr + assert "Started app label=8" in completed.stderr + assert "App exited label=7" in completed.stderr + assert "App exited label=8" in completed.stderr + assert "All startup tasks have terminated, stopping emulator daemon" in completed.stderr + assert "Sentry emulator stopped" in completed.stderr diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py index fecdb093..b6574454 100644 --- a/tools/sentry-emulator/tests/test_server.py +++ b/tools/sentry-emulator/tests/test_server.py @@ -122,11 +122,58 @@ def test_grpc_server_receives_and_sorts_messages( assert other_handle > 0 assert other_handle != self_handle + wait_non_blocking = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="wait_for_event", args=[2, -1], label=8) + ) + assert wait_non_blocking.status == 8 + + wait_timeout = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="wait_for_event", args=[2, 25], label=8) + ) + assert wait_timeout.status == 7 + send_sig = stub.Dispatch( emulator_pb2.DispatchRequest(syscall="send_signal", args=[other_handle, 11], label=7) ) assert send_sig.status == 0 + wait_result: dict[str, object] = {} + + def wait_for_signal() -> None: + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as wait_channel: + wait_stub = emulator_pb2_grpc.EmulatorStub(wait_channel) + wait_result["response"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="wait_for_event", + args=[2, 1000], + label=8, + ) + ) + wait_result["exchange"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=8) + ) + + waiter = threading.Thread(target=wait_for_signal, daemon=True) + waiter.start() + time.sleep(0.05) + + send_sig_wait = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="send_signal", args=[other_handle, 11], label=7) + ) + assert send_sig_wait.status == 0 + waiter.join(timeout=2.0) + assert not waiter.is_alive() + assert "response" in wait_result + assert "exchange" in wait_result + assert wait_result["response"].status == 0 + event_payload = wait_result["exchange"].payload + assert event_payload[0] == 2 + assert int.from_bytes(event_payload[2:4], "little") == 0x4242 + assert int.from_bytes(event_payload[4:8], "little") == self_handle + assert int.from_bytes(event_payload[8:12], "little") == 11 + alarm_start = stub.Dispatch( emulator_pb2.DispatchRequest(syscall="alarm", args=[50, 1], label=7) ) @@ -167,3 +214,159 @@ def test_grpc_server_receives_and_sorts_messages( captured = capsys.readouterr() assert "[app:7] hello from task" in captured.out + + +def test_exit_deactivates_context_and_stops_daemon(tmp_path: pathlib.Path) -> None: + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + + exit_first = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exit", args=[0], label=7) + ) + assert exit_first.status == 0 + assert exit_first.detail == "context deactivated" + + with pytest.raises(grpc.RpcError): + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[10], label=7)) + + exit_second = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exit", args=[0], label=8) + ) + assert exit_second.status == 0 + assert exit_second.detail == "all tasks exited" + + thread.join(timeout=2.0) + assert not thread.is_alive() + + +def test_wait_for_event_blocking_wakes_on_signal(tmp_path: pathlib.Path) -> None: + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + + sender_self = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[7], label=7) + ) + assert sender_self.status == 0 + sender_self_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + sender_handle = int.from_bytes(sender_self_reply.payload[:4], "little") + assert sender_handle > 0 + + target_lookup = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[8], label=7) + ) + assert target_lookup.status == 0 + target_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + target_handle = int.from_bytes(target_reply.payload[:4], "little") + assert target_handle > 0 + assert target_handle != sender_handle + + wait_result: dict[str, object] = {} + + def wait_blocking() -> None: + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as wait_channel: + wait_stub = emulator_pb2_grpc.EmulatorStub(wait_channel) + wait_result["response"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="wait_for_event", + args=[2, 0], + label=8, + ) + ) + wait_result["exchange"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=8) + ) + + waiter = threading.Thread(target=wait_blocking, daemon=True) + waiter.start() + time.sleep(0.05) + assert waiter.is_alive() + + send_sig = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="send_signal", args=[target_handle, 11], label=7) + ) + assert send_sig.status == 0 + + waiter.join(timeout=2.0) + assert not waiter.is_alive() + assert "response" in wait_result + assert "exchange" in wait_result + + response = wait_result["response"] + exchange = wait_result["exchange"] + assert response.status == 0 + payload = exchange.payload + assert payload[0] == 2 + assert payload[1] == 4 + assert int.from_bytes(payload[2:4], "little") == 0x4242 + assert int.from_bytes(payload[4:8], "little") == sender_handle + assert int.from_bytes(payload[8:12], "little") == 11 + + stop_event.set() + thread.join(timeout=2.0) + assert not thread.is_alive() diff --git a/tools/sentry-emulator/tox.ini b/tools/sentry-emulator/tox.ini index 5d3e8f93..cdfa1039 100644 --- a/tools/sentry-emulator/tox.ini +++ b/tools/sentry-emulator/tox.ini @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [tox] -envlist = py,mypy,build +envlist = py,mypy,build,emulator isolated_build = true [testenv] @@ -26,3 +26,11 @@ deps = build>=1.2 commands = python -m build --sdist --wheel + +[testenv:emulator] +deps = + pytest>=8 + grpcio>=1.80 + protobuf>=6.31 +commands = + pytest -m emulator diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index 54e26409..ddee2441 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -191,6 +191,7 @@ pub(crate) fn exchange_from_daemon(data: &mut [u8]) -> Status { #[inline(always)] pub fn exit(status: i32) -> Status { + let _ = forward_syscall("exit", &[status as i128]); std::process::exit(status); } From d40aef92e5abb278988e0e4ba1bda07a2102ea94 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 15:51:17 +0200 Subject: [PATCH 11/19] emulator: adding cleaner emulator hierarchy --- tools/meson.build | 6 +- .../sample-rust-app/src/lib.rs | 41 +- .../emulator_server/__init__.py | 61 ++ .../emulator_server/constants.py | 30 + .../sentry_emulator/emulator_server/daemon.py | 417 ++++++++ .../sentry_emulator/emulator_server/models.py | 73 ++ .../emulator_server/servicer.py | 235 +++++ .../src/camelot/sentry_emulator/server.py | 896 +----------------- tools/sentry-emulator/tests/test_server.py | 2 +- 9 files changed, 870 insertions(+), 891 deletions(-) create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/__init__.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py create mode 100644 tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py diff --git a/tools/meson.build b/tools/meson.build index 669849ed..a0a44502 100644 --- a/tools/meson.build +++ b/tools/meson.build @@ -18,10 +18,10 @@ if get_option('with_emulator') 'sentry-emulator-rust-apps-e2e', py3, args: [ - '-m', - 'camelot.sentry_emulator', + '-c', + 'from camelot.sentry_emulator.cli import main; raise SystemExit(main())', '--log-level', - 'INFO', + 'ERROR', '--start', emulator_start_app_one, '--start', diff --git a/tools/sentry-emulator/sample-rust-app/src/lib.rs b/tools/sentry-emulator/sample-rust-app/src/lib.rs index b5dd52a6..22034563 100644 --- a/tools/sentry-emulator/sample-rust-app/src/lib.rs +++ b/tools/sentry-emulator/sample-rust-app/src/lib.rs @@ -7,8 +7,22 @@ use sentry_uapi::systypes::{ use std::thread; use std::time::Duration; +fn emit_app_log(message: &str) { + let payload = message.as_bytes(); + let st_copy = sentry_uapi::copy_to_kernel(&payload).unwrap_or(Status::Invalid); + if st_copy != Status::Ok { + eprintln!("[sample-rust-app][fallback] copy_to_kernel(log) failed: {st_copy:?}"); + return; + } + + let st_log = sentry_uapi::syscall::log(payload.len()); + if st_log != Status::Ok { + eprintln!("[sample-rust-app][fallback] syscall::log failed: {st_log:?}"); + } +} + fn report_status(context: &str, status: Status) { - eprintln!("[sample-rust-app] {context}: {status:?}"); + emit_app_log(&format!("{context}: {status:?}")); } const SIGNAL_EVENT_TYPE: u8 = 2; @@ -46,13 +60,15 @@ fn read_signal_event_from_exchange() -> Option { || event_magic != SIGNAL_EVENT_MAGIC || event_peer == 0 { - eprintln!( - "[sample-rust-app] invalid signal event header: type={event_type} len={event_len} magic=0x{event_magic:04x} peer={event_peer}" - ); + emit_app_log(&format!( + "invalid signal event header: type={event_type} len={event_len} magic=0x{event_magic:04x} peer={event_peer}" + )); return None; } - eprintln!("[sample-rust-app] signal event received from peer={event_peer} value={signal}"); + emit_app_log(&format!( + "signal event received from peer={event_peer} value={signal}" + )); Some(signal) } @@ -62,22 +78,22 @@ fn run_alarm_random_cycle_checks() { let st_alarm_stop = sentry_uapi::syscall::alarm(5000, AlarmFlag::AlarmStop); report_status("alarm(start)", st_alarm_start); if st_alarm_stop == Status::NoEntity { - eprintln!("[sample-rust-app] alarm(stop): Ok (already stopped)"); + emit_app_log("alarm(stop): Ok (already stopped)"); } else { report_status("alarm(stop)", st_alarm_stop); } let st_rng = sentry_uapi::syscall::get_random(); let mut rng_value: u32 = 0; - report_status("get_random", st_rng); let st_copy_rng = sentry_uapi::copy_from_kernel(&mut rng_value).unwrap_or(Status::Invalid); + report_status("get_random", st_rng); report_status("copy_from_kernel(random)", st_copy_rng); let st_cycle = sentry_uapi::syscall::get_cycle(Precision::Milliseconds); let mut cycle_value: u64 = 0; - report_status("get_cycle", st_cycle); let st_copy_cycle = sentry_uapi::copy_from_kernel(&mut cycle_value).unwrap_or(Status::Invalid); + report_status("get_cycle", st_cycle); report_status("copy_from_kernel(cycle)", st_copy_cycle); } @@ -93,25 +109,28 @@ pub fn run_sample_app_one(peer_label: u32) { // Emit a second signal to make the emulator e2e startup phase robust against transient ordering. let st_sig_peer_retry = sentry_uapi::syscall::send_signal(TARGET_APP_TWO_HANDLE, Signal::Usr1); report_status("send_signal(peer, SIGUSR1, retry)", st_sig_peer_retry); + run_alarm_random_cycle_checks(); } pub fn run_sample_app_two() { // Wait without timeout and return only when SIGUSR1 has been serialized by daemon. loop { let st_wait_signal = sentry_uapi::syscall::wait_for_event(EventType::Signal.into(), 0); - report_status("wait_for_event(signal, no timeout)", st_wait_signal); if st_wait_signal != Status::Ok { + report_status("wait_for_event(signal, no timeout)", st_wait_signal); continue; } let Some(signal) = read_signal_event_from_exchange() else { continue; }; + report_status("wait_for_event(signal, no timeout)", st_wait_signal); + if signal == Signal::Usr1 as u32 { break; } - eprintln!("[sample-rust-app] ignoring other signal value={signal}"); + emit_app_log(&format!("ignoring other signal value={signal}")); } - + run_alarm_random_cycle_checks(); } diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/__init__.py new file mode 100644 index 00000000..74c9941e --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/__init__.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Internal module split for emulator server runtime.""" + +from .constants import ( + DEFAULT_HOST, + DEFAULT_PORT, + EVENT_MAGIC, + EVENT_TYPE_SIGNAL, + EXCHANGE_BUFFER_LEN, + MAX_PENDING_SIGNALS, + PRECISION_CYCLE, + PRECISION_MICROSECONDS, + PRECISION_MILLISECONDS, + PRECISION_NANOSECONDS, + SIGNAL_ABORT, + SIGNAL_ALARM, + SIGNAL_USR2, + STATUS_AGAIN, + STATUS_BUSY, + STATUS_DENIED, + STATUS_INVALID, + STATUS_NO_ENTITY, + STATUS_OK, + STATUS_TIMEOUT, + UINT32_MAX, +) +from .daemon import GrpcEmulatorDaemon +from .models import AlarmRegistration, AppContext, StartSpec, parse_start_option +from .servicer import EmulatorServicer + +__all__ = [ + "AlarmRegistration", + "AppContext", + "DEFAULT_HOST", + "DEFAULT_PORT", + "EVENT_MAGIC", + "EVENT_TYPE_SIGNAL", + "EXCHANGE_BUFFER_LEN", + "EmulatorServicer", + "GrpcEmulatorDaemon", + "MAX_PENDING_SIGNALS", + "PRECISION_CYCLE", + "PRECISION_MICROSECONDS", + "PRECISION_MILLISECONDS", + "PRECISION_NANOSECONDS", + "SIGNAL_ABORT", + "SIGNAL_ALARM", + "SIGNAL_USR2", + "STATUS_AGAIN", + "STATUS_BUSY", + "STATUS_DENIED", + "STATUS_INVALID", + "STATUS_NO_ENTITY", + "STATUS_OK", + "STATUS_TIMEOUT", + "StartSpec", + "UINT32_MAX", + "parse_start_option", +] diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py new file mode 100644 index 00000000..fb45af1e --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Constants used by the emulator daemon and gRPC servicer.""" + +from typing import Final + +DEFAULT_HOST: Final[str] = "127.0.0.1" +DEFAULT_PORT: Final[int] = 44044 +UINT32_MAX: Final[int] = (1 << 32) - 1 +EXCHANGE_BUFFER_LEN: Final[int] = 128 +SIGNAL_ABORT: Final[int] = 1 +EVENT_TYPE_SIGNAL: Final[int] = 2 +SIGNAL_USR2: Final[int] = 12 +SIGNAL_ALARM: Final[int] = 2 +EVENT_MAGIC: Final[int] = 0x4242 +PRECISION_CYCLE: Final[int] = 0 +PRECISION_NANOSECONDS: Final[int] = 1 +PRECISION_MICROSECONDS: Final[int] = 2 +PRECISION_MILLISECONDS: Final[int] = 3 + +STATUS_OK: Final[int] = 0 +STATUS_INVALID: Final[int] = 1 +STATUS_DENIED: Final[int] = 2 +STATUS_NO_ENTITY: Final[int] = 3 +STATUS_BUSY: Final[int] = 4 +STATUS_TIMEOUT: Final[int] = 7 +STATUS_AGAIN: Final[int] = 8 + +MAX_PENDING_SIGNALS: Final[int] = 32 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py new file mode 100644 index 00000000..34734365 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py @@ -0,0 +1,417 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Daemon lifecycle and app context management for the emulator.""" + +from concurrent import futures +from dataclasses import dataclass, field +import logging +import os +import random +import subprocess +import threading +import time + +import grpc + +from ..dispatcher import SyscallStore +from ..grpc import emulator_pb2_grpc +from .constants import ( + DEFAULT_HOST, + DEFAULT_PORT, + EVENT_MAGIC, + EVENT_TYPE_SIGNAL, + EXCHANGE_BUFFER_LEN, + MAX_PENDING_SIGNALS, + PRECISION_CYCLE, + PRECISION_MICROSECONDS, + PRECISION_MILLISECONDS, + PRECISION_NANOSECONDS, + SIGNAL_ALARM, + STATUS_BUSY, + STATUS_OK, + UINT32_MAX, +) +from .models import AlarmRegistration, AppContext, StartSpec + + +@dataclass(slots=True) +class GrpcEmulatorDaemon: + """Lifecycle manager for the emulator gRPC daemon.""" + + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + start_specs: tuple[StartSpec, ...] = () + store: SyscallStore = field(default_factory=SyscallStore) + logger: logging.Logger = field( + default_factory=lambda: logging.getLogger("camelot.sentry_emulator") + ) + + _bound_address: tuple[str, int] | None = field(default=None, init=False) + _contexts_by_label: dict[int, AppContext] = field(default_factory=dict, init=False) + _contexts_by_handle: dict[int, AppContext] = field(default_factory=dict, init=False) + _started_processes: list[subprocess.Popen[bytes]] = field(default_factory=list, init=False) + _next_handle: int = field(default=1, init=False) + _rng: random.Random = field(default_factory=random.Random, init=False) + _lock: threading.Lock = field(default_factory=threading.Lock, init=False) + _start_monotonic_ns: int = field(default_factory=time.monotonic_ns, init=False) + + def _all_startup_contexts_stopped(self) -> bool: + if not self.start_specs: + return False + with self._lock: + return not self._contexts_by_label + + def deactivate_context(self, app_context: AppContext, exit_code: int) -> bool: + with self._lock: + removed_by_label = self._contexts_by_label.pop(app_context.label, None) + removed_by_handle = self._contexts_by_handle.pop(app_context.handle, None) + contexts_remaining = len(self._contexts_by_label) + + if removed_by_label is None or removed_by_handle is None: + return self._all_startup_contexts_stopped() + + app_context.exit_code = exit_code + with app_context.event_condition: + app_context.pending_signals.clear() + app_context.event_condition.notify_all() + + for registration in app_context.alarms.values(): + registration.timer.cancel() + app_context.alarms.clear() + + self.logger.info( + "App exited label=%d handle=%d code=%d remaining_contexts=%d", + app_context.label, + app_context.handle, + exit_code, + contexts_remaining, + ) + return contexts_remaining == 0 and bool(self.start_specs) + + def _allocate_handle(self) -> int: + if self._next_handle > UINT32_MAX: + raise RuntimeError("app context handle overflow") + handle = self._next_handle + self._next_handle += 1 + return handle + + def _prepare_start_specs(self) -> None: + prepared_contexts: list[AppContext] = [] + labels: set[int] = set() + for spec in self.start_specs: + if spec.label in labels or spec.label in self._contexts_by_label: + self.logger.error("Duplicate startup label detected: %d", spec.label) + raise RuntimeError(f"duplicate app label: {spec.label}") + + app_path = spec.app_path.expanduser().resolve() + if not app_path.exists(): + self.logger.error("Startup executable does not exist: %s", app_path) + raise RuntimeError(f"app does not exist: {app_path}") + + prepared_contexts.append( + AppContext( + label=spec.label, + handle=self._allocate_handle(), + app_path=app_path, + ) + ) + labels.add(spec.label) + + with self._lock: + for context in prepared_contexts: + self._contexts_by_label[context.label] = context + self._contexts_by_handle[context.handle] = context + + for context in prepared_contexts: + self.logger.debug( + "Initialized app context label=%d handle=%d path=%s", + context.label, + context.handle, + context.app_path, + ) + + def _start_prepared_apps(self) -> None: + for spec in self.start_specs: + with self._lock: + context = self._contexts_by_label.get(spec.label) + + if context is None: + self.logger.error("Missing initialized context for label=%d", spec.label) + raise RuntimeError(f"missing app context for label: {spec.label}") + + child_env = os.environ.copy() + child_env["SENTRY_APP_LABEL"] = str(spec.label) + child_env["SENTRY_EMULATOR_HOST"] = self.host + child_env["SENTRY_EMULATOR_PORT"] = str(self.port) + + try: + process = subprocess.Popen([str(context.app_path)], env=child_env) + except OSError as exc: + self.logger.error( + "Cannot start app label=%d path=%s: %s", + spec.label, + context.app_path, + exc, + ) + raise RuntimeError(f"cannot start app: {context.app_path}") from exc + + context.process = process + with self._lock: + self._started_processes.append(process) + + self.logger.info( + "Started app label=%d handle=%d pid=%d path=%s", + context.label, + context.handle, + process.pid, + context.app_path, + ) + + def _startup_apps(self) -> None: + if not self.start_specs: + self.logger.info("No startup tasks configured") + return + + self._prepare_start_specs() + self._start_prepared_apps() + + def _terminate_started_apps(self) -> None: + for process in self._started_processes: + if process.poll() is None: + self.logger.debug("Stopping child process pid=%d", process.pid) + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + self.logger.warning( + "Child process pid=%d did not stop gracefully, killing", + process.pid, + ) + process.kill() + process.wait(timeout=2) + + with self._lock: + for app_context in self._contexts_by_label.values(): + for registration in app_context.alarms.values(): + registration.timer.cancel() + app_context.alarms.clear() + app_context.pending_signals.clear() + app_context.process = None + self._contexts_by_label.clear() + self._contexts_by_handle.clear() + + def context_for_label(self, label: int) -> AppContext | None: + with self._lock: + return self._contexts_by_label.get(label) + + def context_for_handle(self, handle: int) -> AppContext | None: + with self._lock: + return self._contexts_by_handle.get(handle) + + def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None: + with self._lock: + app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN + copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) + app_context.exchange_buffer[:copy_len] = payload[:copy_len] + self.logger.debug( + "exchange_to_kernel label=%d handle=%d bytes=%d", + app_context.label, + app_context.handle, + copy_len, + ) + + def read_exchange_buffer(self, app_context: AppContext) -> bytes: + with self._lock: + payload = bytes(app_context.exchange_buffer) + self.logger.debug( + "exchange_from_kernel label=%d handle=%d bytes=%d", + app_context.label, + app_context.handle, + len(payload), + ) + return payload + + def write_u32_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + self.write_exchange_buffer(app_context, int(value).to_bytes(4, "little", signed=False)) + + def write_u64_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + self.write_exchange_buffer(app_context, int(value).to_bytes(8, "little", signed=False)) + + def queue_signal(self, target: AppContext, signal: int, source_handle: int) -> int: + with target.event_condition: + if len(target.pending_signals) >= MAX_PENDING_SIGNALS: + return STATUS_BUSY + target.pending_signals.append((signal, source_handle)) + target.event_condition.notify_all() + self.logger.debug( + "Queued signal=%d source=%d for label=%d handle=%d", + signal, + source_handle, + target.label, + target.handle, + ) + return STATUS_OK + + def _alarm_fire(self, label: int, delay_ms: int) -> None: + with self._lock: + target = self._contexts_by_label.get(label) + if target is None: + return + status = self.queue_signal(target, SIGNAL_ALARM, target.handle) + if status != STATUS_OK: + self.logger.warning( + "Alarm delivery failed label=%d delay_ms=%d status=%d", + label, + delay_ms, + status, + ) + + def _schedule_alarm(self, app_context: AppContext, delay_ms: int, periodic: bool) -> int: + with self._lock: + if delay_ms in app_context.alarms: + return STATUS_BUSY + + interval_s = max(0.0, delay_ms / 1000.0) + + def _callback() -> None: + self._alarm_fire(app_context.label, delay_ms) + if not periodic: + with self._lock: + app_context.alarms.pop(delay_ms, None) + return + + with self._lock: + still_registered = app_context.alarms.get(delay_ms) + if still_registered is None: + return + + next_timer = threading.Timer(interval_s, _callback) + app_context.alarms[delay_ms] = AlarmRegistration( + delay_ms=delay_ms, + periodic=True, + timer=next_timer, + ) + next_timer.start() + + timer = threading.Timer(interval_s, _callback) + app_context.alarms[delay_ms] = AlarmRegistration( + delay_ms=delay_ms, + periodic=periodic, + timer=timer, + ) + timer.start() + self.logger.debug( + "Scheduled alarm label=%d handle=%d delay_ms=%d periodic=%s", + app_context.label, + app_context.handle, + delay_ms, + periodic, + ) + return STATUS_OK + + def _stop_alarm(self, app_context: AppContext, delay_ms: int) -> int: + with self._lock: + registration = app_context.alarms.pop(delay_ms, None) + if registration is None: + self.logger.debug( + "Alarm already stopped label=%d handle=%d delay_ms=%d", + app_context.label, + app_context.handle, + delay_ms, + ) + return STATUS_OK + registration.timer.cancel() + self.logger.debug( + "Stopped alarm label=%d handle=%d delay_ms=%d", + app_context.label, + app_context.handle, + delay_ms, + ) + return STATUS_OK + + def current_cycle_value(self, precision: int) -> int: + elapsed_ns = max(0, time.monotonic_ns() - self._start_monotonic_ns) + if precision == PRECISION_CYCLE: + return elapsed_ns + if precision == PRECISION_NANOSECONDS: + return elapsed_ns + if precision == PRECISION_MICROSECONDS: + return elapsed_ns // 1_000 + if precision == PRECISION_MILLISECONDS: + return elapsed_ns // 1_000_000 + raise ValueError("invalid precision") + + def _dequeue_matching_signal( + self, app_context: AppContext, mask: int + ) -> tuple[int, int] | None: + if not (mask & EVENT_TYPE_SIGNAL): + return None + + with app_context.event_condition: + if not app_context.pending_signals: + return None + return app_context.pending_signals.pop(0) + + def _serialize_signal_event( + self, app_context: AppContext, signal: int, source_handle: int + ) -> None: + header = bytes([EVENT_TYPE_SIGNAL, 4]) + EVENT_MAGIC.to_bytes( + 2, "little" + ) + int(source_handle).to_bytes(4, "little", signed=False) + payload = int(signal).to_bytes(4, "little", signed=False) + self.write_exchange_buffer(app_context, header + payload) + + @property + def bound_address(self) -> tuple[str, int]: + if self._bound_address is None: + raise RuntimeError("daemon is not bound yet") + return self._bound_address + + def serve_forever( + self, + stop_event: threading.Event | None = None, + ready_event: threading.Event | None = None, + poll_interval: float = 0.2, + ) -> None: + event = stop_event if stop_event is not None else threading.Event() + + grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) + from .servicer import EmulatorServicer + + emulator_pb2_grpc.add_EmulatorServicer_to_server( + EmulatorServicer(daemon=self, store=self.store, logger=self.logger), grpc_server + ) # type: ignore[no-untyped-call] + + bound_port = grpc_server.add_insecure_port(f"{self.host}:{self.port}") + if bound_port == 0: + self.logger.error("Cannot bind gRPC server on %s:%d", self.host, self.port) + raise RuntimeError(f"cannot bind gRPC server on {self.host}:{self.port}") + + self._bound_address = (self.host, int(bound_port)) + grpc_server.start() + + self._startup_apps() + + if ready_event is not None: + ready_event.set() + + self.logger.info( + "Sentry emulator listening on grpc://%s:%d", + self._bound_address[0], + self._bound_address[1], + ) + + try: + while True: + if event.wait(timeout=poll_interval): + break + if self._all_startup_contexts_stopped(): + self.logger.info( + "All startup tasks have terminated, stopping emulator daemon" + ) + break + finally: + grpc_server.stop(grace=0).wait() + self._terminate_started_apps() + self.logger.info("Sentry emulator stopped") diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py new file mode 100644 index 00000000..ce0db52d --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Core data models for emulator startup and app runtime contexts.""" + +from dataclasses import dataclass, field +from pathlib import Path +import subprocess +import threading + +from .constants import EXCHANGE_BUFFER_LEN, UINT32_MAX + + +@dataclass(slots=True) +class AlarmRegistration: + delay_ms: int + periodic: bool + timer: threading.Timer + + +@dataclass(frozen=True, slots=True) +class StartSpec: + """Definition of one application to start with the daemon.""" + + app_path: Path + label: int + + +@dataclass(slots=True) +class AppContext: + """Runtime context associated with one started application.""" + + label: int + handle: int + app_path: Path + process: subprocess.Popen[bytes] | None = None + exchange_buffer: bytearray = field( + default_factory=lambda: bytearray(EXCHANGE_BUFFER_LEN) + ) + pending_signals: list[tuple[int, int]] = field(default_factory=list) + alarms: dict[int, AlarmRegistration] = field(default_factory=dict) + event_condition: threading.Condition = field(default_factory=threading.Condition) + exit_code: int | None = None + + @property + def app_name(self) -> str: + """Return a stable display name for daemon-emitted app logs.""" + return self.app_path.stem or self.app_path.name + + +def parse_start_option(value: str) -> StartSpec: + """Parse one ``--start`` argument value (``APP_PATH,label=``).""" + app_part, sep, label_part = value.partition(",") + if sep == "": + raise ValueError("--start expects 'app.elf,label='") + + app = app_part.strip() + if not app: + raise ValueError("--start application path cannot be empty") + + key, eq, raw_label = label_part.partition("=") + if eq == "" or key.strip() != "label": + raise ValueError("--start expects 'label='") + + try: + label = int(raw_label.strip(), 0) + except ValueError as exc: + raise ValueError("label must be an integer in [0, 4294967295]") from exc + + if label < 0 or label > UINT32_MAX: + raise ValueError("label must be an integer in [0, 4294967295]") + + return StartSpec(app_path=Path(app), label=label) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py new file mode 100644 index 00000000..fd1457aa --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""gRPC dispatch servicer for the emulator daemon.""" + +from dataclasses import dataclass +import logging +from typing import Any + +import grpc + +from ..dispatcher import SyscallStore +from ..grpc import emulator_pb2, emulator_pb2_grpc +from ..protocol import ProtocolError, deserialize_request +from .constants import ( + EXCHANGE_BUFFER_LEN, + EVENT_TYPE_SIGNAL, + PRECISION_CYCLE, + PRECISION_MILLISECONDS, + SIGNAL_ABORT, + SIGNAL_USR2, + STATUS_AGAIN, + STATUS_INVALID, + STATUS_OK, + STATUS_TIMEOUT, +) + + +@dataclass(slots=True) +class EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): + """Internal gRPC service implementation for syscall dispatch.""" + + daemon: Any + store: SyscallStore + logger: logging.Logger + + def Dispatch(self, request: Any, context: grpc.ServicerContext) -> Any: + response_cls = getattr(emulator_pb2, "DispatchResponse") + self.logger.debug( + "Received request peer=%s syscall=%s args=%s label=%s payload_len=%d", + context.peer(), + getattr(request, "syscall", ""), + list(getattr(request, "args", [])), + getattr(request, "label", ""), + len(getattr(request, "payload", b"")), + ) + + try: + message = deserialize_request(request) + except ProtocolError as exc: + self.store.register_invalid() + self.logger.warning("Rejected command from %s: %s", context.peer(), exc) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(exc)) + return response_cls(status=1, detail=str(exc)) + + app_context = self.daemon.context_for_label(message.label) + if app_context is None: + self.store.register_invalid() + detail = f"unknown app label: {message.label}" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(detail) + return response_cls(status=1, detail=detail) + + if message.syscall == "exchange_to_kernel": + self.daemon.write_exchange_buffer(app_context, message.payload) + self.logger.debug( + "Responding syscall=%s label=%d status=0", + message.syscall, + message.label, + ) + return response_cls(status=0, detail="ok") + + if message.syscall == "exchange_from_kernel": + payload = self.daemon.read_exchange_buffer(app_context) + self.logger.debug( + "Responding syscall=%s label=%d status=0 payload_len=%d", + message.syscall, + message.label, + len(payload), + ) + return response_cls(status=0, detail="ok", payload=payload) + + if message.syscall == "log": + log_len = int(message.args[0]) if message.args else 0 + log_len = max(0, min(log_len, EXCHANGE_BUFFER_LEN)) + raw = bytes(app_context.exchange_buffer[:log_len]) + text = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + print(f"[{app_context.app_name}:{message.label}] {text}", flush=True) + self.logger.debug( + "Responding syscall=%s label=%d status=0 printed_len=%d", + message.syscall, + message.label, + len(text), + ) + return response_cls(status=0, detail="ok") + + if message.syscall == "get_process_handle": + if not message.args: + detail = "missing label argument" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_label = int(message.args[0]) + target_context = self.daemon.context_for_label(target_label) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown label") + + self.daemon.write_u32_to_exchange_buffer(app_context, target_context.handle) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "send_signal": + if len(message.args) < 2: + detail = "missing send_signal arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_handle = int(message.args[0]) + signal = int(message.args[1]) + if target_handle == app_context.handle: + return response_cls(status=STATUS_INVALID, detail="self signal forbidden") + if signal < SIGNAL_ABORT or signal > SIGNAL_USR2: + return response_cls(status=STATUS_INVALID, detail="invalid signal") + + target_context = self.daemon.context_for_handle(target_handle) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown target handle") + + status = self.daemon.queue_signal(target_context, signal, app_context.handle) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + + if message.syscall == "wait_for_event": + if len(message.args) < 2: + detail = "missing wait_for_event arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + mask = int(message.args[0]) + timeout = int(message.args[1]) + + pending = self.daemon._dequeue_matching_signal(app_context, mask) + if pending is not None: + signal, source_handle = pending + self.daemon._serialize_signal_event(app_context, signal, source_handle) + return response_cls(status=STATUS_OK, detail="ok") + + if timeout == -1: + return response_cls(status=STATUS_AGAIN, detail="again") + + wait_timeout = None if timeout == 0 else max(0.0, timeout / 1000.0) + with app_context.event_condition: + has_event = app_context.event_condition.wait_for( + lambda: bool(app_context.pending_signals) and bool(mask & EVENT_TYPE_SIGNAL), + timeout=wait_timeout, + ) + if not has_event: + return response_cls(status=STATUS_TIMEOUT, detail="timeout") + signal, source_handle = app_context.pending_signals.pop(0) + + self.daemon._serialize_signal_event(app_context, signal, source_handle) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "alarm": + if len(message.args) < 2: + detail = "missing alarm arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + delay_ms = max(0, int(message.args[0])) + flag = int(message.args[1]) + if flag == 0: + status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=False) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if flag == 1: + status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=True) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if flag == 2: + status = self.daemon._stop_alarm(app_context, delay_ms) + return response_cls( + status=status, + detail="ok" if status == STATUS_OK else "no entity", + ) + return response_cls(status=STATUS_INVALID, detail="invalid alarm flag") + + if message.syscall == "get_random": + value = self.daemon._rng.getrandbits(32) + self.daemon.write_u32_to_exchange_buffer(app_context, value) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "get_cycle": + if not message.args: + detail = "missing precision argument" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + precision = int(message.args[0]) + if precision < PRECISION_CYCLE or precision > PRECISION_MILLISECONDS: + return response_cls(status=STATUS_INVALID, detail="invalid precision") + + cycle_value = self.daemon.current_cycle_value(precision) + self.daemon.write_u64_to_exchange_buffer(app_context, cycle_value) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "exit": + exit_code = int(message.args[0]) if message.args else 0 + all_done = self.daemon.deactivate_context(app_context, exit_code) + detail = "all tasks exited" if all_done else "context deactivated" + return response_cls(status=STATUS_OK, detail=detail) + + self.store.register(message) + self.logger.info( + "grpc command label=%d handle=%d syscall=%s args=%s peer=%s", + message.label, + app_context.handle, + message.syscall, + list(message.args), + context.peer(), + ) + self.logger.debug( + "Responding syscall=%s label=%d status=0", + message.syscall, + message.label, + ) + return response_cls(status=0, detail="ok") diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py index a27eb7d0..567c17e0 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -1,882 +1,26 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 -"""gRPC server implementation for the Sentry userspace emulator. +"""Backward-compatible public server API for the emulator. -The daemon accepts gRPC syscall dispatch requests, validates payloads, -associates them with startup-managed application contexts, and records calls in -an in-memory store. +The implementation is split into ``camelot.sentry_emulator.emulator_server`` +submodules to keep concerns isolated and source files manageable. """ -from concurrent import futures -import logging -import os -from pathlib import Path -import random -import subprocess -import threading -import time -from dataclasses import dataclass, field -from typing import Any -from typing import Final - -import grpc - -from .dispatcher import SyscallStore -from .grpc import emulator_pb2, emulator_pb2_grpc -from .protocol import ProtocolError, deserialize_request - -DEFAULT_HOST: Final[str] = "127.0.0.1" -DEFAULT_PORT: Final[int] = 44044 -UINT32_MAX: Final[int] = (1 << 32) - 1 -EXCHANGE_BUFFER_LEN: Final[int] = 128 -SIGNAL_ABORT: Final[int] = 1 -EVENT_TYPE_SIGNAL: Final[int] = 2 -SIGNAL_USR2: Final[int] = 12 -SIGNAL_ALARM: Final[int] = 2 -EVENT_MAGIC: Final[int] = 0x4242 -PRECISION_CYCLE: Final[int] = 0 -PRECISION_NANOSECONDS: Final[int] = 1 -PRECISION_MICROSECONDS: Final[int] = 2 -PRECISION_MILLISECONDS: Final[int] = 3 - -STATUS_OK: Final[int] = 0 -STATUS_INVALID: Final[int] = 1 -STATUS_DENIED: Final[int] = 2 -STATUS_NO_ENTITY: Final[int] = 3 -STATUS_BUSY: Final[int] = 4 -STATUS_TIMEOUT: Final[int] = 7 -STATUS_AGAIN: Final[int] = 8 - -MAX_PENDING_SIGNALS: Final[int] = 32 - - -@dataclass(slots=True) -class AlarmRegistration: - delay_ms: int - periodic: bool - timer: threading.Timer - - -@dataclass(frozen=True, slots=True) -class StartSpec: - """Definition of one application to start with the daemon. - - Attributes - ---------- - app_path : Path - Executable path to start. - label : int - Application label used to route incoming syscall requests. - """ - - app_path: Path - label: int - - -@dataclass(slots=True) -class AppContext: - """Runtime context associated with one started application. - - Attributes - ---------- - label : int - Static application label used by requests. - handle : int - Unique ``u32`` handle allocated by the daemon. - app_path : Path - Resolved executable path used to spawn the process. - process : subprocess.Popen[bytes] | None - Spawned process object for lifecycle management. - exchange_buffer : bytearray - Per-application exchange zone content. - pending_signals : list[tuple[int, int]] - Pending signals queued as ``(signal, source_handle)`` tuples. - alarms : dict[int, AlarmRegistration] - Alarm registrations keyed by timeout in milliseconds. - event_condition : threading.Condition - Condition used to block ``wait_for_event`` until an event is available. - """ - - label: int - handle: int - app_path: Path - process: subprocess.Popen[bytes] | None = None - exchange_buffer: bytearray = field( - default_factory=lambda: bytearray(EXCHANGE_BUFFER_LEN) - ) - pending_signals: list[tuple[int, int]] = field(default_factory=list) - alarms: dict[int, AlarmRegistration] = field(default_factory=dict) - event_condition: threading.Condition = field(default_factory=threading.Condition) - exit_code: int | None = None - - -def parse_start_option(value: str) -> StartSpec: - """Parse one ``--start`` argument value. - - Parameters - ---------- - value : str - Argument value formatted as ``APP_PATH,label=``. - - Returns - ------- - StartSpec - Parsed startup specification. - - Raises - ------ - ValueError - If the format is invalid or label is out of ``u32`` range. - """ - app_part, sep, label_part = value.partition(",") - if sep == "": - raise ValueError("--start expects 'app.elf,label='") - - app = app_part.strip() - if not app: - raise ValueError("--start application path cannot be empty") - - key, eq, raw_label = label_part.partition("=") - if eq == "" or key.strip() != "label": - raise ValueError("--start expects 'label='") - - try: - label = int(raw_label.strip(), 0) - except ValueError as exc: - raise ValueError("label must be an integer in [0, 4294967295]") from exc - - if label < 0 or label > UINT32_MAX: - raise ValueError("label must be an integer in [0, 4294967295]") - - return StartSpec(app_path=Path(app), label=label) - - -@dataclass(slots=True) -class GrpcEmulatorDaemon: - """Lifecycle manager for the emulator gRPC daemon. - - Parameters - ---------- - host : str, optional - Interface to bind for gRPC service. - port : int, optional - Port to bind for gRPC service (``0`` allows dynamic allocation). - start_specs : tuple[StartSpec, ...], optional - Startup application specifications to spawn before serving. - store : SyscallStore, optional - In-memory syscall storage backend. - logger : logging.Logger, optional - Logger used for daemon diagnostics. - """ - - host: str = DEFAULT_HOST - port: int = DEFAULT_PORT - start_specs: tuple[StartSpec, ...] = () - store: SyscallStore = field(default_factory=SyscallStore) - logger: logging.Logger = field( - default_factory=lambda: logging.getLogger("camelot.sentry_emulator") - ) - - _bound_address: tuple[str, int] | None = field(default=None, init=False) - _contexts_by_label: dict[int, AppContext] = field(default_factory=dict, init=False) - _contexts_by_handle: dict[int, AppContext] = field(default_factory=dict, init=False) - _started_processes: list[subprocess.Popen[bytes]] = field(default_factory=list, init=False) - _next_handle: int = field(default=1, init=False) - _rng: random.Random = field(default_factory=random.Random, init=False) - _lock: threading.Lock = field(default_factory=threading.Lock, init=False) - _start_monotonic_ns: int = field(default_factory=time.monotonic_ns, init=False) - - def _all_startup_contexts_stopped(self) -> bool: - """Return ``True`` when all startup contexts have been deactivated.""" - if not self.start_specs: - return False - with self._lock: - return not self._contexts_by_label - - def deactivate_context(self, app_context: AppContext, exit_code: int) -> bool: - """Deactivate one application context after an ``exit`` syscall. - - Parameters - ---------- - app_context : AppContext - Context to deactivate. - exit_code : int - Application-provided process return code. - - Returns - ------- - bool - ``True`` when this deactivation makes all startup contexts inactive. - """ - with self._lock: - removed_by_label = self._contexts_by_label.pop(app_context.label, None) - removed_by_handle = self._contexts_by_handle.pop(app_context.handle, None) - contexts_remaining = len(self._contexts_by_label) - - if removed_by_label is None or removed_by_handle is None: - return self._all_startup_contexts_stopped() - - app_context.exit_code = exit_code - with app_context.event_condition: - app_context.pending_signals.clear() - app_context.event_condition.notify_all() - - for registration in app_context.alarms.values(): - registration.timer.cancel() - app_context.alarms.clear() - - self.logger.info( - "App exited label=%d handle=%d code=%d remaining_contexts=%d", - app_context.label, - app_context.handle, - exit_code, - contexts_remaining, - ) - return contexts_remaining == 0 and bool(self.start_specs) - - def _allocate_handle(self) -> int: - """Allocate the next unique context handle. - - Returns - ------- - int - Newly allocated ``u32`` handle. - - Raises - ------ - RuntimeError - If ``u32`` handle space is exhausted. - """ - if self._next_handle > UINT32_MAX: - raise RuntimeError("app context handle overflow") - handle = self._next_handle - self._next_handle += 1 - return handle - - def _prepare_start_specs(self) -> None: - """Create and register all startup contexts before launching apps. - - Parameters - ---------- - Raises - ------ - RuntimeError - If labels are duplicated or one executable path is invalid. - """ - prepared_contexts: list[AppContext] = [] - labels: set[int] = set() - for spec in self.start_specs: - if spec.label in labels or spec.label in self._contexts_by_label: - self.logger.error("Duplicate startup label detected: %d", spec.label) - raise RuntimeError(f"duplicate app label: {spec.label}") - - app_path = spec.app_path.expanduser().resolve() - if not app_path.exists(): - self.logger.error("Startup executable does not exist: %s", app_path) - raise RuntimeError(f"app does not exist: {app_path}") - - prepared_contexts.append( - AppContext( - label=spec.label, - handle=self._allocate_handle(), - app_path=app_path, - ) - ) - labels.add(spec.label) - - with self._lock: - for context in prepared_contexts: - self._contexts_by_label[context.label] = context - self._contexts_by_handle[context.handle] = context - - for context in prepared_contexts: - self.logger.debug( - "Initialized app context label=%d handle=%d path=%s", - context.label, - context.handle, - context.app_path, - ) - - def _start_prepared_apps(self) -> None: - """Start all applications after contexts have been registered.""" - for spec in self.start_specs: - with self._lock: - context = self._contexts_by_label.get(spec.label) - - if context is None: - self.logger.error("Missing initialized context for label=%d", spec.label) - raise RuntimeError(f"missing app context for label: {spec.label}") - - child_env = os.environ.copy() - child_env["SENTRY_APP_LABEL"] = str(spec.label) - child_env["SENTRY_EMULATOR_HOST"] = self.host - child_env["SENTRY_EMULATOR_PORT"] = str(self.port) - - try: - process = subprocess.Popen([str(context.app_path)], env=child_env) - except OSError as exc: - self.logger.error( - "Cannot start app label=%d path=%s: %s", - spec.label, - context.app_path, - exc, - ) - raise RuntimeError(f"cannot start app: {context.app_path}") from exc - - context.process = process - with self._lock: - self._started_processes.append(process) - - self.logger.info( - "Started app label=%d handle=%d pid=%d path=%s", - context.label, - context.handle, - process.pid, - context.app_path, - ) - - def _startup_apps(self) -> None: - """Start and register all configured startup applications.""" - if not self.start_specs: - self.logger.info("No startup tasks configured") - return - - self._prepare_start_specs() - self._start_prepared_apps() - - def _terminate_started_apps(self) -> None: - """Terminate all child processes started by the daemon.""" - for process in self._started_processes: - if process.poll() is None: - self.logger.debug("Stopping child process pid=%d", process.pid) - process.terminate() - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - self.logger.warning( - "Child process pid=%d did not stop gracefully, killing", - process.pid, - ) - process.kill() - process.wait(timeout=2) - - with self._lock: - for app_context in self._contexts_by_label.values(): - for registration in app_context.alarms.values(): - registration.timer.cancel() - app_context.alarms.clear() - app_context.pending_signals.clear() - app_context.process = None - self._contexts_by_label.clear() - self._contexts_by_handle.clear() - - def context_for_label(self, label: int) -> AppContext | None: - """Look up the runtime context bound to a label. - - Parameters - ---------- - label : int - Application label coming from request payload. - - Returns - ------- - AppContext | None - Matching context or ``None`` if the label is unknown. - """ - with self._lock: - return self._contexts_by_label.get(label) - - def context_for_handle(self, handle: int) -> AppContext | None: - """Look up the runtime context bound to a handle.""" - with self._lock: - return self._contexts_by_handle.get(handle) - - def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None: - """Write payload to the context exchange buffer. - - Parameters - ---------- - app_context : AppContext - Target context. - payload : bytes - Source bytes copied into the buffer and zero-padded. - """ - with self._lock: - app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN - copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) - app_context.exchange_buffer[:copy_len] = payload[:copy_len] - self.logger.debug( - "exchange_to_kernel label=%d handle=%d bytes=%d", - app_context.label, - app_context.handle, - copy_len, - ) - - def read_exchange_buffer(self, app_context: AppContext) -> bytes: - """Read full exchange buffer for a context. - - Parameters - ---------- - app_context : AppContext - Source context. - - Returns - ------- - bytes - Full serialized exchange buffer. - """ - with self._lock: - payload = bytes(app_context.exchange_buffer) - self.logger.debug( - "exchange_from_kernel label=%d handle=%d bytes=%d", - app_context.label, - app_context.handle, - len(payload), - ) - return payload - - def write_u32_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: - """Store one ``u32`` value into app exchange buffer.""" - self.write_exchange_buffer(app_context, int(value).to_bytes(4, "little", signed=False)) - - def write_u64_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: - """Store one ``u64`` value into app exchange buffer.""" - self.write_exchange_buffer(app_context, int(value).to_bytes(8, "little", signed=False)) - - def queue_signal(self, target: AppContext, signal: int, source_handle: int) -> int: - """Queue a signal for a target context. - - Returns - ------- - int - Emulator status code. - """ - with target.event_condition: - if len(target.pending_signals) >= MAX_PENDING_SIGNALS: - return STATUS_BUSY - target.pending_signals.append((signal, source_handle)) - target.event_condition.notify_all() - self.logger.debug( - "Queued signal=%d source=%d for label=%d handle=%d", - signal, - source_handle, - target.label, - target.handle, - ) - return STATUS_OK - - def _alarm_fire(self, label: int, delay_ms: int) -> None: - with self._lock: - target = self._contexts_by_label.get(label) - if target is None: - return - status = self.queue_signal(target, SIGNAL_ALARM, target.handle) - if status != STATUS_OK: - self.logger.warning( - "Alarm delivery failed label=%d delay_ms=%d status=%d", - label, - delay_ms, - status, - ) - - def _schedule_alarm(self, app_context: AppContext, delay_ms: int, periodic: bool) -> int: - with self._lock: - if delay_ms in app_context.alarms: - return STATUS_BUSY - - interval_s = max(0.0, delay_ms / 1000.0) - - def _callback() -> None: - self._alarm_fire(app_context.label, delay_ms) - if not periodic: - with self._lock: - app_context.alarms.pop(delay_ms, None) - return - - with self._lock: - still_registered = app_context.alarms.get(delay_ms) - if still_registered is None: - return - - next_timer = threading.Timer(interval_s, _callback) - app_context.alarms[delay_ms] = AlarmRegistration( - delay_ms=delay_ms, - periodic=True, - timer=next_timer, - ) - next_timer.start() - - timer = threading.Timer(interval_s, _callback) - registration = AlarmRegistration(delay_ms=delay_ms, periodic=periodic, timer=timer) - with self._lock: - app_context.alarms[delay_ms] = registration - timer.start() - self.logger.debug( - "Scheduled alarm label=%d handle=%d delay_ms=%d periodic=%s", - app_context.label, - app_context.handle, - delay_ms, - periodic, - ) - return STATUS_OK - - def _stop_alarm(self, app_context: AppContext, delay_ms: int) -> int: - with self._lock: - registration = app_context.alarms.pop(delay_ms, None) - if registration is None: - self.logger.debug( - "Alarm already stopped label=%d handle=%d delay_ms=%d", - app_context.label, - app_context.handle, - delay_ms, - ) - return STATUS_OK - registration.timer.cancel() - self.logger.debug( - "Stopped alarm label=%d handle=%d delay_ms=%d", - app_context.label, - app_context.handle, - delay_ms, - ) - return STATUS_OK - - def current_cycle_value(self, precision: int) -> int: - """Compute a synthetic cycle/timestamp value for requested precision.""" - elapsed_ns = max(0, time.monotonic_ns() - self._start_monotonic_ns) - if precision == PRECISION_CYCLE: - return elapsed_ns - if precision == PRECISION_NANOSECONDS: - return elapsed_ns - if precision == PRECISION_MICROSECONDS: - return elapsed_ns // 1_000 - if precision == PRECISION_MILLISECONDS: - return elapsed_ns // 1_000_000 - raise ValueError("invalid precision") - - def _dequeue_matching_signal( - self, app_context: AppContext, mask: int - ) -> tuple[int, int] | None: - if not (mask & EVENT_TYPE_SIGNAL): - return None - - with app_context.event_condition: - if not app_context.pending_signals: - return None - return app_context.pending_signals.pop(0) - - def _serialize_signal_event( - self, app_context: AppContext, signal: int, source_handle: int - ) -> None: - header = bytes( - [EVENT_TYPE_SIGNAL, 4] - ) + EVENT_MAGIC.to_bytes(2, "little") + int(source_handle).to_bytes( - 4, "little", signed=False - ) - payload = int(signal).to_bytes(4, "little", signed=False) - self.write_exchange_buffer(app_context, header + payload) - - @property - def bound_address(self) -> tuple[str, int]: - """Return the effective bind address once server is started. - - Returns - ------- - tuple[str, int] - Bound host and port. - - Raises - ------ - RuntimeError - If server has not been started yet. - """ - if self._bound_address is None: - raise RuntimeError("daemon is not bound yet") - return self._bound_address - - def serve_forever( - self, - stop_event: threading.Event | None = None, - ready_event: threading.Event | None = None, - poll_interval: float = 0.2, - ) -> None: - """Start the daemon and serve requests until stop is requested. - - Parameters - ---------- - stop_event : threading.Event | None, optional - External event used to request server shutdown. - ready_event : threading.Event | None, optional - Event set once binding and server startup are complete. - poll_interval : float, optional - Poll period in seconds when waiting for ``stop_event``. - """ - event = stop_event if stop_event is not None else threading.Event() - - grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) - emulator_pb2_grpc.add_EmulatorServicer_to_server( - _EmulatorServicer(daemon=self, store=self.store, logger=self.logger), grpc_server - ) # type: ignore[no-untyped-call] - - bound_port = grpc_server.add_insecure_port(f"{self.host}:{self.port}") - if bound_port == 0: - self.logger.error("Cannot bind gRPC server on %s:%d", self.host, self.port) - raise RuntimeError(f"cannot bind gRPC server on {self.host}:{self.port}") - - self._bound_address = (self.host, int(bound_port)) - grpc_server.start() - - self._startup_apps() - - if ready_event is not None: - ready_event.set() - - self.logger.info( - "Sentry emulator listening on grpc://%s:%d", - self._bound_address[0], - self._bound_address[1], - ) - - try: - while True: - if event.wait(timeout=poll_interval): - break - if self._all_startup_contexts_stopped(): - self.logger.info( - "All startup tasks have terminated, stopping emulator daemon" - ) - break - finally: - grpc_server.stop(grace=0).wait() - self._terminate_started_apps() - self.logger.info("Sentry emulator stopped") - - -@dataclass(slots=True) -class _EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): - """Internal gRPC service implementation for syscall dispatch.""" - - daemon: GrpcEmulatorDaemon - store: SyscallStore - logger: logging.Logger - - def Dispatch( - self, request: Any, context: grpc.ServicerContext - ) -> Any: - """Handle one syscall dispatch gRPC request. - - Parameters - ---------- - request : Any - Incoming protobuf request payload. - context : grpc.ServicerContext - gRPC context used to return detailed error status. - - Returns - ------- - Any - ``DispatchResponse`` protobuf instance. - """ - response_cls = getattr(emulator_pb2, "DispatchResponse") - self.logger.debug( - "Received request peer=%s syscall=%s args=%s label=%s payload_len=%d", - context.peer(), - getattr(request, "syscall", ""), - list(getattr(request, "args", [])), - getattr(request, "label", ""), - len(getattr(request, "payload", b"")), - ) - - try: - message = deserialize_request(request) - except ProtocolError as exc: - self.store.register_invalid() - self.logger.warning("Rejected command from %s: %s", context.peer(), exc) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(str(exc)) - return response_cls(status=1, detail=str(exc)) - - app_context = self.daemon.context_for_label(message.label) - if app_context is None: - self.store.register_invalid() - detail = f"unknown app label: {message.label}" - self.logger.warning("Rejected command from %s: %s", context.peer(), detail) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(detail) - return response_cls(status=1, detail=detail) - - if message.syscall == "exchange_to_kernel": - self.daemon.write_exchange_buffer(app_context, message.payload) - self.logger.debug( - "Responding syscall=%s label=%d status=0", - message.syscall, - message.label, - ) - return response_cls(status=0, detail="ok") - - if message.syscall == "exchange_from_kernel": - payload = self.daemon.read_exchange_buffer(app_context) - self.logger.debug( - "Responding syscall=%s label=%d status=0 payload_len=%d", - message.syscall, - message.label, - len(payload), - ) - return response_cls( - status=0, - detail="ok", - payload=payload, - ) - - if message.syscall == "log": - log_len = int(message.args[0]) if message.args else 0 - log_len = max(0, min(log_len, EXCHANGE_BUFFER_LEN)) - raw = bytes(app_context.exchange_buffer[:log_len]) - text = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") - print(f"[app:{message.label}] {text}", flush=True) - self.logger.debug( - "Responding syscall=%s label=%d status=0 printed_len=%d", - message.syscall, - message.label, - len(text), - ) - return response_cls(status=0, detail="ok") - - if message.syscall == "get_process_handle": - if not message.args: - detail = "missing label argument" - self.logger.warning("Rejected command from %s: %s", context.peer(), detail) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(detail) - return response_cls(status=STATUS_INVALID, detail=detail) - - target_label = int(message.args[0]) - target_context = self.daemon.context_for_label(target_label) - if target_context is None: - return response_cls(status=STATUS_INVALID, detail="unknown label") - - self.daemon.write_u32_to_exchange_buffer(app_context, target_context.handle) - return response_cls(status=STATUS_OK, detail="ok") - - if message.syscall == "send_signal": - if len(message.args) < 2: - detail = "missing send_signal arguments" - self.logger.warning("Rejected command from %s: %s", context.peer(), detail) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(detail) - return response_cls(status=STATUS_INVALID, detail=detail) - - target_handle = int(message.args[0]) - signal = int(message.args[1]) - if target_handle == app_context.handle: - return response_cls(status=STATUS_INVALID, detail="self signal forbidden") - if signal < SIGNAL_ABORT or signal > SIGNAL_USR2: - return response_cls(status=STATUS_INVALID, detail="invalid signal") - - target_context = self.daemon.context_for_handle(target_handle) - if target_context is None: - return response_cls(status=STATUS_INVALID, detail="unknown target handle") - - status = self.daemon.queue_signal(target_context, signal, app_context.handle) - return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") - - if message.syscall == "wait_for_event": - if len(message.args) < 2: - detail = "missing wait_for_event arguments" - self.logger.warning("Rejected command from %s: %s", context.peer(), detail) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(detail) - return response_cls(status=STATUS_INVALID, detail=detail) - - mask = int(message.args[0]) - timeout = int(message.args[1]) - - pending = self.daemon._dequeue_matching_signal(app_context, mask) - if pending is not None: - signal, source_handle = pending - self.daemon._serialize_signal_event(app_context, signal, source_handle) - return response_cls(status=STATUS_OK, detail="ok") - - if timeout == -1: - return response_cls(status=STATUS_AGAIN, detail="again") - - wait_timeout = None if timeout == 0 else max(0.0, timeout / 1000.0) - with app_context.event_condition: - has_event = app_context.event_condition.wait_for( - lambda: bool(app_context.pending_signals) and bool(mask & EVENT_TYPE_SIGNAL), - timeout=wait_timeout, - ) - if not has_event: - return response_cls(status=STATUS_TIMEOUT, detail="timeout") - signal, source_handle = app_context.pending_signals.pop(0) - - self.daemon._serialize_signal_event(app_context, signal, source_handle) - return response_cls(status=STATUS_OK, detail="ok") - - if message.syscall == "alarm": - if len(message.args) < 2: - detail = "missing alarm arguments" - self.logger.warning("Rejected command from %s: %s", context.peer(), detail) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(detail) - return response_cls(status=STATUS_INVALID, detail=detail) - - delay_ms = max(0, int(message.args[0])) - flag = int(message.args[1]) - if flag == 0: - status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=False) - return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") - if flag == 1: - status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=True) - return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") - if flag == 2: - status = self.daemon._stop_alarm(app_context, delay_ms) - return response_cls( - status=status, - detail="ok" if status == STATUS_OK else "no entity", - ) - return response_cls(status=STATUS_INVALID, detail="invalid alarm flag") - - if message.syscall == "get_random": - value = self.daemon._rng.getrandbits(32) - self.daemon.write_u32_to_exchange_buffer(app_context, value) - return response_cls(status=STATUS_OK, detail="ok") - - if message.syscall == "get_cycle": - if not message.args: - detail = "missing precision argument" - self.logger.warning("Rejected command from %s: %s", context.peer(), detail) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(detail) - return response_cls(status=STATUS_INVALID, detail=detail) - - precision = int(message.args[0]) - if precision < PRECISION_CYCLE or precision > PRECISION_MILLISECONDS: - return response_cls(status=STATUS_INVALID, detail="invalid precision") - - cycle_value = self.daemon.current_cycle_value(precision) - self.daemon.write_u64_to_exchange_buffer(app_context, cycle_value) - return response_cls(status=STATUS_OK, detail="ok") - - if message.syscall == "exit": - exit_code = int(message.args[0]) if message.args else 0 - all_done = self.daemon.deactivate_context(app_context, exit_code) - detail = "all tasks exited" if all_done else "context deactivated" - return response_cls(status=STATUS_OK, detail=detail) - - self.store.register(message) - # Keep this trace explicit for early integration/debug phases. - self.logger.info( - "grpc command label=%d handle=%d syscall=%s args=%s peer=%s", - message.label, - app_context.handle, - message.syscall, - list(message.args), - context.peer(), - ) - self.logger.debug( - "Responding syscall=%s label=%d status=0", - message.syscall, - message.label, - ) - return response_cls(status=0, detail="ok") +from .emulator_server import ( + DEFAULT_HOST, + DEFAULT_PORT, + EXCHANGE_BUFFER_LEN, + GrpcEmulatorDaemon, + StartSpec, + parse_start_option, +) + +__all__ = [ + "DEFAULT_HOST", + "DEFAULT_PORT", + "EXCHANGE_BUFFER_LEN", + "GrpcEmulatorDaemon", + "StartSpec", + "parse_start_option", +] diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py index b6574454..e57e9610 100644 --- a/tools/sentry-emulator/tests/test_server.py +++ b/tools/sentry-emulator/tests/test_server.py @@ -213,7 +213,7 @@ def wait_for_signal() -> None: assert daemon.store.invalid_packets == 2 captured = capsys.readouterr() - assert "[app:7] hello from task" in captured.out + assert "[dummy_app_a:7] hello from task" in captured.out def test_exit_deactivates_context_and_stops_daemon(tmp_path: pathlib.Path) -> None: From f8e454025f4ff60181db41f29b3f1f2d95797fe1 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 15:58:56 +0200 Subject: [PATCH 12/19] emulator,doc: enhanced source documentation --- .../sample-rust-app/src/bin/sample-app-one.rs | 5 + .../sample-rust-app/src/bin/sample-app-two.rs | 5 + .../sample-rust-app/src/lib.rs | 21 ++ .../sentry_emulator/emulator_server/daemon.py | 237 ++++++++++++++++++ .../sentry_emulator/emulator_server/models.py | 72 +++++- .../emulator_server/servicer.py | 27 +- tools/sentry-emulator/tests/test_protocol.py | 4 + tools/sentry-emulator/tests/test_server.py | 7 + 8 files changed, 373 insertions(+), 5 deletions(-) diff --git a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs index d231ba32..b0ad5a2a 100644 --- a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs +++ b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 H2Lab Development Team // SPDX-License-Identifier: Apache-2.0 +//! Binary entrypoint for sample Rust app one. +//! +//! This task plays the sender role in emulator end-to-end tests. + +/// Run sender scenario and terminate the task context with code `0`. fn main() { sentry_emulator_sample_rust_tasks::run_sample_app_one(8); let _ = sentry_uapi::syscall::exit(0); diff --git a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs index b81d2fd8..00ddf018 100644 --- a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs +++ b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 H2Lab Development Team // SPDX-License-Identifier: Apache-2.0 +//! Binary entrypoint for sample Rust app two. +//! +//! This task plays the receiver role in emulator end-to-end tests. + +/// Run receiver scenario and terminate the task context with code `0`. fn main() { sentry_emulator_sample_rust_tasks::run_sample_app_two(); let _ = sentry_uapi::syscall::exit(0); diff --git a/tools/sentry-emulator/sample-rust-app/src/lib.rs b/tools/sentry-emulator/sample-rust-app/src/lib.rs index 22034563..a4236e8d 100644 --- a/tools/sentry-emulator/sample-rust-app/src/lib.rs +++ b/tools/sentry-emulator/sample-rust-app/src/lib.rs @@ -1,12 +1,19 @@ // SPDX-FileCopyrightText: 2026 H2Lab Development Team // SPDX-License-Identifier: Apache-2.0 +//! Sample Rust tasks used to validate the sentry-emulator end-to-end behavior. +//! +//! `sample-app-one` actively sends a signal to `sample-app-two`, then runs alarm, +//! random, and cycle syscalls. `sample-app-two` blocks on event delivery, decodes +//! the serialized signal event from exchange memory, and runs the same checks. + use sentry_uapi::systypes::{ AlarmFlag, EventType, Precision, Signal, Status, }; use std::thread; use std::time::Duration; +/// Copy a UTF-8 log message into exchange memory and emit it with `syscall::log`. fn emit_app_log(message: &str) { let payload = message.as_bytes(); let st_copy = sentry_uapi::copy_to_kernel(&payload).unwrap_or(Status::Invalid); @@ -21,6 +28,7 @@ fn emit_app_log(message: &str) { } } +/// Emit a structured status line for one syscall step. fn report_status(context: &str, status: Status) { emit_app_log(&format!("{context}: {status:?}")); } @@ -29,6 +37,10 @@ const SIGNAL_EVENT_TYPE: u8 = 2; const SIGNAL_EVENT_MAGIC: u16 = 0x4242; const TARGET_APP_TWO_HANDLE: u32 = 2; +/// Decode a signal event header and payload from exchange memory. +/// +/// Returns `Some(signal)` when the buffer contains a valid serialized signal +/// event, otherwise returns `None` and logs the invalid frame details. fn read_signal_event_from_exchange() -> Option { let mut raw_exchange = [0u8; 128]; let mut raw_exchange_slice: &mut [u8] = &mut raw_exchange; @@ -72,6 +84,7 @@ fn read_signal_event_from_exchange() -> Option { Some(signal) } +/// Exercise alarm, random, and cycle syscalls and log each returned status. fn run_alarm_random_cycle_checks() { // Start periodic alarm to guarantee that an immediate stop targets a live registration. let st_alarm_start = sentry_uapi::syscall::alarm(5000, AlarmFlag::AlarmStartPeriodic); @@ -97,6 +110,10 @@ fn run_alarm_random_cycle_checks() { report_status("copy_from_kernel(cycle)", st_copy_cycle); } +/// Entry routine for sample app one. +/// +/// The routine sends `SIGUSR1` to app two twice (to tolerate startup ordering), +/// then validates additional emulator syscalls. pub fn run_sample_app_one(peer_label: u32) { let _ = peer_label; @@ -112,6 +129,10 @@ pub fn run_sample_app_one(peer_label: u32) { run_alarm_random_cycle_checks(); } +/// Entry routine for sample app two. +/// +/// The routine blocks on signal events until `SIGUSR1` is received, validates +/// signal event serialization, then runs the shared syscall checks. pub fn run_sample_app_two() { // Wait without timeout and return only when SIGUSR1 has been serialized by daemon. loop { diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py index 34734365..95968ab1 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py @@ -57,12 +57,33 @@ class GrpcEmulatorDaemon: _start_monotonic_ns: int = field(default_factory=time.monotonic_ns, init=False) def _all_startup_contexts_stopped(self) -> bool: + """Report whether every configured startup context has exited. + + Returns + ------- + bool + ``True`` only when startup specs exist and no context remains active. + """ if not self.start_specs: return False with self._lock: return not self._contexts_by_label def deactivate_context(self, app_context: AppContext, exit_code: int) -> bool: + """Deactivate an application context after an ``exit`` syscall. + + Parameters + ---------- + app_context : AppContext + Context to remove from active registries. + exit_code : int + Application exit code reported by userspace. + + Returns + ------- + bool + ``True`` when this deactivation leaves no startup contexts alive. + """ with self._lock: removed_by_label = self._contexts_by_label.pop(app_context.label, None) removed_by_handle = self._contexts_by_handle.pop(app_context.handle, None) @@ -90,6 +111,18 @@ def deactivate_context(self, app_context: AppContext, exit_code: int) -> bool: return contexts_remaining == 0 and bool(self.start_specs) def _allocate_handle(self) -> int: + """Allocate the next unique app handle. + + Returns + ------- + int + Unsigned 32-bit process handle. + + Raises + ------ + RuntimeError + If the handle space is exhausted. + """ if self._next_handle > UINT32_MAX: raise RuntimeError("app context handle overflow") handle = self._next_handle @@ -97,6 +130,13 @@ def _allocate_handle(self) -> int: return handle def _prepare_start_specs(self) -> None: + """Validate startup specs and create contexts before process launch. + + Raises + ------ + RuntimeError + If labels collide or one executable path is missing. + """ prepared_contexts: list[AppContext] = [] labels: set[int] = set() for spec in self.start_specs: @@ -132,6 +172,13 @@ def _prepare_start_specs(self) -> None: ) def _start_prepared_apps(self) -> None: + """Spawn all prepared startup applications. + + Raises + ------ + RuntimeError + If one prepared context is missing or process creation fails. + """ for spec in self.start_specs: with self._lock: context = self._contexts_by_label.get(spec.label) @@ -169,6 +216,7 @@ def _start_prepared_apps(self) -> None: ) def _startup_apps(self) -> None: + """Initialize contexts and launch configured startup apps.""" if not self.start_specs: self.logger.info("No startup tasks configured") return @@ -177,6 +225,7 @@ def _startup_apps(self) -> None: self._start_prepared_apps() def _terminate_started_apps(self) -> None: + """Terminate started apps and clear all in-memory runtime contexts.""" for process in self._started_processes: if process.poll() is None: self.logger.debug("Stopping child process pid=%d", process.pid) @@ -202,14 +251,47 @@ def _terminate_started_apps(self) -> None: self._contexts_by_handle.clear() def context_for_label(self, label: int) -> AppContext | None: + """Return the active context associated with one application label. + + Parameters + ---------- + label : int + Application label. + + Returns + ------- + AppContext | None + Matching active context or ``None`` when absent. + """ with self._lock: return self._contexts_by_label.get(label) def context_for_handle(self, handle: int) -> AppContext | None: + """Return the active context associated with one process handle. + + Parameters + ---------- + handle : int + Process handle returned by ``get_process_handle``. + + Returns + ------- + AppContext | None + Matching active context or ``None`` when absent. + """ with self._lock: return self._contexts_by_handle.get(handle) def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None: + """Write bytes into one app exchange buffer with zero-padding. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + payload : bytes + Payload copied into the fixed-size exchange buffer. + """ with self._lock: app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) @@ -222,6 +304,18 @@ def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None ) def read_exchange_buffer(self, app_context: AppContext) -> bytes: + """Read one app exchange buffer as immutable bytes. + + Parameters + ---------- + app_context : AppContext + Source app runtime context. + + Returns + ------- + bytes + Snapshot of the full fixed-size exchange buffer. + """ with self._lock: payload = bytes(app_context.exchange_buffer) self.logger.debug( @@ -233,12 +327,46 @@ def read_exchange_buffer(self, app_context: AppContext) -> bytes: return payload def write_u32_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + """Serialize and write one unsigned 32-bit integer to exchange buffer. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + value : int + Value encoded in little-endian unsigned representation. + """ self.write_exchange_buffer(app_context, int(value).to_bytes(4, "little", signed=False)) def write_u64_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + """Serialize and write one unsigned 64-bit integer to exchange buffer. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + value : int + Value encoded in little-endian unsigned representation. + """ self.write_exchange_buffer(app_context, int(value).to_bytes(8, "little", signed=False)) def queue_signal(self, target: AppContext, signal: int, source_handle: int) -> int: + """Queue a signal event for one application context. + + Parameters + ---------- + target : AppContext + Destination app context receiving the signal. + signal : int + Signal value to enqueue. + source_handle : int + Handle of the sender process. + + Returns + ------- + int + ``STATUS_OK`` on success, ``STATUS_BUSY`` when queue is full. + """ with target.event_condition: if len(target.pending_signals) >= MAX_PENDING_SIGNALS: return STATUS_BUSY @@ -254,6 +382,15 @@ def queue_signal(self, target: AppContext, signal: int, source_handle: int) -> i return STATUS_OK def _alarm_fire(self, label: int, delay_ms: int) -> None: + """Deliver one alarm tick to the context identified by label. + + Parameters + ---------- + label : int + Target application label. + delay_ms : int + Alarm delay key used for logging context. + """ with self._lock: target = self._contexts_by_label.get(label) if target is None: @@ -268,6 +405,22 @@ def _alarm_fire(self, label: int, delay_ms: int) -> None: ) def _schedule_alarm(self, app_context: AppContext, delay_ms: int, periodic: bool) -> int: + """Schedule a one-shot or periodic alarm for one context. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + delay_ms : int + Alarm delay in milliseconds. + periodic : bool + Whether the alarm should re-arm itself after firing. + + Returns + ------- + int + ``STATUS_OK`` when scheduled, ``STATUS_BUSY`` on duplicate delay. + """ with self._lock: if delay_ms in app_context.alarms: return STATUS_BUSY @@ -311,6 +464,20 @@ def _callback() -> None: return STATUS_OK def _stop_alarm(self, app_context: AppContext, delay_ms: int) -> int: + """Stop one previously scheduled alarm. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + delay_ms : int + Alarm delay key identifying the registration. + + Returns + ------- + int + ``STATUS_OK`` regardless of prior registration presence. + """ with self._lock: registration = app_context.alarms.pop(delay_ms, None) if registration is None: @@ -331,6 +498,23 @@ def _stop_alarm(self, app_context: AppContext, delay_ms: int) -> int: return STATUS_OK def current_cycle_value(self, precision: int) -> int: + """Return monotonic elapsed time encoded for requested precision. + + Parameters + ---------- + precision : int + Precision code from ``PRECISION_*`` constants. + + Returns + ------- + int + Elapsed monotonic time converted to the requested unit. + + Raises + ------ + ValueError + If precision is not supported by the emulator API. + """ elapsed_ns = max(0, time.monotonic_ns() - self._start_monotonic_ns) if precision == PRECISION_CYCLE: return elapsed_ns @@ -345,6 +529,20 @@ def current_cycle_value(self, precision: int) -> int: def _dequeue_matching_signal( self, app_context: AppContext, mask: int ) -> tuple[int, int] | None: + """Pop one pending signal if the provided mask accepts signal events. + + Parameters + ---------- + app_context : AppContext + Source app runtime context. + mask : int + Bitmask of accepted event types. + + Returns + ------- + tuple[int, int] | None + ``(signal, source_handle)`` when available, otherwise ``None``. + """ if not (mask & EVENT_TYPE_SIGNAL): return None @@ -356,6 +554,17 @@ def _dequeue_matching_signal( def _serialize_signal_event( self, app_context: AppContext, signal: int, source_handle: int ) -> None: + """Serialize one signal event into the app exchange buffer. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + signal : int + Signal value to encode in payload. + source_handle : int + Handle of the sender process. + """ header = bytes([EVENT_TYPE_SIGNAL, 4]) + EVENT_MAGIC.to_bytes( 2, "little" ) + int(source_handle).to_bytes(4, "little", signed=False) @@ -364,6 +573,18 @@ def _serialize_signal_event( @property def bound_address(self) -> tuple[str, int]: + """Return bound host and port for the running gRPC server. + + Returns + ------- + tuple[str, int] + Bound ``(host, port)`` pair. + + Raises + ------ + RuntimeError + If server binding has not happened yet. + """ if self._bound_address is None: raise RuntimeError("daemon is not bound yet") return self._bound_address @@ -374,6 +595,22 @@ def serve_forever( ready_event: threading.Event | None = None, poll_interval: float = 0.2, ) -> None: + """Run the gRPC server loop until stop criteria are met. + + Parameters + ---------- + stop_event : threading.Event | None, optional + External stop signal, by default a fresh event local to this call. + ready_event : threading.Event | None, optional + Event set once the server is bound and started. + poll_interval : float, optional + Delay in seconds between stop-condition polls. + + Raises + ------ + RuntimeError + If gRPC binding fails or startup app preparation fails. + """ event = stop_event if stop_event is not None else threading.Event() grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py index ce0db52d..19cdbeb1 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py @@ -13,6 +13,18 @@ @dataclass(slots=True) class AlarmRegistration: + """Bookkeeping for one active alarm registration. + + Attributes + ---------- + delay_ms : int + Alarm delay in milliseconds used as identifier in app context maps. + periodic : bool + Whether the alarm is automatically re-scheduled after firing. + timer : threading.Timer + Live timer object that triggers the alarm callback. + """ + delay_ms: int periodic: bool timer: threading.Timer @@ -20,7 +32,15 @@ class AlarmRegistration: @dataclass(frozen=True, slots=True) class StartSpec: - """Definition of one application to start with the daemon.""" + """Definition of one application to start with the daemon. + + Attributes + ---------- + app_path : Path + Executable path launched during daemon startup. + label : int + Application label advertised to the app runtime via environment. + """ app_path: Path label: int @@ -28,7 +48,29 @@ class StartSpec: @dataclass(slots=True) class AppContext: - """Runtime context associated with one started application.""" + """Runtime context associated with one started application. + + Attributes + ---------- + label : int + Application label used by syscall requests. + handle : int + Stable process handle exposed by ``get_process_handle``. + app_path : Path + Executable path associated with this runtime context. + process : subprocess.Popen[bytes] | None + Child process instance once started, otherwise ``None``. + exchange_buffer : bytearray + Shared 128-byte payload area used by exchange syscalls. + pending_signals : list[tuple[int, int]] + FIFO queue of ``(signal, source_handle)`` waiting to be consumed. + alarms : dict[int, AlarmRegistration] + Registered alarms keyed by delay in milliseconds. + event_condition : threading.Condition + Condition variable used to wake waiters when new events arrive. + exit_code : int | None + Exit code captured when the context is deactivated. + """ label: int handle: int @@ -44,12 +86,34 @@ class AppContext: @property def app_name(self) -> str: - """Return a stable display name for daemon-emitted app logs.""" + """Return a stable display name for daemon-emitted app logs. + + Returns + ------- + str + Stem of ``app_path`` when available, otherwise full filename. + """ return self.app_path.stem or self.app_path.name def parse_start_option(value: str) -> StartSpec: - """Parse one ``--start`` argument value (``APP_PATH,label=``).""" + """Parse one ``--start`` argument value (``APP_PATH,label=``). + + Parameters + ---------- + value : str + Raw CLI value passed to ``--start``. + + Returns + ------- + StartSpec + Validated startup specification used by the daemon. + + Raises + ------ + ValueError + If format is invalid, app path is empty, or label is outside ``u32``. + """ app_part, sep, label_part = value.partition(",") if sep == "": raise ValueError("--start expects 'app.elf,label='") diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py index fd1457aa..09048e1d 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py @@ -28,13 +28,38 @@ @dataclass(slots=True) class EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): - """Internal gRPC service implementation for syscall dispatch.""" + """Internal gRPC service implementation for syscall dispatch. + + Attributes + ---------- + daemon : Any + Runtime daemon object handling app contexts and buffer operations. + store : SyscallStore + In-memory syscall bookkeeping used by tests and diagnostics. + logger : logging.Logger + Logger used for request-level tracing and warnings. + """ daemon: Any store: SyscallStore logger: logging.Logger def Dispatch(self, request: Any, context: grpc.ServicerContext) -> Any: + """Handle one emulator syscall request. + + Parameters + ---------- + request : Any + gRPC ``DispatchRequest`` protobuf payload. + context : grpc.ServicerContext + gRPC request context used for peer metadata and status reporting. + + Returns + ------- + Any + ``DispatchResponse`` protobuf message carrying status, detail, and + optional payload. + """ response_cls = getattr(emulator_pb2, "DispatchResponse") self.logger.debug( "Received request peer=%s syscall=%s args=%s label=%s payload_len=%d", diff --git a/tools/sentry-emulator/tests/test_protocol.py b/tools/sentry-emulator/tests/test_protocol.py index 746332ed..a71380dd 100644 --- a/tools/sentry-emulator/tests/test_protocol.py +++ b/tools/sentry-emulator/tests/test_protocol.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""Protocol decoding tests for emulator request deserialization.""" + import pathlib import sys @@ -17,6 +19,7 @@ def test_deserialize_request_ok() -> None: + """Deserialize a valid request into normalized immutable fields.""" message = deserialize_request( emulator_pb2.DispatchRequest( syscall="map_dev", args=[1, 2, 3], label=17, payload=b"abc" @@ -30,5 +33,6 @@ def test_deserialize_request_ok() -> None: def test_deserialize_request_rejects_empty_syscall() -> None: + """Reject requests where syscall name is empty after trimming.""" with pytest.raises(ProtocolError): deserialize_request(emulator_pb2.DispatchRequest(syscall=" ", args=[12], label=1)) diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py index e57e9610..7e8633ac 100644 --- a/tools/sentry-emulator/tests/test_server.py +++ b/tools/sentry-emulator/tests/test_server.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 H2Lab Development Team # SPDX-License-Identifier: Apache-2.0 +"""Integration tests for emulator server lifecycle and syscalls.""" + import pathlib import importlib @@ -27,6 +29,7 @@ def test_parse_start_option_ok() -> None: + """Accept a well-formed startup spec with numeric label.""" spec = parse_start_option("./app.elf,label=123") assert str(spec.app_path) == "app.elf" @@ -34,6 +37,7 @@ def test_parse_start_option_ok() -> None: def test_parse_start_option_rejects_bad_label() -> None: + """Reject startup labels that exceed unsigned 32-bit bounds.""" with pytest.raises(ValueError): parse_start_option("./app.elf,label=50000000000") @@ -41,6 +45,7 @@ def test_parse_start_option_rejects_bad_label() -> None: def test_grpc_server_receives_and_sorts_messages( tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: + """Exercise end-to-end syscall flow and in-memory accounting.""" app_path_a = tmp_path / "dummy_app_a.sh" app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") app_path_a.chmod(0o755) @@ -217,6 +222,7 @@ def wait_for_signal() -> None: def test_exit_deactivates_context_and_stops_daemon(tmp_path: pathlib.Path) -> None: + """Ensure exit syscall removes contexts and stops daemon when all leave.""" app_path_a = tmp_path / "dummy_app_a.sh" app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") app_path_a.chmod(0o755) @@ -270,6 +276,7 @@ def test_exit_deactivates_context_and_stops_daemon(tmp_path: pathlib.Path) -> No def test_wait_for_event_blocking_wakes_on_signal(tmp_path: pathlib.Path) -> None: + """Ensure blocking wait returns when a matching signal is queued.""" app_path_a = tmp_path / "dummy_app_a.sh" app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") app_path_a.chmod(0o755) From d79c6f7abac1002f28a9f9cbc9ffbe1c348962b9 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 16:08:01 +0200 Subject: [PATCH 13/19] emulator,ci: adding workflow adding emulator requirements to requirements.txt --- .github/workflows/emulation.yml | 81 +++++++++++++++++++++++++++++++++ requirements.txt | 3 ++ 2 files changed, 84 insertions(+) create mode 100644 .github/workflows/emulation.yml diff --git a/.github/workflows/emulation.yml b/.github/workflows/emulation.yml new file mode 100644 index 00000000..5bfcc2e1 --- /dev/null +++ b/.github/workflows/emulation.yml @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +name: Sentry emulator end-to-end + +on: + push: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + emulator: + defaults: + run: + shell: bash + runs-on: ubuntu-latest + container: + image: mesonbuild/ubuntu-rolling + steps: + - name: XXX git permission quirk XXX + run: | + git config --global --add safe.directory $GITHUB_WORKSPACE + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + set-safe-directory: true + - name: Clone cross-files + uses: actions/checkout@v6 + with: + ref: 'main' + repository: 'camelot-os/meson-cross-files' + path: crossfiles + - name: Deploy cross-files + run: | + mkdir -p $HOME/.local/share/meson/cross + cp -a $GITHUB_WORKSPACE/crossfiles/*.ini $HOME/.local/share/meson/cross + echo "MESON_CROSS_FILES=$HOME/.local/share/meson/cross" >> $GITHUB_ENV + - name: install prerequisites pkg + uses: camelot-os/action-install-pkg@v1 + with: + packages: 'dtc|device-tree-compiler,libssh2-1|libssh2,curl,lld' + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: 1.86.0 + targets: thumbv7m-none-eabi,thumbv7em-none-eabi,thumbv7em-none-eabihf,thumbv8m.base-none-eabi,thumbv8m.main-none-eabi,thumbv8m.main-none-eabihf,x86_64-unknown-linux-gnu + components: clippy,rustfmt + - name: Setup C toolchain + uses: camelot-os/action-setup-compiler@v1 + with: + compiler: gcc + triple: arm-none-eabi + ref: '12.3.Rel1' + workspace: $GITHUB_WORKSPACE + - name: deploy local deps + run: | + pip install -r requirements.txt + - name: defconfig + run: | + defconfig configs/stm32f429i_disc1_debug_defconfig + - name: Meson setup and compile + uses: camelot-os/action-meson@v1 + with: + cross_files: ${{ format('{0}/{1}', env.MESON_CROSS_FILES, 'cm4-none-eabi-gcc.ini') }} + actions: '["prefetch", "setup", "compile"]' + options: '-Dconfig=.config -Ddts=dts/examples/stm32f429i_disc1_debug.dts -Ddts-include-dirs=dts -Dwith_doc=false -Dwith_tests=false -Dwith_proof=false -Dwith_kernel=false -Dwith_uapi=true -Dwith_idle=false -Dwith_tools=true -Dwith_emulator=true -Dwith_sbom=false -Dwith-install-crates=false' + - name: Run emulator e2e test suite + run: | + cd builddir + meson test --suite emulator --print-errorlogs + - name: Meson postcheck + if: failure() + run: | + cat builddir/meson-logs/meson-log.txt || true + cat builddir/meson-logs/testlog.txt || true diff --git a/requirements.txt b/requirements.txt index 02a36578..717729eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,6 @@ weasyprint lief GitPython>=3.1.0 cyclonedx-bom +build +grpcio>=1.80 +protobuf>=6.31 From ef0259c8e16c533ef40ed6044de59c3a875b9baa Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 16:19:31 +0200 Subject: [PATCH 14/19] emulator,ci: enhanced verbosity of e2e emulation test --- .github/workflows/emulation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/emulation.yml b/.github/workflows/emulation.yml index 5bfcc2e1..d82e4b84 100644 --- a/.github/workflows/emulation.yml +++ b/.github/workflows/emulation.yml @@ -73,7 +73,7 @@ jobs: - name: Run emulator e2e test suite run: | cd builddir - meson test --suite emulator --print-errorlogs + meson test --suite emulator --verbose - name: Meson postcheck if: failure() run: | From f5afa241c5522c426c37067d71626ee3d58866b4 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 16:31:14 +0200 Subject: [PATCH 15/19] uapi: removing useless autotest-related code in posix mode --- uapi/src/posix.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index ddee2441..7005e54f 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -422,19 +422,6 @@ pub fn dma_resume_stream(dmah: StreamHandle) -> Status { forward_syscall("dma_resume_stream", &[dmah as i128]) } -// Autotest only -#[cfg(CONFIG_BUILD_TARGET_AUTOTEST)] -#[inline(always)] -pub fn autotest_set_capa(_capa: u32) -> Status { - forward_syscall("autotest_set_capa", &[_capa as i128]) -} - -#[cfg(CONFIG_BUILD_TARGET_AUTOTEST)] -#[inline(always)] -pub fn autotest_clear_capa(_capa: u32) -> Status { - forward_syscall("autotest_clear_capa", &[_capa as i128]) -} - #[inline(always)] pub fn unsupported() -> Status { forward_syscall("unsupported", &[]) From 24361b2f3b7af23df53e02d396c0776d50381773 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 16:31:31 +0200 Subject: [PATCH 16/19] emulator: adding support for send_ipc() --- .../sample-rust-app/src/lib.rs | 98 +++++++++++++--- .../emulator_server/constants.py | 2 + .../sentry_emulator/emulator_server/daemon.py | 77 +++++++++++- .../sentry_emulator/emulator_server/models.py | 25 ++++ .../emulator_server/servicer.py | 78 +++++++++++-- tools/sentry-emulator/tests/test_emulator.py | 5 + tools/sentry-emulator/tests/test_server.py | 110 ++++++++++++++++++ 7 files changed, 371 insertions(+), 24 deletions(-) diff --git a/tools/sentry-emulator/sample-rust-app/src/lib.rs b/tools/sentry-emulator/sample-rust-app/src/lib.rs index a4236e8d..89475287 100644 --- a/tools/sentry-emulator/sample-rust-app/src/lib.rs +++ b/tools/sentry-emulator/sample-rust-app/src/lib.rs @@ -3,9 +3,9 @@ //! Sample Rust tasks used to validate the sentry-emulator end-to-end behavior. //! -//! `sample-app-one` actively sends a signal to `sample-app-two`, then runs alarm, -//! random, and cycle syscalls. `sample-app-two` blocks on event delivery, decodes -//! the serialized signal event from exchange memory, and runs the same checks. +//! `sample-app-one` emits a blocking IPC to `sample-app-two`, then sends a signal +//! and runs alarm/random/cycle syscalls. `sample-app-two` first consumes the IPC +//! through `wait_for_event`, then handles the signal path and shared checks. use sentry_uapi::systypes::{ AlarmFlag, EventType, Precision, Signal, Status, @@ -33,23 +33,32 @@ fn report_status(context: &str, status: Status) { emit_app_log(&format!("{context}: {status:?}")); } +const EXCHANGE_BUFFER_LEN: usize = 128; +const IPC_EVENT_TYPE: u8 = 1; const SIGNAL_EVENT_TYPE: u8 = 2; const SIGNAL_EVENT_MAGIC: u16 = 0x4242; const TARGET_APP_TWO_HANDLE: u32 = 2; +const IPC_TEST_MESSAGE: &[u8] = b"ipc-from-sample-app-one"; -/// Decode a signal event header and payload from exchange memory. -/// -/// Returns `Some(signal)` when the buffer contains a valid serialized signal -/// event, otherwise returns `None` and logs the invalid frame details. -fn read_signal_event_from_exchange() -> Option { - let mut raw_exchange = [0u8; 128]; +/// Read raw exchange bytes as emitted by the daemon after `wait_for_event`. +fn read_raw_event_from_exchange() -> Option<[u8; EXCHANGE_BUFFER_LEN]> { + let mut raw_exchange = [0u8; EXCHANGE_BUFFER_LEN]; let mut raw_exchange_slice: &mut [u8] = &mut raw_exchange; let st_copy_event = sentry_uapi::copy_from_kernel(&mut raw_exchange_slice).unwrap_or(Status::Invalid); - report_status("copy_from_kernel(raw signal event)", st_copy_event); + report_status("copy_from_kernel(raw event)", st_copy_event); if st_copy_event != Status::Ok { return None; } + Some(raw_exchange) +} + +/// Decode a signal event header and payload from exchange memory. +/// +/// Returns `Some(signal)` when the buffer contains a valid serialized signal +/// event, otherwise returns `None` and logs the invalid frame details. +fn read_signal_event_from_exchange() -> Option { + let raw_exchange = read_raw_event_from_exchange()?; let event_type = raw_exchange[0]; let event_len = raw_exchange[1]; @@ -84,6 +93,46 @@ fn read_signal_event_from_exchange() -> Option { Some(signal) } +/// Decode an IPC event and verify it carries the expected payload. +fn read_ipc_event_from_exchange(expected_payload: &[u8]) -> bool { + let Some(raw_exchange) = read_raw_event_from_exchange() else { + return false; + }; + + let event_type = raw_exchange[0]; + let event_len = raw_exchange[1] as usize; + let event_magic = u16::from_le_bytes([raw_exchange[2], raw_exchange[3]]); + let event_peer = u32::from_le_bytes([ + raw_exchange[4], + raw_exchange[5], + raw_exchange[6], + raw_exchange[7], + ]); + + if event_type != IPC_EVENT_TYPE || event_magic != SIGNAL_EVENT_MAGIC || event_peer == 0 { + emit_app_log(&format!( + "invalid ipc event header: type={event_type} len={event_len} magic=0x{event_magic:04x} peer={event_peer}" + )); + return false; + } + + let payload_end = 8usize.saturating_add(event_len).min(EXCHANGE_BUFFER_LEN); + let payload = &raw_exchange[8..payload_end]; + if payload != expected_payload { + emit_app_log(&format!( + "unexpected ipc payload: len={} expected_len={}", + payload.len(), + expected_payload.len() + )); + return false; + } + + emit_app_log(&format!( + "ipc event received from peer={event_peer} payload_len={event_len}" + )); + true +} + /// Exercise alarm, random, and cycle syscalls and log each returned status. fn run_alarm_random_cycle_checks() { // Start periodic alarm to guarantee that an immediate stop targets a live registration. @@ -112,14 +161,21 @@ fn run_alarm_random_cycle_checks() { /// Entry routine for sample app one. /// -/// The routine sends `SIGUSR1` to app two twice (to tolerate startup ordering), -/// then validates additional emulator syscalls. +/// The routine sends a blocking IPC to app two, then sends `SIGUSR1` to +/// continue receiver-side checks, and finally validates other syscalls. pub fn run_sample_app_one(peer_label: u32) { let _ = peer_label; - // Let the receiver enter its blocking wait path before sending the signal. + // Let the receiver enter its blocking wait path before sending IPC. thread::sleep(Duration::from_millis(100)); + let st_copy_ipc = sentry_uapi::copy_to_kernel(&IPC_TEST_MESSAGE).unwrap_or(Status::Invalid); + if st_copy_ipc != Status::Ok { + report_status("copy_to_kernel(ipc payload)", st_copy_ipc); + } + let st_send_ipc = sentry_uapi::syscall::send_ipc(TARGET_APP_TWO_HANDLE, IPC_TEST_MESSAGE.len() as u8); + report_status("send_ipc(peer)", st_send_ipc); + let st_sig_peer = sentry_uapi::syscall::send_signal(TARGET_APP_TWO_HANDLE, Signal::Usr1); report_status("send_signal(peer, SIGUSR1)", st_sig_peer); @@ -131,9 +187,21 @@ pub fn run_sample_app_one(peer_label: u32) { /// Entry routine for sample app two. /// -/// The routine blocks on signal events until `SIGUSR1` is received, validates -/// signal event serialization, then runs the shared syscall checks. +/// The routine first blocks on IPC reception, then blocks on signal reception +/// until `SIGUSR1` is received, and finally runs the shared syscall checks. pub fn run_sample_app_two() { + loop { + let st_wait_ipc = sentry_uapi::syscall::wait_for_event(EventType::Ipc.into(), 0); + if st_wait_ipc != Status::Ok { + report_status("wait_for_event(ipc, no timeout)", st_wait_ipc); + continue; + } + if read_ipc_event_from_exchange(IPC_TEST_MESSAGE) { + report_status("wait_for_event(ipc, no timeout)", st_wait_ipc); + break; + } + } + // Wait without timeout and return only when SIGUSR1 has been serialized by daemon. loop { let st_wait_signal = sentry_uapi::syscall::wait_for_event(EventType::Signal.into(), 0); diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py index fb45af1e..00bf94b4 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py @@ -10,6 +10,7 @@ UINT32_MAX: Final[int] = (1 << 32) - 1 EXCHANGE_BUFFER_LEN: Final[int] = 128 SIGNAL_ABORT: Final[int] = 1 +EVENT_TYPE_IPC: Final[int] = 1 EVENT_TYPE_SIGNAL: Final[int] = 2 SIGNAL_USR2: Final[int] = 12 SIGNAL_ALARM: Final[int] = 2 @@ -26,5 +27,6 @@ STATUS_BUSY: Final[int] = 4 STATUS_TIMEOUT: Final[int] = 7 STATUS_AGAIN: Final[int] = 8 +STATUS_INTR: Final[int] = 9 MAX_PENDING_SIGNALS: Final[int] = 32 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py index 95968ab1..f4265d8a 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py @@ -20,6 +20,7 @@ DEFAULT_HOST, DEFAULT_PORT, EVENT_MAGIC, + EVENT_TYPE_IPC, EVENT_TYPE_SIGNAL, EXCHANGE_BUFFER_LEN, MAX_PENDING_SIGNALS, @@ -29,10 +30,11 @@ PRECISION_NANOSECONDS, SIGNAL_ALARM, STATUS_BUSY, + STATUS_INTR, STATUS_OK, UINT32_MAX, ) -from .models import AlarmRegistration, AppContext, StartSpec +from .models import AlarmRegistration, AppContext, PendingIPC, StartSpec @dataclass(slots=True) @@ -95,8 +97,14 @@ def deactivate_context(self, app_context: AppContext, exit_code: int) -> bool: app_context.exit_code = exit_code with app_context.event_condition: app_context.pending_signals.clear() + pending_ipcs = list(app_context.pending_ipcs) + app_context.pending_ipcs.clear() app_context.event_condition.notify_all() + for pending_ipc in pending_ipcs: + pending_ipc.completion_status = STATUS_INTR + pending_ipc.done.set() + for registration in app_context.alarms.values(): registration.timer.cancel() app_context.alarms.clear() @@ -246,6 +254,10 @@ def _terminate_started_apps(self) -> None: registration.timer.cancel() app_context.alarms.clear() app_context.pending_signals.clear() + for pending_ipc in app_context.pending_ipcs: + pending_ipc.completion_status = STATUS_INTR + pending_ipc.done.set() + app_context.pending_ipcs.clear() app_context.process = None self._contexts_by_label.clear() self._contexts_by_handle.clear() @@ -381,6 +393,42 @@ def queue_signal(self, target: AppContext, signal: int, source_handle: int) -> i ) return STATUS_OK + def queue_ipc(self, target: AppContext, source_handle: int, payload: bytes) -> PendingIPC: + """Queue an IPC event for one application context. + + Parameters + ---------- + target : AppContext + Destination app context receiving the IPC payload. + source_handle : int + Handle of the sender process. + payload : bytes + Sender payload copied from exchange memory. + + Returns + ------- + PendingIPC + In-flight IPC transfer token used by sender to wait for completion. + """ + pending = PendingIPC(source_handle=source_handle, payload=bytes(payload)) + with target.event_condition: + target.pending_ipcs.append(pending) + target.event_condition.notify_all() + self.logger.debug( + "Queued IPC source=%d for label=%d handle=%d payload_len=%d", + source_handle, + target.label, + target.handle, + len(payload), + ) + return pending + + @staticmethod + def complete_ipc(pending_ipc: PendingIPC, status: int = STATUS_OK) -> None: + """Resolve a pending IPC transfer with final sender status.""" + pending_ipc.completion_status = status + pending_ipc.done.set() + def _alarm_fire(self, label: int, delay_ms: int) -> None: """Deliver one alarm tick to the context identified by label. @@ -526,6 +574,14 @@ def current_cycle_value(self, precision: int) -> int: return elapsed_ns // 1_000_000 raise ValueError("invalid precision") + def _has_matching_signal(self, app_context: AppContext, mask: int) -> bool: + """Return whether one matching signal is immediately available.""" + return bool(mask & EVENT_TYPE_SIGNAL) and bool(app_context.pending_signals) + + def _has_matching_ipc(self, app_context: AppContext, mask: int) -> bool: + """Return whether one matching IPC event is immediately available.""" + return bool(mask & EVENT_TYPE_IPC) and bool(app_context.pending_ipcs) + def _dequeue_matching_signal( self, app_context: AppContext, mask: int ) -> tuple[int, int] | None: @@ -551,6 +607,16 @@ def _dequeue_matching_signal( return None return app_context.pending_signals.pop(0) + def _dequeue_matching_ipc(self, app_context: AppContext, mask: int) -> PendingIPC | None: + """Pop one pending IPC if the provided mask accepts IPC events.""" + if not (mask & EVENT_TYPE_IPC): + return None + + with app_context.event_condition: + if not app_context.pending_ipcs: + return None + return app_context.pending_ipcs.pop(0) + def _serialize_signal_event( self, app_context: AppContext, signal: int, source_handle: int ) -> None: @@ -571,6 +637,15 @@ def _serialize_signal_event( payload = int(signal).to_bytes(4, "little", signed=False) self.write_exchange_buffer(app_context, header + payload) + def _serialize_ipc_event( + self, app_context: AppContext, payload: bytes, source_handle: int + ) -> None: + """Serialize one IPC event into the app exchange buffer.""" + header = bytes([EVENT_TYPE_IPC, len(payload)]) + EVENT_MAGIC.to_bytes( + 2, "little" + ) + int(source_handle).to_bytes(4, "little", signed=False) + self.write_exchange_buffer(app_context, header + payload) + @property def bound_address(self) -> tuple[str, int]: """Return bound host and port for the running gRPC server. diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py index 19cdbeb1..5aa49b4c 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py @@ -46,6 +46,28 @@ class StartSpec: label: int +@dataclass(slots=True) +class PendingIPC: + """One in-flight IPC transfer waiting to be consumed by target. + + Attributes + ---------- + source_handle : int + Sender process handle. + payload : bytes + Copied payload emitted by sender from its exchange buffer. + completion_status : int | None + Final status returned to sender once delivery is resolved. + done : threading.Event + Event set when the transfer is consumed or aborted. + """ + + source_handle: int + payload: bytes + completion_status: int | None = None + done: threading.Event = field(default_factory=threading.Event) + + @dataclass(slots=True) class AppContext: """Runtime context associated with one started application. @@ -64,6 +86,8 @@ class AppContext: Shared 128-byte payload area used by exchange syscalls. pending_signals : list[tuple[int, int]] FIFO queue of ``(signal, source_handle)`` waiting to be consumed. + pending_ipcs : list[PendingIPC] + FIFO queue of IPC transfers waiting to be consumed. alarms : dict[int, AlarmRegistration] Registered alarms keyed by delay in milliseconds. event_condition : threading.Condition @@ -80,6 +104,7 @@ class AppContext: default_factory=lambda: bytearray(EXCHANGE_BUFFER_LEN) ) pending_signals: list[tuple[int, int]] = field(default_factory=list) + pending_ipcs: list[PendingIPC] = field(default_factory=list) alarms: dict[int, AlarmRegistration] = field(default_factory=dict) event_condition: threading.Condition = field(default_factory=threading.Condition) exit_code: int | None = None diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py index 09048e1d..0859305a 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py @@ -14,17 +14,20 @@ from ..protocol import ProtocolError, deserialize_request from .constants import ( EXCHANGE_BUFFER_LEN, - EVENT_TYPE_SIGNAL, PRECISION_CYCLE, PRECISION_MILLISECONDS, SIGNAL_ABORT, SIGNAL_USR2, STATUS_AGAIN, + STATUS_INTR, STATUS_INVALID, STATUS_OK, STATUS_TIMEOUT, ) +IPC_EVENT_HEADER_LEN = 8 +MAX_IPC_PAYLOAD_LEN = EXCHANGE_BUFFER_LEN - IPC_EVENT_HEADER_LEN + @dataclass(slots=True) class EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): @@ -159,6 +162,37 @@ def Dispatch(self, request: Any, context: grpc.ServicerContext) -> Any: status = self.daemon.queue_signal(target_context, signal, app_context.handle) return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if message.syscall == "send_ipc": + if len(message.args) < 2: + detail = "missing send_ipc arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_handle = int(message.args[0]) + ipc_len = int(message.args[1]) + if target_handle == app_context.handle: + return response_cls(status=STATUS_INVALID, detail="self ipc forbidden") + if ipc_len < 0 or ipc_len > MAX_IPC_PAYLOAD_LEN: + return response_cls(status=STATUS_INVALID, detail="invalid ipc length") + + target_context = self.daemon.context_for_handle(target_handle) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown target handle") + + payload = self.daemon.read_exchange_buffer(app_context)[:ipc_len] + pending_ipc = self.daemon.queue_ipc(target_context, app_context.handle, payload) + pending_ipc.done.wait() + + final_status = pending_ipc.completion_status + if final_status is None: + final_status = STATUS_INTR + return response_cls( + status=final_status, + detail="ok" if final_status == STATUS_OK else "interrupted", + ) + if message.syscall == "wait_for_event": if len(message.args) < 2: detail = "missing wait_for_event arguments" @@ -170,27 +204,55 @@ def Dispatch(self, request: Any, context: grpc.ServicerContext) -> Any: mask = int(message.args[0]) timeout = int(message.args[1]) - pending = self.daemon._dequeue_matching_signal(app_context, mask) - if pending is not None: - signal, source_handle = pending + pending_signal = self.daemon._dequeue_matching_signal(app_context, mask) + if pending_signal is not None: + signal, source_handle = pending_signal self.daemon._serialize_signal_event(app_context, signal, source_handle) return response_cls(status=STATUS_OK, detail="ok") + pending_ipc = self.daemon._dequeue_matching_ipc(app_context, mask) + if pending_ipc is not None: + self.daemon._serialize_ipc_event( + app_context, + pending_ipc.payload, + pending_ipc.source_handle, + ) + self.daemon.complete_ipc(pending_ipc) + return response_cls(status=STATUS_OK, detail="ok") + if timeout == -1: return response_cls(status=STATUS_AGAIN, detail="again") wait_timeout = None if timeout == 0 else max(0.0, timeout / 1000.0) with app_context.event_condition: has_event = app_context.event_condition.wait_for( - lambda: bool(app_context.pending_signals) and bool(mask & EVENT_TYPE_SIGNAL), + lambda: self.daemon._has_matching_signal(app_context, mask) + or self.daemon._has_matching_ipc(app_context, mask), timeout=wait_timeout, ) if not has_event: return response_cls(status=STATUS_TIMEOUT, detail="timeout") - signal, source_handle = app_context.pending_signals.pop(0) - self.daemon._serialize_signal_event(app_context, signal, source_handle) - return response_cls(status=STATUS_OK, detail="ok") + signal: int | None = None + source_handle: int | None = None + pending_ipc = None + if self.daemon._has_matching_signal(app_context, mask): + signal, source_handle = app_context.pending_signals.pop(0) + elif self.daemon._has_matching_ipc(app_context, mask): + pending_ipc = app_context.pending_ipcs.pop(0) + + if signal is not None and source_handle is not None: + self.daemon._serialize_signal_event(app_context, signal, source_handle) + return response_cls(status=STATUS_OK, detail="ok") + if pending_ipc is not None: + self.daemon._serialize_ipc_event( + app_context, + pending_ipc.payload, + pending_ipc.source_handle, + ) + self.daemon.complete_ipc(pending_ipc) + return response_cls(status=STATUS_OK, detail="ok") + return response_cls(status=STATUS_TIMEOUT, detail="timeout") if message.syscall == "alarm": if len(message.args) < 2: diff --git a/tools/sentry-emulator/tests/test_emulator.py b/tools/sentry-emulator/tests/test_emulator.py index 95c4da60..4787b7af 100644 --- a/tools/sentry-emulator/tests/test_emulator.py +++ b/tools/sentry-emulator/tests/test_emulator.py @@ -64,3 +64,8 @@ def test_cli_starts_sample_rust_apps_via_start() -> None: assert "App exited label=8" in completed.stderr assert "All startup tasks have terminated, stopping emulator daemon" in completed.stderr assert "Sentry emulator stopped" in completed.stderr + + # IPC path must be exercised by both sample applications. + assert "[sample-app-one:7] send_ipc(peer): Ok" in completed.stdout + assert "[sample-app-two:8] wait_for_event(ipc, no timeout): Ok" in completed.stdout + assert "[sample-app-two:8] ipc event received" in completed.stdout diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py index 7e8633ac..d3408ed6 100644 --- a/tools/sentry-emulator/tests/test_server.py +++ b/tools/sentry-emulator/tests/test_server.py @@ -377,3 +377,113 @@ def wait_blocking() -> None: stop_event.set() thread.join(timeout=2.0) assert not thread.is_alive() + + +def test_send_ipc_blocks_until_target_reads_event(tmp_path: pathlib.Path) -> None: + """Ensure send_ipc is blocking until target consumes IPC with wait_for_event.""" + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + + sender_self = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[7], label=7) + ) + assert sender_self.status == 0 + sender_self_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + sender_handle = int.from_bytes(sender_self_reply.payload[:4], "little") + assert sender_handle > 0 + + target_lookup = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[8], label=7) + ) + assert target_lookup.status == 0 + target_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + target_handle = int.from_bytes(target_reply.payload[:4], "little") + assert target_handle > 0 + + ipc_payload = b"hello-ipc-blocking" + to_kernel = stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="exchange_to_kernel", + label=7, + payload=ipc_payload, + ) + ) + assert to_kernel.status == 0 + + send_result: dict[str, object] = {} + + def send_ipc_blocking() -> None: + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as send_channel: + send_stub = emulator_pb2_grpc.EmulatorStub(send_channel) + send_result["response"] = send_stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="send_ipc", + args=[target_handle, len(ipc_payload)], + label=7, + ) + ) + + sender = threading.Thread(target=send_ipc_blocking, daemon=True) + sender.start() + time.sleep(0.05) + assert sender.is_alive() + + wait_ipc = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="wait_for_event", args=[1, 1000], label=8) + ) + assert wait_ipc.status == 0 + event_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=8) + ) + + sender.join(timeout=2.0) + assert not sender.is_alive() + assert "response" in send_result + assert send_result["response"].status == 0 + + payload = event_reply.payload + assert payload[0] == 1 + assert payload[1] == len(ipc_payload) + assert int.from_bytes(payload[2:4], "little") == 0x4242 + assert int.from_bytes(payload[4:8], "little") == sender_handle + assert payload[8 : 8 + len(ipc_payload)] == ipc_payload + + stop_event.set() + thread.join(timeout=2.0) + assert not thread.is_alive() From c43b41ea976295f692be65bf0c661dfa7b9b4a37 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 16:47:37 +0200 Subject: [PATCH 17/19] doc,concepts: adding emulator related documentation --- doc/concepts/emulator.rst | 309 ++++++++++++++++++++++++++++++++++++++ doc/concepts/index.rst | 1 + 2 files changed, 310 insertions(+) create mode 100644 doc/concepts/emulator.rst diff --git a/doc/concepts/emulator.rst b/doc/concepts/emulator.rst new file mode 100644 index 00000000..ce667b30 --- /dev/null +++ b/doc/concepts/emulator.rst @@ -0,0 +1,309 @@ +.. _emulator: + +Sentry Emulator +--------------- + +.. index:: + single: emulator; sentry-emulator + single: POSIX; uapi backend + + +Why an emulator? +^^^^^^^^^^^^^^^^ + +The Sentry kernel targets embedded microcontrollers (ARM Cortex-M, etc.), which +are ``no_std`` platforms with no host operating system. Developing and +validating Sentry applications on real hardware involves cross-compilation and +flashing cycles that slow iteration significantly. + +The Sentry emulator allows a Sentry application — written against the +``sentry-uapi`` crate — to run directly on a GNU/Linux x86_64 system *without +any embedded hardware*. It intercepts Sentry system calls and emulates them at +user level, providing: + +* a fast on-host development environment; +* the ability to write automated integration tests covering full end-to-end + application behaviour (IPC, signals, alarms, log, etc.); +* support for continuous integration pipelines that have no access to real + embedded hardware. + + +How the emulator works and how ``sentry-uapi`` integrates with it +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: gRPC; emulator + single: protobuf; emulator + +General architecture +"""""""""""""""""""" + +The emulator is a Python daemon that exposes a gRPC service on +``127.0.0.1:44044`` (configurable). The communication between an application +and the daemon is based on the Protobuf protocol defined under +``tools/sentry-emulator/proto/``. + +When an application compiled in POSIX mode +(``--target x86_64-unknown-linux-gnu`` with the ``std`` feature) issues a Sentry +system call, the POSIX backend of the ``sentry-uapi`` crate +(``uapi/src/posix.rs``) intercepts the call and: + +1. serialises the syscall name, its arguments, and the exchange buffer into a + Protobuf ``DispatchRequest`` message; +2. sends that message to the daemon over gRPC (``Dispatch`` RPC); +3. waits for the ``DispatchResponse`` that carries the Sentry status code and, + when applicable, the updated contents of the exchange buffer. + +The Python daemon receives requests, validates them, traces them in its logs, +and executes the corresponding emulation logic (blocking IPC, signal delivery, +alarm, etc.). Each started application is given a unique *context* identified +by its **label** (a ``u32`` integer). + +.. code-block:: none + :caption: Syscall flow through the emulator + + Rust application + │ + │ using sentry-uapi crate + ▼ + sentry-uapi Rust crate (in native GNU/Linux mode) + │ + │ via sentry-uapi gRPC backend (tonic/gRPC) + ▼ + sentry-emulator daemon (Python / gRPC) + │ + │ DispatchRequest { syscall, args, label, payload } + ▼ + EmulatorServicer.Dispatch() + │ + │ emulation logic + ▼ + DispatchResponse { status, detail, payload } + │ + ▼ + Rust application (syscall result) + +``sentry-uapi`` integration +"""""""""""""""""""""""""""" + +The POSIX backend is activated automatically when the crate is compiled for the +``x86_64-unknown-linux-gnu`` target with the ``std`` feature. Connection +parameters are read from the environment at application startup: + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Environment variable + - Default value + - Purpose + * - ``SENTRY_EMULATOR_HOST`` + - ``127.0.0.1`` + - Daemon listening address. + * - ``SENTRY_EMULATOR_PORT`` + - ``44044`` + - gRPC service port. + * - ``SENTRY_APP_LABEL`` + - ``0`` + - Application label registered in the emulator. + +These variables are set automatically by the daemon when it launches +applications via the ``--start`` option. + +Local syscalls that do not involve exchange with other tasks +(``sched_yield``, ``exit``, etc.) are handled locally by the POSIX backend +without reaching the daemon. + + +Building and installing the emulator as a Python package +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: installation; Python emulator + +The emulator is a standard Python package (``camelot.sentry-emulator``) +managed with ``setuptools`` and ``setuptools-scm``. + +Manual build +"""""""""""" + +From the ``tools/sentry-emulator/`` directory: + +.. code-block:: sh + + # Build the wheel and the sdist + python -m build + +Artifacts (``*.whl`` and ``*.tar.gz``) are written to ``dist/``. + +Installing into a virtual environment +"""""""""""""""""""""""""""""""""""""" + +.. code-block:: sh + + python -m venv .venv + source .venv/bin/activate + pip install dist/camelot.sentry_emulator-*.whl + +Once installed, the ``sentry-emulator`` command is available in the venv +``PATH``: + +.. code-block:: sh + + sentry-emulator --help + +Building via Meson +"""""""""""""""""" + +The Meson build system integrates the emulator build through the +``sentry-emulator-build`` custom target. It is triggered automatically during +the main build (``build_by_default: true``) and writes artifacts to: + +.. code-block:: none + + /tools/sentry-emulator/dist/ + + +Testing the emulator via Meson and the Rust sample applications +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: tests; emulator + single: sample-app-one; sample-app-two + +The two Rust sample applications +"""""""""""""""""""""""""""""""""" + +The ``tools/sentry-emulator/sample-rust-app/`` sub-project produces two +binaries, ``sample-app-one`` and ``sample-app-two``, that together form a +complete end-to-end validation scenario for the emulator: + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Binary + - Role + * - ``sample-app-one`` + - *Sender* — sends a blocking IPC to ``sample-app-two``, then emits a + signal and exercises the ``alarm``, ``random``, and ``get_cycle`` + syscalls. + * - ``sample-app-two`` + - *Receiver* — waits for the IPC via ``wait_for_event``, processes the + payload, handles the incoming signal, then runs the same shared + verification steps. + +Separating sender and receiver allows the set of inter-task communication +primitives (IPC, signals) that form the core of the Sentry execution model to +be tested in isolation. + +Building the sample applications +"""""""""""""""""""""""""""""""""" + +The binaries are compiled automatically by Meson via the +``sentry-emulator-sample-rust-app-build`` target and placed in: + +.. code-block:: none + + /tools/sentry-emulator/sample-rust-target/debug/sample-app-one + /tools/sentry-emulator/sample-rust-target/debug/sample-app-two + +To build them manually: + +.. code-block:: sh + + cd tools/sentry-emulator/sample-rust-app + cargo build + +Running end-to-end tests +""""""""""""""""""""""""" + +End-to-end tests are marked ``emulator`` in pytest and can be run in several +ways. + +With tox (from ``tools/sentry-emulator/``): + +.. code-block:: sh + + tox -e emulator + +With pytest directly (the sample applications must be built first): + +.. code-block:: sh + + pytest -m emulator + +The main test (``test_cli_starts_sample_rust_apps_via_start``): + +1. checks that both binaries are present in ``builddir``; +2. launches the ``camelot.sentry_emulator`` daemon with + ``--start sample-app-one,label=7`` and ``--start sample-app-two,label=8``; +3. waits for the daemon to terminate naturally (when all sample applications + have called ``exit``); +4. asserts that the process return code is ``0``. + +.. note:: + If the Rust binaries are not present in ``builddir``, the test is + automatically skipped (``pytest.skip``) with an explicit message. + + +Using the emulator with your own applications +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: usage; emulator + +Prerequisites +""""""""""""" + +* The emulator is installed (via pip or the Meson venv). +* The application is compiled for ``x86_64-unknown-linux-gnu`` with the ``std`` + feature of ``sentry-uapi``. + +Minimal example +""""""""""""""" + +Assume two applications ``my-task-a`` (label ``10``) and ``my-task-b`` +(label ``11``): + +.. code-block:: sh + + # Start the emulator and register the two tasks to launch + sentry-emulator \ + --log-level INFO \ + --start ./target/x86_64-unknown-linux-gnu/debug/my-task-a,label=10 \ + --start ./target/x86_64-unknown-linux-gnu/debug/my-task-b,label=11 + +The daemon will: + +1. start and listen on ``127.0.0.1:44044``; +2. launch ``my-task-a`` and ``my-task-b``, automatically injecting + ``SENTRY_APP_LABEL``, ``SENTRY_EMULATOR_HOST``, and + ``SENTRY_EMULATOR_PORT`` into their environment; +3. receive and process Sentry syscalls emitted by both tasks; +4. terminate cleanly once all launched tasks have called ``syscall::exit``. + +To run the daemon on its own (without supervised applications), for example +when tests start their own processes, simply omit the ``--start`` options: + +.. code-block:: sh + + sentry-emulator --host 127.0.0.1 --port 44044 --log-level DEBUG + +Verbosity levels +"""""""""""""""" + +.. list-table:: + :header-rows: 1 + :widths: 15 85 + + * - Level + - Information traced + * - ``INFO`` + - Daemon start/stop, application context lifecycle. + * - ``WARNING`` + - Invalid or malformed requests received by the daemon. + * - ``ERROR`` + - Invalid CLI arguments, application launch failures. + * - ``DEBUG`` + - Full detail of every gRPC request/response and task event. diff --git a/doc/concepts/index.rst b/doc/concepts/index.rst index f8699d92..c1ddbc68 100644 --- a/doc/concepts/index.rst +++ b/doc/concepts/index.rst @@ -15,6 +15,7 @@ Welcome to Sentry documentation! sw_architecture/index.rst tests/index.rst proof/index.rst + emulator.rst .. toctree:: :hidden: From 3461f314376d71d9caaae3c509b15f2600152c10 Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 17:04:09 +0200 Subject: [PATCH 18/19] emulator: fixing sample apps to never use std by herself adding emulator-backed sleep() support --- .../sample-rust-app/src/lib.rs | 7 ++-- .../emulator_server/servicer.py | 18 ++++++++++ uapi/src/posix.rs | 33 ++----------------- 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/tools/sentry-emulator/sample-rust-app/src/lib.rs b/tools/sentry-emulator/sample-rust-app/src/lib.rs index 89475287..e6083702 100644 --- a/tools/sentry-emulator/sample-rust-app/src/lib.rs +++ b/tools/sentry-emulator/sample-rust-app/src/lib.rs @@ -8,10 +8,8 @@ //! through `wait_for_event`, then handles the signal path and shared checks. use sentry_uapi::systypes::{ - AlarmFlag, EventType, Precision, Signal, Status, + AlarmFlag, EventType, Precision, Signal, SleepDuration, SleepMode, Status, }; -use std::thread; -use std::time::Duration; /// Copy a UTF-8 log message into exchange memory and emit it with `syscall::log`. fn emit_app_log(message: &str) { @@ -167,7 +165,8 @@ pub fn run_sample_app_one(peer_label: u32) { let _ = peer_label; // Let the receiver enter its blocking wait path before sending IPC. - thread::sleep(Duration::from_millis(100)); + let st_sleep = sentry_uapi::syscall::sleep(SleepDuration::ArbitraryMs(100), SleepMode::Deep); + report_status("sleep(100ms)", st_sleep); let st_copy_ipc = sentry_uapi::copy_to_kernel(&IPC_TEST_MESSAGE).unwrap_or(Status::Invalid); if st_copy_ipc != Status::Ok { diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py index 0859305a..99e87845 100644 --- a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import logging +import time from typing import Any import grpc @@ -254,6 +255,23 @@ def Dispatch(self, request: Any, context: grpc.ServicerContext) -> Any: return response_cls(status=STATUS_OK, detail="ok") return response_cls(status=STATUS_TIMEOUT, detail="timeout") + if message.syscall == "sleep": + if len(message.args) < 2: + detail = "missing sleep arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + duration_ms = int(message.args[0]) + if duration_ms < 0: + return response_cls(status=STATUS_INVALID, detail="invalid sleep duration") + + # In emulator mode, sleep completion is controlled by daemon-side timing. + # The RPC returns only when the requested duration has elapsed. + time.sleep(duration_ms / 1000.0) + return response_cls(status=STATUS_OK, detail="ok") + if message.syscall == "alarm": if len(message.args) < 2: detail = "missing alarm arguments" diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index 7005e54f..4a06e9d1 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -196,37 +196,8 @@ pub fn exit(status: i32) -> Status { } #[inline(always)] -pub fn sleep(_duration_ms: SleepDuration, _mode: SleepMode) -> Status { - match _duration_ms { - SleepDuration::D1ms => { - std::thread::sleep(std::time::Duration::from_millis(1)); - Status::Ok - } - SleepDuration::D2ms => { - std::thread::sleep(std::time::Duration::from_millis(2)); - Status::Ok - } - SleepDuration::D5ms => { - std::thread::sleep(std::time::Duration::from_millis(5)); - Status::Ok - } - SleepDuration::D10ms => { - std::thread::sleep(std::time::Duration::from_millis(10)); - Status::Ok - } - SleepDuration::D20ms => { - std::thread::sleep(std::time::Duration::from_millis(20)); - Status::Ok - } - SleepDuration::D50ms => { - std::thread::sleep(std::time::Duration::from_millis(50)); - Status::Ok - } - SleepDuration::ArbitraryMs(ms) => { - std::thread::sleep(std::time::Duration::from_millis(ms as u64)); - Status::Ok - } - } +pub fn sleep(duration_ms: SleepDuration, mode: SleepMode) -> Status { + forward_syscall("sleep", &[u32::from(duration_ms) as i128, u32::from(mode) as i128]) } #[inline(always)] From 008a2d15eccc887a75c5b6192ee106479b51d00e Mon Sep 17 00:00:00 2001 From: Philippe Thierry Date: Sun, 5 Apr 2026 17:21:31 +0200 Subject: [PATCH 19/19] uapi: fixing fmt and clippy warns --- uapi/Cargo.toml | 2 +- uapi/src/exchange.rs | 5 +++++ uapi/src/lib.rs | 5 +---- uapi/src/posix.rs | 13 +++++-------- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/uapi/Cargo.toml b/uapi/Cargo.toml index 63f82336..5b99c616 100644 --- a/uapi/Cargo.toml +++ b/uapi/Cargo.toml @@ -29,7 +29,7 @@ default = [] std = [] [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(CONFIG_BUILD_TARGET_AUTOTEST)'] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(CONFIG_BUILD_TARGET_AUTOTEST)', 'cfg(kani)'] } [profile.release] opt-level = "z" diff --git a/uapi/src/exchange.rs b/uapi/src/exchange.rs index 10990db2..baf65de2 100644 --- a/uapi/src/exchange.rs +++ b/uapi/src/exchange.rs @@ -188,6 +188,11 @@ impl ExchangeHeader { } #[cfg(test)] + /// # Safety + /// - `EXCHANGE_AREA` must be correctly aligned for `ExchangeHeader`. + /// - `EXCHANGE_AREA` must be large enough to contain a full `ExchangeHeader`. + /// - The caller must ensure exclusive mutable access to `EXCHANGE_AREA` + /// for the entire lifetime of the returned reference. pub unsafe fn from_exchange_mut(self) -> &'static mut Self { unsafe { self.from_addr_mut() } } diff --git a/uapi/src/lib.rs b/uapi/src/lib.rs index 62c3c99a..9dc81f9a 100644 --- a/uapi/src/lib.rs +++ b/uapi/src/lib.rs @@ -19,10 +19,7 @@ //! are implemented in the [`mod@syscall`] module, while the upper, easy interface //! is out of the scope of this very crate, and written in the shield crate instead. -#![cfg_attr( - not(all(target_arch = "x86_64", target_os = "linux")), - no_std -)] +#![cfg_attr(not(all(target_arch = "x86_64", target_os = "linux")), no_std)] /// Support for std in POSIX mode, only for linux/x86_64 targets #[cfg(all(target_arch = "x86_64", target_os = "linux"))] diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs index 4a06e9d1..c2c47aaf 100644 --- a/uapi/src/posix.rs +++ b/uapi/src/posix.rs @@ -23,7 +23,6 @@ /// will behave as a proxy to the server, forwarding the syscall number and arguments to the /// server and returning the server's response as the syscall result. /// - use crate::systypes::*; use std::sync::OnceLock; use tonic::codegen::http::uri::PathAndQuery; @@ -188,7 +187,6 @@ pub(crate) fn exchange_from_daemon(data: &mut [u8]) -> Status { } } - #[inline(always)] pub fn exit(status: i32) -> Status { let _ = forward_syscall("exit", &[status as i128]); @@ -197,7 +195,10 @@ pub fn exit(status: i32) -> Status { #[inline(always)] pub fn sleep(duration_ms: SleepDuration, mode: SleepMode) -> Status { - forward_syscall("sleep", &[u32::from(duration_ms) as i128, u32::from(mode) as i128]) + forward_syscall( + "sleep", + &[u32::from(duration_ms) as i128, u32::from(mode) as i128], + ) } #[inline(always)] @@ -261,11 +262,7 @@ pub fn unmap_shm(shm: ShmHandle) -> Status { } #[inline(always)] -pub fn shm_set_credential( - shm: ShmHandle, - id: TaskHandle, - shm_perm: u32, -) -> Status { +pub fn shm_set_credential(shm: ShmHandle, id: TaskHandle, shm_perm: u32) -> Status { forward_syscall( "shm_set_credential", &[shm as i128, id as i128, shm_perm as i128],