From afc180f004cfbbdb01741ccab9b3a205482f388b Mon Sep 17 00:00:00 2001 From: Clement Tsang <34804052+ClementTsang@users.noreply.github.com> Date: Sun, 16 Apr 2023 01:28:37 -0400 Subject: other: directly implement procfs functionality with small optimizations --- .gitignore | 2 +- Cargo.lock | 68 +++--- Cargo.toml | 3 +- src/app/data_harvester.rs | 6 +- src/app/data_harvester/processes/linux.rs | 110 +++++---- src/app/data_harvester/processes/linux/process.rs | 276 ++++++++++++++++++++++ src/data_conversion.rs | 1 + src/lib.rs | 1 + src/utils/error.rs | 27 --- 9 files changed, 386 insertions(+), 108 deletions(-) create mode 100644 src/app/data_harvester/processes/linux/process.rs diff --git a/.gitignore b/.gitignore index 1fe7c3a7..702b63da 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ site/ # dhat heap profiling dhat-heap.json -dhat/ \ No newline at end of file +dhat/ diff --git a/Cargo.lock b/Cargo.lock index eab63a38..60eee165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,7 +70,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", "winapi", ] @@ -134,9 +134,10 @@ dependencies = [ "nvml-wrapper", "once_cell", "predicates", - "procfs", "ratatui", + "rayon", "regex", + "rustix", "serde", "serde_json", "starship-battery", @@ -447,13 +448,13 @@ dependencies = [ [[package]] name = "errno" -version = "0.2.8" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" dependencies = [ "errno-dragonfly", "libc", - "winapi", + "windows-sys 0.48.0", ] [[package]] @@ -549,10 +550,10 @@ dependencies = [ ] [[package]] -name = "hex" -version = "0.4.3" +name = "hermit-abi" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" [[package]] name = "humantime" @@ -588,12 +589,13 @@ dependencies = [ [[package]] name = "io-lifetimes" -version = "1.0.4" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7d6c6f8c91b4b9ed43484ad1a938e393caf35960fce7f82a040497207bd8e9e" +checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" dependencies = [ + "hermit-abi 0.3.1", "libc", - "windows-sys 0.42.0", + "windows-sys 0.48.0", ] [[package]] @@ -650,9 +652,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.1.4" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" +checksum = "d59d8c75012853d2e872fb56bc8a2e53718e2cafe1a4c823143141c6d90c322f" [[package]] name = "lock_api" @@ -760,7 +762,7 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", ] @@ -871,19 +873,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "procfs" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ca7f9f29bab5844ecd8fdb3992c5969b6622bb9609b9502fef9b4310e3f1f" -dependencies = [ - "bitflags", - "byteorder", - "hex", - "lazy_static", - "rustix", -] - [[package]] name = "quote" version = "1.0.26" @@ -908,21 +897,19 @@ dependencies = [ [[package]] name = "rayon" -version = "1.5.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd249e82c21598a9a426a4e00dd7adc1d640b22445ec8545feef801d1a74c221" +checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" dependencies = [ - "autocfg", - "crossbeam-deque", "either", "rayon-core", ] [[package]] name = "rayon-core" -version = "1.9.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4" +checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" dependencies = [ "crossbeam-channel", "crossbeam-deque", @@ -987,16 +974,16 @@ checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" [[package]] name = "rustix" -version = "0.36.6" +version = "0.37.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4feacf7db682c6c329c4ede12649cd36ecab0f3be5b7d74e6a20304725db4549" +checksum = "85597d61f83914ddeba6a47b3b8ffe7365107221c2e557ed94426489fefb5f77" dependencies = [ "bitflags", "errno", "io-lifetimes", "libc", "linux-raw-sys", - "windows-sys 0.42.0", + "windows-sys 0.48.0", ] [[package]] @@ -1438,6 +1425,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + [[package]] name = "windows-targets" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 314f0fca..8f232f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,8 @@ unicode-width = "0.1.10" libc = "0.2.141" [target.'cfg(target_os = "linux")'.dependencies] -procfs = { version = "0.15.1", default-features = false } +rayon = "1.7.0" +rustix = { version = "0.37.11", features = ["fs", "param", "process"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9.3" diff --git a/src/app/data_harvester.rs b/src/app/data_harvester.rs index 2f4633bf..798e6086 100644 --- a/src/app/data_harvester.rs +++ b/src/app/data_harvester.rs @@ -25,6 +25,9 @@ pub mod network; pub mod processes; pub mod temperature; +// Refresh certain details once every minute. If it's too frequent it can cause segfaults. +const LIST_REFRESH_TIME: Duration = Duration::from_secs(60); + #[derive(Clone, Debug)] pub struct Data { pub last_collection_time: Instant, @@ -229,8 +232,6 @@ impl DataCollector { /// - Disk (Windows) /// - Temperatures (non-Linux) fn refresh_sysinfo_data(&mut self) { - // Refresh once every minute. If it's too frequent it can cause segfaults. - const LIST_REFRESH_TIME: Duration = Duration::from_secs(60); let refresh_start = Instant::now(); if self.widgets_to_harvest.use_cpu || self.widgets_to_harvest.use_proc { @@ -344,6 +345,7 @@ impl DataCollector { .duration_since(self.last_collection_time) .as_secs(); + // TODO: Can I reuse the previous data's buffer? processes::get_process_data( &self.sys, prev_proc, diff --git a/src/app/data_harvester/processes/linux.rs b/src/app/data_harvester/processes/linux.rs index 4918230f..48adb8d7 100644 --- a/src/app/data_harvester/processes/linux.rs +++ b/src/app/data_harvester/processes/linux.rs @@ -1,10 +1,12 @@ //! Process data collection for Linux. -use std::fs::File; +mod process; +use process::*; + +use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use hashbrown::{HashMap, HashSet}; -use procfs::process::{Process, Stat}; use sysinfo::{ProcessStatus, System}; use super::{ProcessHarvest, UserTable}; @@ -96,7 +98,7 @@ fn cpu_usage_calculation(prev_idle: &mut f64, prev_non_idle: &mut f64) -> error: /// Returns the usage and a new set of process times. /// -/// NB: cpu_fraction should be represented WITHOUT the x100 factor! +/// NB: `cpu_fraction` should be represented WITHOUT the x100 factor! fn get_linux_cpu_usage( stat: &Stat, cpu_usage: f64, cpu_fraction: f64, prev_proc_times: u64, use_current_cpu_total: bool, @@ -115,14 +117,21 @@ fn get_linux_cpu_usage( } fn read_proc( - prev_proc: &PrevProcDetails, process: &Process, cpu_usage: f64, cpu_fraction: f64, + prev_proc: &PrevProcDetails, process: Process, cpu_usage: f64, cpu_fraction: f64, use_current_cpu_total: bool, time_difference_in_secs: u64, total_memory: u64, user_table: &mut UserTable, ) -> error::Result<(ProcessHarvest, u64)> { - let stat = process.stat()?; + let Process { + pid: _, + uid, + stat, + io, + cmdline, + } = process; + let (command, name) = { let truncated_name = stat.comm.as_str(); - if let Ok(cmdline) = process.cmdline() { + if let Ok(cmdline) = cmdline { if cmdline.is_empty() { (format!("[{}]", truncated_name), truncated_name.to_string()) } else { @@ -168,7 +177,7 @@ fn read_proc( // This can fail if permission is denied! let (total_read_bytes, total_write_bytes, read_bytes_per_sec, write_bytes_per_sec) = - if let Ok(io) = process.io() { + if let Ok(io) = io { let total_read_bytes = io.read_bytes; let total_write_bytes = io.write_bytes; let prev_total_read_bytes = prev_proc.total_read_bytes; @@ -194,7 +203,14 @@ fn read_proc( (0, 0, 0, 0) }; - let uid = process.uid()?; + let user = uid + .and_then(|uid| { + user_table + .get_uid_to_username_mapping(uid) + .map(Into::into) + .ok() + }) + .unwrap_or_else(|| "N/A".into()); Ok(( ProcessHarvest { @@ -210,11 +226,8 @@ fn read_proc( total_read_bytes, total_write_bytes, process_state, - uid: Some(uid), - user: user_table - .get_uid_to_username_mapping(uid) - .map(Into::into) - .unwrap_or_else(|_| "N/A".into()), + uid, + user, }, new_process_times, )) @@ -230,11 +243,15 @@ pub(crate) struct ProcHarvestOptions { pub unnormalized_cpu: bool, } +fn is_str_numeric(s: &str) -> bool { + s.chars().all(|c| c.is_ascii_digit()) +} + pub(crate) fn get_process_data( sys: &System, prev_proc: PrevProc<'_>, pid_mapping: &mut HashMap, proc_harvest_options: ProcHarvestOptions, time_difference_in_secs: u64, total_memory: u64, user_table: &mut UserTable, -) -> crate::utils::error::Result> { +) -> error::Result> { let ProcHarvestOptions { use_current_cpu_total, unnormalized_cpu, @@ -263,36 +280,47 @@ pub(crate) fn get_process_data( let mut pids_to_clear: HashSet = pid_mapping.keys().cloned().collect(); - let process_vector: Vec = std::fs::read_dir("/proc")? + let pids = fs::read_dir("/proc")? + .flatten() .filter_map(|dir| { - if let Ok(dir) = dir { - if let Ok(pid) = dir.file_name().to_string_lossy().trim().parse::() { - let Ok(process) = Process::new(pid) else { - return None; - }; - let prev_proc_details = pid_mapping.entry(pid).or_default(); - - if let Ok((process_harvest, new_process_times)) = read_proc( - prev_proc_details, - &process, - cpu_usage, - cpu_fraction, - use_current_cpu_total, - time_difference_in_secs, - total_memory, - user_table, - ) { - prev_proc_details.cpu_time = new_process_times; - prev_proc_details.total_read_bytes = process_harvest.total_read_bytes; - prev_proc_details.total_write_bytes = process_harvest.total_write_bytes; - - pids_to_clear.remove(&pid); - return Some(process_harvest); - } + if is_str_numeric(dir.file_name().to_string_lossy().trim()) { + Some(dir.path()) + } else { + None + } + }) + .collect::>(); + + use rayon::prelude::*; + let process_vector: Vec = pids + .into_par_iter() + .filter_map(|pid_path| { + if let Ok(process) = Process::from_path(pid_path) { + let pid = process.pid; + let prev_proc_details = pid_mapping.entry(pid).or_default(); + + if let Ok((process_harvest, new_process_times)) = read_proc( + prev_proc_details, + process, + cpu_usage, + cpu_fraction, + use_current_cpu_total, + time_difference_in_secs, + total_memory, + user_table, + ) { + prev_proc_details.cpu_time = new_process_times; + prev_proc_details.total_read_bytes = process_harvest.total_read_bytes; + prev_proc_details.total_write_bytes = process_harvest.total_write_bytes; + + pids_to_clear.remove(&pid); + Some(process_harvest) + } else { + None } + } else { + None } - - None }) .collect(); diff --git a/src/app/data_harvester/processes/linux/process.rs b/src/app/data_harvester/processes/linux/process.rs new file mode 100644 index 00000000..f2fc6327 --- /dev/null +++ b/src/app/data_harvester/processes/linux/process.rs @@ -0,0 +1,276 @@ +//! Linux process code for getting process data via `/proc/`. +//! Based on the [procfs](https://github.com/eminence/procfs) crate. + +use std::{ + fs::File, + io::{self, BufRead, BufReader, Read}, + path::PathBuf, +}; + +use anyhow::anyhow; +use libc::uid_t; +use once_cell::sync::Lazy; +use rustix::{ + fd::OwnedFd, + fs::{Mode, OFlags}, + path::Arg, +}; + +use crate::Pid; + +static PAGESIZE: Lazy = Lazy::new(|| rustix::param::page_size() as u64); + +fn next_part<'a>(iter: &mut impl Iterator) -> Result<&'a str, io::Error> { + iter.next() + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData)) +} + +/// A wrapper around the data in `/proc//stat`. For documentation, see [here](https://man7.org/linux/man-pages/man5/proc.5.html). +/// +/// Note this does not necessarily get all fields, only the ones we use in bottom. +pub(crate) struct Stat { + /// The filename of the executable without parentheses. + pub comm: String, + + /// The current process state, represented by a char. + pub state: char, + + /// The parent process PID. + pub ppid: Pid, + + /// The amount of time this process has been scheduled in user mode in clock ticks. + pub utime: u64, + + /// The amount of time this process has been scheduled in kernel mode in clock ticks. + pub stime: u64, + + /// The resident set size, or the number of pages the process has in real memory. + pub rss: u64, +} + +impl Stat { + fn from_file(mut f: File) -> anyhow::Result { + // Since this is just one line, we can read it all at once. However, since it might have non-utf8 characters, + // we can't just use read_to_string. + let mut buffer = Vec::with_capacity(500); + f.read_to_end(&mut buffer)?; + + let line = buffer.to_string_lossy(); + let line = line.trim(); + + let (comm, rest) = { + let start_paren = line + .find('(') + .ok_or_else(|| anyhow!("start paren missing"))?; + let end_paren = line.find(')').ok_or_else(|| anyhow!("end paren missing"))?; + + ( + line[start_paren + 1..end_paren].to_string(), + &line[end_paren + 2..], + ) + }; + + let mut rest = rest.split(' '); + let state = next_part(&mut rest)? + .chars() + .next() + .ok_or_else(|| anyhow!("missing state"))?; + + let ppid: Pid = next_part(&mut rest)?.parse()?; + + // Skip 9 fields until utime (pgrp, session, tty_nr, tpgid, flags, minflt, cminflt, majflt, cmajflt). + let mut rest = rest.skip(9); + + let utime: u64 = next_part(&mut rest)?.parse()?; + let stime: u64 = next_part(&mut rest)?.parse()?; + + // Skip 8 fields until rss (cutime, cstime, priority, nice, num_threads, itrealvalue, starttime, vsize). + let mut rest = rest.skip(8); + + let rss: u64 = next_part(&mut rest)?.parse()?; + + Ok(Stat { + comm, + state, + ppid, + utime, + stime, + rss, + }) + } + + /// Returns the Resident Set Size in bytes. + pub fn rss_bytes(&self) -> u64 { + self.rss * *PAGESIZE + } +} + +/// A wrapper around the data in `/proc//io`. +/// +/// Note this does not necessarily get all fields, only the ones we use in bottom. +pub(crate) struct Io { + pub read_bytes: u64, + pub write_bytes: u64, +} + +impl Io { + fn from_file(f: File) -> anyhow::Result { + const NUM_FIELDS: u16 = 0; // Make sure to update this if you want more fields! + enum Fields { + ReadBytes, + WriteBytes, + } + + let mut read_fields = 0; + let mut line = String::new(); + let mut reader = BufReader::new(f); + + let mut read_bytes = 0; + let mut write_bytes = 0; + + // This saves us from doing a string allocation on each iteration compared to `lines()`. + while let Ok(bytes) = reader.read_line(&mut line) { + if bytes > 0 { + if line.is_empty() { + // Empty, no need to clear. + continue; + } + + let mut parts = line.split_whitespace(); + + if let Some(field) = parts.next() { + let curr_field = match field { + "read_bytes:" => Fields::ReadBytes, + "write_bytes:" => Fields::WriteBytes, + _ => { + line.clear(); + continue; + } + }; + + if let Some(value) = parts.next() { + let value = value.parse::()?; + match curr_field { + Fields::ReadBytes => { + read_bytes = value; + read_fields += 1; + } + Fields::WriteBytes => { + write_bytes = value; + read_fields += 1; + } + } + } + } + + // Quick short circuit if we read all required fields. + if read_fields == NUM_FIELDS { + break; + } + + line.clear(); + } else { + break; + } + } + + Ok(Io { + read_bytes, + write_bytes, + }) + } +} + +/// A wrapper around a Linux process operations in `/proc/`. +/// +/// Core documentation based on [proc's manpages](https://man7.org/linux/man-pages/man5/proc.5.html). +pub(crate) struct Process { + pub pid: Pid, + pub uid: Option, + pub stat: Stat, + pub io: anyhow::Result, + pub cmdline: anyhow::Result>, +} + +impl Process { + /// Creates a new [`Process`] given a `/proc/` path. This may fail if the process + /// no longer exists or there are permissions issues. + /// + /// Note that this pre-allocates fields on **creation**! As such, some data might end + /// up "outdated" depending on when you call some of the methods. Therefore, this struct + /// is only useful for either fields that are unlikely to change, or are short-lived and + /// will be discarded quickly. + pub(crate) fn from_path(pid_path: PathBuf) -> anyhow::Result { + // TODO: Pass in a buffer vec/string to share? + + let fd = rustix::fs::openat( + rustix::fs::cwd(), + &pid_path, + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + + let pid = pid_path + .as_path() + .components() + .last() + .and_then(|s| s.to_string_lossy().parse::().ok()) + .or_else(|| { + rustix::fs::readlinkat(rustix::fs::cwd(), &pid_path, vec![]) + .ok() + .and_then(|s| s.to_string_lossy().parse::().ok()) + }) + .ok_or_else(|| anyhow!("PID for {pid_path:?} was not found"))?; + + let uid = { + let metadata = rustix::fs::fstat(&fd); + match metadata { + Ok(md) => Some(md.st_uid), + Err(_) => None, + } + }; + + let mut root = pid_path.clone(); + let cmdline = cmdline(&mut root, &fd); + root.pop(); + let stat = open_at(&mut root, "stat", &fd).and_then(Stat::from_file)?; + root.pop(); + let io = open_at(&mut root, "io", &fd).and_then(Io::from_file); + + Ok(Process { + pid, + uid, + stat, + io, + cmdline, + }) + } +} + +fn cmdline(root: &mut PathBuf, fd: &OwnedFd) -> anyhow::Result> { + let mut buf = String::new(); + open_at(root, "cmdline", fd) + .map(|mut file| file.read_to_string(&mut buf)) + .map(|_| { + buf.split('\0') + .filter_map(|s| { + if !s.is_empty() { + Some(s.to_string()) + } else { + None + } + }) + .collect::>() + }) + .map_err(Into::into) +} + +/// Opens a path. Note that this function takes in a mutable root - this will mutate it to avoid allocations. You +/// probably will want to pop the most recent child after if you need to use the buffer again. +#[inline] +fn open_at(root: &mut PathBuf, child: &str, fd: &OwnedFd) -> anyhow::Result { + root.push(child); + let new_fd = rustix::fs::openat(&fd, &*root, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty())?; + + Ok(File::from(new_fd)) +} diff --git a/src/data_conversion.rs b/src/data_conversion.rs index 03044c9c..392bae3d 100644 --- a/src/data_conversion.rs +++ b/src/data_conversion.rs @@ -283,6 +283,7 @@ fn get_mem_binary_unit_and_denominator(bytes: u64) -> (&'static str, f64) { /// Returns the unit type and denominator for given total amount of memory in kibibytes. pub fn convert_mem_label(harvest: &MemHarvest) -> Option<(String, String)> { + // TODO: Add an alignment field. if harvest.total_bytes > 0 { Some((format!("{:3.0}%", harvest.use_percent.unwrap_or(0.0)), { let (unit, denominator) = get_mem_binary_unit_and_denominator(harvest.total_bytes); diff --git a/src/lib.rs b/src/lib.rs index 5439b9f0..c630ab78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -548,6 +548,7 @@ pub fn create_collection_thread( } let event = BottomEvent::Update(Box::from(data_state.data)); + // TODO: Should we just keep copies of existing data to avoid reallocs? data_state.data = data_harvester::Data::default(); if sender.send(event).is_err() { break; diff --git a/src/utils/error.rs b/src/utils/error.rs index 3764469c..9e556f57 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -2,9 +2,6 @@ use std::{borrow::Cow, result}; use thiserror::Error; -#[cfg(target_os = "linux")] -use procfs::ProcError; - /// A type alias for handling errors related to Bottom. pub type Result = result::Result; @@ -35,10 +32,6 @@ pub enum BottomError { /// An error that just signifies something minor went wrong; no message. #[error("Minor error.")] MinorError, - /// An error to represent errors with procfs - #[cfg(target_os = "linux")] - #[error("Procfs error, {0}")] - ProcfsError(String), } impl From for BottomError { @@ -93,23 +86,3 @@ impl From for BottomError { BottomError::QueryError(format!("Regex error: {}", error.last().unwrap_or(&"")).into()) } } - -#[cfg(target_os = "linux")] -impl From for BottomError { - fn from(err: ProcError) -> Self { - match err { - ProcError::PermissionDenied(p) => { - BottomError::ProcfsError(format!("Permission denied for {:?}", p)) - } - ProcError::NotFound(p) => BottomError::ProcfsError(format!("{:?} not found", p)), - ProcError::Incomplete(p) => BottomError::ProcfsError(format!("{:?} incomplete", p)), - ProcError::Io(e, p) => { - BottomError::ProcfsError(format!("io error: {:?} for {:?}", e, p)) - } - ProcError::Other(s) => BottomError::ProcfsError(format!("Other procfs error: {}", s)), - ProcError::InternalError(e) => { - BottomError::ProcfsError(format!("procfs internal error: {:?}", e)) - } - } - } -} -- cgit v1.2.3