use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use crate::ui;
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Lease {
renew_after_seconds: u64,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Held {
module_id: String,
machine: String,
}
pub struct DevSession {
local: crate::watch_lock::WatchLock,
lease: Option<Arc<LeaseHolder>>,
}
#[derive(Clone)]
pub struct Release {
lock: std::path::PathBuf,
lease: Option<Arc<LeaseHolder>>,
}
impl Release {
pub async fn now(&self) {
let _ = std::fs::remove_file(&self.lock);
let Some(holder) = &self.lease else {
return;
};
let _ = reqwest::Client::new()
.delete(format!("{}/dev/v1/dev-watch", holder.base_url))
.query(&[("sessionId", &holder.session_id)])
.bearer_auth(&holder.token)
.timeout(Duration::from_secs(3))
.send()
.await;
}
}
struct LeaseHolder {
base_url: String,
session_id: String,
token: String,
}
pub async fn start(base_url: &str, module_id: &str, token: &str) -> Result<DevSession> {
let local = crate::watch_lock::acquire(module_id)?;
let session_id = uuid::Uuid::new_v4().to_string();
let holder = Arc::new(LeaseHolder {
base_url: base_url.trim_end_matches('/').to_string(),
session_id,
token: token.to_string(),
});
match hold(&holder, module_id).await {
Ok(Kept::Ours(lease)) => {
spawn_renewal(Arc::clone(&holder), module_id.to_string(), lease);
Ok(DevSession {
local,
lease: Some(holder),
})
}
Ok(Kept::Theirs(held)) => anyhow::bail!(
"your account already has a dev --watch session on {} ({}) — stop it there, or \
run this one without --watch",
held.module_id,
held.machine
),
Err(unreachable) => {
ui::warn("could not reach the dev platform — this session is not held account-wide");
ui::detail(format!("{unreachable:#}"));
ui::advice("another machine on this account could start one too");
Ok(DevSession { local, lease: None })
}
}
}
impl DevSession {
pub fn session_id(&self) -> Option<&str> {
self.lease.as_ref().map(|holder| holder.session_id.as_str())
}
pub fn release(&self) -> Release {
Release {
lock: self.local.path().to_path_buf(),
lease: self.lease.clone(),
}
}
}
enum Kept {
Ours(Lease),
Theirs(Held),
}
async fn hold(holder: &LeaseHolder, module_id: &str) -> Result<Kept> {
let response = reqwest::Client::new()
.put(format!("{}/dev/v1/dev-watch", holder.base_url))
.bearer_auth(&holder.token)
.json(&serde_json::json!({
"sessionId": holder.session_id,
"moduleId": module_id,
"machine": machine(),
}))
.timeout(Duration::from_secs(10))
.send()
.await
.context("ask the dev platform for the watch session")?;
if response.status() == reqwest::StatusCode::CONFLICT {
return Ok(Kept::Theirs(
response
.json()
.await
.context("read who holds the session")?,
));
}
let status = response.status();
if !status.is_success() {
anyhow::bail!("the dev platform answered {status}");
}
Ok(Kept::Ours(response.json().await.context("read the lease")?))
}
fn spawn_renewal(holder: Arc<LeaseHolder>, module_id: String, first: Lease) {
tokio::spawn(async move {
let every = Duration::from_secs(first.renew_after_seconds.clamp(5, 600));
let mut missed = 0_u32;
loop {
tokio::time::sleep(every).await;
match hold(&holder, &module_id).await {
Ok(Kept::Ours(_)) => missed = 0,
Ok(Kept::Theirs(held)) => {
ui::blank();
ui::failure(format!(
"this account's dev --watch session was taken over by {} ({})",
held.module_id, held.machine
));
ui::detail("stopping — two sessions would undo each other's deploys");
std::process::exit(1);
}
Err(_) => {
missed += 1;
if missed == 3 {
ui::blank();
ui::warn("the dev platform has not answered for a while");
ui::detail("this session may no longer be held account-wide");
}
}
}
}
});
}
fn machine() -> String {
for variable in ["HOSTNAME", "HOST", "COMPUTERNAME"] {
if let Ok(name) = std::env::var(variable) {
if !name.trim().is_empty() {
return name.trim().to_string();
}
}
}
std::process::Command::new("hostname")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_machine_always_has_a_name() {
assert!(!machine().is_empty());
}
#[test]
fn a_nonsensical_interval_is_brought_back_to_reason() {
assert_eq!(0_u64.clamp(5, 600), 5);
assert_eq!(86_400_u64.clamp(5, 600), 600);
assert_eq!(30_u64.clamp(5, 600), 30);
}
#[test]
fn the_server_answer_reads_as_the_cli_expects() {
let lease: Lease =
serde_json::from_value(serde_json::json!({ "renewAfterSeconds": 30 })).unwrap();
assert_eq!(lease.renew_after_seconds, 30);
let held: Held = serde_json::from_value(
serde_json::json!({ "moduleId": "weather", "machine": "laptop" }),
)
.unwrap();
assert_eq!(held.module_id, "weather");
}
}