mirror of
https://github.com/atuinsh/atuin.git
synced 2026-10-12 02:11:09 +00:00
423 lines
18 KiB
Rust
423 lines
18 KiB
Rust
use std::num::NonZeroUsize;
|
|
use std::sync::Arc;
|
|
|
|
use atuin_client::ai_session::{AiSessionDatabase, AiSessionStore};
|
|
use atuin_client::database::Sqlite as HistoryDatabase;
|
|
use atuin_client::history::store::HistoryStore;
|
|
use atuin_client::record::sqlite_store::SqliteStore;
|
|
use atuin_client::settings::Settings;
|
|
use atuin_client::settings::watcher::global_settings_watcher;
|
|
use atuin_common::sync::BlockingPool;
|
|
use eyre::Result;
|
|
|
|
use crate::grpc::ai::session::pb::ai_session_server::AiSessionServer;
|
|
use crate::grpc::history::pb::history_server::HistoryServer;
|
|
use crate::session_capture::AiHarnessSessionCapture;
|
|
|
|
pub mod client;
|
|
pub mod components;
|
|
pub mod daemon;
|
|
pub mod events;
|
|
pub mod grpc;
|
|
pub(crate) mod history_journal;
|
|
mod octavo;
|
|
mod output_capture;
|
|
pub mod pidfile;
|
|
pub mod search;
|
|
pub mod server;
|
|
pub mod session_capture;
|
|
mod sync;
|
|
|
|
// Re-export core daemon types for convenience
|
|
// Re-export client helpers
|
|
pub use client::{AiClient, HistoryClient};
|
|
// Re-export components
|
|
pub use components::SearchComponent;
|
|
pub use daemon::{AnyComponent, Daemon, DaemonBuilder, DaemonHandle};
|
|
pub use events::DaemonEvent;
|
|
pub use history_journal::{
|
|
CmdCancelError, CmdDeleteError, CmdEvent, CmdFinishError, CmdRebuildError, FinishedCmd,
|
|
GetCmdInFlightError, HistoryJournal, RegisterOutputError,
|
|
};
|
|
pub use octavo::Octavo;
|
|
pub use output_capture::{
|
|
CaptureError, DeleteOutputError, GetOutputError, OutputCaptureEngine, OutputLine, OutputMatch,
|
|
};
|
|
|
|
/// Blocking work running at once in the daemon's [`BlockingPool`]. Tokio's own blocking pool
|
|
/// allows 512 threads, past macOS's default soft limit of 256 open files.
|
|
const MAX_BLOCKING_WORKERS: NonZeroUsize = NonZeroUsize::new(32).expect("32 is non-zero");
|
|
|
|
/// The daemon's version (the Atuin version).
|
|
///
|
|
/// Clients restart a daemon whose version doesn't match theirs; see [`PROTOCOL_VERSION`].
|
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
/// TL;DR:
|
|
///
|
|
/// - If you change the proto files, ensure you use the `reserved` proto keyword for modifying
|
|
/// anything related to the `Shutdown` and `Status` RPCs.
|
|
/// - Go ham on breaking wire compatibility on every other RPC. **Make sure you bump
|
|
/// `PROTOCOL_VERSION`.** The daemon and the client both use this constant.
|
|
///
|
|
/// # Breaking Wire Compatibility
|
|
///
|
|
/// Changing this version impacts the grpc version and at first glance risks incompatibility
|
|
/// changes. This documentation aims to instill some confidence in making sure this prevents
|
|
/// breakages.
|
|
///
|
|
/// ## Restart Behavior
|
|
///
|
|
/// First and foremost, let us consider how the client restarts the daemon. Nominally, the client
|
|
/// will restart the daemon if the daemon has a mismatched version in the following cases:
|
|
///
|
|
/// `atuin::command::client::daemon:ready_client` is the function responsible for fetching a valid
|
|
/// client. Each **history** client->daemon connection goes through this function, which:
|
|
///
|
|
/// 1. Probes the daemon via the `history.Status` RPC.
|
|
/// 2. Checks that the response version `VERSION` (ie. the Atuin version) matches that of
|
|
/// the client and also that `PROTOCOL_VERSION` matches that of the client.
|
|
/// 3. If there is a mismatch, one of two things will happen:
|
|
/// a) If `daemon.autostart = false`, then we kindly ask the user to restart the daemon and
|
|
/// return an error.
|
|
/// b) If `daemon.autostart = true`, then we try to restart the daemon. This means one of
|
|
/// multiple things:
|
|
/// - If `daemon.systemd_socket` is set to false, we tell the user to restart the daemon, and
|
|
/// we bail out.
|
|
/// - Otherwise, we send a `history.Shutdown` RPC (version unchecked) to the daemon which
|
|
/// causes the daemon to shut down. The client then brings it back up, and spinloops until
|
|
/// the daemon is back online.
|
|
///
|
|
/// 4. At this point, either:
|
|
/// - We were unable to restart the daemon and have told the user to manually restart or
|
|
/// - We have restarted the daemon.
|
|
///
|
|
/// ### Transport Layer
|
|
///
|
|
/// I would like to note that restarting the daemon causes the UNIX socket file to be completely
|
|
/// erased (in the non-systemd path), effectively flushing any messages that are enqueued to the
|
|
/// daemon.
|
|
///
|
|
/// One of the concerns was that the daemon would receive old data, even if both the client **and**
|
|
/// the daemon were to be restarted, since stale data could be queued. Luckily, that's not the case!
|
|
///
|
|
/// TODO(markovejnovic): Is this a concern in the systemd case?
|
|
///
|
|
/// ### Different Package Managers
|
|
///
|
|
/// There are many ways to install Atuin and consequently let's analyze which installation method
|
|
/// hits which code path.
|
|
///
|
|
/// #### curl Install Script
|
|
///
|
|
/// - If `daemon.autostart = true` (default when the daemon is enabled in the setup), the daemon
|
|
/// will gracefully be restarted when `PROTOCOL_VERSION` changes. Wire incompatibility is
|
|
/// a-ok in this case.
|
|
/// - If `daemon.autostart = false`, we will tell the user to restart Atuin.
|
|
///
|
|
/// #### Homebrew
|
|
///
|
|
/// <https://raw.githubusercontent.com/Homebrew/homebrew-core/master/Formula/a/atuin.rb> presents
|
|
/// the user with a homebrew service. This means the user can run `brew services start atuin` to run
|
|
/// a new atuin daemon.
|
|
///
|
|
/// We will try to restart it via the `Shutdown` RPC. When the daemon attempts to game end, it will
|
|
/// terminate, and the client will immediately to try to run another daemon:
|
|
///
|
|
/// - Either `keep_alive` (on macOS launchd) wins, which means a new version of the daemon is
|
|
/// spawned, and managed by launchd.
|
|
/// - Or, more likely, the client-spawned daemon wins, which means a new version of the daemon is
|
|
/// managed by the client.
|
|
///
|
|
/// **In effect, if the user updates via brew, the next command we send to the daemon will hit that
|
|
/// `PROTOCOL_VERSION` mismatch and the daemon will get restarted, one way or another.**
|
|
///
|
|
/// The only exception to this rule is if they:
|
|
/// - Ran `brew service start atuin`
|
|
/// - And also said **no** to the daemon during setup.
|
|
///
|
|
/// But in that case, we won't even try to talk to the daemon, so a mismatched version doesn't
|
|
/// matter.
|
|
///
|
|
/// #### Debian
|
|
///
|
|
/// <https://packages.debian.org/sid/amd64/atuin/filelist> is not managed by systemctl and is
|
|
/// therefore identical to the curl Install Script case above, governed entirely by
|
|
/// `enabled`/`autostart`.
|
|
///
|
|
/// #### Ubuntu
|
|
///
|
|
/// <https://packages.ubuntu.com/questing/amd64/atuin/filelist> is the same as the curl Install
|
|
/// Script case above.
|
|
///
|
|
/// #### Fedora
|
|
///
|
|
/// <https://packages.fedoraproject.org/pkgs/atuin/atuin/> seems identical to the curl Install
|
|
/// Script case.
|
|
///
|
|
/// #### Arch
|
|
///
|
|
/// <https://archlinux.org/packages/extra/x86_64/atuin/> is identical to the curl Install Script
|
|
/// case -- we manage the daemon.
|
|
///
|
|
/// #### Alpine
|
|
///
|
|
/// <https://pkgs.alpinelinux.org/package/edge/community/x86_64/atuin> -- the main package install
|
|
/// matches the curl Install Script case.
|
|
///
|
|
/// #### Void
|
|
///
|
|
/// <https://raw.githubusercontent.com/void-linux/void-packages/master/srcpkgs/atuin/template> --
|
|
/// binary-only: the template installs the binary, license, and completions, and the `srcpkgs/atuin`
|
|
/// dir contains only `template` (no `patches/`, no `files/`). Void is runit-based with no daemon
|
|
/// service, so `systemd_socket` is irrelevant and behavior matches the curl Install Script case.
|
|
///
|
|
/// #### home-manager (Nix)
|
|
///
|
|
/// <https://github.com/nix-community/home-manager/blob/master/modules/programs/atuin.nix> exposes
|
|
/// `programs.atuin.daemon.enable`. When enabled it generates a `systemd.user.services.atuin-daemon` +
|
|
/// `systemd.user.sockets.atuin-daemon` pair on Linux, or a `launchd.agents.atuin-daemon` on
|
|
/// macOS. Crucially, it also WRITES our config into `config.toml`:
|
|
///
|
|
/// - `daemon.enabled = true` (always),
|
|
/// - `daemon.systemd_socket = true` on systemd, `false` on launchd,
|
|
/// - `daemon.socket_path = $XDG_DATA_HOME/atuin/daemon.sock` on launchd only.
|
|
///
|
|
/// It never sets `daemon.autostart`, so it stays `false`. That means the client ALWAYS takes the
|
|
/// bail-on-mismatch path -- it never sends `Shutdown` and never respawns the daemon itself.
|
|
/// Replacing the daemon is left to the service manager plus `home-manager switch`.
|
|
///
|
|
/// - On Linux (`systemd_socket = true`, `autostart = false`), the `.socket` listens on
|
|
/// `%t/atuin.sock` (`$XDG_RUNTIME_DIR/atuin.sock`), exactly our socket-activation path. On a
|
|
/// mismatch the client bails. A rebuild changes the unit's `ExecStart` store path, so
|
|
/// activation (the default `sd-switch` backend) restarts the unit; the next connection then
|
|
/// socket-activates the new binary.
|
|
/// - On macOS (`systemd_socket = false`, `autostart = false`), the launchd agent uses a
|
|
/// conditional `KeepAlive` (`Crashed = true; SuccessfulExit = false`), so launchd will NOT
|
|
/// respawn a cleanly-exited daemon -- a stale-version daemon persists until `home-manager
|
|
/// switch` reloads the agent (its `ProgramArguments` store path changes).
|
|
///
|
|
/// Caveat: these settings only land if home-manager can write `config.toml` -- it is generated with
|
|
/// `force = forceOverwriteSettings` (default `false`), and since atuin rewrites its own config, a
|
|
/// pre-existing real `config.toml` blocks them unless `forceOverwriteSettings = true`.
|
|
///
|
|
/// #### Gentoo
|
|
///
|
|
/// The `app-shells/atuin` ebuild, behind the default-on `daemon` USE flag, installs two systemd
|
|
/// USER units via `systemd_douserunit` (it patches nothing and writes no config):
|
|
/// <https://github.com/gentoo/gentoo/blob/master/app-shells/atuin/files/atuin-daemon.socket> and
|
|
/// <https://github.com/gentoo/gentoo/blob/master/app-shells/atuin/files/atuin-daemon.service>.
|
|
///
|
|
/// The socket listens on `%t/atuin.sock` (`$XDG_RUNTIME_DIR/atuin.sock`, our socket-activation
|
|
/// path) with `RemoveOnStop=true`. The service is pure socket-activation: `Requires=` the socket,
|
|
/// `ExecStart=atuin daemon` (the deprecated bare form; we warn in favor of `atuin daemon start`),
|
|
/// and it sets no `Restart=`, so systemd defaults to `Restart=no` and never supervise-restarts it.
|
|
///
|
|
/// Because the ebuild sets no config, the user must set `daemon.systemd_socket = true` (and
|
|
/// `enabled = true`) themselves; the intended pairing is `systemd_socket = true` + `autostart =
|
|
/// false`. On a mismatch the client therefore bails and defers to systemd (autostart is
|
|
/// incompatible with `systemd_socket`).
|
|
///
|
|
/// **In effect an `emerge` upgrade leaves the stale daemon running until it is stopped; the next
|
|
/// client connection then socket-activates a fresh daemon from the new binary.**
|
|
///
|
|
/// ## Evil Cases
|
|
///
|
|
/// Let's consider some cases that severely risk breaking wire compatibility.
|
|
///
|
|
/// ### Mismatched Daemon & Client Versions
|
|
///
|
|
/// If the daemon and client are not running the same daemon protocol version, then a breakage in
|
|
/// wire compatibility means one of three things:
|
|
///
|
|
/// ### You have used the `reserved` keyword
|
|
///
|
|
/// Modifying the `.proto` by changing existing fields is **heavily advised against**.
|
|
/// <https://protobuf.dev/programming-guides/proto3/#assigning> **heavily** urges you to delete
|
|
/// fields and mark the deleted field numbers as reserved.
|
|
///
|
|
/// Consider the case of an updated client and an old server. The old server, as per
|
|
/// <https://protobuf.dev/programming-guides/encoding/#structure>, will **skip** old fields, which
|
|
/// means that old fields will receive the **default** value. In most cases, this is `Option::None`.
|
|
///
|
|
/// ### You have modified (or removed) a field
|
|
///
|
|
/// If you change the type of the field (or remove a field) **without** changing the field number,
|
|
/// then one of two things can happen:
|
|
///
|
|
/// - Either gRPC recognizes the change and returns an error on the mismatched request, or
|
|
/// - gRPC is oblivious to the change you've made, in which case it will misinterpret the data.
|
|
/// Hopefully your domain conversion catches it, and if it doesn't, the daemon will get
|
|
/// mangled data. See <https://protobuf.dev/programming-guides/encoding/#structure> for more
|
|
/// info on when gRPC will misinterpret your data (an example is string -> bytes conversions).
|
|
///
|
|
/// **This can only happen if the `PROTOCOL_VERSION` the daemon was built with and the
|
|
/// `PROTOCOL_VERSION` the client was built with are mismatched**, as that avoids the restart path described above.
|
|
///
|
|
/// **Note that you should never change the `Shutdown` and `Status` RPCs as they do not have the
|
|
/// protocol version guards.** They are **assumed** to be stable and if you want to modify them, you
|
|
/// **must** use the `reserved` keyword.
|
|
pub const PROTOCOL_VERSION: u32 = 5;
|
|
|
|
/// Boot the daemon using the new component-based architecture.
|
|
///
|
|
/// This creates a daemon with the search component, spawns the background sync
|
|
/// engine, starts the gRPC server with their services, and runs the event loop.
|
|
pub async fn boot(
|
|
settings: Settings,
|
|
store: SqliteStore,
|
|
history_db: HistoryDatabase,
|
|
) -> Result<()> {
|
|
// Create the components
|
|
let search_component = SearchComponent::new();
|
|
let blocking_pool = BlockingPool::new(MAX_BLOCKING_WORKERS);
|
|
|
|
let output_capture = match settings.output.limits() {
|
|
Some(limits) => {
|
|
OutputCaptureEngine::open(Settings::command_capture_dir(), limits.max_disk_usage).await
|
|
}
|
|
None => OutputCaptureEngine::nop(),
|
|
};
|
|
|
|
// Get the gRPC services before moving components into the daemon
|
|
// (The services share state with the components via Arc)
|
|
let search_service = search_component.grpc_service(output_capture.store());
|
|
let search_index = search_component.index();
|
|
|
|
// Build the daemon
|
|
let mut daemon = Daemon::builder(settings.clone())
|
|
.store(store)
|
|
.history_db(history_db)
|
|
.component(search_component)
|
|
.build()?;
|
|
|
|
let handle = daemon.handle();
|
|
|
|
let host_id = Settings::host_id().await?;
|
|
|
|
let ai_session_db_path = atuin_client::ai_session::sidecar_path();
|
|
let ai_session_db = match AiSessionDatabase::open(&ai_session_db_path).await {
|
|
Ok(db) => Some(db),
|
|
Err(err) => {
|
|
tracing::error!(
|
|
?err,
|
|
path = ?ai_session_db_path,
|
|
"failed to open the ai-session sidecar; ai-session capture is disabled"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
let ai_session_capture = Arc::new(match &ai_session_db {
|
|
Some(db) => {
|
|
let records = AiSessionStore::builder()
|
|
.store(handle.store().clone())
|
|
.host_id(host_id)
|
|
.key(handle.encryption_key().clone())
|
|
.build();
|
|
|
|
// Reprojects the sidecar from the synced record store in the background, then starts
|
|
// capture. The record store is the source of truth; a sidecar that missed an append
|
|
// (transient error, crash between the two writes, or a lost db file) is repaired there
|
|
// instead of being stranded until — or re-pushed as duplicate records by — file
|
|
// re-capture. Serving does not wait on it.
|
|
AiHarnessSessionCapture::open(
|
|
records,
|
|
db.clone(),
|
|
settings.ai.capture_sessions,
|
|
blocking_pool.clone(),
|
|
)
|
|
}
|
|
None => AiHarnessSessionCapture::nop().await,
|
|
});
|
|
|
|
let _sync_engine = sync::SyncEngine::spawn(
|
|
handle.clone(),
|
|
search_index.clone(),
|
|
ai_session_db,
|
|
ai_session_capture.recovery(),
|
|
);
|
|
|
|
let history_store =
|
|
HistoryStore::new(handle.store().clone(), host_id, handle.encryption_key().clone());
|
|
let journal = Arc::new(HistoryJournal::new(
|
|
handle.caps().clone(),
|
|
history_store,
|
|
handle.history_db().clone(),
|
|
search_index,
|
|
output_capture,
|
|
Octavo::open(&settings, handle.clone()).await,
|
|
));
|
|
let history_service = HistoryServer::new(grpc::HistoryService::new(journal, handle.clone()));
|
|
let ai_session_service = AiSessionServer::new(grpc::AiSessionService::new(ai_session_capture));
|
|
|
|
// Start all components first (so gRPC services can work)
|
|
daemon.start_components().await?;
|
|
|
|
// Spawn config file watcher to reload settings on changes
|
|
if let Ok(watcher) = global_settings_watcher() {
|
|
let mut settings_rx = watcher.subscribe();
|
|
let watcher_handle = handle.clone();
|
|
tokio::spawn(async move {
|
|
tracing::info!("config file watcher started");
|
|
while settings_rx.changed().await.is_ok() {
|
|
// Use the already-loaded settings from the watcher
|
|
// (avoids parsing the config file twice)
|
|
let new_settings = (*settings_rx.borrow()).clone();
|
|
watcher_handle.apply_settings((*new_settings).clone()).await;
|
|
}
|
|
tracing::debug!("config file watcher stopped");
|
|
});
|
|
} else {
|
|
tracing::warn!(
|
|
"failed to start config file watcher; settings changes will require daemon restart"
|
|
);
|
|
}
|
|
|
|
// Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM
|
|
let signal_handle = handle.clone();
|
|
tokio::spawn(async move {
|
|
shutdown_signal().await;
|
|
tracing::info!("received shutdown signal");
|
|
signal_handle.shutdown();
|
|
});
|
|
|
|
// Start the gRPC server in the background
|
|
server::run_grpc_server(
|
|
settings,
|
|
history_service,
|
|
search_service.build(handle.clone()),
|
|
ai_session_service,
|
|
handle,
|
|
)
|
|
.await?;
|
|
|
|
// Run the daemon event loop
|
|
daemon.run_event_loop().await?;
|
|
|
|
// Stop all components on shutdown
|
|
daemon.stop_components().await;
|
|
|
|
tracing::info!("daemon shut down complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait for a shutdown signal (Ctrl+C or SIGTERM).
|
|
#[cfg(unix)]
|
|
async fn shutdown_signal() {
|
|
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
.expect("failed to register sigterm handler");
|
|
let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
|
.expect("failed to register sigint handler");
|
|
|
|
tokio::select! {
|
|
_ = term.recv() => {},
|
|
_ = int.recv() => {},
|
|
}
|
|
}
|
|
|
|
/// Wait for a shutdown signal (Ctrl+C).
|
|
#[cfg(not(unix))]
|
|
async fn shutdown_signal() {
|
|
tokio::signal::ctrl_c().await.expect("failed to listen for ctrl+c");
|
|
}
|