use std::time::Duration;
use super::actor::SessionActor;
use super::types::{
ClientId, EndReason, OwnedConfig, OwnerBind, OwnerToken, SecretEnv, SessionCommand, SessionEventWire, ToolEnv,
};
pub fn owner_rebind_grace() -> Duration {
std::env::var("SYNAPS_DAEMON_OWNER_REBIND_SECS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(30))
}
pub fn owner_restore_cap() -> Duration {
std::env::var("SYNAPS_DAEMON_OWNER_RESTORE_CAP_SECS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300))
}
pub(crate) fn initial_rebind_deadline(restored: bool) -> tokio::time::Instant {
let grace = owner_rebind_grace();
tokio::time::Instant::now() + if restored { grace + owner_restore_cap() } else { grace }
}
pub(crate) fn armed_rebind_deadline(current: Option<tokio::time::Instant>) -> tokio::time::Instant {
let armed = tokio::time::Instant::now() + owner_rebind_grace();
current.map_or(armed, |c| c.min(armed))
}
pub const OWNED_INPUT_REFUSAL: &str = "this session is owned by the client that created it; only that client can \
drive it (others may watch: attach with mirror or observe)";
pub const OWNED_TAKEOVER_REFUSAL: &str = "this session is owned by the client that created it and cannot be taken \
over; attach with mirror or observe to watch it";
pub const OWNED_PIN_REFUSAL: &str = "this session is owned by the client that created it and ends when that \
client disconnects, so it cannot be pinned or kept warm";
pub(crate) struct OwnedState {
token: OwnerToken,
pub(crate) client: Option<ClientId>,
pub(crate) rebind_deadline: Option<tokio::time::Instant>,
creator_pending: bool,
alive: Option<tokio_util::sync::CancellationToken>,
pub(crate) awaiting_owner: bool,
}
impl OwnedState {
pub(crate) fn new(owned: OwnedConfig) -> Self {
Self {
token: owned.token,
client: None,
rebind_deadline: Some(initial_rebind_deadline(owned.restored)),
creator_pending: !owned.restored,
alive: None,
awaiting_owner: owned.restored,
}
}
pub(crate) fn arm(&mut self) -> bool {
if self.client.is_some() {
return false;
}
self.rebind_deadline = Some(armed_rebind_deadline(self.rebind_deadline));
true
}
pub(crate) fn lease(&self) -> Option<tokio_util::sync::CancellationToken> {
self.alive.clone()
}
}
pub(crate) async fn rebind_timer(deadline: Option<tokio::time::Instant>) {
match deadline {
Some(t) => tokio::time::sleep_until(t).await,
None => std::future::pending().await,
}
}
pub(crate) async fn lease_closed(lease: Option<tokio_util::sync::CancellationToken>) {
match lease {
Some(t) => t.cancelled().await,
None => std::future::pending().await,
}
}
fn process_env_utf8() -> super::types::SessionEnv {
std::env::vars_os().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))).collect()
}
impl SessionActor {
pub(crate) fn is_session_owner(&self, client: ClientId) -> bool {
self.owned.as_ref().is_some_and(|o| o.client == Some(client))
}
pub(crate) fn drop_dead_claim(&mut self, bind: &OwnerBind) -> bool {
if !bind.alive.is_cancelled() {
return false;
}
let Some(owned) = self.owned.as_mut() else { return true };
if owned.creator_pending && owned.token.matches(&bind.claim.token) {
tracing::info!(session = %self.id, "owned session: the creator's connection is gone before it bound; ending");
owned.rebind_deadline = Some(tokio::time::Instant::now());
} else {
tracing::info!(session = %self.id, "owned session: a re-bind whose connection is already gone; ignored");
}
true
}
pub(crate) fn bind_owner(&mut self, client: ClientId, bind: OwnerBind) {
let Some(owned) = self.owned.as_mut() else {
tracing::warn!(session = %self.id, "owner claim on a session that is not owned; attached as a plain client");
return;
};
if !owned.token.matches(&bind.claim.token) {
tracing::warn!(session = %self.id, "owner claim with the wrong token; attached as a plain client");
return;
}
owned.client = Some(client);
owned.rebind_deadline = None;
owned.alive = Some(bind.alive);
owned.creator_pending = false;
owned.awaiting_owner = false;
tracing::info!(session = %self.id, client = client.0, "owned session: owner bound");
if let Some(secrets) = bind.claim.forward_secrets {
self.apply_forwarded_secrets(secrets);
}
}
pub(crate) fn arm_owner_rebind(&mut self) {
if self.owned.as_mut().is_some_and(OwnedState::arm) {
tracing::info!(session = %self.id, "owned session: waiting for the owner to re-bind");
}
}
pub(crate) fn watches_owned(&self, c: ClientId) -> bool {
self.owned.is_some() && !self.is_session_owner(c)
}
pub(crate) fn owned_refusal(&self, c: ClientId, cmd: &SessionCommand) -> Option<String> {
let read_only = matches!(
cmd,
SessionCommand::Query { .. } | SessionCommand::Detach { .. } | SessionCommand::Resync { .. } | SessionCommand::Save
);
(self.watches_owned(c) && !read_only).then(|| OWNED_INPUT_REFUSAL.to_string())
}
fn apply_forwarded_secrets(&mut self, secrets: SecretEnv) {
if !self.runtime.is_live() {
return;
}
let vars = self.config.env.clone().unwrap_or_else(process_env_utf8);
let stripped: Vec<String> = self
.config
.env_stripped
.iter()
.filter(|n| !secrets.0.iter().any(|(s, _)| s == *n))
.cloned()
.collect();
let forwarded = secrets.0.len();
self.runtime.set_tool_env(Some(ToolEnv::with_secrets(vars, secrets)));
self.runtime.set_env_stripped(stripped);
tracing::info!(session = %self.id, forwarded, "owned session: forwarded secrets applied to the tool env");
}
pub(crate) fn detach_client(&mut self, client: ClientId) -> Option<EndReason> {
let owner_left = self.is_session_owner(client);
self.detach(client);
if !owner_left {
return None;
}
let reason = self.owner_left()?;
self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
if self.driver.is_some() {
self.driver_revoke("session ending");
}
Some(reason)
}
pub(crate) fn owner_lease_closed(&mut self) -> Option<EndReason> {
let owned = self.owned.as_mut()?;
owned.alive = None;
let client = owned.client?;
tracing::info!(session = %self.id, client = client.0, "owned session: the owner's connection is gone");
self.detach_client(client)
}
pub(crate) fn owner_left(&mut self) -> Option<EndReason> {
let fenced = self.reload_fence.is_some();
let owned = self.owned.as_mut()?;
owned.client = None;
owned.alive = None;
if fenced {
owned.rebind_deadline = Some(tokio::time::Instant::now() + owner_rebind_grace());
owned.awaiting_owner = true;
tracing::info!(session = %self.id, "owned session: owner left for a reload; waiting for it to re-bind");
self.publish_presence();
return None;
}
tracing::info!(session = %self.id, "owned session: owner gone; ending the session");
Some(EndReason::OwnerGone)
}
pub(crate) fn owner_rebind_expired(&mut self) -> Option<EndReason> {
let fenced = self.reload_fence.is_some();
let owned = self.owned.as_mut()?;
if owned.client.is_some() {
owned.rebind_deadline = None;
return None;
}
if fenced {
owned.rebind_deadline = Some(tokio::time::Instant::now() + owner_rebind_grace());
return None;
}
owned.rebind_deadline = None;
tracing::info!(session = %self.id, "owned session: owner did not bind within the grace; ending the session");
Some(EndReason::OwnerGone)
}
pub(crate) fn refuse_owned_pin(&mut self, from: Option<ClientId>) {
match from {
Some(c) => self.emit(SessionEventWire::Refused {
client: c,
command: super::actor::command_name(&SessionCommand::KeepWarm { on: true }).to_string(),
reason: OWNED_PIN_REFUSAL.to_string(),
}),
None => self.emit(SessionEventWire::SystemNotice(OWNED_PIN_REFUSAL.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn a_restored_session_has_a_fallback_deadline_the_arm_only_shortens() {
let now = tokio::time::Instant::now();
let grace = owner_rebind_grace();
let fresh = OwnedState::new(OwnedConfig { token: OwnerToken::mint(), restored: false });
assert_eq!(fresh.rebind_deadline, Some(now + grace));
let mut restored = OwnedState::new(OwnedConfig { token: OwnerToken::mint(), restored: true });
assert_eq!(restored.rebind_deadline, Some(now + grace + owner_restore_cap()));
assert!(restored.arm());
assert_eq!(restored.rebind_deadline, Some(now + grace));
tokio::time::advance(Duration::from_secs(10)).await;
assert!(restored.arm());
assert_eq!(restored.rebind_deadline, Some(now + grace), "a repeated arm never extends it");
restored.client = Some(ClientId(1));
assert!(!restored.arm(), "a bound session has no deadline to arm");
}
}