pub(crate) use astrid_uplink::socket_client::{
SocketClient, pid_path, proxy_socket_path, readiness_path,
};
use std::future::Future;
use anyhow::Result;
use astrid_core::PrincipalId;
use astrid_uplink::KernelClient;
enum KernelConnectionScope<'a> {
Workspace(Option<&'a std::path::Path>),
Recovery,
}
impl<'a> KernelConnectionScope<'a> {
const fn requires_workspace_check(&self) -> bool {
matches!(self, Self::Workspace(_))
}
fn workspace_root(self) -> Option<&'a std::path::Path> {
match self {
Self::Workspace(workspace_root) => workspace_root,
Self::Recovery => None,
}
}
}
pub(crate) async fn connect_for_workspace(
session: astrid_core::SessionId,
principal: astrid_core::PrincipalId,
workspace_root: Option<&std::path::Path>,
) -> WorkspaceConnectionResult<SocketClient> {
connect_workspace_client(workspace_root, || SocketClient::connect(session, principal)).await
}
pub(crate) async fn reconnect_for_workspace<Validate>(
client: &mut SocketClient,
principal: PrincipalId,
workspace_root: Option<&std::path::Path>,
validate: Validate,
) -> WorkspaceConnectionResult<()>
where
Validate: FnOnce(&SocketClient) -> Result<()>,
{
let session = client.session_id.clone();
replace_between_workspace_checks(
client,
|| crate::commands::daemon::ensure_daemon_workspace_matches(workspace_root),
|| SocketClient::connect(session, principal),
validate,
)
.await
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum WorkspaceConnectionError {
#[error("{0:#}")]
Selection(#[source] anyhow::Error),
#[error("{0:#}")]
Connect(#[source] anyhow::Error),
}
pub(crate) type WorkspaceConnectionResult<T> = std::result::Result<T, WorkspaceConnectionError>;
pub(crate) async fn connect_workspace_client<T, Connect, ConnectFuture>(
workspace_root: Option<&std::path::Path>,
connect: Connect,
) -> WorkspaceConnectionResult<T>
where
Connect: FnOnce() -> ConnectFuture,
ConnectFuture: Future<Output = Result<T>>,
{
connect_between_workspace_checks(
|| crate::commands::daemon::ensure_daemon_workspace_matches(workspace_root),
connect,
)
.await
}
pub(crate) async fn connect_kernel_for_workspace(
workspace_root: Option<&std::path::Path>,
) -> Result<KernelClient> {
connect_kernel(KernelConnectionScope::Workspace(workspace_root)).await
}
pub(crate) async fn connect_kernel_for_recovery() -> Result<KernelClient> {
connect_kernel(KernelConnectionScope::Recovery).await
}
async fn connect_kernel(scope: KernelConnectionScope<'_>) -> Result<KernelClient> {
if scope.requires_workspace_check() {
let workspace_root = scope.workspace_root();
return Ok(connect_workspace_client(workspace_root, || {
KernelClient::connect(crate::principal::current())
})
.await?);
}
KernelClient::connect(crate::principal::current()).await
}
async fn connect_between_workspace_checks<T, Check, CheckFuture, Connect, ConnectFuture>(
mut check: Check,
connect: Connect,
) -> WorkspaceConnectionResult<T>
where
Check: FnMut() -> CheckFuture,
CheckFuture: Future<Output = Result<()>>,
Connect: FnOnce() -> ConnectFuture,
ConnectFuture: Future<Output = Result<T>>,
{
check().await.map_err(WorkspaceConnectionError::Selection)?;
let client = connect().await.map_err(WorkspaceConnectionError::Connect)?;
check().await.map_err(WorkspaceConnectionError::Selection)?;
Ok(client)
}
async fn replace_between_workspace_checks<T, Check, CheckFuture, Connect, ConnectFuture>(
current: &mut T,
check: Check,
connect: Connect,
validate: impl FnOnce(&T) -> Result<()>,
) -> WorkspaceConnectionResult<()>
where
Check: FnMut() -> CheckFuture,
CheckFuture: Future<Output = Result<()>>,
Connect: FnOnce() -> ConnectFuture,
ConnectFuture: Future<Output = Result<T>>,
{
let replacement = connect_between_workspace_checks(check, connect).await?;
validate(&replacement).map_err(WorkspaceConnectionError::Connect)?;
*current = replacement;
Ok(())
}
pub(crate) async fn send_input_as_active_agent(
client: &mut SocketClient,
text: String,
) -> Result<()> {
client.send_input(text).await
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::future;
use std::path::Path;
use super::{
KernelConnectionScope, WorkspaceConnectionError, connect_between_workspace_checks,
replace_between_workspace_checks,
};
#[test]
fn kernel_connection_scope_checks_project_reads_but_keeps_recovery_global() {
let explicit = Path::new("/selected/project");
let explicit_scope = KernelConnectionScope::Workspace(Some(explicit));
assert!(explicit_scope.requires_workspace_check());
assert_eq!(explicit_scope.workspace_root(), Some(explicit));
let default_scope = KernelConnectionScope::Workspace(None);
assert!(default_scope.requires_workspace_check());
assert_eq!(default_scope.workspace_root(), None);
let recovery_scope = KernelConnectionScope::Recovery;
assert!(!recovery_scope.requires_workspace_check());
assert_eq!(recovery_scope.workspace_root(), None);
}
#[tokio::test]
async fn workspace_mismatch_prevents_connection() {
let connected = Cell::new(false);
let result = connect_between_workspace_checks(
|| future::ready(Err(anyhow::anyhow!("workspace mismatch"))),
|| async {
connected.set(true);
Ok(())
},
)
.await;
assert!(matches!(
result,
Err(WorkspaceConnectionError::Selection(_))
));
assert!(!connected.get());
}
#[tokio::test]
async fn post_connection_mismatch_rejects_retargeted_client() {
let checks = Cell::new(0_u8);
let connected = Cell::new(false);
let result = connect_between_workspace_checks(
|| {
checks.set(checks.get() + 1);
future::ready(if checks.get() == 1 {
Ok(())
} else {
Err(anyhow::anyhow!("daemon restarted for another workspace"))
})
},
|| async {
connected.set(true);
Ok(())
},
)
.await
.unwrap_err();
assert!(matches!(result, WorkspaceConnectionError::Selection(_)));
assert!(connected.get());
assert_eq!(checks.get(), 2);
}
#[tokio::test]
async fn matching_workspace_connects_between_two_checks() {
let checks = Cell::new(0_u8);
let client = connect_between_workspace_checks(
|| {
checks.set(checks.get() + 1);
future::ready(Ok(()))
},
|| async { Ok(42_u8) },
)
.await
.unwrap();
assert_eq!(client, 42);
assert_eq!(checks.get(), 2);
}
#[tokio::test]
async fn pre_reconnect_mismatch_never_dials_or_replaces() {
let dialed = Cell::new(false);
let mut client = 7_u8;
let result = replace_between_workspace_checks(
&mut client,
|| future::ready(Err(anyhow::anyhow!("workspace mismatch"))),
|| async {
dialed.set(true);
Ok(9_u8)
},
|_| Ok(()),
)
.await;
assert!(matches!(
result,
Err(WorkspaceConnectionError::Selection(_))
));
assert!(!dialed.get());
assert_eq!(client, 7);
}
#[tokio::test]
async fn post_reconnect_mismatch_keeps_the_previous_client() {
let checks = Cell::new(0_u8);
let mut client = 7_u8;
let result = replace_between_workspace_checks(
&mut client,
|| {
checks.set(checks.get() + 1);
future::ready(if checks.get() == 1 {
Ok(())
} else {
Err(anyhow::anyhow!("daemon retargeted during reconnect"))
})
},
|| async { Ok(9_u8) },
|_| Ok(()),
)
.await;
assert!(matches!(
result,
Err(WorkspaceConnectionError::Selection(_))
));
assert_eq!(checks.get(), 2);
assert_eq!(client, 7);
}
#[tokio::test]
async fn matching_reconnect_replaces_only_after_both_checks() {
let checks = Cell::new(0_u8);
let mut client = 7_u8;
replace_between_workspace_checks(
&mut client,
|| {
checks.set(checks.get() + 1);
future::ready(Ok(()))
},
|| async { Ok(9_u8) },
|_| Ok(()),
)
.await
.unwrap();
assert_eq!(checks.get(), 2);
assert_eq!(client, 9);
}
#[tokio::test]
async fn rejected_reconnect_candidate_does_not_replace_the_client() {
let mut client = 7_u8;
let result = replace_between_workspace_checks(
&mut client,
|| future::ready(Ok(())),
|| async { Ok(9_u8) },
|_| Err(anyhow::anyhow!("replacement authentication failed")),
)
.await;
assert!(matches!(result, Err(WorkspaceConnectionError::Connect(_))));
assert_eq!(client, 7);
}
}