use std::io;
#[cfg(unix)]
use std::path::PathBuf;
use std::sync::Arc;
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use interprocess::local_socket::tokio::{prelude::*, Listener, Stream};
use interprocess::local_socket::{ListenerOptions, Name};
use tokio::sync::mpsc;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
use super::proto::{ControlRequest, ControlResponse};
use crate::workspace::{expand_and_canonicalize, WorkspaceConfig, WorkspaceRegistry};
const CONNECTION_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const CLIENT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const ACCEPT_ERROR_BACKOFF: std::time::Duration = std::time::Duration::from_millis(100);
#[derive(Clone, Debug)]
pub struct ControlSocketName {
raw: String,
}
impl ControlSocketName {
pub fn default_name() -> io::Result<Self> {
#[cfg(unix)]
{
let path = default_socket_path()?;
Ok(Self {
raw: path.to_string_lossy().into_owned(),
})
}
#[cfg(windows)]
{
Ok(Self {
raw: "markon-control".to_string(),
})
}
}
pub fn from_raw(raw: impl Into<String>) -> Self {
Self { raw: raw.into() }
}
pub fn as_str(&self) -> &str {
&self.raw
}
#[cfg(unix)]
fn path(&self) -> PathBuf {
PathBuf::from(&self.raw)
}
fn to_name(&self) -> io::Result<Name<'_>> {
#[cfg(unix)]
{
use interprocess::local_socket::GenericFilePath;
self.raw.as_str().to_fs_name::<GenericFilePath>()
}
#[cfg(windows)]
{
use interprocess::local_socket::GenericNamespaced;
self.raw.as_str().to_ns_name::<GenericNamespaced>()
}
}
}
#[cfg(unix)]
fn default_socket_path() -> io::Result<PathBuf> {
let home = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME directory required"))?;
Ok(home.join(".markon").join("control.sock"))
}
#[derive(Clone)]
pub struct ControlContext {
pub registry: Arc<WorkspaceRegistry>,
pub shutdown: Option<mpsc::Sender<()>>,
pub admin_bootstrap: Option<AdminBootstrapFn>,
pub admin_bootstrap_code: Option<AdminBootstrapCodeFn>,
}
pub type AdminBootstrapFn = Arc<dyn Fn(&str) -> Result<String, String> + Send + Sync>;
pub type AdminBootstrapCodeFn = Arc<dyn Fn(&str) -> Result<(String, String), String> + Send + Sync>;
impl ControlContext {
pub fn new(registry: Arc<WorkspaceRegistry>) -> Self {
Self {
registry,
shutdown: None,
admin_bootstrap: None,
admin_bootstrap_code: None,
}
}
}
pub fn dispatch(req: ControlRequest, ctx: &ControlContext) -> ControlResponse {
match req {
ControlRequest::ListWorkspaces => ControlResponse::Workspaces(ctx.registry.info_list()),
ControlRequest::AddWorkspace {
path,
flags,
collaborator_access_code_hash,
single_file,
alias,
} => {
let path = match expand_and_canonicalize(&path) {
Ok(p) => p,
Err(e) => return ControlResponse::Err(format!("invalid path: {e}")),
};
if let Some(name) = single_file.as_deref() {
let mut components = std::path::Path::new(name).components();
let exactly_one_normal =
matches!(components.next(), Some(std::path::Component::Normal(_)))
&& components.next().is_none();
if !exactly_one_normal {
return ControlResponse::Err(
"single_file must be one workspace-relative file name".to_string(),
);
}
}
let id = ctx.registry.add(WorkspaceConfig {
path,
flags,
single_file,
collaborator_access_code_hash,
alias,
});
ControlResponse::WorkspaceId(id)
}
ControlRequest::UpdateFlags { id, flags } => {
if ctx.registry.update_flags(&id, flags) {
ControlResponse::Ok
} else {
ControlResponse::Err(format!("no such workspace: {id}"))
}
}
ControlRequest::SetAlias { id, alias } => {
if ctx.registry.set_alias(&id, &alias) {
ControlResponse::Ok
} else {
ControlResponse::Err(format!("no such workspace: {id}"))
}
}
ControlRequest::RemoveWorkspace { id } => {
if ctx.registry.remove(&id) {
ControlResponse::Ok
} else {
ControlResponse::Err(format!("no such workspace: {id}"))
}
}
ControlRequest::SetAccessCode {
id,
collaborator_access_code_hash,
} => match collaborator_access_code_hash {
Some(hash) => {
if ctx.registry.set_collaborator_access_code(&id, &hash) {
ControlResponse::Ok
} else {
ControlResponse::Err(format!("no such workspace: {id}"))
}
}
None => ControlResponse::Ok,
},
ControlRequest::AdminBootstrap { redirect } => match &ctx.admin_bootstrap {
Some(issue) => match issue(&redirect) {
Ok(url) => ControlResponse::Url(url),
Err(e) => ControlResponse::Err(e),
},
None => ControlResponse::Err("admin bootstrap unsupported".to_string()),
},
ControlRequest::AdminBootstrapCode { redirect } => match &ctx.admin_bootstrap_code {
Some(issue) => match issue(&redirect) {
Ok((url, code)) => ControlResponse::AdminCode { url, code },
Err(e) => ControlResponse::Err(e),
},
None => ControlResponse::Err("admin code bootstrap unsupported".to_string()),
},
ControlRequest::Shutdown => match &ctx.shutdown {
Some(tx) => {
let _ = tx.try_send(());
ControlResponse::Ok
}
None => ControlResponse::Err("shutdown unsupported".to_string()),
},
}
}
pub struct ControlServer {
name: ControlSocketName,
listener: Listener,
}
impl ControlServer {
pub fn name(&self) -> &ControlSocketName {
&self.name
}
pub async fn run(
self,
ctx: ControlContext,
mut stop: impl std::future::Future<Output = ()> + Unpin,
) -> io::Result<()> {
tracing::debug!(socket = %self.name.as_str(), "control server listening");
loop {
tokio::select! {
_ = &mut stop => {
tracing::debug!("control server stopping");
break;
}
accepted = self.listener.accept() => {
match accepted {
Ok(stream) => {
let ctx = ctx.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, &ctx).await {
tracing::debug!("control connection error: {e}");
}
});
}
Err(e) => {
tracing::warn!("control accept error: {e}");
tokio::time::sleep(ACCEPT_ERROR_BACKOFF).await;
}
}
}
}
}
#[cfg(unix)]
let _ = std::fs::remove_file(self.name.path());
Ok(())
}
}
pub fn bind(name: &ControlSocketName) -> io::Result<ControlServer> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = name.path().parent() {
std::fs::create_dir_all(parent)?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
}
if name.path().exists() {
if probe(name) {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
"a live markon control server is already bound to this socket",
));
}
let _ = std::fs::remove_file(name.path());
}
}
let listener = ListenerOptions::new()
.name(name.to_name()?)
.create_tokio()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(name.path(), std::fs::Permissions::from_mode(0o600))?;
}
Ok(ControlServer {
name: name.clone(),
listener,
})
}
pub async fn serve(
name: &ControlSocketName,
ctx: ControlContext,
stop: impl std::future::Future<Output = ()> + Unpin,
) -> io::Result<()> {
bind(name)?.run(ctx, stop).await
}
pub fn probe(name: &ControlSocketName) -> bool {
use interprocess::local_socket::traits::Stream as _;
match name.to_name() {
Ok(n) => interprocess::local_socket::Stream::connect(n).is_ok(),
Err(_) => false,
}
}
async fn handle_connection(stream: Stream, ctx: &ControlContext) -> io::Result<()> {
let mut framed = Framed::new(stream, LengthDelimitedCodec::new());
let next = match tokio::time::timeout(CONNECTION_READ_TIMEOUT, framed.next()).await {
Ok(next) => next,
Err(_) => return Ok(()), };
let Some(frame) = next else {
return Ok(()); };
let frame = frame?;
let response = match serde_json::from_slice::<ControlRequest>(&frame) {
Ok(req) => dispatch(req, ctx),
Err(e) => ControlResponse::Err(format!("malformed request: {e}")),
};
let bytes =
serde_json::to_vec(&response).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
framed.send(Bytes::from(bytes)).await?;
framed.flush().await?;
Ok(())
}
pub(super) async fn request(
name: &ControlSocketName,
req: &ControlRequest,
) -> io::Result<ControlResponse> {
tokio::time::timeout(CLIENT_REQUEST_TIMEOUT, request_inner(name, req))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "control request timed out"))?
}
async fn request_inner(
name: &ControlSocketName,
req: &ControlRequest,
) -> io::Result<ControlResponse> {
let stream = Stream::connect(name.to_name()?).await?;
let mut framed = Framed::new(stream, LengthDelimitedCodec::new());
let bytes =
serde_json::to_vec(req).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
framed.send(Bytes::from(bytes)).await?;
framed.flush().await?;
let Some(frame) = framed.next().await else {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"control server closed the connection without responding",
));
};
let frame = frame?;
serde_json::from_slice::<ControlResponse>(&frame)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}