Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
use async_channel::Sender;
|
||||
use async_fs::OpenOptions;
|
||||
use futures::AsyncWriteExt as _;
|
||||
use warpui::r#async::executor::{Background, BackgroundTask};
|
||||
|
||||
pub mod manager;
|
||||
|
||||
/// Shared state for a [`SimpleLogger`].
|
||||
///
|
||||
/// When all [`SimpleLogger`] clones are dropped, this is dropped too, which closes
|
||||
/// the logging channel and lets the background writing task finish.
|
||||
///
|
||||
/// We also support explicit shutdown via [`SimpleLogger::close`]. That allows a
|
||||
/// caller to mark a log stream as finished immediately, even if some incidental
|
||||
/// clones are still alive briefly in background tasks or callback state.
|
||||
pub(crate) struct LogFileWriter {
|
||||
log_tx: Sender<String>,
|
||||
_logging_task: BackgroundTask,
|
||||
}
|
||||
|
||||
impl LogFileWriter {
|
||||
/// Returns true if the underlying channel has been closed.
|
||||
///
|
||||
/// A closed writer is logically dead even if some [`Arc`] handles still
|
||||
/// exist, because it can no longer accept new log lines.
|
||||
pub(crate) fn is_closed(&self) -> bool {
|
||||
self.log_tx.is_closed()
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple file-based logger for server stderr output.
|
||||
/// Writes timestamped log entries to a file asynchronously.
|
||||
#[derive(Clone)]
|
||||
pub struct SimpleLogger {
|
||||
// Cheaply cloneable reference to the log file writer.
|
||||
writer: Arc<LogFileWriter>,
|
||||
}
|
||||
|
||||
impl SimpleLogger {
|
||||
/// Creates a new logger that writes to the specified file path.
|
||||
/// The file is truncated on creation.
|
||||
pub(crate) fn new(log_path: PathBuf, executor: Arc<Background>) -> Self {
|
||||
let (log_tx, log_rx) = async_channel::unbounded::<String>();
|
||||
|
||||
if let Some(directory) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(directory);
|
||||
}
|
||||
|
||||
let logging_task = executor.spawn(async move {
|
||||
let mut log_file = match OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&log_path)
|
||||
.await
|
||||
{
|
||||
Ok(log_file) => log_file,
|
||||
Err(e) => {
|
||||
log::warn!("Could not open file for logging: {:?}. {:?}", &log_path, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match log_rx.recv().await {
|
||||
Ok(log_line) => {
|
||||
let _ = log_file
|
||||
.write_all(
|
||||
format!(
|
||||
"{} | {}\n",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"),
|
||||
log_line
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
// Flush after each line to ensure logs are visible immediately
|
||||
let _ = log_file.flush().await;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("SimpleLogger: channel closed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final flush
|
||||
let _ = log_file.flush().await;
|
||||
});
|
||||
|
||||
Self {
|
||||
writer: Arc::new(LogFileWriter {
|
||||
log_tx,
|
||||
_logging_task: logging_task,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a message to the file.
|
||||
pub fn log(&self, message: String) {
|
||||
let _ = self.writer.log_tx.try_send(message);
|
||||
}
|
||||
|
||||
/// Explicitly close the logger channel before all clones are dropped.
|
||||
///
|
||||
/// This is useful when the caller wants "this log stream is finished" to be
|
||||
/// a first-class state, rather than waiting for every clone to be dropped.
|
||||
/// For example, a failed connection attempt may want to write a final error
|
||||
/// line, close the stream immediately, and let a later retry reclaim the
|
||||
/// same log path even if some transient clones have not been dropped yet.
|
||||
///
|
||||
/// This is a no-op if the channel is already closed. Shutdown also happens
|
||||
/// automatically when the last [`SimpleLogger`] clone is dropped.
|
||||
pub fn close(&self) {
|
||||
self.writer.log_tx.close();
|
||||
}
|
||||
|
||||
/// Returns a weak reference to the shared writer, used by [`manager::LogManager`]
|
||||
/// to track liveness without preventing shutdown.
|
||||
pub(crate) fn downgrade(&self) -> Weak<LogFileWriter> {
|
||||
Arc::downgrade(&self.writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::{LogFileWriter, SimpleLogger};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
use thiserror::Error;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LogManagerError {
|
||||
#[error("A logger is already active for this path: {}", path.display())]
|
||||
LoggerAlreadyActive { path: PathBuf },
|
||||
#[error("Unknown log namespace: {namespace}")]
|
||||
UnknownNamespace { namespace: String },
|
||||
}
|
||||
|
||||
impl LogManagerError {
|
||||
/// Returns a description of the error suitable for use in release-channel error reporting.
|
||||
/// User-specific data (e.g. file paths) is omitted; non-sensitive details are preserved.
|
||||
pub fn safe_message(&self) -> String {
|
||||
match self {
|
||||
Self::LoggerAlreadyActive { .. } => "logger already active for path".to_string(),
|
||||
Self::UnknownNamespace { .. } => format!("{self:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the log file path for a given namespace and relative path,
|
||||
/// without requiring access to [`LogManager`].
|
||||
///
|
||||
/// This is useful for read-only path resolution (e.g. reading log files for display).
|
||||
pub fn resolve_log_path(namespace: &str, relative_path: impl AsRef<Path>) -> PathBuf {
|
||||
log_directory_path(namespace).join(relative_path)
|
||||
}
|
||||
|
||||
/// Returns the base log directory for a given namespace name.
|
||||
fn log_directory_path(namespace: &str) -> PathBuf {
|
||||
let base_dir = warp_core::paths::secure_state_dir().unwrap_or_else(warp_core::paths::state_dir);
|
||||
if cfg!(windows) {
|
||||
base_dir
|
||||
.join(warp_core::paths::WARP_LOGS_DIR)
|
||||
.join(namespace)
|
||||
} else {
|
||||
base_dir.join(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton that owns all file-based loggers in the app.
|
||||
///
|
||||
/// Enforces that at most one active [`SimpleLogger`] exists per log file path.
|
||||
/// Stale registrations are reclaimed automatically on the next
|
||||
/// [`register`](LogManager::register) call for that path.
|
||||
///
|
||||
/// A registration is considered stale in two cases:
|
||||
/// - all [`SimpleLogger`] clones have been dropped, so the [`Weak`] entry can no
|
||||
/// longer be upgraded
|
||||
/// - the underlying channel has already been explicitly closed via
|
||||
/// [`SimpleLogger::close`], which means the writer is logically dead even if
|
||||
/// some [`Arc`] handles still exist briefly
|
||||
///
|
||||
/// Supporting both cases lets callers opt into eager, explicit shutdown without
|
||||
/// tying path reuse strictly to the last clone being dropped.
|
||||
pub struct LogManager {
|
||||
namespaces: HashSet<String>,
|
||||
loggers: HashMap<PathBuf, Weak<LogFileWriter>>,
|
||||
}
|
||||
|
||||
impl Default for LogManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LogManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
namespaces: HashSet::new(),
|
||||
loggers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a log namespace with the given cleanup policy.
|
||||
///
|
||||
/// On first call for a given name, stores the namespace and purges its
|
||||
/// directory if `purge_on_startup` is true. Subsequent calls for the same
|
||||
/// name are no-ops.
|
||||
pub fn register_namespace(&mut self, name: &str, purge_on_startup: bool) {
|
||||
if self.namespaces.contains(name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if purge_on_startup {
|
||||
let dir = log_directory_path(name);
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir) {
|
||||
if e.kind() != ErrorKind::NotFound {
|
||||
log::warn!("Failed to purge log directory {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.namespaces.insert(name.to_string());
|
||||
}
|
||||
|
||||
/// Registers a logger for a path relative to the namespace's log directory.
|
||||
///
|
||||
/// Returns an error if the namespace has not been registered, or if a logger
|
||||
/// is already alive for that path. Stale registrations (where all clones have
|
||||
/// been dropped or the channel has been explicitly closed) are reclaimed silently.
|
||||
pub fn register(
|
||||
&mut self,
|
||||
namespace: &str,
|
||||
relative_path: impl AsRef<Path>,
|
||||
executor: Arc<Background>,
|
||||
) -> Result<SimpleLogger, LogManagerError> {
|
||||
if !self.namespaces.contains(namespace) {
|
||||
return Err(LogManagerError::UnknownNamespace {
|
||||
namespace: namespace.to_string(),
|
||||
});
|
||||
}
|
||||
let path = resolve_log_path(namespace, relative_path);
|
||||
self.register_resolved_path(path, executor)
|
||||
}
|
||||
|
||||
fn register_resolved_path(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
executor: Arc<Background>,
|
||||
) -> Result<SimpleLogger, LogManagerError> {
|
||||
if let Some(existing) = self.loggers.get(&path) {
|
||||
if let Some(writer) = existing.upgrade() {
|
||||
// A live `Arc` alone is not enough to keep the path reserved.
|
||||
// Callers may explicitly close a logger to mark the stream as
|
||||
// finished before every clone has been dropped.
|
||||
if !writer.is_closed() {
|
||||
return Err(LogManagerError::LoggerAlreadyActive { path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In the absence of an active logger at this path, initialize and return a new logger,
|
||||
// which truncates any existing log file on creation.
|
||||
let logger = SimpleLogger::new(path.clone(), executor);
|
||||
self.loggers.insert(path, logger.downgrade());
|
||||
Ok(logger)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum LogManagerEvent {}
|
||||
|
||||
impl Entity for LogManager {
|
||||
type Event = LogManagerEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for LogManager {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "manager_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
use super::LogManager;
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"simple-logger-tests-{name}-{}-{id}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
fn cleanup_log_path(log_path: &Path) {
|
||||
let _ = std::fs::remove_file(log_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::remove_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_resolved_path_reuses_stale_entries_after_drop() {
|
||||
let mut manager = LogManager::new();
|
||||
let executor = Arc::new(Background::default());
|
||||
let log_path = temp_path("re-register").join("server.log");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone())
|
||||
.expect("initial registration should succeed");
|
||||
|
||||
drop(logger);
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor)
|
||||
.expect("stale entry should be reclaimed after the logger is dropped");
|
||||
drop(logger);
|
||||
cleanup_log_path(&log_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_resolved_path_rejects_duplicate_active_loggers() {
|
||||
let mut manager = LogManager::new();
|
||||
let executor = Arc::new(Background::default());
|
||||
let log_path = temp_path("collision").join("server.log");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone())
|
||||
.expect("initial registration should succeed");
|
||||
assert!(
|
||||
manager
|
||||
.register_resolved_path(log_path.clone(), executor)
|
||||
.is_err(),
|
||||
"live logger should block duplicate registration"
|
||||
);
|
||||
|
||||
drop(logger);
|
||||
cleanup_log_path(&log_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_reclaims_closed_logger() {
|
||||
let mut manager = LogManager::new();
|
||||
let executor = Arc::new(Background::default());
|
||||
let log_path = temp_path("close-reclaim").join("server.log");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone())
|
||||
.expect("initial registration should succeed");
|
||||
|
||||
// Close the channel without dropping the logger — the Arc<LogFileWriter> is still alive.
|
||||
logger.close();
|
||||
|
||||
let new_logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor)
|
||||
.expect("closed logger should be reclaimed even when Arc is still alive");
|
||||
|
||||
drop(logger);
|
||||
drop(new_logger);
|
||||
cleanup_log_path(&log_path);
|
||||
}
|
||||
Reference in New Issue
Block a user