From 74c2eaf66d6367a1588bfdec3c2654ae41f6c7d5 Mon Sep 17 00:00:00 2001 From: Qyriad Date: Wed, 11 Mar 2026 14:26:59 +0100 Subject: [PATCH] some semblance of an event loop --- Cargo.lock | 49 ++++++++++ Cargo.toml | 9 +- rustfmt.toml | 6 ++ src/args.rs | 10 +- src/daemon.rs | 244 +++++++++++++++++++++++++++++++++++++++++++++++ src/daemon_io.rs | 60 ++++++++++-- src/lib.rs | 19 +++- src/main.rs | 1 + 8 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 rustfmt.toml create mode 100644 src/daemon.rs diff --git a/Cargo.lock b/Cargo.lock index 9c57e7d..e641a9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -85,6 +96,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "circular-buffer" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c638459986b83c2b885179bd4ea6a2cbb05697b001501a56adb3a3d230803b" + [[package]] name = "clap" version = "4.5.60" @@ -153,16 +170,21 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" name = "dynix" version = "0.1.0" dependencies = [ + "bitflags", + "bstr", + "circular-buffer", "clap", "command-error", "fs-err", "itertools", "libc", + "mio", "regex", "regex-lite", "rustix", "serde", "serde_json", + "sync-fd", "tap", "tracing", "tracing-human-layer", @@ -314,6 +336,18 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.30.1" @@ -585,6 +619,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync-fd" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5666edb8017247cac3f25fe3f2a8c889177d8adda7ce7d29a1f7278e2858aee2" +dependencies = [ + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -734,6 +777,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[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" version = "0.61.3" diff --git a/Cargo.toml b/Cargo.toml index 3223b6b..b63a6a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,16 +22,21 @@ regex-full = ["dep:regex"] regex-lite = ["dep:regex-lite"] [dependencies] +bitflags = { version = "2.11.0", features = ["std"] } +bstr = "1.12.1" +circular-buffer = "1.2.0" clap = { version = "4.5.54", features = ["color", "derive"] } command-error = "0.8.0" fs-err = "3.2.2" itertools = "0.14.0" libc = { version = "0.2.180", features = ["extra_traits"] } +mio = { version = "1.1.1", features = ["os-ext", "os-poll"] } regex = { version = "1.12.3", optional = true } regex-lite = { version = "0.1.9", optional = true } -rustix = { version = "1.1.4", features = ["event", "fs"] } -serde = { version = "1.0.228", features = ["derive"] } +rustix = { version = "1.1.4", features = ["event", "fs", "termios"] } +serde = { version = "1.0.228", features = ["derive", "rc"] } serde_json = "1.0.149" +sync-fd = "0.1.0" tap = "1.0.1" tracing = { version = "0.1.44", features = ["attributes"] } tracing-human-layer = "0.2.1" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..d662dc1 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Qyriad +# +# SPDX-License-Identifier: EUPL-1.1 + +match_block_trailing_comma = true +merge_derives = false diff --git a/src/args.rs b/src/args.rs index 8c528a2..65232a7 100644 --- a/src/args.rs +++ b/src/args.rs @@ -12,6 +12,7 @@ use clap::ColorChoice; use crate::prelude::*; #[derive(Debug, Clone, PartialEq, clap::Parser)] +#[command(long_about = None)] pub struct AppendCmd { #[arg(required = true)] pub name: Arc, @@ -20,10 +21,17 @@ pub struct AppendCmd { pub value: Arc, } +/// Accept commands over (default stdin). +#[derive(Debug, Clone, PartialEq, clap::Parser)] +#[command(long_about = None)] +pub struct DaemonCmd { + // FIXME: support Unix sockets. +} + #[derive(Debug, Clone, PartialEq, clap::Subcommand)] -#[command(flatten_help = true)] pub enum Subcommand { Append(AppendCmd), + Daemon(DaemonCmd), } static DEFAULT_PATH: LazyLock> = LazyLock::new(|| { diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..48498b9 --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,244 @@ +use std::{ + io, + ops::Deref, + os::fd::{AsFd, BorrowedFd, OwnedFd}, + time::Duration, +}; + +use circular_buffer::CircularBuffer; + +use mio::{Events, Interest, Poll, Token, unix::SourceFd}; + +use rustix::{ + buffer::{Buffer, spare_capacity}, + fs::{OFlags, fcntl_setfl}, + io::Errno, + termios::{ + ControlModes, InputModes, LocalModes, OptionalActions, OutputModes, SpecialCodeIndex, + SpecialCodes, Termios, tcgetattr, tcsetattr, + }, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::StreamDeserializer; + +use crate::prelude::*; + +use crate::OwnedFdWithFlags; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Deserialize, Serialize)] +#[serde(untagged)] +pub enum ConvenientAttrPath { + Dotted(Box), + Split(Box<[Box]>), +} + +impl ConvenientAttrPath { + pub fn clone_from_dotted(s: &str) -> Self { + Self::Dotted(Box::from(s)) + } + + pub fn clone_from_split(s: &[&str]) -> Self { + Self::from_str_iter(s.into_iter().map(Deref::deref)) + } + + pub fn from_str_iter<'i, I>(iter: I) -> Self + where + I: Iterator, + { + let boxed = iter.map(|s| Box::from(s)); + Self::Split(Box::from_iter(boxed)) + } +} + +impl<'i> FromIterator<&'i str> for ConvenientAttrPath { + fn from_iter(iter: I) -> Self + where + I: IntoIterator, + { + Self::from_str_iter(iter.into_iter()) + } +} + +impl FromIterator> for ConvenientAttrPath { + fn from_iter(iter: I) -> Self + where + I: IntoIterator>, + { + Self::Split(Box::from_iter(iter)) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(tag = "action", content = "args", rename_all = "snake_case")] +pub enum DaemonCmd { + Append { + name: ConvenientAttrPath, + value: Box, + }, +} + +const TIMEOUT_NEVER: Option = None; + +#[derive(Debug)] +pub struct Daemon { + fd: OwnedFdWithFlags, + poll_error_buffer: CircularBuffer, + fd_error_buffer: CircularBuffer, + + cmd_buffer: Vec, + next_timeout: Option, +} + +impl Daemon { + /// Panics if we cannot make `fd` non-blocking. + pub fn new(fd: OwnedFd) -> Self { + let fd = OwnedFdWithFlags::new_with_fallback(fd); + + // Make non-blocking. + //fcntl_setfl(fd.as_fd(), fd.oflags().union(OFlags::NONBLOCK)) + // .tap_err(|e| error!("F_SETFL O_NONBLOCK failed on fd {:?}: {e}", fd.as_fd())) + // .unwrap(); + + Self { + fd, + poll_error_buffer: Default::default(), + fd_error_buffer: Default::default(), + cmd_buffer: Vec::with_capacity(1024), + next_timeout: TIMEOUT_NEVER, + } + } + + /// This panics if stdin cannot be opened. + /// + /// If you want to handle that error, use [`Daemon::from_raw_parts()`]. + pub fn from_stdin() -> Self { + let stdin = io::stdin(); + let fd = stdin + .as_fd() + .try_clone_to_owned() + .expect("dynix daemon could not open stdin; try a Unix socket?"); + + debug!("opened daemon to stdin file descriptor {fd:?}"); + + Self::new(fd) + } + + //pub unsafe fn from_raw_parts(fd: OwnedFd) -> Self { + // Self { + // fd: OwnedFdWithFlags::new_with_fallback(fd), + // } + //} + + pub fn fd(&self) -> BorrowedFd<'_> { + self.fd.as_fd() + } +} + +const ERROR_BUFFER_LEN: usize = 8; + +/// Private helpers. +impl Daemon { + fn read_cmd(&mut self) -> Result<(), IoError> { + if self.cmd_buffer.len() == self.cmd_buffer.capacity() { + self.cmd_buffer.reserve(1024); + } + + let _count = rustix::io::read(&self.fd, spare_capacity(&mut self.cmd_buffer))?; + + // The buffer might have existing data from the last read. + let deserializer = serde_json::Deserializer::from_slice(&self.cmd_buffer); + let stream: StreamDeserializer<_, DaemonCmd> = deserializer.into_iter(); + for cmd in stream { + let cmd = match cmd { + Ok(cmd) => cmd, + Err(e) if e.is_eof() => { + self.next_timeout = Some(Duration::from_secs(4)); + warn!("Didn't get a valid daemon command; giving the other side 4 seconds..."); + return Ok(()); + }, + Err(e) => { + warn!("error deserializing command: {e}"); + warn!("error count: {}", self.fd_error_buffer.len()); + self.cmd_buffer.clear(); + // Don't propagate the error unless we have too many. + self.fd_error_buffer.try_push_back(e.into()).tap_err(|e| { + error!( + "Accumulated too many errors for daemon fd {:?}: {e}", + self.fd(), + ) + })?; + return Ok(()); + }, + }; + debug!("got cmd: {cmd:?}"); + } + + self.cmd_buffer.clear(); + + Ok(()) + } + + pub(crate) fn enter_loop(&mut self) -> Result, IoError> { + let raw_fd = self.fd.as_raw_fd(); + let mut daemon_source = SourceFd(&raw_fd); + const DAEMON: Token = Token(0); + let mut poll = Poll::new().unwrap_or_else(|e| unreachable!("creating new mio Poll: {e}")); + + poll.registry() + .register(&mut daemon_source, DAEMON, Interest::READABLE) + .unwrap_or_else(|e| unreachable!("registering mio Poll for daemon fd {raw_fd:?}: {e}")); + + let mut events = Events::with_capacity(1024); + + let example = DaemonCmd::Append { + //name: ConvenientAttrPath::Dotted(Box::from( + // "services.gotosocial.settings.application-name", + //)), + //name: ConvenientAttrPath::Split(Box::from([ + // Box::from("services"), + // Box::from("gotosocial"), + //])), + name: ConvenientAttrPath::clone_from_split(&[ + "services", + "gotosocial", + "settings", + "application-name", + ]), + value: Box::from("foo"), + }; + + //let example_as_json = serde_json::to_string_pretty(&example).unwrap(); + //info!("{}", example_as_json); + + loop { + match poll.poll(&mut events, self.next_timeout.take()) { + Ok(_) => { + if events.is_empty() { + warn!("timeout expired"); + self.cmd_buffer.clear(); + } else { + let _ = self.poll_error_buffer.pop_front(); + } + }, + Err(e) => { + warn!("mio Poll::poll() error: {e}"); + self.poll_error_buffer.try_push_back(e).tap_err(|e| { + error!("accumulated too many errors for mio::Poll::poll(): {e}") + })?; + }, + } + + for event in &events { + match event.token() { + DAEMON => { + self.read_cmd().unwrap(); + }, + _ => unreachable!(), + } + } + } + } +} diff --git a/src/daemon_io.rs b/src/daemon_io.rs index f080ca8..40beed1 100644 --- a/src/daemon_io.rs +++ b/src/daemon_io.rs @@ -17,7 +17,6 @@ use crate::prelude::*; /// /// - [`IntoRawFd`] /// - [`drop()`] -#[derive(Debug)] pub struct OwnedFdWithFlags { fd: OwnedFd, oflags: OFlags, @@ -29,14 +28,34 @@ impl OwnedFdWithFlags { Ok(Self { fd, oflags }) } + pub fn as_ref_owned(&self) -> &OwnedFd { + &self.fd + } + /// If, for some ungodly reason, `fcntl(F_GETFL)` fails, then empty flags are used. /// Empty flags should not be restored on close. /// /// I'm pretty sure this should never happen if the program is IO-safe. pub fn new_with_fallback(fd: OwnedFd) -> Self { - let oflags = Self::get_flags_or_log(fd.as_fd()); + let oflags = Self::get_flags_or_log(&fd); Self { fd, oflags } } + + pub fn oflags(&self) -> OFlags { + self.oflags + } +} + +impl Debug for OwnedFdWithFlags { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + let mut flags_str: String = Default::default(); + bitflags::parser::to_writer(&self.oflags, &mut flags_str)?; + + f.debug_struct("OwnedFdWithFlags") + .field("fd", &self.fd) + .field("oflags", &flags_str) + .finish() + } } impl Drop for OwnedFdWithFlags { @@ -56,15 +75,19 @@ impl OwnedFdWithFlags { return; } - if let Err(e) = fcntl_setfl(&self.fd, self.oflags) { - error!( + match fcntl_setfl(&self.fd, self.oflags) { + Ok(_) => (), + // EBADF + //Err(Errno::BADF) => (), + Err(e) => error!( "fcntl(F_SETFL) to restore flags {:?} on fd {:?} failed: {e}\nIO safety violation?", self.oflags, self.fd, - ); - }; + ), + } } - fn get_flags_or_log(fd: BorrowedFd) -> OFlags { + fn get_flags_or_log(fd: Fd) -> OFlags { + let fd = fd.as_fd(); fcntl_getfl(fd) .tap_err(|e| error!("fcntl(F_GETFL) failed on fd {fd:?}: {e}\nIO-safety violation?")) .unwrap_or(OFlags::empty()) @@ -87,7 +110,7 @@ impl IntoRawFd for OwnedFdWithFlags { impl FromRawFd for OwnedFdWithFlags { unsafe fn from_raw_fd(fd: RawFd) -> Self { let fd = unsafe { OwnedFd::from_raw_fd(fd) }; - let oflags = Self::get_flags_or_log(fd.as_fd()); + let oflags = Self::get_flags_or_log(&fd); Self { fd, oflags } } } @@ -104,3 +127,24 @@ impl From for OwnedFdWithFlags { Self { fd, oflags } } } + +impl Read for &OwnedFdWithFlags { + fn read(&mut self, buf: &mut [u8]) -> Result { + debug_assert!(buf.len() > 0); + loop { + buf.fill(0); + match rustix::io::read(self.as_ref_owned(), &mut *buf) { + Ok(count) => { + debug_assert!(count <= buf.len()); + return Ok(count); + }, + Err(e) if e.kind() == IoErrorKind::Interrupted => continue, + Err(e) if e.kind() == IoErrorKind::WouldBlock => return Ok(0), + Err(e) => { + let e = IoError::from_raw_os_error(e.raw_os_error()); + return Err(e); + }, + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 7533c04..3997aec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,8 +13,8 @@ pub(crate) mod prelude { pub use std::{ error::Error as StdError, ffi::{OsStr, OsString}, - fmt::{Display, Formatter, Result as FmtResult}, - io::{Error as IoError, Read, Seek, SeekFrom, Write}, + fmt::{Debug, Display, Formatter, Result as FmtResult}, + io::{Error as IoError, ErrorKind as IoErrorKind, Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, process::{Command, ExitCode}, str::FromStr, @@ -33,6 +33,8 @@ pub(crate) mod prelude { #[cfg(unix)] pub use fs_err::os::unix::fs::{FileExt, OpenOptionsExt}; + pub use bstr::ByteSlice; + pub use tap::{Pipe, Tap, TapFallible}; pub use tracing::{Level, debug, error, info, trace, warn}; @@ -44,11 +46,13 @@ pub mod args; pub use args::{AppendCmd, Args}; mod color; pub use color::{_CLI_ENABLE_COLOR, SHOULD_COLOR}; +mod daemon; +pub use daemon::Daemon; mod daemon_io; pub use daemon_io::OwnedFdWithFlags; pub mod line; -mod nixcmd; pub use line::Line; +mod nixcmd; pub mod source; pub use source::{SourceFile, SourceLine}; @@ -67,6 +71,8 @@ use _regex::Regex; use itertools::Itertools; use serde::{Deserialize, Serialize}; +use crate::args::DaemonCmd; + pub const ASCII_WHITESPACE: &[char] = &['\t', '\n', '\x0C', '\r', ' ']; /// Regex pattern to extract the priority in a `lib.mkOverride` call. @@ -118,6 +124,13 @@ pub fn do_append(args: Arc, append_args: AppendCmd) -> Result<(), BoxDynEr Ok(()) } +//#[tracing::instrument(level = "debug")] +pub fn do_daemon(args: Arc, daemon_args: DaemonCmd) -> Result<(), BoxDynError> { + let mut daemon = Daemon::from_stdin(); + daemon.enter_loop().unwrap(); + todo!(); +} + #[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize)] pub struct DefinitionWithLocation { pub file: Box, diff --git a/src/main.rs b/src/main.rs index c45a56a..940ce0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ fn main_wrapped() -> Result<(), Box> { use dynix::args::Subcommand::*; match &args.subcommand { Append(append_args) => dynix::do_append(args.clone(), append_args.clone())?, + Daemon(daemon_args) => dynix::do_daemon(args.clone(), daemon_args.clone())?, }; }