671 lines
23 KiB
Rust
671 lines
23 KiB
Rust
use std::collections::{HashMap, VecDeque};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, mpsc};
|
|
use std::thread::JoinHandle;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Result, anyhow};
|
|
use bds_core::db::Database;
|
|
use bds_core::engine::domain_events::EventSubscription;
|
|
use bds_core::engine::task::{TaskManager, TaskSnapshot, TaskStatus};
|
|
use bds_core::engine::{domain_events, project, settings};
|
|
use bds_core::scripting::{CoreHost, HostApi};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::protocol::{Command, PROTOCOL_VERSION, RemoteTask, Request, ServerMessage};
|
|
|
|
pub fn run_local_terminal(host: ApplicationHost) -> Result<()> {
|
|
crate::tui::run_local(host)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ApplicationHost {
|
|
inner: Arc<HostInner>,
|
|
}
|
|
|
|
struct HostInner {
|
|
database_path: PathBuf,
|
|
data_root: PathBuf,
|
|
tasks: Arc<TaskManager>,
|
|
sync_watcher: SyncWatcher,
|
|
completed_requests: Mutex<HashMap<String, ServerMessage>>,
|
|
execution_lock: Mutex<()>,
|
|
}
|
|
|
|
struct SyncWatcher {
|
|
shutdown: mpsc::Sender<()>,
|
|
thread: Option<JoinHandle<()>>,
|
|
errors: Arc<Mutex<WatcherErrors>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct WatcherErrors {
|
|
next_id: u64,
|
|
entries: VecDeque<(u64, String)>,
|
|
}
|
|
|
|
impl SyncWatcher {
|
|
fn start(database_path: PathBuf) -> Result<Self> {
|
|
let (shutdown, shutdown_rx) = mpsc::channel();
|
|
let (started_tx, started_rx) = mpsc::sync_channel(1);
|
|
let errors = Arc::new(Mutex::new(WatcherErrors::default()));
|
|
let thread_errors = Arc::clone(&errors);
|
|
let thread = std::thread::Builder::new()
|
|
.name("bds-cli-sync-watcher".into())
|
|
.spawn(move || {
|
|
let database = match Database::open(&database_path) {
|
|
Ok(database) => database,
|
|
Err(error) => {
|
|
let _ = started_tx.send(Err(error.to_string()));
|
|
return;
|
|
}
|
|
};
|
|
let _ = started_tx.send(Ok(()));
|
|
loop {
|
|
match shutdown_rx.recv_timeout(Duration::from_millis(100)) {
|
|
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
if let Err(error) =
|
|
bds_core::engine::cli_sync::poll_notifications(database.conn())
|
|
{
|
|
let mut errors = lock(&thread_errors);
|
|
let message = error.to_string();
|
|
if errors
|
|
.entries
|
|
.back()
|
|
.is_some_and(|(_, last)| last == &message)
|
|
{
|
|
continue;
|
|
}
|
|
errors.next_id += 1;
|
|
let id = errors.next_id;
|
|
errors.entries.push_back((id, message));
|
|
if errors.entries.len() > 128 {
|
|
errors.entries.pop_front();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})?;
|
|
match started_rx.recv() {
|
|
Ok(Ok(())) => Ok(Self {
|
|
shutdown,
|
|
thread: Some(thread),
|
|
errors,
|
|
}),
|
|
Ok(Err(error)) => {
|
|
let _ = thread.join();
|
|
Err(anyhow!("could not start the CLI sync watcher: {error}"))
|
|
}
|
|
Err(_) => {
|
|
let _ = thread.join();
|
|
Err(anyhow!("the CLI sync watcher stopped during startup"))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn errors_since(&self, id: u64) -> Vec<(u64, String)> {
|
|
lock(&self.errors)
|
|
.entries
|
|
.iter()
|
|
.filter(|(entry_id, _)| *entry_id > id)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl Drop for SyncWatcher {
|
|
fn drop(&mut self) {
|
|
let _ = self.shutdown.send(());
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ApplicationHost {
|
|
pub fn start(database_path: PathBuf, data_root: PathBuf) -> Result<Self> {
|
|
if let Some(parent) = database_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let db = Database::open(&database_path)?;
|
|
db.migrate()
|
|
.map_err(|error| anyhow!("could not migrate the application database: {error}"))?;
|
|
bds_core::engine::search::prepare_search_index(db.conn())?;
|
|
let sync_watcher = SyncWatcher::start(database_path.clone())?;
|
|
Ok(Self {
|
|
inner: Arc::new(HostInner {
|
|
database_path,
|
|
data_root,
|
|
tasks: Arc::new(TaskManager::default()),
|
|
sync_watcher,
|
|
completed_requests: Mutex::new(HashMap::new()),
|
|
execution_lock: Mutex::new(()),
|
|
}),
|
|
})
|
|
}
|
|
|
|
pub fn tasks(&self) -> Arc<TaskManager> {
|
|
Arc::clone(&self.inner.tasks)
|
|
}
|
|
|
|
pub fn session(&self) -> Result<ApplicationSession> {
|
|
let db = self.database()?;
|
|
let locale = settings::ui_language(db.conn())?
|
|
.map(|value| bds_core::i18n::normalize_language(&value))
|
|
.unwrap_or_else(bds_core::i18n::detect_os_locale)
|
|
.code()
|
|
.to_owned();
|
|
Ok(ApplicationSession {
|
|
host: self.clone(),
|
|
selected_project: None,
|
|
negotiated: false,
|
|
locale,
|
|
sequence: 0,
|
|
events: domain_events::subscribe(),
|
|
last_tasks: Vec::new(),
|
|
last_watcher_error: 0,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn database(&self) -> Result<Database> {
|
|
Database::open(&self.inner.database_path).map_err(Into::into)
|
|
}
|
|
|
|
pub(crate) fn database_path(&self) -> &std::path::Path {
|
|
&self.inner.database_path
|
|
}
|
|
|
|
pub(crate) fn project_data_dir(&self, project: &bds_core::model::Project) -> PathBuf {
|
|
project
|
|
.data_path
|
|
.as_deref()
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| self.inner.data_root.join("projects").join(&project.id))
|
|
}
|
|
|
|
fn cached(&self, id: &str) -> Option<ServerMessage> {
|
|
lock(&self.inner.completed_requests).get(id).cloned()
|
|
}
|
|
|
|
fn remember(&self, id: String, response: ServerMessage) {
|
|
let mut completed = lock(&self.inner.completed_requests);
|
|
if completed.len() >= 4_096
|
|
&& let Some(oldest) = completed.keys().next().cloned()
|
|
{
|
|
completed.remove(&oldest);
|
|
}
|
|
completed.insert(id, response);
|
|
}
|
|
}
|
|
|
|
pub struct ApplicationSession {
|
|
host: ApplicationHost,
|
|
selected_project: Option<SelectedProject>,
|
|
negotiated: bool,
|
|
locale: String,
|
|
sequence: u64,
|
|
events: EventSubscription,
|
|
last_tasks: Vec<RemoteTask>,
|
|
last_watcher_error: u64,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SelectedProject {
|
|
id: String,
|
|
data_dir: PathBuf,
|
|
}
|
|
|
|
impl ApplicationSession {
|
|
pub fn locale(&self) -> &str {
|
|
&self.locale
|
|
}
|
|
|
|
pub fn handle(&mut self, request: Request) -> ServerMessage {
|
|
let idempotent = matches!(request.command, Command::Call { .. });
|
|
if idempotent {
|
|
let inner = Arc::clone(&self.host.inner);
|
|
let _execution = lock(&inner.execution_lock);
|
|
if let Some(response) = self.host.cached(&request.id) {
|
|
return response;
|
|
}
|
|
let id = request.id;
|
|
let response = self.response(id.clone(), &request.command);
|
|
self.host.remember(id, response.clone());
|
|
return response;
|
|
}
|
|
let id = request.id;
|
|
self.response(id, &request.command)
|
|
}
|
|
|
|
fn response(&mut self, id: String, command: &Command) -> ServerMessage {
|
|
match self.execute(command) {
|
|
Ok(result) => ServerMessage::Response { id, result },
|
|
Err(error) => ServerMessage::Error {
|
|
id,
|
|
code: error.code.to_owned(),
|
|
message: error.message,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn pending(&mut self) -> Vec<ServerMessage> {
|
|
let mut messages = self
|
|
.events
|
|
.drain()
|
|
.into_iter()
|
|
.filter(|event| {
|
|
event.project_id().is_none()
|
|
|| self
|
|
.selected_project
|
|
.as_ref()
|
|
.is_some_and(|project| event.project_id() == Some(project.id.as_str()))
|
|
})
|
|
.map(|event| {
|
|
self.sequence += 1;
|
|
ServerMessage::Event {
|
|
sequence: self.sequence,
|
|
event,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let tasks = self
|
|
.host
|
|
.inner
|
|
.tasks
|
|
.snapshots()
|
|
.into_iter()
|
|
.map(remote_task)
|
|
.collect::<Vec<_>>();
|
|
if tasks != self.last_tasks {
|
|
self.last_tasks.clone_from(&tasks);
|
|
self.sequence += 1;
|
|
messages.push(ServerMessage::Tasks {
|
|
sequence: self.sequence,
|
|
tasks,
|
|
});
|
|
}
|
|
for (id, message) in self
|
|
.host
|
|
.inner
|
|
.sync_watcher
|
|
.errors_since(self.last_watcher_error)
|
|
{
|
|
self.last_watcher_error = id;
|
|
messages.push(ServerMessage::Error {
|
|
id: String::new(),
|
|
code: "sync_watcher_error".into(),
|
|
message,
|
|
});
|
|
}
|
|
messages
|
|
}
|
|
|
|
fn execute(&mut self, command: &Command) -> Result<Value, ProtocolError> {
|
|
if !matches!(command, Command::Hello { .. }) && !self.negotiated {
|
|
return Err(ProtocolError::new(
|
|
"protocol_required",
|
|
"hello must be the first request",
|
|
));
|
|
}
|
|
match command {
|
|
Command::Hello { protocol_version } => {
|
|
if *protocol_version != PROTOCOL_VERSION {
|
|
return Err(ProtocolError::new(
|
|
"unsupported_protocol",
|
|
format!(
|
|
"unsupported remote protocol {protocol_version}; server requires {PROTOCOL_VERSION}"
|
|
),
|
|
));
|
|
}
|
|
self.negotiated = true;
|
|
Ok(json!({
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"server_name": "Blogging Desktop Server",
|
|
"locale": self.locale,
|
|
}))
|
|
}
|
|
Command::ListProjects => {
|
|
let db = self.host.database().map_err(ProtocolError::engine)?;
|
|
let projects = project::list_projects(db.conn()).map_err(ProtocolError::engine)?;
|
|
serde_json::to_value(projects).map_err(ProtocolError::engine)
|
|
}
|
|
Command::OpenProject { project_id } => {
|
|
let db = self.host.database().map_err(ProtocolError::engine)?;
|
|
let value =
|
|
bds_core::db::queries::project::get_project_by_id(db.conn(), project_id)
|
|
.map_err(|_| {
|
|
ProtocolError::new(
|
|
"project_not_found",
|
|
format!("project '{project_id}' was not found on the server"),
|
|
)
|
|
})?;
|
|
let data_dir = value
|
|
.data_path
|
|
.as_deref()
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| self.host.inner.data_root.join("projects").join(&value.id));
|
|
if !data_dir.join("meta/project.json").is_file() {
|
|
return Err(ProtocolError::new(
|
|
"project_unavailable",
|
|
format!("project '{}' data is unavailable", value.name),
|
|
));
|
|
}
|
|
self.selected_project = Some(SelectedProject {
|
|
id: value.id.clone(),
|
|
data_dir,
|
|
});
|
|
serde_json::to_value(value).map_err(ProtocolError::engine)
|
|
}
|
|
Command::Call {
|
|
namespace,
|
|
method,
|
|
arguments,
|
|
} => {
|
|
let selected = self.selected_project.as_ref().ok_or_else(|| {
|
|
ProtocolError::new("project_required", "open a remote project first")
|
|
})?;
|
|
CoreHost::new(
|
|
&self.host.inner.database_path,
|
|
&selected.id,
|
|
&selected.data_dir,
|
|
)
|
|
.with_task_manager(Arc::clone(&self.host.inner.tasks))
|
|
.call(namespace, method, arguments.clone())
|
|
.map_err(|message| ProtocolError::new("engine_error", message))
|
|
}
|
|
Command::Ping => Ok(json!({"ok": true})),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ProtocolError {
|
|
code: &'static str,
|
|
message: String,
|
|
}
|
|
|
|
impl ProtocolError {
|
|
fn new(code: &'static str, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn engine(error: impl std::fmt::Display) -> Self {
|
|
Self::new("engine_error", error.to_string())
|
|
}
|
|
}
|
|
|
|
fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
|
|
let (status, failure) = match snapshot.status {
|
|
TaskStatus::Pending => ("pending", None),
|
|
TaskStatus::Running => ("running", None),
|
|
TaskStatus::Completed => ("completed", None),
|
|
TaskStatus::Failed(message) => ("failed", Some(message)),
|
|
TaskStatus::Cancelled => ("cancelled", None),
|
|
};
|
|
RemoteTask {
|
|
id: snapshot.id,
|
|
label: snapshot.label,
|
|
status: status.to_owned(),
|
|
progress: snapshot.progress,
|
|
message: snapshot.message.or(failure),
|
|
}
|
|
}
|
|
|
|
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
mutex
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::protocol::{Command, Request};
|
|
|
|
struct Fixture {
|
|
_root: tempfile::TempDir,
|
|
host: ApplicationHost,
|
|
project_id: String,
|
|
}
|
|
|
|
impl Fixture {
|
|
fn new() -> Self {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let database_path = root.path().join("bds.db");
|
|
let data_root = root.path().join("data");
|
|
let host = ApplicationHost::start(database_path.clone(), data_root.clone()).unwrap();
|
|
let db = Database::open(&database_path).unwrap();
|
|
let project_dir = root.path().join("blog");
|
|
let value = project::create_project(
|
|
db.conn(),
|
|
"Remote Blog",
|
|
Some(project_dir.to_str().unwrap()),
|
|
)
|
|
.unwrap();
|
|
settings::set(db.conn(), settings::UI_LANGUAGE_KEY, "de").unwrap();
|
|
Self {
|
|
_root: root,
|
|
host,
|
|
project_id: value.id,
|
|
}
|
|
}
|
|
|
|
fn session(&self) -> ApplicationSession {
|
|
let mut session = self.host.session().unwrap();
|
|
assert!(matches!(
|
|
session.handle(request(
|
|
"hello",
|
|
Command::Hello {
|
|
protocol_version: PROTOCOL_VERSION,
|
|
}
|
|
)),
|
|
ServerMessage::Response { .. }
|
|
));
|
|
session
|
|
}
|
|
}
|
|
|
|
fn request(id: &str, command: Command) -> Request {
|
|
Request {
|
|
id: id.to_owned(),
|
|
command,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn session_negotiates_server_locale_and_selects_a_project() {
|
|
let fixture = Fixture::new();
|
|
let mut session = fixture.host.session().unwrap();
|
|
assert_eq!(session.locale(), "de");
|
|
assert!(matches!(
|
|
session.handle(request("early", Command::ListProjects)),
|
|
ServerMessage::Error { ref code, .. } if code == "protocol_required"
|
|
));
|
|
let mut session = fixture.session();
|
|
let listed = session.handle(request("list", Command::ListProjects));
|
|
assert!(matches!(listed, ServerMessage::Response { .. }));
|
|
let opened = session.handle(request(
|
|
"open",
|
|
Command::OpenProject {
|
|
project_id: fixture.project_id.clone(),
|
|
},
|
|
));
|
|
assert!(matches!(opened, ServerMessage::Response { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn two_clients_observe_one_ordered_mutation_and_request_replay_is_idempotent() {
|
|
let fixture = Fixture::new();
|
|
let mut first = fixture.session();
|
|
let mut second = fixture.session();
|
|
for (index, session) in [&mut first, &mut second].into_iter().enumerate() {
|
|
session.handle(request(
|
|
&format!("open-{index}"),
|
|
Command::OpenProject {
|
|
project_id: fixture.project_id.clone(),
|
|
},
|
|
));
|
|
let _ = session.pending();
|
|
}
|
|
let create = request(
|
|
"globally-unique-create",
|
|
Command::Call {
|
|
namespace: "posts".into(),
|
|
method: "create".into(),
|
|
arguments: vec![json!({"title":"Exactly Once","content":"body"})],
|
|
},
|
|
);
|
|
assert!(matches!(
|
|
first.handle(create.clone()),
|
|
ServerMessage::Response { .. }
|
|
));
|
|
assert_eq!(second.handle(create.clone()), first.handle(create));
|
|
|
|
let first_events = first.pending();
|
|
let second_events = second.pending();
|
|
assert_eq!(event_count(&first_events, &fixture.project_id), 1);
|
|
assert_eq!(event_count(&second_events, &fixture.project_id), 1);
|
|
assert!(strictly_ordered(&first_events));
|
|
assert!(strictly_ordered(&second_events));
|
|
|
|
let db = fixture.host.database().unwrap();
|
|
assert_eq!(
|
|
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn simultaneous_replay_from_two_clients_executes_the_write_once() {
|
|
let fixture = Fixture::new();
|
|
let mut first = fixture.session();
|
|
let mut second = fixture.session();
|
|
for (index, session) in [&mut first, &mut second].into_iter().enumerate() {
|
|
session.handle(request(
|
|
&format!("parallel-open-{index}"),
|
|
Command::OpenProject {
|
|
project_id: fixture.project_id.clone(),
|
|
},
|
|
));
|
|
}
|
|
let barrier = Arc::new(std::sync::Barrier::new(2));
|
|
let command = request(
|
|
"same-concurrent-id",
|
|
Command::Call {
|
|
namespace: "posts".into(),
|
|
method: "create".into(),
|
|
arguments: vec![json!({"title":"Concurrent","content":"body"})],
|
|
},
|
|
);
|
|
let first_barrier = Arc::clone(&barrier);
|
|
let first_command = command.clone();
|
|
let first = std::thread::spawn(move || {
|
|
first_barrier.wait();
|
|
first.handle(first_command)
|
|
});
|
|
let second = std::thread::spawn(move || {
|
|
barrier.wait();
|
|
second.handle(command)
|
|
});
|
|
assert_eq!(first.join().unwrap(), second.join().unwrap());
|
|
let db = fixture.host.database().unwrap();
|
|
assert_eq!(
|
|
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn task_progress_is_shared_without_repeating_unchanged_snapshots() {
|
|
let fixture = Fixture::new();
|
|
let mut session = fixture.session();
|
|
let _ = session.pending();
|
|
let task = fixture.host.tasks().submit("Generate site");
|
|
fixture
|
|
.host
|
|
.tasks()
|
|
.report_progress(task, Some(0.5), Some("Writing".into()));
|
|
let update = session.pending();
|
|
assert!(matches!(
|
|
update.as_slice(),
|
|
[ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5)
|
|
));
|
|
assert!(session.pending().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn headless_sync_watcher_republishes_external_cli_notifications() {
|
|
let fixture = Fixture::new();
|
|
let mut session = fixture.session();
|
|
session.handle(request(
|
|
"open-for-sync",
|
|
Command::OpenProject {
|
|
project_id: fixture.project_id.clone(),
|
|
},
|
|
));
|
|
let _ = session.pending();
|
|
|
|
let db = fixture.host.database().unwrap();
|
|
bds_core::engine::cli_sync::record_cli_event(
|
|
db.conn(),
|
|
&bds_core::model::DomainEvent::EntityChanged {
|
|
project_id: fixture.project_id.clone(),
|
|
entity: bds_core::model::DomainEntity::Post,
|
|
entity_id: "external-post".into(),
|
|
action: bds_core::model::NotificationAction::Created,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
|
let mut updates = Vec::new();
|
|
while std::time::Instant::now() < deadline {
|
|
updates.extend(session.pending());
|
|
if event_count(&updates, &fixture.project_id) == 1 {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
|
|
assert_eq!(event_count(&updates, &fixture.project_id), 1);
|
|
let notifications =
|
|
bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap();
|
|
assert_eq!(notifications.len(), 1);
|
|
assert!(notifications[0].seen_at.is_some());
|
|
}
|
|
|
|
fn event_count(messages: &[ServerMessage], project_id: &str) -> usize {
|
|
messages
|
|
.iter()
|
|
.filter(|message| {
|
|
matches!(
|
|
message,
|
|
ServerMessage::Event { event, .. }
|
|
if event.project_id() == Some(project_id)
|
|
)
|
|
})
|
|
.count()
|
|
}
|
|
|
|
fn strictly_ordered(messages: &[ServerMessage]) -> bool {
|
|
let sequences = messages
|
|
.iter()
|
|
.filter_map(|message| match message {
|
|
ServerMessage::Event { sequence, .. } | ServerMessage::Tasks { sequence, .. } => {
|
|
Some(*sequence)
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
sequences.windows(2).all(|pair| pair[0] < pair[1])
|
|
}
|
|
}
|