From 404a24dc806f5c53192cdf57a02987cea43dc443 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 20 Aug 2026 18:25:44 -0700 Subject: [PATCH 1/3] refactor(platform): migrate daemon IPC lifecycle Closes #1312 --- Cargo.lock | 5 +- Cargo.toml | 7 +- ci/enforce_platform_boundary.py | 22 ++- ci/platform_boundary_ledger.tsv | 13 -- ci/platform_boundary_research.py | 9 +- ci/platform_boundary_research.tsv | 45 +++-- ci/test_enforce_platform_boundary.py | 19 +- ci/test_platform_boundary_research.py | 22 +++ crates/fbuild-core/Cargo.toml | 6 + crates/fbuild-core/src/platform/README.md | 6 + crates/fbuild-core/src/platform/ipc.rs | 175 ++++++++++++++++ crates/fbuild-core/src/platform/linux/ipc.rs | 78 ++++++++ crates/fbuild-core/src/platform/linux/mod.rs | 1 + .../fbuild-core/src/platform/linux/process.rs | 6 + crates/fbuild-core/src/platform/macos/ipc.rs | 78 ++++++++ crates/fbuild-core/src/platform/macos/mod.rs | 1 + .../fbuild-core/src/platform/macos/process.rs | 6 + crates/fbuild-core/src/platform/process.rs | 8 + .../fbuild-core/src/platform/windows/ipc.rs | 147 ++++++++++++++ .../fbuild-core/src/platform/windows/mod.rs | 1 + .../src/platform/windows/process.rs | 50 +++++ crates/fbuild-daemon/Cargo.toml | 7 - crates/fbuild-daemon/src/broker/backend.rs | 24 +-- crates/fbuild-daemon/src/main.rs | 187 ++---------------- docs/architecture/portability.md | 3 +- docs/architecture/runtime.md | 6 + docs/platform-boundary-research-inventory.md | 43 +++- docs/platform-boundary-research.md | 20 ++ .../src/baseline.txt | 11 -- dylints/enforce_platform_boundary/src/lib.rs | 1 + 30 files changed, 747 insertions(+), 260 deletions(-) create mode 100644 crates/fbuild-core/src/platform/linux/ipc.rs create mode 100644 crates/fbuild-core/src/platform/macos/ipc.rs create mode 100644 crates/fbuild-core/src/platform/windows/ipc.rs diff --git a/Cargo.lock b/Cargo.lock index 940816451..ec45b467a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1189,6 +1189,7 @@ version = "2.5.19" dependencies = [ "async-trait", "fs2", + "interprocess", "libc", "prost", "reqwest", @@ -1197,11 +1198,13 @@ dependencies = [ "serde", "serde_json", "sha2", + "socket2", "tar", "tempfile", "thiserror 2.0.18", "tokio", "tracing", + "widestring", "windows-sys 0.52.0", "zstd", ] @@ -1225,7 +1228,6 @@ dependencies = [ "fbuild-serial", "fbuild-test-support", "futures", - "interprocess", "mimalloc", "prost", "regex", @@ -1235,7 +1237,6 @@ dependencies = [ "serde_json", "serialport", "sha2", - "socket2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 3d0090bcd..373f19136 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,9 +132,12 @@ bincode = "1" # to the version running-process 4.3.0 re-exports so the wire types stay # compatible. See crates/fbuild-daemon/src/broker. prost = "0.14" -# fbuild-daemon binds the running-process backend endpoint directly so the -# broker can verify and route to versioned daemon processes. +# fbuild-core's selected IPC implementations bind the private running-process +# backend endpoint and construct the daemon TCP listener. interprocess = "2.4.2" +socket2 = "0.6" +# Windows owner-only endpoint descriptors use static UTF-16 SDDL. +widestring = "1.2" rayon = "1" tracing-test = "0.2" # Terminal coloring for `fbuild build --shrink` reporting (FastLED/fbuild#493). diff --git a/ci/enforce_platform_boundary.py b/ci/enforce_platform_boundary.py index 0741c813e..ade4f0738 100644 --- a/ci/enforce_platform_boundary.py +++ b/ci/enforce_platform_boundary.py @@ -69,6 +69,18 @@ ), } ) +AUTHORIZED_BOUNDARY_FINDINGS.update( + { + ( + "crates/fbuild-core/\x43argo.toml", + "native_dependency", + dependency, + "ipc", + "host_mechanic", + ) + for dependency in ("interprocess", "socket2") + } +) LEDGER_KINDS = { "attr_cfg", "cfg_macro", @@ -81,14 +93,8 @@ # Pre-expansion rustc visits exactly one of these mutually exclusive cfg # bodies on any host, while the whole-tree scanner intentionally inventories # both. Keep this projection adjustment explicit and occurrence-specific. -DYLINT_HOST_EXCLUSIVE_ADJUSTMENTS = collections.Counter( - { - ( - "crates/fbuild-daemon/src/broker/backend.rs", - "native_import", - "interprocess", - ): 1, - } +DYLINT_HOST_EXCLUSIVE_ADJUSTMENTS: collections.Counter[tuple[str, str, str]] = ( + collections.Counter() ) diff --git a/ci/platform_boundary_ledger.tsv b/ci/platform_boundary_ledger.tsv index f202c51db..122e91ac8 100644 --- a/ci/platform_boundary_ledger.tsv +++ b/ci/platform_boundary_ledger.tsv @@ -19,22 +19,9 @@ crates/fbuild-cli/src/cli/usb_recovery.rs native_path windows_sys:: 2 process ho crates/fbuild-cli/src/cli/usb_recovery.rs native_path windows_sys:: 3 process host_mechanic crates/fbuild-config/src/bin/enrich_boards.rs attr_cfg #[cfg(not(windows))] 0 host host_mechanic crates/fbuild-config/src/bin/enrich_boards.rs attr_cfg #[cfg(windows)] 0 host host_mechanic -crates/fbuild-daemon/Cargo.toml native_dependency interprocess 0 process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs attr_cfg #[cfg(unix)] 0 ipc host_mechanic -crates/fbuild-daemon/src/broker/backend.rs attr_cfg #[cfg(windows)] 0 ipc host_mechanic -crates/fbuild-daemon/src/broker/backend.rs native_path interprocess::local_socket 0 process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs native_path interprocess::local_socket 1 process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs native_path interprocess::local_socket 2 process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs native_path interprocess::local_socket 3 process host_mechanic crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs attr_cfg #[cfg(windows)] 0 host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg #[cfg(not(windows))] 0 host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg #[cfg(windows)] 0 host_executable host_artifact_policy -crates/fbuild-daemon/src/main.rs attr_cfg #[cfg(not(windows))] 0 host host_mechanic -crates/fbuild-daemon/src/main.rs attr_cfg #[cfg(windows)] 0 host host_mechanic -crates/fbuild-daemon/src/main.rs attr_cfg #[cfg(windows)] 1 host host_mechanic -crates/fbuild-daemon/src/main.rs attr_cfg #[cfg(windows)] 2 host host_mechanic -crates/fbuild-daemon/src/main.rs attr_cfg #[cfg(windows)] 3 host host_mechanic -crates/fbuild-daemon/src/main.rs native_path std::os::windows::io::AsRawSocket 0 process host_mechanic crates/fbuild-deploy/src/lpc.rs attr_cfg #[cfg(not(target_os=))] 0 device host_mechanic crates/fbuild-deploy/src/lpc.rs attr_cfg #[cfg(not(windows))] 0 device host_mechanic crates/fbuild-deploy/src/lpc.rs attr_cfg #[cfg(target_os=)] 0 device host_mechanic diff --git a/ci/platform_boundary_research.py b/ci/platform_boundary_research.py index 91a7b6244..5dc5e7760 100644 --- a/ci/platform_boundary_research.py +++ b/ci/platform_boundary_research.py @@ -37,6 +37,7 @@ "mach2", "nix", "portable_pty", + "socket2", "winapi", "windows", "windows_sys", @@ -51,7 +52,7 @@ r"\bstd\s*::\s*os\s*::\s*(?:windows|unix|linux|macos)\b" r"(?:\s*::\s*[A-Za-z_][A-Za-z0-9_]*)*" ), - re.compile(r"\b(?:windows_sys|winapi|libc|mach2|nix|portable_pty)\s*::"), + re.compile(r"\b(?:windows_sys|winapi|libc|mach2|nix|portable_pty|socket2)\s*::"), re.compile(r"\bwindows\s*::\s*Win32\b"), re.compile(r"\binterprocess\s*::\s*os\s*::\s*(?:windows|unix)\b"), re.compile(r"\binterprocess\s*::\s*local_socket\b"), @@ -59,7 +60,7 @@ re.compile(r"\bwindows\s*::"), ) SINGLE_NATIVE_USE = re.compile( - r"\buse\s+(interprocess|libc|mach2|nix|portable_pty|winapi|windows|windows_sys)" + r"\buse\s+(interprocess|libc|mach2|nix|portable_pty|socket2|winapi|windows|windows_sys)" r"\s*(?:as\s+[A-Za-z_][A-Za-z0-9_]*\s*)?;" ) COMPILE_HOST_CONST = re.compile(r"\bstd\s*::\s*env\s*::\s*consts\s*::\s*(?:OS|ARCH)\b") @@ -210,6 +211,8 @@ def enclosing_function(text: str, offset: int) -> str: def classify(path: str, kind: str, normalized: str = "", context: str = "") -> tuple[str, str]: """Assign the phase-1 owner class; phase 2 validates this per occurrence.""" + if path.startswith("crates/fbuild-core/src/platform/") and path.endswith("/ipc.rs"): + return "ipc", "host_mechanic" if path == "crates/fbuild-core/\x43argo.toml": unix_table = "[target.'cfg(unix)'.dependencies]" windows_table = "[target.'cfg(windows)'.dependencies]" @@ -220,6 +223,8 @@ def classify(path: str, kind: str, normalized: str = "", context: str = "") -> t ("windows-sys", windows_table), }: return "fs", "host_mechanic" + if kind == "native_dependency" and normalized in {"interprocess", "socket2"}: + return "ipc", "host_mechanic" if kind in {"native_import", "native_path", "native_dependency"}: if normalized == "std::env::current_exe": return "host_executable", "host_mechanic" diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 4c63cb503..176725582 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -19,10 +19,12 @@ crates/fbuild-cli/src/cli/usb_recovery.rs 259 native_path windows_sys:: process crates/fbuild-cli/src/cli/usb_recovery.rs 262 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-config/src/bin/enrich_boards.rs 74 attr_cfg #[cfg(windows)] host host_mechanic crates/fbuild-config/src/bin/enrich_boards.rs 78 attr_cfg #[cfg(not(windows))] host host_mechanic -crates/fbuild-core/Cargo.toml 46 target_dependency_table [target.'cfg(unix)'.dependencies] fs host_mechanic -crates/fbuild-core/Cargo.toml 48 native_dependency libc fs host_mechanic -crates/fbuild-core/Cargo.toml 50 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic -crates/fbuild-core/Cargo.toml 53 native_dependency windows-sys fs host_mechanic +crates/fbuild-core/Cargo.toml 47 native_dependency interprocess ipc host_mechanic +crates/fbuild-core/Cargo.toml 48 native_dependency socket2 ipc host_mechanic +crates/fbuild-core/Cargo.toml 50 target_dependency_table [target.'cfg(unix)'.dependencies] fs host_mechanic +crates/fbuild-core/Cargo.toml 52 native_dependency libc fs host_mechanic +crates/fbuild-core/Cargo.toml 54 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic +crates/fbuild-core/Cargo.toml 57 native_dependency windows-sys fs host_mechanic crates/fbuild-core/src/platform/executable.rs 56 native_path std::env::current_exe host_executable host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 40 native_path std::os::unix::fs::symlink fs host_mechanic @@ -30,7 +32,12 @@ crates/fbuild-core/src/platform/linux/fs.rs 84 native_path std::os::unix::ffi::O crates/fbuild-core/src/platform/linux/fs.rs 89 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 91 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 101 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/mod.rs 9 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/linux/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic +crates/fbuild-core/src/platform/linux/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic +crates/fbuild-core/src/platform/linux/ipc.rs 3 native_path interprocess::os::unix ipc host_mechanic +crates/fbuild-core/src/platform/linux/ipc.rs 4 native_path socket2:: ipc host_mechanic +crates/fbuild-core/src/platform/linux/ipc.rs 66 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic +crates/fbuild-core/src/platform/linux/mod.rs 10 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/linux/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 40 native_path std::os::unix::fs::symlink fs host_mechanic @@ -38,7 +45,12 @@ crates/fbuild-core/src/platform/macos/fs.rs 84 native_path std::os::unix::ffi::O crates/fbuild-core/src/platform/macos/fs.rs 89 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 91 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 101 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/macos/mod.rs 9 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 3 native_path interprocess::os::unix ipc host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 4 native_path socket2:: ipc host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 66 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic +crates/fbuild-core/src/platform/macos/mod.rs 10 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/macos/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 2 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 3 native_path std::os::windows::fs fs host_mechanic @@ -47,24 +59,17 @@ crates/fbuild-core/src/platform/windows/fs.rs 50 native_path std::os::windows::f crates/fbuild-core/src/platform/windows/fs.rs 78 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 133 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 177 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/mod.rs 9 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/windows/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic +crates/fbuild-core/src/platform/windows/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic +crates/fbuild-core/src/platform/windows/ipc.rs 3 native_path interprocess::os::windows ipc host_mechanic +crates/fbuild-core/src/platform/windows/ipc.rs 4 native_path interprocess::os::windows ipc host_mechanic +crates/fbuild-core/src/platform/windows/ipc.rs 5 native_path socket2:: ipc host_mechanic +crates/fbuild-core/src/platform/windows/ipc.rs 6 native_path std::os::windows::io::AsRawSocket ipc host_mechanic +crates/fbuild-core/src/platform/windows/mod.rs 10 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/windows/process.rs 1 native_path std::os::windows::io::AsHandle process host_mechanic -crates/fbuild-daemon/Cargo.toml 58 native_dependency interprocess process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs 11 native_path interprocess::local_socket process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs 188 native_path interprocess::local_socket process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs 189 attr_cfg #[cfg(unix)] ipc host_mechanic -crates/fbuild-daemon/src/broker/backend.rs 195 native_path interprocess::local_socket process host_mechanic -crates/fbuild-daemon/src/broker/backend.rs 200 attr_cfg #[cfg(windows)] ipc host_mechanic -crates/fbuild-daemon/src/broker/backend.rs 202 native_path interprocess::local_socket process host_mechanic crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs 146 attr_cfg #[cfg(windows)] host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 9 attr_cfg #[cfg(windows)] host_executable host_artifact_policy crates/fbuild-daemon/src/handlers/emulator/tests_process.rs 21 attr_cfg #[cfg(not(windows))] host_executable host_artifact_policy -crates/fbuild-daemon/src/main.rs 319 attr_cfg #[cfg(windows)] host host_mechanic -crates/fbuild-daemon/src/main.rs 646 attr_cfg #[cfg(windows)] host host_mechanic -crates/fbuild-daemon/src/main.rs 652 attr_cfg #[cfg(not(windows))] host host_mechanic -crates/fbuild-daemon/src/main.rs 729 attr_cfg #[cfg(windows)] host host_mechanic -crates/fbuild-daemon/src/main.rs 731 native_path std::os::windows::io::AsRawSocket process host_mechanic -crates/fbuild-daemon/src/main.rs 776 attr_cfg #[cfg(windows)] host host_mechanic crates/fbuild-deploy/src/lpc.rs 27 attr_cfg #[cfg(target_os=)] device host_mechanic crates/fbuild-deploy/src/lpc.rs 31 attr_cfg #[cfg(not(target_os=))] device host_mechanic crates/fbuild-deploy/src/lpc.rs 972 attr_cfg #[cfg(windows)] device host_mechanic diff --git a/ci/test_enforce_platform_boundary.py b/ci/test_enforce_platform_boundary.py index 3eabd6153..cdfadf61a 100644 --- a/ci/test_enforce_platform_boundary.py +++ b/ci/test_enforce_platform_boundary.py @@ -14,7 +14,7 @@ def setUpClass(cls) -> None: cls.observed = boundary.rows_from_findings(boundary.research.inventory()) def test_committed_exact_occurrence_ledger_matches_whole_tree(self) -> None: - self.assertEqual(len(self.expected), 107) + self.assertEqual(len(self.expected), 94) self.assertFalse(boundary.validate_ledger(self.expected)) self.assertFalse(boundary.compare(self.expected, self.observed)) @@ -105,6 +105,23 @@ def test_rp2040_filesystem_mechanics_use_the_neutral_facade(self) -> None: for forbidden in ("AsRawHandle", "CancelSynchronousIo", ".raw_os_error()"): self.assertNotIn(forbidden, source) + def test_daemon_ipc_and_shutdown_use_neutral_facades(self) -> None: + backend = ( + boundary.ROOT / "crates/fbuild-daemon/src/broker/backend.rs" + ).read_text(encoding="utf-8") + main = (boundary.ROOT / "crates/fbuild-daemon/src/main.rs").read_text( + encoding="utf-8" + ) + + self.assertNotIn("interprocess", backend) + for forbidden in ( + "socket2", + "AsRawSocket", + "SetConsoleCtrlHandler", + "windows_console", + ): + self.assertNotIn(forbidden, main) + def test_executable_spelling_does_not_bypass_the_executable_facade(self) -> None: host_selected_exe = re.compile( r"if\s+(?:fbuild_core|crate)::platform::host::is_windows\(\)" diff --git a/ci/test_platform_boundary_research.py b/ci/test_platform_boundary_research.py index 545fbd947..17088ef4a 100644 --- a/ci/test_platform_boundary_research.py +++ b/ci/test_platform_boundary_research.py @@ -131,6 +131,28 @@ def test_core_native_dependency_ownership_requires_matching_target_table(self) - [("process", "host_mechanic"), ("fs", "host_mechanic")], ) + def test_core_ipc_dependencies_have_exact_ownership(self) -> None: + path = "crates/fbuild-core/\x43argo.toml" + for dependency in ("interprocess", "socket2"): + with self.subTest(dependency=dependency): + self.assertEqual( + platform_boundary_research.classify( + path, "native_dependency", dependency, "" + ), + ("ipc", "host_mechanic"), + ) + + def test_selected_ipc_implementation_has_exact_ownership(self) -> None: + self.assertEqual( + platform_boundary_research.classify( + "crates/fbuild-core/src/platform/windows/ipc.rs", + "native_path", + "socket2::", + "", + ), + ("ipc", "host_mechanic"), + ) + def test_mixed_qemu_permissions_are_migrated_but_context_stays_classified(self) -> None: path = platform_boundary_research.ROOT / "crates/fbuild-toolchain/src/toolchain/esp_qemu.rs" findings = platform_boundary_research.scan_rust(path, platform_boundary_research.ROOT) diff --git a/crates/fbuild-core/Cargo.toml b/crates/fbuild-core/Cargo.toml index 02f36a050..2b440c32a 100644 --- a/crates/fbuild-core/Cargo.toml +++ b/crates/fbuild-core/Cargo.toml @@ -42,6 +42,10 @@ fs2 = { workspace = true } # Stable cross-platform file identity handles; construction remains confined # to the selected filesystem implementation. same-file = { workspace = true } +# Local endpoint transport and native TCP listener construction are confined +# to the selected `platform::ipc` implementations. +interprocess = { workspace = true } +socket2 = { workspace = true } [target.'cfg(unix)'.dependencies] # Selected Unix filesystem mechanics query the exact mount containing a path. @@ -51,6 +55,8 @@ libc = { workspace = true } # Selected Windows filesystem mechanics: removable-volume facts and retiring # a kernel-blocked output handle after its watchdog expires. windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO"] } +# Selected Windows IPC creates a protected owner-only pipe descriptor. +widestring = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/fbuild-core/src/platform/README.md b/crates/fbuild-core/src/platform/README.md index 2272fc12d..c96314132 100644 --- a/crates/fbuild-core/src/platform/README.md +++ b/crates/fbuild-core/src/platform/README.md @@ -17,3 +17,9 @@ link/reparse classification, volume facts, native error classification, shared destination opening, atomic replacement, and blocked-I/O retirement. Cache sizing, archive traversal, authorization, locking, diagnostics, and retry policy remain with their product owners. + +`ipc` owns fbuild local-endpoint bind/connect/accept and peer facts, owner-only +Unix endpoint creation, TCP listener socket policy, and endpoint readiness +probing. Broker framing/routing, daemon retry/yield policy, and HTTP/protobuf +compatibility remain with the daemon. Additional native shutdown notifications +route through `process` into the daemon's neutral shutdown channel. diff --git a/crates/fbuild-core/src/platform/ipc.rs b/crates/fbuild-core/src/platform/ipc.rs index 1ac3185ab..4653897cc 100644 --- a/crates/fbuild-core/src/platform/ipc.rs +++ b/crates/fbuild-core/src/platform/ipc.rs @@ -1 +1,176 @@ //! Neutral fbuild-owned IPC endpoint and peer APIs. + +use std::io::{Read, Write}; +use std::time::Duration; + +/// Host-neutral facts about the process connected to a local endpoint. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PeerFacts { + pub pid: Option, + pub user_id: Option, + pub group_id: Option, +} + +/// Failure while constructing one native TCP listener. +#[derive(Debug, thiserror::Error)] +pub enum TcpListenerError { + /// The endpoint is currently unavailable; daemon policy may probe/retry it. + #[error(transparent)] + Bind(std::io::Error), + /// Listener construction failed outside the retryable bind operation. + #[error("failed to {operation}: {source}")] + Setup { + operation: &'static str, + #[source] + source: std::io::Error, + }, +} + +impl TcpListenerError { + pub(crate) fn setup(operation: &'static str, source: std::io::Error) -> Self { + Self::Setup { operation, source } + } +} + +/// An fbuild-owned local endpoint listener. +pub struct LocalListener { + inner: super::selected::ipc::LocalListener, +} + +impl LocalListener { + /// Bind an owner-private local endpoint using the host's native transport. + pub fn bind(endpoint: &str) -> std::io::Result { + super::selected::ipc::bind_local(endpoint).map(|inner| Self { inner }) + } + + pub fn accept(&self) -> std::io::Result { + super::selected::ipc::accept(&self.inner).map(|inner| LocalStream { inner }) + } + + pub fn incoming(&self) -> Incoming<'_> { + Incoming { listener: self } + } +} + +/// Infinite iterator over clients accepted by a [`LocalListener`]. +pub struct Incoming<'a> { + listener: &'a LocalListener, +} + +impl Iterator for Incoming<'_> { + type Item = std::io::Result; + + fn next(&mut self) -> Option { + Some(self.listener.accept()) + } +} + +/// A byte stream connected to an fbuild-owned local endpoint. +pub struct LocalStream { + inner: super::selected::ipc::LocalStream, +} + +impl LocalStream { + pub fn peer_facts(&self) -> std::io::Result { + super::selected::ipc::peer_facts(&self.inner) + } +} + +impl Read for LocalStream { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.inner.read(buffer) + } +} + +impl Write for LocalStream { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.inner.write(buffer) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +/// Connect to an fbuild-owned local endpoint. +pub fn connect(endpoint: &str) -> std::io::Result { + super::selected::ipc::connect_local(endpoint).map(|inner| LocalStream { inner }) +} + +/// Build one host-configured TCP listener. Retry and ownership policy remain +/// with the daemon caller. +pub fn bind_tcp_listener( + address: std::net::SocketAddr, +) -> Result { + super::selected::ipc::bind_tcp_listener(address) +} + +/// Return whether a TCP endpoint accepts a connection within `timeout`. +pub async fn tcp_endpoint_ready(address: std::net::SocketAddr, timeout: Duration) -> bool { + matches!( + tokio::time::timeout(timeout, tokio::net::TcpStream::connect(address)).await, + Ok(Ok(_)) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::time::Duration; + + fn unique_endpoint() -> String { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + if crate::platform::host::is_windows() { + format!("fbuild-platform-ipc-{}-{nonce}", std::process::id()) + } else { + std::env::temp_dir() + .join(format!( + "fbuild-platform-ipc-{}-{nonce}.sock", + std::process::id() + )) + .to_string_lossy() + .into_owned() + } + } + + #[test] + fn local_endpoint_round_trip_and_peer_facts() { + let endpoint = unique_endpoint(); + let listener = LocalListener::bind(&endpoint).expect("bind local endpoint"); + let server = std::thread::spawn(move || { + let mut stream = listener.accept().expect("accept local client"); + let facts = stream.peer_facts().expect("query peer facts"); + if !crate::platform::host::is_macos() { + assert_eq!(facts.pid, Some(std::process::id())); + } + if crate::platform::host::is_unix() { + assert!(facts.user_id.is_some()); + } + let mut request = [0_u8; 4]; + stream.read_exact(&mut request).expect("read request"); + assert_eq!(&request, b"ping"); + stream.write_all(b"pong").expect("write response"); + }); + + let mut client = connect(&endpoint).expect("connect local endpoint"); + client.write_all(b"ping").expect("write request"); + let mut response = [0_u8; 4]; + client.read_exact(&mut response).expect("read response"); + assert_eq!(&response, b"pong"); + server.join().expect("server thread"); + } + + #[tokio::test] + async fn tcp_readiness_distinguishes_live_and_free_endpoints() { + let listener = + bind_tcp_listener("127.0.0.1:0".parse().unwrap()).expect("bind ephemeral listener"); + let address = listener.local_addr().expect("listener address"); + assert!(tcp_endpoint_ready(address, Duration::from_millis(500)).await); + drop(listener); + assert!(!tcp_endpoint_ready(address, Duration::from_millis(100)).await); + } +} diff --git a/crates/fbuild-core/src/platform/linux/ipc.rs b/crates/fbuild-core/src/platform/linux/ipc.rs new file mode 100644 index 000000000..339b83c6c --- /dev/null +++ b/crates/fbuild-core/src/platform/linux/ipc.rs @@ -0,0 +1,78 @@ +use interprocess::local_socket::prelude::*; +use interprocess::local_socket::{GenericFilePath, ListenerOptions}; +use interprocess::os::unix::local_socket::ListenerOptionsExt; +use socket2::{Domain, Protocol, Socket, Type}; + +pub(crate) type LocalListener = LocalSocketListener; +pub(crate) type LocalStream = LocalSocketStream; + +pub(crate) fn bind_local(endpoint: &str) -> std::io::Result { + if let Some(parent) = std::path::Path::new(endpoint).parent() { + std::fs::create_dir_all(parent)?; + } + let _ = std::fs::remove_file(endpoint); + let name = endpoint.to_fs_name::()?; + ListenerOptions::new().name(name).mode(0o600).create_sync() +} + +pub(crate) fn connect_local(endpoint: &str) -> std::io::Result { + let name = endpoint.to_fs_name::()?; + LocalSocketStream::connect(name) +} + +pub(crate) fn accept(listener: &LocalListener) -> std::io::Result { + listener.accept() +} + +pub(crate) fn peer_facts(stream: &LocalStream) -> std::io::Result { + let credentials = stream.peer_creds()?; + Ok(super::super::ipc::PeerFacts { + pid: credentials.pid().and_then(|pid| u32::try_from(pid).ok()), + user_id: credentials.euid(), + group_id: credentials.egid(), + }) +} + +pub(crate) fn bind_tcp_listener( + address: std::net::SocketAddr, +) -> Result { + use super::super::ipc::TcpListenerError; + let domain = if address.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 + }; + let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP)) + .map_err(|source| TcpListenerError::setup("create TCP socket", source))?; + if let Err(error) = socket.set_reuse_address(true) { + tracing::warn!("failed to set SO_REUSEADDR: {error}"); + } + if let Err(error) = socket.set_linger(Some(std::time::Duration::ZERO)) { + tracing::warn!("failed to set SO_LINGER=0 on listener: {error}"); + } + socket + .set_nonblocking(true) + .map_err(|source| TcpListenerError::setup("set TCP listener nonblocking", source))?; + socket.bind(&address.into()).map_err(TcpListenerError::Bind)?; + socket + .listen(128) + .map_err(|source| TcpListenerError::setup("listen on TCP endpoint", source))?; + tokio::net::TcpListener::from_std(socket.into()) + .map_err(|source| TcpListenerError::setup("convert TCP listener to Tokio", source)) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + #[test] + fn local_endpoint_is_owner_only() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = directory.path().join("owner-only.sock"); + let endpoint_text = endpoint.to_string_lossy(); + let listener = super::bind_local(&endpoint_text).unwrap(); + let mode = std::fs::metadata(&endpoint).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0); + drop(listener); + } +} diff --git a/crates/fbuild-core/src/platform/linux/mod.rs b/crates/fbuild-core/src/platform/linux/mod.rs index 3d6c81fab..e81bca903 100644 --- a/crates/fbuild-core/src/platform/linux/mod.rs +++ b/crates/fbuild-core/src/platform/linux/mod.rs @@ -1,6 +1,7 @@ use super::host::{HostArch, HostOs}; pub(super) mod fs; +pub(super) mod ipc; pub(super) mod process; pub(super) const HOST_OS: HostOs = HostOs::Linux; diff --git a/crates/fbuild-core/src/platform/linux/process.rs b/crates/fbuild-core/src/platform/linux/process.rs index ca2ecb616..b2f7edba9 100644 --- a/crates/fbuild-core/src/platform/linux/process.rs +++ b/crates/fbuild-core/src/platform/linux/process.rs @@ -4,6 +4,12 @@ use std::os::fd::AsFd; use crate::path::NormalizedPath; use crate::platform::process::{DetachedEnvironment, Termination}; +pub(crate) fn register_daemon_shutdown_handler( + _shutdown_tx: tokio::sync::watch::Sender, +) -> std::io::Result<()> { + Ok(()) +} + pub(crate) fn configure_tokio_owner_death( command: &mut tokio::process::Command, ) -> std::io::Result<()> { diff --git a/crates/fbuild-core/src/platform/macos/ipc.rs b/crates/fbuild-core/src/platform/macos/ipc.rs new file mode 100644 index 000000000..339b83c6c --- /dev/null +++ b/crates/fbuild-core/src/platform/macos/ipc.rs @@ -0,0 +1,78 @@ +use interprocess::local_socket::prelude::*; +use interprocess::local_socket::{GenericFilePath, ListenerOptions}; +use interprocess::os::unix::local_socket::ListenerOptionsExt; +use socket2::{Domain, Protocol, Socket, Type}; + +pub(crate) type LocalListener = LocalSocketListener; +pub(crate) type LocalStream = LocalSocketStream; + +pub(crate) fn bind_local(endpoint: &str) -> std::io::Result { + if let Some(parent) = std::path::Path::new(endpoint).parent() { + std::fs::create_dir_all(parent)?; + } + let _ = std::fs::remove_file(endpoint); + let name = endpoint.to_fs_name::()?; + ListenerOptions::new().name(name).mode(0o600).create_sync() +} + +pub(crate) fn connect_local(endpoint: &str) -> std::io::Result { + let name = endpoint.to_fs_name::()?; + LocalSocketStream::connect(name) +} + +pub(crate) fn accept(listener: &LocalListener) -> std::io::Result { + listener.accept() +} + +pub(crate) fn peer_facts(stream: &LocalStream) -> std::io::Result { + let credentials = stream.peer_creds()?; + Ok(super::super::ipc::PeerFacts { + pid: credentials.pid().and_then(|pid| u32::try_from(pid).ok()), + user_id: credentials.euid(), + group_id: credentials.egid(), + }) +} + +pub(crate) fn bind_tcp_listener( + address: std::net::SocketAddr, +) -> Result { + use super::super::ipc::TcpListenerError; + let domain = if address.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 + }; + let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP)) + .map_err(|source| TcpListenerError::setup("create TCP socket", source))?; + if let Err(error) = socket.set_reuse_address(true) { + tracing::warn!("failed to set SO_REUSEADDR: {error}"); + } + if let Err(error) = socket.set_linger(Some(std::time::Duration::ZERO)) { + tracing::warn!("failed to set SO_LINGER=0 on listener: {error}"); + } + socket + .set_nonblocking(true) + .map_err(|source| TcpListenerError::setup("set TCP listener nonblocking", source))?; + socket.bind(&address.into()).map_err(TcpListenerError::Bind)?; + socket + .listen(128) + .map_err(|source| TcpListenerError::setup("listen on TCP endpoint", source))?; + tokio::net::TcpListener::from_std(socket.into()) + .map_err(|source| TcpListenerError::setup("convert TCP listener to Tokio", source)) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + #[test] + fn local_endpoint_is_owner_only() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = directory.path().join("owner-only.sock"); + let endpoint_text = endpoint.to_string_lossy(); + let listener = super::bind_local(&endpoint_text).unwrap(); + let mode = std::fs::metadata(&endpoint).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0); + drop(listener); + } +} diff --git a/crates/fbuild-core/src/platform/macos/mod.rs b/crates/fbuild-core/src/platform/macos/mod.rs index 01ebe258b..c8d721be2 100644 --- a/crates/fbuild-core/src/platform/macos/mod.rs +++ b/crates/fbuild-core/src/platform/macos/mod.rs @@ -1,6 +1,7 @@ use super::host::{HostArch, HostOs}; pub(super) mod fs; +pub(super) mod ipc; pub(super) mod process; pub(super) const HOST_OS: HostOs = HostOs::Macos; diff --git a/crates/fbuild-core/src/platform/macos/process.rs b/crates/fbuild-core/src/platform/macos/process.rs index 4c2d07c45..006cbc40c 100644 --- a/crates/fbuild-core/src/platform/macos/process.rs +++ b/crates/fbuild-core/src/platform/macos/process.rs @@ -4,6 +4,12 @@ use std::os::fd::AsFd; use crate::path::NormalizedPath; use crate::platform::process::{DetachedEnvironment, Termination}; +pub(crate) fn register_daemon_shutdown_handler( + _shutdown_tx: tokio::sync::watch::Sender, +) -> std::io::Result<()> { + Ok(()) +} + pub(crate) fn configure_tokio_owner_death( command: &mut tokio::process::Command, ) -> std::io::Result<()> { diff --git a/crates/fbuild-core/src/platform/process.rs b/crates/fbuild-core/src/platform/process.rs index a8c375f8a..437aa5bf8 100644 --- a/crates/fbuild-core/src/platform/process.rs +++ b/crates/fbuild-core/src/platform/process.rs @@ -213,6 +213,14 @@ pub fn exit_code(status: ExitStatus) -> i32 { super::selected::process::exit_code(status) } +/// Bridge native daemon-shutdown notifications into the shared Tokio watch +/// channel. Hosts without an additional native notification source are a no-op. +pub fn register_daemon_shutdown_handler( + shutdown_tx: tokio::sync::watch::Sender, +) -> std::io::Result<()> { + super::selected::process::register_daemon_shutdown_handler(shutdown_tx) +} + /// Build the host-correct child environment while preserving caller overlays. pub(crate) fn command_environment( program: &str, diff --git a/crates/fbuild-core/src/platform/windows/ipc.rs b/crates/fbuild-core/src/platform/windows/ipc.rs new file mode 100644 index 000000000..0822e11b3 --- /dev/null +++ b/crates/fbuild-core/src/platform/windows/ipc.rs @@ -0,0 +1,147 @@ +use interprocess::local_socket::prelude::*; +use interprocess::local_socket::{GenericNamespaced, ListenerOptions}; +use interprocess::os::windows::local_socket::ListenerOptionsExt; +use interprocess::os::windows::security_descriptor::SecurityDescriptor; +use socket2::{Domain, Protocol, Socket, Type}; +use std::os::windows::io::AsRawSocket; + +pub(crate) type LocalListener = LocalSocketListener; +pub(crate) type LocalStream = LocalSocketStream; + +pub(crate) fn bind_local(endpoint: &str) -> std::io::Result { + let name = endpoint.to_ns_name::()?; + let security = SecurityDescriptor::deserialize(widestring::u16cstr!( + "D:P(A;;GA;;;OW)" + ))?; + ListenerOptions::new() + .name(name) + .security_descriptor(security) + .create_sync() +} + +pub(crate) fn connect_local(endpoint: &str) -> std::io::Result { + let name = endpoint.to_ns_name::()?; + LocalSocketStream::connect(name) +} + +pub(crate) fn accept(listener: &LocalListener) -> std::io::Result { + listener.accept() +} + +pub(crate) fn peer_facts(stream: &LocalStream) -> std::io::Result { + let credentials = stream.peer_creds()?; + Ok(super::super::ipc::PeerFacts { + pid: credentials.pid(), + user_id: None, + group_id: None, + }) +} + +pub(crate) fn bind_tcp_listener( + address: std::net::SocketAddr, +) -> Result { + use super::super::ipc::TcpListenerError; + let domain = if address.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 + }; + let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP)) + .map_err(|source| TcpListenerError::setup("create TCP socket", source))?; + if let Err(error) = set_exclusive_address(&socket) { + tracing::warn!("failed to set SO_EXCLUSIVEADDRUSE: {error}"); + } + if let Err(error) = socket.set_linger(Some(std::time::Duration::ZERO)) { + tracing::warn!("failed to set SO_LINGER=0 on listener: {error}"); + } + socket + .set_nonblocking(true) + .map_err(|source| TcpListenerError::setup("set TCP listener nonblocking", source))?; + socket.bind(&address.into()).map_err(TcpListenerError::Bind)?; + socket + .listen(128) + .map_err(|source| TcpListenerError::setup("listen on TCP endpoint", source))?; + tokio::net::TcpListener::from_std(socket.into()) + .map_err(|source| TcpListenerError::setup("convert TCP listener to Tokio", source)) +} + +fn set_exclusive_address(socket: &Socket) -> std::io::Result<()> { + const SOL_SOCKET: i32 = 0xFFFF; + const SO_EXCLUSIVEADDRUSE: i32 = !0x0004; + + type SocketHandle = usize; + #[link(name = "ws2_32")] + extern "system" { + fn setsockopt( + socket: SocketHandle, + level: i32, + option_name: i32, + option_value: *const u8, + option_length: i32, + ) -> i32; + } + + let enabled = 1_i32; + // SAFETY: the socket is live and `enabled` remains readable for the exact + // byte length supplied to Winsock. + let result = unsafe { + setsockopt( + socket.as_raw_socket() as SocketHandle, + SOL_SOCKET, + SO_EXCLUSIVEADDRUSE, + (&enabled as *const i32).cast(), + std::mem::size_of::() as i32, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(test)] +mod tests { + type Handle = *mut std::ffi::c_void; + + #[test] + fn anonymous_identity_cannot_connect_to_owner_only_endpoint() { + let endpoint = format!( + "fbuild-platform-ipc-owner-only-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let _listener = super::bind_local(&endpoint).unwrap(); + let result = std::thread::spawn(move || { + // SAFETY: GetCurrentThread returns the calling thread's valid + // pseudo-handle, accepted by ImpersonateAnonymousToken. + let impersonated = unsafe { ImpersonateAnonymousToken(GetCurrentThread()) }; + assert_ne!(impersonated, 0, "anonymous impersonation failed"); + let connect = super::connect_local(&endpoint).map(|_| ()); + // SAFETY: this thread successfully entered impersonation above and + // reverts itself before it exits. + let reverted = unsafe { RevertToSelf() }; + assert_ne!(reverted, 0, "failed to revert anonymous impersonation"); + connect + }) + .join() + .unwrap(); + + let error = result.expect_err("anonymous identity must not open owner-only endpoint"); + assert_eq!(error.raw_os_error(), Some(5)); + } + + #[link(name = "kernel32")] + extern "system" { + fn GetCurrentThread() -> Handle; + } + + #[link(name = "advapi32")] + extern "system" { + fn ImpersonateAnonymousToken(thread_handle: Handle) -> i32; + fn RevertToSelf() -> i32; + } +} diff --git a/crates/fbuild-core/src/platform/windows/mod.rs b/crates/fbuild-core/src/platform/windows/mod.rs index ca2db3fe7..dafb5bf8d 100644 --- a/crates/fbuild-core/src/platform/windows/mod.rs +++ b/crates/fbuild-core/src/platform/windows/mod.rs @@ -1,6 +1,7 @@ use super::host::{HostArch, HostOs}; pub(super) mod fs; +pub(super) mod ipc; pub(super) mod process; pub(super) const HOST_OS: HostOs = HostOs::Windows; diff --git a/crates/fbuild-core/src/platform/windows/process.rs b/crates/fbuild-core/src/platform/windows/process.rs index bb0b8ca31..a9711c9fe 100644 --- a/crates/fbuild-core/src/platform/windows/process.rs +++ b/crates/fbuild-core/src/platform/windows/process.rs @@ -16,6 +16,39 @@ unsafe impl Send for JobHandle {} unsafe impl Sync for JobHandle {} static TOKIO_JOB: OnceLock = OnceLock::new(); +static SHUTDOWN_TX: OnceLock> = OnceLock::new(); + +pub(crate) fn register_daemon_shutdown_handler( + shutdown_tx: tokio::sync::watch::Sender, +) -> std::io::Result<()> { + let _ = SHUTDOWN_TX.set(shutdown_tx); + // SAFETY: the callback has the required system ABI and process lifetime. + let registered = unsafe { SetConsoleCtrlHandler(Some(console_ctrl_handler), 1) }; + if registered == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +unsafe extern "system" fn console_ctrl_handler(control_type: u32) -> i32 { + if is_shutdown_control_event(control_type) { + if let Some(shutdown_tx) = SHUTDOWN_TX.get() { + let _ = shutdown_tx.send(true); + // Windows gives close handlers about five seconds and shutdown + // handlers longer. Hold the native callback while the neutral + // daemon shutdown path runs; normal process exit cuts this short. + std::thread::sleep(std::time::Duration::from_millis(3500)); + } + 1 + } else { + 0 + } +} + +const fn is_shutdown_control_event(control_type: u32) -> bool { + matches!(control_type, 2 | 5 | 6) +} pub(crate) fn configure_tokio_owner_death( _command: &mut tokio::process::Command, @@ -278,6 +311,10 @@ struct JobObjectExtendedLimitInformation { #[link(name = "kernel32")] extern "system" { + fn SetConsoleCtrlHandler( + handler_routine: Option i32>, + add: i32, + ) -> i32; fn CreateJobObjectW(security_attrs: Handle, name: *const u16) -> Handle; fn SetInformationJobObject( job: Handle, @@ -297,3 +334,16 @@ extern "system" { ) -> i32; fn TerminateProcess(handle: Handle, exit_code: u32) -> i32; } + +#[cfg(test)] +mod shutdown_handler_tests { + #[test] + fn only_close_logoff_and_shutdown_are_bridged() { + for event in [2, 5, 6] { + assert!(super::is_shutdown_control_event(event)); + } + for event in [0, 1, 3, 4, 7] { + assert!(!super::is_shutdown_control_event(event)); + } + } +} diff --git a/crates/fbuild-daemon/Cargo.toml b/crates/fbuild-daemon/Cargo.toml index 7cfcae889..cd9d044e9 100644 --- a/crates/fbuild-daemon/Cargo.toml +++ b/crates/fbuild-daemon/Cargo.toml @@ -55,17 +55,10 @@ bytes = { workspace = true } # folded in from the former standalone fbuild-broker crate (FastLED/fbuild#560). running-process = { workspace = true } prost = { workspace = true } -interprocess = { workspace = true } futures = { workspace = true } regex = { workspace = true } async-trait = { workspace = true } tempfile = { workspace = true } -# socket2 used in main.rs to set SO_REUSEADDR (Unix) / -# SO_EXCLUSIVEADDRUSE (Windows) before binding the daemon's TCP listener, -# so the daemon can recover when a previous (crashed) instance left -# CLOSE_WAIT/TIME_WAIT entries on the port without permitting port hijack -# on Windows. See ISSUES.md "Issue B5a". -socket2 = "0.6" [dependencies.mimalloc] workspace = true diff --git a/crates/fbuild-daemon/src/broker/backend.rs b/crates/fbuild-daemon/src/broker/backend.rs index 21b3a5a73..6d27fab62 100644 --- a/crates/fbuild-daemon/src/broker/backend.rs +++ b/crates/fbuild-daemon/src/broker/backend.rs @@ -8,7 +8,7 @@ use std::io::{Read, Write}; use std::sync::Arc; -use interprocess::local_socket::prelude::*; +use fbuild_core::platform::ipc::LocalListener; use prost::Message; use running_process::broker::backend_lifecycle::DaemonProcess; use running_process::broker::backend_lifecycle::probe::{ @@ -58,7 +58,7 @@ fn serve_backend_endpoint( ctx: Arc, ) -> Result<(), BackendEndpointError> { let daemon = DaemonProcess::current_process(endpoint.clone(), Some(30))?; - let listener = bind_local_socket(&endpoint.path)?; + let listener = LocalListener::bind(&endpoint.path)?; tracing::info!("serving running-process backend endpoint {}", endpoint.path); for stream in listener.incoming() { @@ -185,26 +185,6 @@ fn write_frame_bytes(writer: &mut W, frame: &Frame) -> Result<(), Back Ok(()) } -fn bind_local_socket(path: &str) -> Result { - #[cfg(unix)] - { - if let Some(parent) = std::path::Path::new(path).parent() { - std::fs::create_dir_all(parent)?; - } - let _ = std::fs::remove_file(path); - use interprocess::local_socket::{GenericFilePath, ListenerOptions, ToFsName}; - let name = path.to_fs_name::()?; - ListenerOptions::new().name(name).create_sync() - } - - #[cfg(windows)] - { - use interprocess::local_socket::{GenericNamespaced, ListenerOptions, ToNsName}; - let name = path.to_ns_name::()?; - ListenerOptions::new().name(name).create_sync() - } -} - #[derive(Debug, thiserror::Error)] enum BackendEndpointError { #[error(transparent)] diff --git a/crates/fbuild-daemon/src/main.rs b/crates/fbuild-daemon/src/main.rs index 7e4d3c46c..516823704 100644 --- a/crates/fbuild-daemon/src/main.rs +++ b/crates/fbuild-daemon/src/main.rs @@ -316,8 +316,13 @@ async fn main() { // handler that funnels them into the same `shutdown_tx` the HTTP // endpoint and Ctrl+C paths use. See FastLED/fbuild#18 ("B5a // hardening leftovers"). - #[cfg(windows)] - windows_console::register_ctrl_handler(context.shutdown_tx.clone()); + if let Err(error) = fbuild_core::platform::process::register_daemon_shutdown_handler( + context.shutdown_tx.clone(), + ) { + tracing::warn!( + "native daemon shutdown handler registration failed: {error}; native close events may bypass graceful shutdown" + ); + } // Spawn background maintenance task (self-eviction, idle timeout, stale lock cleanup) { @@ -615,18 +620,11 @@ fn is_pid_alive(pid: u32) -> bool { /// retry). Because the endpoint is version+identity-keyed (FastLED/fbuild#1009), /// a live listener on it is necessarily an fbuild-daemon of our own version. async fn another_daemon_is_listening(addr: &std::net::SocketAddr) -> bool { - matches!( - tokio::time::timeout( - std::time::Duration::from_millis(500), - tokio::net::TcpStream::connect(addr), - ) - .await, - Ok(Ok(_)) - ) + fbuild_core::platform::ipc::tcp_endpoint_ready(*addr, std::time::Duration::from_millis(500)) + .await } async fn bind_listener_with_retry(addr: &str) -> tokio::net::TcpListener { - use socket2::{Domain, Protocol, Socket, Type}; let std_addr: std::net::SocketAddr = addr.parse().unwrap_or_else(|e| { eprintln!("invalid bind address {}: {}", addr, e); std::process::exit(1); @@ -634,55 +632,18 @@ async fn bind_listener_with_retry(addr: &str) -> tokio::net::TcpListener { let mut last_err: Option = None; for attempt in 0..3u32 { - let sock = match Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP)) { - Ok(s) => s, - Err(e) => { - eprintln!("failed to create socket: {}", e); - std::process::exit(1); - } - }; - - // Apply platform-specific address-reuse policy. - #[cfg(windows)] - { - if let Err(e) = set_exclusive_address_windows(&sock) { - tracing::warn!("failed to set SO_EXCLUSIVEADDRUSE: {}", e); - } - } - #[cfg(not(windows))] - { - if let Err(e) = sock.set_reuse_address(true) { - tracing::warn!("failed to set SO_REUSEADDR: {}", e); - } - } + let listener = fbuild_core::platform::ipc::bind_tcp_listener(std_addr); // Force RST on close for accepted client sockets — inherited via // `accept(2)` on Linux/macOS/Windows. See doc comment above and // FastLED/fbuild#32. - if let Err(e) = sock.set_linger(Some(std::time::Duration::ZERO)) { - tracing::warn!("failed to set SO_LINGER=0 on listener: {}", e); - } - - if let Err(e) = sock.set_nonblocking(true) { - eprintln!("failed to set non-blocking: {}", e); - std::process::exit(1); - } - - match sock.bind(&std_addr.into()) { - Ok(()) => match sock.listen(128) { - Ok(()) => { - let std_listener: std::net::TcpListener = sock.into(); - return tokio::net::TcpListener::from_std(std_listener).unwrap_or_else(|e| { - eprintln!("failed to convert listener to tokio: {}", e); - std::process::exit(1); - }); - } - Err(e) => { - eprintln!("failed to listen on {}: {}", addr, e); - std::process::exit(1); - } - }, - Err(e) => { + match listener { + Ok(listener) => return listener, + Err(e @ fbuild_core::platform::ipc::TcpListenerError::Setup { .. }) => { + eprintln!("{e}"); + std::process::exit(1); + } + Err(fbuild_core::platform::ipc::TcpListenerError::Bind(e)) => { // FastLED/fbuild#1010 single-flight: the endpoint is now keyed // by version+identity (#1009), so anything actually LISTENING on // this port is another fbuild-daemon of our exact version — we @@ -722,120 +683,6 @@ async fn bind_listener_with_retry(addr: &str) -> tokio::net::TcpListener { std::process::exit(1); } -/// Set `SO_EXCLUSIVEADDRUSE` on a Windows socket using a manual FFI call, -/// since socket2 0.6 does not yet expose this option. The constant value -/// is `~SO_REUSEADDR = -5` (i.e. the bitwise complement of `SO_REUSEADDR`). -/// `SOL_SOCKET` on Winsock is `0xFFFF`, NOT `1` like on Linux. -#[cfg(windows)] -fn set_exclusive_address_windows(sock: &socket2::Socket) -> std::io::Result<()> { - use std::os::windows::io::AsRawSocket; - - const SOL_SOCKET: i32 = 0xFFFF; - const SO_EXCLUSIVEADDRUSE: i32 = !0x0004; // = -5 - - type SocketHandle = usize; - #[link(name = "ws2_32")] - extern "system" { - fn setsockopt( - s: SocketHandle, - level: i32, - optname: i32, - optval: *const u8, - optlen: i32, - ) -> i32; - } - - let raw: SocketHandle = sock.as_raw_socket() as SocketHandle; - let on: i32 = 1; - let ret = unsafe { - setsockopt( - raw, - SOL_SOCKET, - SO_EXCLUSIVEADDRUSE, - &on as *const i32 as *const u8, - std::mem::size_of::() as i32, - ) - }; - if ret == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } -} - -/// Windows-only console ctrl handler registration. -/// -/// `tokio::signal::ctrl_c()` covers CTRL_C_EVENT / CTRL_BREAK_EVENT on -/// Windows but the console subsystem also fires CTRL_CLOSE_EVENT (window -/// X button), CTRL_LOGOFF_EVENT, and CTRL_SHUTDOWN_EVENT — each of which -/// terminates the process unless an explicit handler is registered via -/// `SetConsoleCtrlHandler`. Without the hook, the daemon dies without -/// running its graceful-shutdown path, leaving stale PID/port files and -/// potentially orphaned child processes. See FastLED/fbuild#18 ("B5a -/// hardening leftovers"). -#[cfg(windows)] -mod windows_console { - use std::sync::OnceLock; - use tokio::sync::watch; - - /// Globally accessible shutdown sender — the console ctrl handler - /// has a fixed C-ABI signature with no user-data pointer, so the only - /// way to reach the daemon's shutdown channel from inside it is - /// through process-wide state. - static SHUTDOWN_TX: OnceLock> = OnceLock::new(); - - /// Windows console control events: `CTRL_CLOSE_EVENT = 2`, - /// `CTRL_LOGOFF_EVENT = 5`, `CTRL_SHUTDOWN_EVENT = 6`. `CTRL_C_EVENT` - /// and `CTRL_BREAK_EVENT` are already covered by `tokio::signal::ctrl_c` - /// so we deliberately fall through (return 0 / FALSE) to let the - /// default handler chain propagate them to tokio's signal driver. - unsafe extern "system" fn console_ctrl_handler(ctrl_type: u32) -> i32 { - const CTRL_CLOSE_EVENT: u32 = 2; - const CTRL_LOGOFF_EVENT: u32 = 5; - const CTRL_SHUTDOWN_EVENT: u32 = 6; - - match ctrl_type { - CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => { - if let Some(tx) = SHUTDOWN_TX.get() { - let _ = tx.send(true); - // Windows gives a CTRL_CLOSE handler ~5s and a - // CTRL_SHUTDOWN handler ~20s before it force-kills - // the process. Block here so the main graceful-shutdown - // path has a chance to run to completion; if it - // finishes sooner, the process exits normally from - // `main` and this sleep is cut short by that exit. - std::thread::sleep(std::time::Duration::from_millis(3500)); - } - 1 // TRUE — handled - } - _ => 0, // FALSE — let the default handler take it - } - } - - pub fn register_ctrl_handler(shutdown_tx: watch::Sender) { - // Idempotent on repeated calls; `OnceLock::set` returns Err if - // already initialised — we ignore it. - let _ = SHUTDOWN_TX.set(shutdown_tx); - - #[link(name = "kernel32")] - extern "system" { - fn SetConsoleCtrlHandler( - handler_routine: Option i32>, - add: i32, - ) -> i32; - } - - let ret = unsafe { SetConsoleCtrlHandler(Some(console_ctrl_handler), 1) }; - if ret == 0 { - tracing::warn!( - "SetConsoleCtrlHandler failed (err={}); \ - CTRL_CLOSE/LOGOFF/SHUTDOWN events will bypass graceful shutdown", - std::io::Error::last_os_error() - ); - } - } -} - /// Compact byte formatter for log messages. fn format_bytes_compact(bytes: u64) -> String { const GIB: u64 = 1024 * 1024 * 1024; diff --git a/docs/architecture/portability.md b/docs/architecture/portability.md index 48235d285..48d494633 100644 --- a/docs/architecture/portability.md +++ b/docs/architecture/portability.md @@ -57,7 +57,8 @@ ESP32-S3 and similar chips use USB CDC for serial communication. Windows has sig - `pythonw.exe` vs `python.exe` — subprocess safety wrapper handles this - Never use `taskkill /IM python.exe /F` — kills everything including Claude Code -- Named pipes for IPC if ever needed (currently HTTP) +- Named pipes for the running-process broker endpoint; HTTP remains the public + daemon protocol ## Linux / macOS diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 109338e13..47c12c6be 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -35,6 +35,12 @@ These are OS-released file locks, never manually broken or deleted, and they never gate zccache object reads/writes — those stay in-memory-synchronized inside zccache itself. +The running-process broker's private local endpoint is bound through +`fbuild_core::platform::ipc`: a namespaced pipe on Windows and an owner-only +local socket on Unix. That boundary also supplies peer process facts and daemon +TCP readiness/listener mechanics; broker frames, HTTP/protobuf payloads, and +startup retry/yield policy remain host-neutral daemon concerns. + ## Error Recovery - **Daemon crash**: CLI detects connection failure, restarts daemon automatically diff --git a/docs/platform-boundary-research-inventory.md b/docs/platform-boundary-research-inventory.md index e2d43dbf8..c2e2a46fa 100644 --- a/docs/platform-boundary-research-inventory.md +++ b/docs/platform-boundary-research-inventory.md @@ -218,13 +218,54 @@ Target-specific native ownership currently exists in: - `fbuild-core`: Unix `libc` and Windows `windows-sys`, confined to selected filesystem implementations; - `fbuild-cli`: Windows `windows-sys`; - `fbuild-serial`: Windows `windows-sys`; -- `fbuild-daemon`: cross-platform `interprocess`. +- `fbuild-core`: cross-platform `interprocess` and `socket2`, confined to + selected IPC implementations. Phase 2's manifest checker must freeze exact occurrences. Later capability phases move native dependency ownership into `fbuild-core`'s private concrete platform implementation and remove caller target tables when their final use is migrated. +## Phase-6 IPC and daemon-lifecycle contraction + +Phase 6 moved fbuild-owned local endpoint bind/connect/accept, peer facts, +owner-only endpoint creation, TCP listener construction/readiness, and the +Windows console-close shutdown bridge behind `platform::ipc` and +`platform::process`. Broker framing and routing, daemon bind retry/yield policy, +and HTTP/protobuf behavior remain with `fbuild-daemon`. + +The exact enforcement ledger fell from **107 to 94 rows**, deleting all 13 +migrated daemon IPC/lifecycle occurrences. Its current shape is: + +| Kind | Rows | +| --- | ---: | +| `attr_cfg` | 74 | +| `native_path` | 16 | +| `native_dependency` | 2 | +| `target_dependency_table` | 2 | +| `cfg_macro` | 0 | +| `compile_host_fact` | 0 | + +| Classification | Rows | +| --- | ---: | +| Host mechanic | 91 | +| Host artifact policy | 3 | + +| Capability | Rows | +| --- | ---: | +| `device` | 62 | +| `host` | 15 | +| `host_executable` | 11 | +| `process` | 6 | +| `ipc` | 0 | +| `fs` | 0 | + +The normalized Dylint projection is 92 rows. The host-independent research +inventory contains 142 rows: 94 enforced caller occurrences plus 48 exact +authorized facade/private-implementation occurrences. Eighteen authorized +occurrences belong to the selected IPC implementation and its two native +dependencies. + ## Classification limits Phase 1 classifies by source ownership and reviewed subsystem responsibility. diff --git a/docs/platform-boundary-research.md b/docs/platform-boundary-research.md index 3aea271e2..75bfa2ba5 100644 --- a/docs/platform-boundary-research.md +++ b/docs/platform-boundary-research.md @@ -226,3 +226,23 @@ occurrences; it contains zero filesystem-capability rows. The normalized Dylint projection contracted from 160 to 103. The independent research inventory contracted from 171 to 137 rows, including ten authorized native filesystem implementation or dependency occurrences. + +## Phase 6: daemon IPC and lifecycle + +`platform::ipc` now owns fbuild local-endpoint listener/connect/accept and peer +facts, owner-private Unix endpoint creation, daemon TCP listener socket policy, +and readiness probes. The Windows console-close bridge is selected through +`platform::process`. Broker framing/routing, bind retry and spawn-race yield +policy, and HTTP/protobuf compatibility remain unchanged in `fbuild-daemon`. + +RED characterization began with an empty IPC facade while the daemon directly +selected Unix sockets versus Windows named pipes, configured native TCP socket +options, and registered a Windows console handler. The focused facade tests now +exercise local endpoint round trips, peer PID facts, and live-versus-free TCP +readiness through the selected implementation. + +The exact ledger contracted from **107 to 94 rows**, deleting all 13 migrated +daemon occurrences and leaving zero IPC-capability rows. The normalized Dylint +projection contracted from 103 to 92. The independent research inventory moved +from 137 to 142 rows: 94 enforced caller occurrences plus 48 authorized boundary +occurrences, including 18 selected IPC implementation/dependency/test occurrences. diff --git a/dylints/enforce_platform_boundary/src/baseline.txt b/dylints/enforce_platform_boundary/src/baseline.txt index cb2ffdf52..405536117 100644 --- a/dylints/enforce_platform_boundary/src/baseline.txt +++ b/dylints/enforce_platform_boundary/src/baseline.txt @@ -17,20 +17,9 @@ crates/fbuild-cli/src/cli/usb_recovery.rs native_import windows_sys 2 crates/fbuild-cli/src/cli/usb_recovery.rs native_import windows_sys 3 crates/fbuild-config/src/bin/enrich_boards.rs attr_cfg windows 0 crates/fbuild-config/src/bin/enrich_boards.rs attr_cfg windows 1 -crates/fbuild-daemon/src/broker/backend.rs attr_cfg unix 0 -crates/fbuild-daemon/src/broker/backend.rs attr_cfg windows 0 -crates/fbuild-daemon/src/broker/backend.rs native_import interprocess 0 -crates/fbuild-daemon/src/broker/backend.rs native_import interprocess 1 -crates/fbuild-daemon/src/broker/backend.rs native_import interprocess 2 crates/fbuild-daemon/src/handlers/emulator/tests_npm_cache.rs attr_cfg windows 0 crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg windows 0 crates/fbuild-daemon/src/handlers/emulator/tests_process.rs attr_cfg windows 1 -crates/fbuild-daemon/src/main.rs attr_cfg windows 0 -crates/fbuild-daemon/src/main.rs attr_cfg windows 1 -crates/fbuild-daemon/src/main.rs attr_cfg windows 2 -crates/fbuild-daemon/src/main.rs attr_cfg windows 3 -crates/fbuild-daemon/src/main.rs attr_cfg windows 4 -crates/fbuild-daemon/src/main.rs native_import std::os::windows 0 crates/fbuild-deploy/src/lpc.rs attr_cfg target_os 0 crates/fbuild-deploy/src/lpc.rs attr_cfg target_os 1 crates/fbuild-deploy/src/lpc.rs attr_cfg windows 0 diff --git a/dylints/enforce_platform_boundary/src/lib.rs b/dylints/enforce_platform_boundary/src/lib.rs index e649b8b1f..164c61e2d 100644 --- a/dylints/enforce_platform_boundary/src/lib.rs +++ b/dylints/enforce_platform_boundary/src/lib.rs @@ -52,6 +52,7 @@ const NATIVE_ROOTS: &[&str] = &[ "mach2", "nix", "portable_pty", + "socket2", "winapi", "windows", "windows_sys", From 722ad4c660b39d85e2c00c29b71729c318a2a83e Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 21 Aug 2026 14:08:26 -0700 Subject: [PATCH 2/3] fix(platform): enforce owner-only macOS local endpoints post-bind Darwin does not support fchmod() on socket fds; interprocess maps the EINVAL from ListenerOptions::mode() to ErrorKind::Unsupported, so every macOS local-endpoint bind failed (Check macOS on #1334: 'bind local endpoint: Kind(Unsupported)'). Linux keeps the atomic pre-bind fchmod. On macOS, bind first and chmod the socket path to 0600 immediately after, failing closed if the chmod fails. Co-Authored-By: Claude --- crates/fbuild-core/src/platform/macos/ipc.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/fbuild-core/src/platform/macos/ipc.rs b/crates/fbuild-core/src/platform/macos/ipc.rs index 339b83c6c..7a343a89a 100644 --- a/crates/fbuild-core/src/platform/macos/ipc.rs +++ b/crates/fbuild-core/src/platform/macos/ipc.rs @@ -1,6 +1,5 @@ use interprocess::local_socket::prelude::*; use interprocess::local_socket::{GenericFilePath, ListenerOptions}; -use interprocess::os::unix::local_socket::ListenerOptionsExt; use socket2::{Domain, Protocol, Socket, Type}; pub(crate) type LocalListener = LocalSocketListener; @@ -12,7 +11,14 @@ pub(crate) fn bind_local(endpoint: &str) -> std::io::Result { } let _ = std::fs::remove_file(endpoint); let name = endpoint.to_fs_name::()?; - ListenerOptions::new().name(name).mode(0o600).create_sync() + // Darwin does not support fchmod() on socket fds; interprocess maps the + // EINVAL to ErrorKind::Unsupported when ListenerOptions::mode() is used. + // Enforce owner-only by chmodding the bound socket path instead. Linux + // keeps the atomic pre-bind fchmod via mode(). + let listener = ListenerOptions::new().name(name).create_sync()?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(endpoint, std::fs::Permissions::from_mode(0o600))?; + Ok(listener) } pub(crate) fn connect_local(endpoint: &str) -> std::io::Result { From ef36a1ee7a7bdd0b2ed588f83c34e1440f002578 Mon Sep 17 00:00:00 2001 From: zackees Date: Fri, 21 Aug 2026 14:37:53 -0700 Subject: [PATCH 3/3] chore(ci): regenerate platform boundary inventory after macOS IPC fix The Darwin post-bind-chmod change moved the PermissionsExt import and usage lines in macos/ipc.rs and dropped the unused ListenerOptionsExt import; the committed inventory drifted accordingly. Co-Authored-By: Claude --- ci/platform_boundary_research.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 176725582..f15eacbdb 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -47,9 +47,9 @@ crates/fbuild-core/src/platform/macos/fs.rs 91 native_path libc:: process host_m crates/fbuild-core/src/platform/macos/fs.rs 101 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic -crates/fbuild-core/src/platform/macos/ipc.rs 3 native_path interprocess::os::unix ipc host_mechanic -crates/fbuild-core/src/platform/macos/ipc.rs 4 native_path socket2:: ipc host_mechanic -crates/fbuild-core/src/platform/macos/ipc.rs 66 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 3 native_path socket2:: ipc host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 19 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic +crates/fbuild-core/src/platform/macos/ipc.rs 72 native_path std::os::unix::fs::PermissionsExt ipc host_mechanic crates/fbuild-core/src/platform/macos/mod.rs 10 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/macos/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 2 native_path std::os::windows::ffi::OsStrExt process host_mechanic