use std::ffi::OsStr;
use std::path::{Path, PathBuf};
#[cfg(feature = "model-relay")]
use std::time::Duration;
#[cfg(feature = "model-relay")]
use crate::error::{OlError, ERR_CLINE_APP_NOT_CAPTURED};
#[cfg(feature = "model-relay")]
use crate::hooks::bindings::cline::ClineBinding;
#[cfg(feature = "model-relay")]
pub(crate) mod process;
#[cfg(feature = "model-relay")]
use process::ProcessTable;
pub(crate) const BUNDLE_ID: &str = "bot.cline.app";
pub const APP_DIR_ENV: &str = "OPENLATCH_CLINE_APP_DIR";
const MAX_PLIST_BYTES: u64 = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ClineApp {
pub(crate) bundle: PathBuf,
pub(crate) sidecar: PathBuf,
pub(crate) version: Option<String>,
}
pub(crate) fn detect() -> Option<ClineApp> {
if cfg!(windows) {
let app_dir = app_dir_value();
detect_in_windows(&windows_candidates(
app_dir.as_deref().map(Path::new),
&uninstall_entries(),
std::env::var_os("LOCALAPPDATA")
.filter(|v| !v.is_empty())
.as_deref()
.map(Path::new),
std::env::var_os("ProgramFiles")
.filter(|v| !v.is_empty())
.as_deref()
.map(Path::new),
))
.or_else(|| app_dir.as_ref().and_then(|_| detect_in(&app_roots())))
} else {
detect_in(&app_roots())
}
}
const WINDOWS_SIDECAR: &str = "code-sidecar.exe";
const WINDOWS_DISPLAY_NAME: &str = "Cline";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UninstallEntry {
pub(crate) display_name: String,
pub(crate) install_location: Option<PathBuf>,
pub(crate) display_version: Option<String>,
}
pub(crate) fn windows_candidates(
app_dir: Option<&Path>,
uninstall: &[UninstallEntry],
local_appdata: Option<&Path>,
program_files: Option<&Path>,
) -> Vec<(PathBuf, Option<String>)> {
let cline_entries: Vec<&UninstallEntry> = uninstall
.iter()
.filter(|e| e.display_name.trim() == WINDOWS_DISPLAY_NAME)
.collect();
let version_for = |dir: &Path| {
cline_entries
.iter()
.find(|e| e.install_location.as_deref() == Some(dir))
.and_then(|e| e.display_version.clone())
};
let mut dirs: Vec<PathBuf> = Vec::new();
match app_dir {
Some(dir) => {
if dir.is_absolute() {
dirs.push(dir.to_path_buf());
}
}
None => {
dirs.extend(
cline_entries
.iter()
.filter_map(|e| e.install_location.clone())
.filter(|p| p.is_absolute()),
);
dirs.extend(local_appdata.map(|d| d.join("Cline")));
dirs.extend(program_files.map(|d| d.join("Cline")));
}
}
let mut out: Vec<(PathBuf, Option<String>)> = Vec::new();
for dir in dirs {
if out.iter().any(|(seen, _)| *seen == dir) {
continue;
}
let version = version_for(&dir);
out.push((dir, version));
}
out
}
pub(crate) fn detect_in_windows(candidates: &[(PathBuf, Option<String>)]) -> Option<ClineApp> {
candidates.iter().find_map(|(dir, version)| {
let sidecar = dir.join(WINDOWS_SIDECAR);
sidecar.is_file().then(|| ClineApp {
bundle: dir.clone(),
sidecar,
version: version.clone(),
})
})
}
#[cfg(windows)]
fn uninstall_entries() -> Vec<UninstallEntry> {
use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
let mut out = win_registry::entries_under(HKEY_CURRENT_USER);
out.extend(win_registry::entries_under(HKEY_LOCAL_MACHINE));
out
}
#[cfg(not(windows))]
fn uninstall_entries() -> Vec<UninstallEntry> {
Vec::new()
}
#[cfg(windows)]
mod win_registry {
use std::ffi::{OsStr, OsString};
use std::os::windows::ffi::{OsStrExt, OsStringExt};
use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegEnumKeyExW, RegGetValueW, RegOpenKeyExW, HKEY, KEY_READ, RRF_RT_REG_SZ,
};
use super::UninstallEntry;
const MAX_VALUE_BYTES: u32 = 64 * 1024;
const MAX_SUBKEYS: u32 = 4096;
fn wide(s: &str) -> Vec<u16> {
OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
pub(super) fn entries_under(root: HKEY) -> Vec<UninstallEntry> {
let subkey = wide("Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall");
let mut hkey: HKEY = std::ptr::null_mut();
let rc = unsafe { RegOpenKeyExW(root, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
if rc != ERROR_SUCCESS {
return Vec::new();
}
let mut out = Vec::new();
for index in 0..MAX_SUBKEYS {
let mut name = [0u16; 256];
let mut len: u32 = name.len() as u32;
let rc = unsafe {
RegEnumKeyExW(
hkey,
index,
name.as_mut_ptr(),
&mut len,
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if rc != ERROR_SUCCESS {
break;
}
let mut sub: Vec<u16> = name[..(len as usize).min(name.len())].to_vec();
sub.push(0);
let Some(display_name) = read_sz(hkey, &sub, "DisplayName") else {
continue;
};
out.push(UninstallEntry {
display_name,
install_location: read_sz(hkey, &sub, "InstallLocation")
.map(|v| PathBuf::from(v.trim().trim_matches('"')))
.filter(|p| !p.as_os_str().is_empty()),
display_version: read_sz(hkey, &sub, "DisplayVersion")
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty()),
});
}
unsafe { RegCloseKey(hkey) };
out
}
fn read_sz(parent: HKEY, sub: &[u16], value: &str) -> Option<String> {
let value = wide(value);
let mut bytes: u32 = 0;
let rc = unsafe {
RegGetValueW(
parent,
sub.as_ptr(),
value.as_ptr(),
RRF_RT_REG_SZ,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut bytes,
)
};
if rc != ERROR_SUCCESS || bytes == 0 || bytes > MAX_VALUE_BYTES {
return None;
}
let mut buf = vec![0u16; bytes as usize / 2 + 1];
let mut written = bytes;
let rc = unsafe {
RegGetValueW(
parent,
sub.as_ptr(),
value.as_ptr(),
RRF_RT_REG_SZ,
std::ptr::null_mut(),
buf.as_mut_ptr().cast(),
&mut written,
)
};
if rc != ERROR_SUCCESS {
return None;
}
let len = (written as usize / 2).saturating_sub(1).min(buf.len());
let text = OsString::from_wide(&buf[..len])
.to_string_lossy()
.into_owned();
(!text.is_empty()).then_some(text)
}
}
fn app_dir_value() -> Option<std::ffi::OsString> {
std::env::var_os(APP_DIR_ENV).filter(|v| !v.is_empty())
}
fn app_roots() -> Vec<PathBuf> {
match app_dir_value() {
Some(dir) => {
let dir = PathBuf::from(dir);
if dir.is_absolute() {
vec![dir.join("Cline.app")]
} else {
Vec::new()
}
}
None => production_roots(dirs::home_dir().as_deref()),
}
}
fn production_roots(home: Option<&Path>) -> Vec<PathBuf> {
if !cfg!(target_os = "macos") {
return Vec::new();
}
let mut v = vec![PathBuf::from("/Applications").join("Cline.app")]; if let Some(h) = home {
v.push(h.join("Applications").join("Cline.app"));
}
v
}
pub(crate) fn not_detectable_here() -> bool {
!cfg!(windows)
&& not_detectable_with(
app_dir_value().as_deref(),
&production_roots(dirs::home_dir().as_deref()),
)
}
fn not_detectable_with(app_dir: Option<&OsStr>, production: &[PathBuf]) -> bool {
app_dir.is_none() && production.is_empty()
}
fn detect_in(roots: &[PathBuf]) -> Option<ClineApp> {
roots.iter().find_map(|bundle| {
let plist = bundle.join("Contents").join("Info.plist");
let len = std::fs::metadata(&plist).ok()?.len();
if len > MAX_PLIST_BYTES {
return None;
}
let xml = std::fs::read_to_string(&plist).ok()?;
if plist_string(&xml, "CFBundleIdentifier").as_deref() != Some(BUNDLE_ID) {
return None;
}
let version = plist_string(&xml, "CFBundleShortVersionString")?;
let sidecar = bundle.join("Contents").join("MacOS").join("code-sidecar");
if !sidecar.is_file() {
return None;
}
Some(ClineApp {
bundle: bundle.clone(),
sidecar,
version: Some(version),
})
})
}
fn plist_string(xml: &str, key: &str) -> Option<String> {
if xml.starts_with("bplist00") {
return None;
}
let needle = format!("<key>{key}</key>");
let after = &xml[xml.find(&needle)? + needle.len()..];
let rest = after.trim_start().strip_prefix("<string>")?;
let raw = &rest[..rest.find("</string>")?];
Some(decode_xml_entities(raw))
}
fn decode_xml_entities(raw: &str) -> String {
raw.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
#[cfg(feature = "model-relay")]
pub(crate) const HUB_ARG: &str = "--cline-hub-daemon";
#[cfg(feature = "model-relay")]
pub(crate) const HUB_PORT_ENV: &str = "CLINE_HUB_PORT";
#[cfg(feature = "model-relay")]
pub(crate) const HUB_DAEMON_ENV: &str = "CLINE_RUN_AS_HUB_DAEMON";
#[cfg(feature = "model-relay")]
pub(crate) const HUB_HOST_ENV: &str = "CLINE_HUB_HOST";
#[cfg(feature = "model-relay")]
const HUB_HOST: &str = "127.0.0.1";
#[cfg(feature = "model-relay")]
pub(crate) const DEFAULT_HUB_PORT: u16 = 25463;
#[cfg(feature = "model-relay")]
pub(crate) const WRAPPER_VARS: [&str; 7] = [
"HTTPS_PROXY",
"HTTP_PROXY",
"https_proxy",
"http_proxy",
"NODE_EXTRA_CA_CERTS",
"NO_PROXY",
"no_proxy",
];
#[cfg(feature = "model-relay")]
pub(crate) const HUB_STARTUP_POLL: Duration = Duration::from_millis(200);
#[cfg(feature = "model-relay")]
const HUB_STARTUP_POLLS: u32 = 40;
#[cfg(feature = "model-relay")]
const HUB_RELEASE_POLLS: u32 = 10;
#[cfg(feature = "model-relay")]
pub(crate) fn hub_port() -> u16 {
std::env::var(HUB_PORT_ENV)
.ok()
.and_then(|v| v.trim().parse::<u16>().ok())
.filter(|p| *p != 0)
.unwrap_or(DEFAULT_HUB_PORT)
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StaleWhy {
NoOurEnv,
OlderApp {
hub: String,
installed: String,
},
ProxyChanged,
EnvChanged,
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HubState {
Absent,
Ours {
pid: u32,
},
Foreign {
pid: u32,
},
Stale {
pid: u32,
why: StaleWhy,
},
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct ProxyEnvVars {
pub(crate) proxy_url: String,
pub(crate) ca_pem: PathBuf,
pub(crate) no_proxy: String,
}
#[cfg(feature = "model-relay")]
impl ProxyEnvVars {
pub(crate) fn for_relay(relay_port: u16) -> ProxyEnvVars {
ProxyEnvVars {
proxy_url: format!("http://127.0.0.1:{relay_port}"),
ca_pem: this_install_ca_pem(),
no_proxy: crate::hooks::proxy_env_file::relay_no_proxy(relay_port),
}
}
pub(crate) fn overrides(
&self,
inherited_upper: Option<&str>,
inherited_lower: Option<&str>,
) -> Vec<(String, String)> {
let merge = |inherited: Option<&str>| match inherited {
Some(v) if !v.is_empty() => format!("{v},{}", self.no_proxy),
_ => self.no_proxy.clone(),
};
let values = [
self.proxy_url.clone(),
self.proxy_url.clone(),
self.proxy_url.clone(),
self.proxy_url.clone(),
self.ca_pem.display().to_string(),
merge(inherited_upper),
merge(inherited_lower),
];
WRAPPER_VARS
.iter()
.zip(values)
.map(|(name, value)| ((*name).to_string(), value))
.collect()
}
fn is_exactly_in(&self, env: &[(String, String)]) -> bool {
env_value(env, "NODE_EXTRA_CA_CERTS") == Some(self.ca_pem.display().to_string().as_str())
&& env_value(env, "HTTPS_PROXY") == Some(self.proxy_url.as_str())
}
}
#[cfg(feature = "model-relay")]
pub(crate) fn this_install_ca_pem() -> PathBuf {
let ol = crate::config::openlatch_dir();
crate::model_relay::ca::ca_pem_path(&crate::model_relay::ca::ca_dir(&ol))
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct PluginEnv {
pub(crate) wrapper_path: PathBuf,
pub(crate) runtime: PathBuf,
}
#[cfg(feature = "model-relay")]
impl PluginEnv {
fn vars(&self) -> [(&'static str, String); 2] {
let [wrapper, runtime] = crate::core::login_env::ALLOWED;
[
(wrapper, self.wrapper_path.display().to_string()),
(runtime, self.runtime.display().to_string()),
]
}
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct Desired {
pub(crate) capture: Option<ProxyEnvVars>,
pub(crate) plugin: Option<PluginEnv>,
}
#[cfg(feature = "model-relay")]
impl Desired {
pub(crate) fn is_empty(&self) -> bool {
self.capture.is_none() && self.plugin.is_none()
}
}
#[cfg(feature = "model-relay")]
pub(crate) fn hub_env(port: u16, d: &Desired) -> Vec<(String, String)> {
let mut env = hub_mode_env(port);
if let Some(capture) = &d.capture {
env.extend(capture.overrides(
std::env::var("NO_PROXY").ok().as_deref(),
std::env::var("no_proxy").ok().as_deref(),
));
}
if let Some(plugin) = &d.plugin {
env.extend(plugin.vars().map(|(k, v)| (k.to_string(), v)));
}
env
}
#[cfg(feature = "model-relay")]
pub(crate) fn signature(d: &Desired) -> String {
let mut fields: std::collections::BTreeMap<&str, String> = std::collections::BTreeMap::new();
if let Some(c) = &d.capture {
fields.insert("capture.proxy_url", c.proxy_url.clone());
fields.insert("capture.ca_pem", c.ca_pem.display().to_string());
fields.insert("capture.no_proxy", c.no_proxy.clone());
}
if let Some(p) = &d.plugin {
fields.insert("plugin.wrapper_path", p.wrapper_path.display().to_string());
fields.insert("plugin.runtime", p.runtime.display().to_string());
}
let body: String = fields.iter().map(|(k, v)| format!("{k}={v}\n")).collect();
crate::hooks::hook_files::sha256_hex(&body)
}
#[cfg(feature = "model-relay")]
pub(crate) fn carries_desired(env: &[(String, String)], d: &Desired) -> bool {
d.capture.as_ref().is_none_or(|c| c.is_exactly_in(env))
&& d.plugin.as_ref().is_none_or(|p| {
p.vars()
.iter()
.all(|(k, v)| env_value(env, k) == Some(v.as_str()))
})
}
#[cfg(feature = "model-relay")]
fn env_value<'a>(env: &'a [(String, String)], key: &str) -> Option<&'a str> {
env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
#[cfg(feature = "model-relay")]
fn env_https_proxy(env: &[(String, String)]) -> Option<&str> {
env_value(env, "HTTPS_PROXY").or_else(|| env_value(env, "https_proxy"))
}
#[cfg(feature = "model-relay")]
fn carries_our_proxy(env: &[(String, String)]) -> bool {
env_value(env, "NODE_EXTRA_CA_CERTS")
== Some(this_install_ca_pem().display().to_string().as_str())
&& env_https_proxy(env).is_some_and(|u| u.starts_with("http://127.0.0.1:"))
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct HubRecord {
pid: u32,
start: u64,
exe: PathBuf,
port: u16,
#[serde(default)]
app_version: Option<String>,
#[serde(default)]
proxy_url: Option<String>,
#[serde(default)]
ca_pem: Option<PathBuf>,
#[serde(default)]
env_sig: String,
#[serde(default)]
plugin: bool,
}
#[cfg(feature = "model-relay")]
impl HubRecord {
fn of(pid: u32, start: u64, app: &ClineApp, port: u16, desired: &Desired) -> HubRecord {
HubRecord {
pid,
start,
exe: app.sidecar.clone(),
port,
app_version: app.version.clone(),
proxy_url: desired.capture.as_ref().map(|c| c.proxy_url.clone()),
ca_pem: desired.capture.as_ref().map(|c| c.ca_pem.clone()),
env_sig: signature(desired),
plugin: desired.plugin.is_some(),
}
}
}
#[cfg(feature = "model-relay")]
fn record_path() -> PathBuf {
crate::config::openlatch_dir()
.join("model-relay")
.join("cline-hub.json")
}
#[cfg(feature = "model-relay")]
fn read_record() -> Option<HubRecord> {
let raw = std::fs::read_to_string(record_path()).ok()?;
serde_json::from_str(&raw).ok()
}
#[cfg(feature = "model-relay")]
fn write_record(record: &HubRecord) -> Result<(), OlError> {
let path = record_path();
let fail = |e: String| {
OlError::new(
ERR_CLINE_APP_NOT_CAPTURED,
format!("could not write the Cline.app hub ownership record: {e}"),
)
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| fail(e.to_string()))?;
}
let body = serde_json::to_string_pretty(record).map_err(|e| fail(e.to_string()))?;
crate::fs_secure::write_readable(&path, &body).map_err(|e| fail(e.to_string()))
}
#[cfg(feature = "model-relay")]
pub(crate) fn remove_record() {
match std::fs::remove_file(record_path()) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(
code = ERR_CLINE_APP_NOT_CAPTURED,
error = %e,
"could not remove the Cline.app hub ownership record"
),
}
}
#[cfg(feature = "model-relay")]
pub(crate) fn forget_record_unless(state: &HubState) {
let Some(r) = read_record() else { return };
let gone = match state {
HubState::Absent => true,
HubState::Foreign { pid: 0 } => false,
HubState::Ours { pid } | HubState::Stale { pid, .. } | HubState::Foreign { pid } => {
*pid != r.pid
}
};
if gone {
remove_record();
}
}
#[cfg(feature = "model-relay")]
pub(crate) fn hub_state(port: u16) -> HubState {
hub_state_with(
&*process::table(),
port,
detect().as_ref(),
&Desired::default(),
)
}
#[cfg(feature = "model-relay")]
pub(crate) fn same_exe(a: Option<&Path>, b: &Path) -> bool {
let Some(a) = a else { return false };
match (canon_key(a), canon_key(b)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
#[cfg(feature = "model-relay")]
pub(crate) fn canon_key(path: &Path) -> Option<PathBuf> {
std::fs::canonicalize(path)
.ok()
.map(|p| crate::path_compat::dedup_key(&p))
}
#[cfg(feature = "model-relay")]
fn is_record_of(rec: Option<&HubRecord>, pid: u32, info: &process::ProcInfo) -> bool {
rec.is_some_and(|r| {
r.pid == pid && same_exe(info.exe.as_deref(), &r.exe) && info.start == Some(r.start)
})
}
#[cfg(feature = "model-relay")]
pub(crate) fn hub_state_with(
t: &dyn ProcessTable,
port: u16,
app: Option<&ClineApp>,
desired: &Desired,
) -> HubState {
hub_state_inspected(t, port, app, desired).0
}
#[cfg(feature = "model-relay")]
pub(crate) fn hub_state_inspected(
t: &dyn ProcessTable,
port: u16,
app: Option<&ClineApp>,
desired: &Desired,
) -> (HubState, Option<process::ProcInfo>) {
hub_state_of(t, t.listener(port), app, desired)
}
#[cfg(feature = "model-relay")]
fn hub_state_of(
t: &dyn ProcessTable,
listener: Result<Option<u32>, String>,
app: Option<&ClineApp>,
desired: &Desired,
) -> (HubState, Option<process::ProcInfo>) {
let pid = match listener {
Ok(None) => return (HubState::Absent, None),
Ok(Some(p)) => p,
Err(_) => return (HubState::Foreign { pid: 0 }, None), };
let Some(info) = t.inspect(pid) else {
return (HubState::Foreign { pid }, None);
};
let state = classify(pid, &info, app, desired);
(state, Some(info))
}
#[cfg(feature = "model-relay")]
fn classify(
pid: u32,
info: &process::ProcInfo,
app: Option<&ClineApp>,
desired: &Desired,
) -> HubState {
if !info.args.iter().any(|a| a.contains(HUB_ARG)) {
return HubState::Foreign { pid };
}
let rec = read_record();
let by_record = is_record_of(rec.as_ref(), pid, info);
match app {
Some(a) => {
if !same_exe(info.exe.as_deref(), &a.sidecar) {
return HubState::Foreign { pid };
}
}
None => {
if !by_record {
return HubState::Foreign { pid };
}
}
}
let hub_capture: Option<String> = if by_record {
rec.as_ref().and_then(|r| r.proxy_url.clone())
} else {
info.env
.as_deref()
.filter(|e| carries_our_proxy(e))
.and_then(env_https_proxy)
.map(str::to_string)
};
if let Some(hub) = hub_capture.as_deref() {
if Some(hub) != desired.capture.as_ref().map(|c| c.proxy_url.as_str()) {
return HubState::Stale {
pid,
why: StaleWhy::ProxyChanged,
};
}
}
if !by_record {
let carries = info
.env
.as_deref()
.is_some_and(|e| !desired.is_empty() && carries_desired(e, desired));
return if carries {
HubState::Ours { pid }
} else {
HubState::Stale {
pid,
why: StaleWhy::NoOurEnv,
}
};
}
if let (Some(hub), Some(installed)) = (
rec.as_ref()
.and_then(|r| r.app_version.as_ref())
.filter(|v| !v.is_empty()),
app.and_then(|a| a.version.as_ref()),
) {
if hub != installed {
return HubState::Stale {
pid,
why: StaleWhy::OlderApp {
hub: hub.clone(),
installed: installed.clone(),
},
};
}
}
if rec.as_ref().map(|r| r.env_sig.as_str()) != Some(signature(desired).as_str()) {
return HubState::Stale {
pid,
why: StaleWhy::EnvChanged,
};
}
HubState::Ours { pid }
}
#[cfg(feature = "model-relay")]
pub(crate) fn adopt_record(
pid: u32,
info: &process::ProcInfo,
app: &ClineApp,
port: u16,
desired: &Desired,
) -> Result<(), OlError> {
if read_record().is_some_and(|r| r.pid == pid) {
return Ok(());
}
let Some(start) = info.start else {
return Err(not_captured(
"the hub's start identity is unreadable — it was not adopted",
));
};
write_record(&HubRecord::of(pid, start, app, port, desired))
}
#[cfg(feature = "model-relay")]
fn hub_args(port: u16) -> Vec<String> {
[
HUB_ARG,
"--cwd",
"/",
"--host",
HUB_HOST,
"--port",
&port.to_string(),
"--pathname",
"/hub",
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
#[cfg(feature = "model-relay")]
fn hub_mode_env(port: u16) -> Vec<(String, String)> {
vec![
(HUB_DAEMON_ENV.to_string(), "1".to_string()),
(HUB_PORT_ENV.to_string(), port.to_string()),
(HUB_HOST_ENV.to_string(), HUB_HOST.to_string()),
]
}
#[cfg(feature = "model-relay")]
fn hub_cwd(app: &ClineApp) -> PathBuf {
if cfg!(windows) {
app.sidecar
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
} else {
PathBuf::from("/")
}
}
#[cfg(feature = "model-relay")]
fn not_captured(message: impl Into<String>) -> OlError {
OlError::new(ERR_CLINE_APP_NOT_CAPTURED, message)
}
#[cfg(feature = "model-relay")]
pub(crate) fn start_hub_with(
t: &dyn ProcessTable,
app: &ClineApp,
desired: &Desired,
port: u16,
poll: Duration,
) -> Result<u32, OlError> {
if !matches!(t.listener(port), Ok(None)) {
return Err(not_captured(format!(
"port {port} is already held — the hub was not started"
)));
}
let args = hub_args(port);
let spawn_env = hub_env(port, desired);
let spawned = t
.spawn_detached(&app.sidecar, &args, &hub_cwd(app), &spawn_env)
.map_err(|e| not_captured(format!("the Cline.app hub could not be spawned: {e}")))?;
let claim = process::poll_until(HUB_STARTUP_POLLS + 1, poll, || match t.listener(port) {
Ok(Some(p)) if p == spawned => Some(Ok((p, t.inspect(p)))),
Ok(Some(p)) => Some(match t.inspect(p) {
Some(i)
if i.args.iter().any(|a| a.contains(HUB_ARG))
&& same_exe(i.exe.as_deref(), &app.sidecar)
&& i.env
.as_deref()
.is_some_and(|e| carries_desired(e, desired)) =>
{
Ok((p, Some(i)))
}
_ => Err(p),
}),
_ => None,
});
let (listener, info) = match claim {
Some(Ok(claimed)) => claimed,
Some(Err(p)) => {
let _ = process::terminate(t, spawned, None); return Err(not_captured(format!(
"startup contention: port {port} was taken by pid {p}, not the hub OpenLatch \
started"
)));
}
None => {
let _ = process::terminate(t, spawned, None);
return Err(not_captured("the hub did not listen within 8 s"));
}
};
let abandon = |info: Option<&process::ProcInfo>| {
if listener != spawned {
let id = info.map(process::ProcIdentity::of);
if let Some(id) = &id {
let _ = process::terminate(t, listener, Some(id));
}
}
let _ = process::terminate(t, spawned, None);
};
let Some(start) = info.as_ref().and_then(|i| i.start) else {
abandon(info.as_ref());
return Err(not_captured(
"the hub's start identity is unreadable — no ownership record was written",
));
};
let record = HubRecord::of(listener, start, app, port, desired);
if let Err(e) = write_record(&record) {
abandon(info.as_ref());
return Err(e);
}
tracing::info!(
pid = listener,
port,
version = app.version.as_deref().unwrap_or("unknown"),
capture = desired.capture.is_some(),
plugin = desired.plugin.is_some(),
"Cline.app hub started with OpenLatch's environment"
);
Ok(listener)
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RestartWhen {
IfIdle,
Now,
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RestartOutcome {
Started {
pid: u32,
},
Restarted {
old: u32,
new: Option<u32>,
},
DeferredBusy {
clients: usize,
},
IdleUnknown(String),
LeftForeign {
pid: u32,
},
NothingToDo,
}
#[cfg(feature = "model-relay")]
const HUB_IDLE_BY_CONNECTIONS: bool = true;
#[cfg(feature = "model-relay")]
pub(crate) const IDLE_RECHECK: Duration = Duration::from_secs(2);
#[cfg(feature = "model-relay")]
pub(crate) fn restart_hub_with(
t: &dyn ProcessTable,
app: &ClineApp,
desired: &Desired,
when: RestartWhen,
port: u16,
recheck: Duration,
poll: Duration,
) -> Result<RestartOutcome, OlError> {
let (listener, clients) = t.sockets(port);
let (state, info) = hub_state_of(t, listener, Some(app), desired);
let pid = match state {
HubState::Absent => {
return if desired.is_empty() {
Ok(RestartOutcome::NothingToDo)
} else {
start_hub_with(t, app, desired, port, poll)
.map(|pid| RestartOutcome::Started { pid })
}
}
HubState::Foreign { pid } => return Ok(RestartOutcome::LeftForeign { pid }),
HubState::Ours { pid } | HubState::Stale { pid, .. } => pid,
};
let Some(identity) = info.as_ref().map(process::ProcIdentity::of) else {
return Ok(RestartOutcome::LeftForeign { pid });
};
let attached = match idle(t, port, pid, recheck, clients) {
Idle::Idle => 0,
Idle::Busy(n) if when == RestartWhen::IfIdle => {
return Ok(RestartOutcome::DeferredBusy { clients: n })
}
Idle::Busy(n) => n,
Idle::Unknown(why) if when == RestartWhen::IfIdle => {
return Ok(RestartOutcome::IdleUnknown(why))
}
Idle::Unknown(_) => 1,
};
process::terminate(t, pid, Some(&identity)).map_err(not_captured)?;
let _ = process::poll_until(HUB_RELEASE_POLLS, poll, || {
matches!(t.listener(port), Ok(None)).then_some(())
});
remove_record();
let new = if !desired.is_empty() {
Some(start_hub_with(t, app, desired, port, poll)?)
} else if attached > 0 {
t.spawn_detached(
&app.sidecar,
&hub_args(port),
&hub_cwd(app),
&hub_env(port, desired),
)
.ok()
} else {
None
};
Ok(RestartOutcome::Restarted { old: pid, new })
}
#[cfg(feature = "model-relay")]
#[allow(clippy::enum_variant_names)] enum Idle {
Idle,
Busy(usize),
Unknown(String),
}
#[cfg(feature = "model-relay")]
fn idle(
t: &dyn ProcessTable,
port: u16,
hub: u32,
recheck: Duration,
first: Result<Vec<u32>, String>,
) -> Idle {
if !HUB_IDLE_BY_CONNECTIONS {
return Idle::Unknown("the idle signal is not proven on this host (06 §0 L4)".into());
}
let busy = |seen: Result<Vec<u32>, String>| match seen {
Err(e) => Some(Idle::Unknown(e)),
Ok(pids) => {
let n = pids
.iter()
.filter(|&&x| x != hub)
.collect::<std::collections::BTreeSet<_>>()
.len();
(n > 0).then_some(Idle::Busy(n))
}
};
if let Some(verdict) = busy(first) {
return verdict;
}
std::thread::sleep(recheck);
busy(t.clients(port)).unwrap_or(Idle::Idle)
}
#[cfg(feature = "model-relay")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HubFinding {
Captured {
pid: u32,
},
NotLive,
NoHub,
WaitingForRestart {
pid: u32,
},
OlderApp {
pid: u32,
hub: String,
installed: String,
},
Foreign {
pid: u32,
port: u16,
},
NotDetectedOnPlatform,
}
#[cfg(feature = "model-relay")]
pub(crate) fn hub_finding(live: bool, desired: &Desired) -> Option<HubFinding> {
let Some(app) = visible_app()? else {
return Some(HubFinding::NotDetectedOnPlatform); };
let port = hub_port();
let t = process::table();
let state = hub_state_with(&*t, port, Some(&app), desired);
Some(finding_of(&*t, state, port, live))
}
#[cfg(feature = "model-relay")]
pub(crate) fn hub_visible() -> bool {
visible_app().is_some()
}
#[cfg(feature = "model-relay")]
pub(crate) fn visible_app() -> Option<Option<ClineApp>> {
if not_detectable_here() {
return Some(None);
}
detect().map(Some)
}
#[cfg(feature = "model-relay")]
pub(crate) fn visible_app_from(detected: Option<ClineApp>) -> Option<Option<ClineApp>> {
if not_detectable_here() {
return Some(None);
}
detected.map(Some)
}
#[cfg(feature = "model-relay")]
fn finding_of(t: &dyn ProcessTable, state: HubState, port: u16, live: bool) -> HubFinding {
match (state, live) {
(HubState::Ours { pid }, _) => HubFinding::Captured { pid },
(HubState::Foreign { pid }, _) if captured_off_path(t, pid) => HubFinding::Captured { pid },
(
HubState::Stale {
pid,
why: StaleWhy::OlderApp { hub, installed },
},
_,
) => HubFinding::OlderApp {
pid,
hub,
installed,
},
(_, false) => HubFinding::NotLive,
(HubState::Absent, true) => HubFinding::NoHub,
(HubState::Stale { pid, .. }, true) => HubFinding::WaitingForRestart { pid },
(HubState::Foreign { pid }, true) => HubFinding::Foreign { pid, port },
}
}
#[cfg(feature = "model-relay")]
fn captured_off_path(t: &dyn ProcessTable, pid: u32) -> bool {
pid != 0
&& t.inspect(pid).is_some_and(|i| {
i.args.iter().any(|a| a.contains(HUB_ARG))
&& i.env.as_deref().is_some_and(|e| {
carries_our_proxy(e)
&& crate::hooks::proxy_env_file::live_proxy_url(ClineBinding::AGENT_TYPE)
.is_some_and(|u| env_value(e, "HTTPS_PROXY") == Some(u.as_str()))
})
})
}
#[cfg(test)]
pub(crate) fn write_fake_bundle(apps: &Path, id: &str, version: &str) -> PathBuf {
let bundle = apps.join("Cline.app");
let contents = bundle.join("Contents");
std::fs::create_dir_all(contents.join("MacOS")).expect("create the fake bundle");
std::fs::write(
contents.join("Info.plist"),
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n<dict>\n\
\t<key>CFBundleExecutable</key>\n\t<string>cline</string>\n\
\t<key>CFBundleIdentifier</key>\n\t<string>{id}</string>\n\
\t<key>CFBundleShortVersionString</key>\n\t<string>{version}</string>\n\
</dict>\n</plist>\n"
),
)
.expect("write the fake Info.plist");
std::fs::write(contents.join("MacOS").join("code-sidecar"), "").expect("write the sidecar");
bundle
}
#[cfg(all(test, feature = "model-relay"))]
pub(crate) mod test_fixture {
use std::path::PathBuf;
use std::sync::Arc;
use super::process::test_support::FakeTable;
use super::process::{install_for_tests, ProcInfo, TableGuard};
use super::{hub_args, ClineApp, Desired, ProxyEnvVars, APP_DIR_ENV, HUB_PORT_ENV};
pub(crate) struct HubFx {
pub(crate) table: Arc<FakeTable>,
_guard: TableGuard,
_seam: crate::hooks::cline::ClineSeam,
_state: std::sync::MutexGuard<'static, ()>,
pub(crate) root: tempfile::TempDir,
pub(crate) app: ClineApp,
pub(crate) port: u16,
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.map(|a| a.port())
.expect("bind an ephemeral loopback port")
}
pub(crate) fn hub_fx() -> HubFx {
hub_fx_at("0.0.34")
}
pub(crate) fn hub_fx_at(version: &str) -> HubFx {
let state = crate::config::OPENLATCH_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let root = tempfile::tempdir().expect("tempdir");
let ol = root.path().join("openlatch");
std::fs::create_dir_all(&ol).expect("create the isolated OPENLATCH_DIR");
let apps = root.path().join("apps");
super::write_fake_bundle(&apps, super::BUNDLE_ID, version);
let port = free_port();
assert_ne!(port, super::DEFAULT_HUB_PORT);
let seam = crate::hooks::cline::cline_isolated([
("OPENLATCH_DIR", Some(ol.into_os_string())),
(
crate::hooks::cline::STORE_DIR_ENV,
Some(root.path().join("store").into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(root.path().join("data").into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
(APP_DIR_ENV, Some(apps.into_os_string())),
(HUB_PORT_ENV, Some(port.to_string().into())),
]);
let app = super::detect().expect("the fake bundle is detected");
let table = Arc::new(FakeTable::new(port));
let guard = install_for_tests(table.clone());
HubFx {
table,
_guard: guard,
_seam: seam,
_state: state,
root,
app,
port,
}
}
impl HubFx {
pub(crate) fn hub_info(&self, env: Option<Vec<(String, String)>>) -> ProcInfo {
ProcInfo {
exe: Some(self.app.sidecar.clone()),
args: hub_args(self.port),
env,
start: Some(1),
ppid: None,
}
}
pub(crate) fn other_exe(&self, rel: &str) -> PathBuf {
let p = self.root.path().join(rel);
std::fs::create_dir_all(p.parent().expect("a parent")).expect("mkdir");
std::fs::write(&p, "").expect("write the other executable");
p
}
pub(crate) fn fake(&self) -> FakeTable {
FakeTable::new(self.port)
}
}
pub(crate) fn our_env(proxy: &str) -> Vec<(String, String)> {
vec![
(
"NODE_EXTRA_CA_CERTS".to_string(),
super::this_install_ca_pem().display().to_string(),
),
("HTTPS_PROXY".to_string(), proxy.to_string()),
("LANG".to_string(), "C".to_string()),
]
}
pub(crate) fn clean_env() -> Vec<(String, String)> {
vec![("LANG".to_string(), "C".to_string())]
}
pub(crate) fn captured(proxy_url: &str) -> Desired {
Desired {
capture: Some(ProxyEnvVars {
proxy_url: proxy_url.to_string(),
ca_pem: super::this_install_ca_pem(),
no_proxy: crate::hooks::proxy_env_file::relay_no_proxy(7600),
}),
plugin: None,
}
}
pub(crate) fn cap(env: &ProxyEnvVars) -> Desired {
Desired {
capture: Some(env.clone()),
plugin: None,
}
}
pub(crate) fn seed_record(fx: &HubFx, pid: u32, start: u64, version: &str, proxy_url: &str) {
seed_record_for(fx, pid, start, version, &captured(proxy_url));
}
pub(crate) fn seed_record_for(
fx: &HubFx,
pid: u32,
start: u64,
version: &str,
desired: &Desired,
) {
let mut app = fx.app.clone();
app.version = Some(version.to_string());
super::write_record(&super::HubRecord::of(pid, start, &app, fx.port, desired))
.expect("seed the record");
}
pub(crate) fn record_exists() -> bool {
super::record_path().exists()
}
pub(crate) fn record_pid() -> Option<u32> {
super::read_record().map(|r| r.pid)
}
pub(crate) fn listen(t: &FakeTable, pid: u32, info: ProcInfo) {
*t.listener.lock().unwrap() = Ok(Some(pid));
t.procs.lock().unwrap().insert(pid, info);
}
pub(crate) fn wrapper_at(url: &str, live: bool) {
let ca = super::this_install_ca_pem();
crate::hooks::proxy_env_file::write_block(
"cline",
&["cline"],
crate::hooks::bindings::cline::CLI_ENV_DEFAULTS,
url,
&ca,
&crate::hooks::proxy_env_file::relay_no_proxy(7600),
)
.expect("write Cline's wrapper block");
crate::hooks::proxy_env_file::set_live(live).expect("set the live marker");
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLIST: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n\
<dict>\n\t<key>CFBundleIdentifier</key>\n\t<string>bot.cline.app</string>\n\
\t<key>CFBundleShortVersionString</key>\n\t<string>0.0.34</string>\n\
\t<key>CFBundleName</key>\n\t<string>Cline & Co</string>\n\
</dict>\n</plist>\n";
#[test]
fn plist_string_reads_identifier_and_version() {
assert_eq!(
plist_string(PLIST, "CFBundleIdentifier").as_deref(),
Some("bot.cline.app")
);
assert_eq!(
plist_string(PLIST, "CFBundleShortVersionString").as_deref(),
Some("0.0.34")
);
assert_eq!(plist_string(PLIST, "CFBundleMissing"), None);
let binary = format!("bplist00{PLIST}");
assert_eq!(plist_string(&binary, "CFBundleIdentifier"), None);
assert_eq!(
plist_string(PLIST, "CFBundleName").as_deref(),
Some("Cline & Co")
);
}
#[test]
fn detect_in_needs_the_identifier_and_the_sidecar() {
let root = tempfile::tempdir().expect("tempdir");
let bundle = write_fake_bundle(&root.path().join("a"), BUNDLE_ID, "0.0.34");
let app = detect_in(std::slice::from_ref(&bundle)).expect("the fake bundle is Cline's");
assert_eq!(app.version.as_deref(), Some("0.0.34"));
assert_eq!(
app.sidecar,
bundle.join("Contents").join("MacOS").join("code-sidecar")
);
let other = write_fake_bundle(&root.path().join("b"), "com.other.app", "0.0.34");
assert_eq!(detect_in(&[other]), None, "another identifier");
std::fs::remove_file(bundle.join("Contents").join("MacOS").join("code-sidecar"))
.expect("delete the sidecar");
assert_eq!(detect_in(&[bundle]), None, "no sidecar");
}
#[test]
fn detect_in_takes_the_first_matching_root() {
let root = tempfile::tempdir().expect("tempdir");
let first = write_fake_bundle(&root.path().join("a"), BUNDLE_ID, "0.0.1");
let second = write_fake_bundle(&root.path().join("b"), BUNDLE_ID, "0.0.2");
let found = detect_in(&[first.clone(), second.clone()]).expect("a match");
assert_eq!(found.bundle, first);
let invalid = root.path().join("missing").join("Cline.app");
let found = detect_in(&[invalid, second.clone()]).expect("a match");
assert_eq!(found.bundle, second);
}
#[test]
fn production_roots_are_macos_only() {
let home = Path::new("/home-for-test");
if cfg!(target_os = "macos") {
assert_eq!(
production_roots(Some(home)),
vec![
PathBuf::from("/Applications/Cline.app"),
home.join("Applications").join("Cline.app"),
]
);
assert_eq!(
production_roots(None),
vec![PathBuf::from("/Applications/Cline.app")]
);
} else {
assert!(production_roots(Some(home)).is_empty());
assert!(production_roots(None).is_empty());
}
}
#[test]
fn app_dir_env_replaces_the_default_roots() {
let root = tempfile::tempdir().expect("tempdir");
let apps = root.path().join("apps");
write_fake_bundle(&apps, BUNDLE_ID, "0.0.34");
let _seam = crate::hooks::cline::cline_isolated([
(
crate::hooks::cline::STORE_DIR_ENV,
Some(root.path().join("store").into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(root.path().join("data").into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
(APP_DIR_ENV, Some(apps.clone().into_os_string())),
]);
let app = detect().expect("APP_DIR_ENV names the fake bundle's parent");
assert_eq!(app.bundle, apps.join("Cline.app"));
let _relative =
crate::hooks::cline::EnvOverride::apply([(APP_DIR_ENV, Some("apps".into()))]);
assert_eq!(detect(), None, "a relative root is not a location");
}
#[test]
fn cline_isolated_hides_the_real_app() {
let prior = {
let _lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
std::env::var_os(APP_DIR_ENV)
};
let root = tempfile::tempdir().expect("tempdir");
{
let _seam = crate::hooks::cline::cline_isolated([
(
crate::hooks::cline::STORE_DIR_ENV,
Some(root.path().join("store").into_os_string()),
),
(
crate::hooks::cline::DATA_DIR_ENV,
Some(root.path().join("data").into_os_string()),
),
(
crate::hooks::cline::ASSETS_DIR_ENV,
Some(root.path().join("assets").into_os_string()),
),
]);
let set = std::env::var_os(APP_DIR_ENV).expect("the seam default is applied");
assert!(
Path::new(&set).ends_with(".openlatch-test-no-cline-app"),
"{set:?}"
);
assert_eq!(detect(), None);
}
let _lock = crate::hooks::cline::SEAM_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_eq!(std::env::var_os(APP_DIR_ENV), prior);
}
fn windows_install(root: &Path, name: &str, sidecar: bool) -> PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(&dir).expect("mkdir");
if sidecar {
std::fs::write(dir.join(WINDOWS_SIDECAR), "").expect("sidecar");
}
dir
}
#[test]
fn windows_dir_without_sidecar_is_not_detected() {
let root = tempfile::tempdir().expect("tempdir");
let empty = windows_install(root.path(), "empty", false);
let real = windows_install(root.path(), "real", true);
assert_eq!(
detect_in_windows(&[(empty.clone(), Some("0.0.39".to_string()))]),
None,
"a directory without code-sidecar.exe is not Cline.app"
);
let found =
detect_in_windows(&[(empty, None), (real.clone(), None)]).expect("the next one");
assert_eq!(found.bundle, real);
assert_eq!(found.sidecar, real.join(WINDOWS_SIDECAR));
assert_eq!(found.version, None);
}
#[test]
fn windows_registry_install_location_wins() {
let root = tempfile::tempdir().expect("tempdir");
let a = windows_install(root.path(), "a", true);
let local = root.path().join("local");
let b = windows_install(&local, "Cline", true);
let entries = vec![
UninstallEntry {
display_name: "Something Else".to_string(),
install_location: Some(root.path().join("other")),
display_version: Some("9.9.9".to_string()),
},
UninstallEntry {
display_name: "Cline".to_string(),
install_location: Some(a.clone()),
display_version: Some("0.0.39".to_string()),
},
];
let app = detect_in_windows(&windows_candidates(None, &entries, Some(&local), None))
.expect("the registry's install location");
assert_eq!(app.bundle, a);
assert_eq!(app.version.as_deref(), Some("0.0.39"));
let app = detect_in_windows(&windows_candidates(None, &[], Some(&local), None))
.expect("%LOCALAPPDATA%\\Cline");
assert_eq!(app.bundle, b);
assert_eq!(app.version, None, "no entry, no version — never guessed");
let seam = windows_install(root.path(), "seam", true);
let only = windows_candidates(Some(&seam), &entries, Some(&local), None);
assert_eq!(
only,
vec![(seam.clone(), None)],
"the seam is the only candidate"
);
assert!(
windows_candidates(Some(Path::new("relative")), &entries, Some(&local), None)
.is_empty(),
"a relative seam is not a location"
);
}
#[test]
fn not_detectable_only_without_paths_or_seam() {
assert!(not_detectable_with(None, &[]));
assert!(!not_detectable_with(Some(OsStr::new("/x")), &[]));
assert!(!not_detectable_with(
None,
&[PathBuf::from("/Applications/Cline.app")]
));
}
#[cfg(feature = "model-relay")]
use super::process::test_support::FakeTable;
#[cfg(feature = "model-relay")]
use super::test_fixture::{
cap, captured, clean_env, hub_fx, hub_fx_at, listen, our_env, record_exists, record_pid,
seed_record, seed_record_for, wrapper_at,
};
#[cfg(feature = "model-relay")]
use crate::hooks::cline::EnvOverride;
#[cfg(feature = "model-relay")]
const RELAY: &str = "http://127.0.0.1:7600";
#[cfg(feature = "model-relay")]
fn spawns(t: &FakeTable) -> Vec<process::test_support::Spawn> {
t.spawns.lock().unwrap().clone()
}
#[cfg(feature = "model-relay")]
fn terminated(t: &FakeTable) -> Vec<u32> {
t.terminated.lock().unwrap().clone()
}
#[cfg(feature = "model-relay")]
fn names(env: &[(String, String)]) -> Vec<&str> {
env.iter().map(|(k, _)| k.as_str()).collect()
}
#[cfg(feature = "model-relay")]
fn stale() -> HubState {
HubState::Stale {
pid: 500,
why: StaleWhy::NoOurEnv,
}
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_port_reads_cline_hub_port() {
let _fx = hub_fx();
for (value, want) in [
(Some("34567"), 34567),
(None, DEFAULT_HUB_PORT),
(Some("abc"), DEFAULT_HUB_PORT),
(Some("0"), DEFAULT_HUB_PORT),
] {
let _v = EnvOverride::apply([(HUB_PORT_ENV, value.map(Into::into))]);
assert_eq!(hub_port(), want, "{value:?}");
}
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_absent_when_nothing_listens() {
let fx = hub_fx();
let t = fx.fake();
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Absent
);
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_foreign_when_the_listener_is_not_a_hub() {
let fx = hub_fx();
let t = fx.fake();
let mut info = fx.hub_info(Some(clean_env()));
info.args = vec!["--serve".to_string()];
listen(&t, 500, info);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Foreign { pid: 500 }
);
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_foreign_zero_when_the_table_errs() {
let fx = hub_fx();
let t = fx.fake();
*t.listener.lock().unwrap() = Err("lsof failed".into());
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Foreign { pid: 0 }
);
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_ours_by_record() {
let fx = hub_fx();
let t = fx.fake();
seed_record(&fx, 500, 1, "0.0.34", RELAY);
listen(&t, 500, fx.hub_info(Some(clean_env())));
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Ours { pid: 500 }
);
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_reused_pid_is_not_ours() {
let fx = hub_fx();
seed_record(&fx, 500, 111, "0.0.34", RELAY);
for (start, want) in [
(Some(222), stale()),
(None, stale()),
(Some(111), HubState::Ours { pid: 500 }),
] {
let t = fx.fake();
let mut info = fx.hub_info(Some(clean_env()));
info.start = start;
listen(&t, 500, info);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
want,
"start {start:?}"
);
}
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_ours_by_environment() {
let fx = hub_fx();
let t = fx.fake();
listen(&t, 500, fx.hub_info(Some(our_env(RELAY))));
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Ours { pid: 500 }
);
let t = fx.fake();
let mut env = our_env(RELAY);
env[0].1 = "/elsewhere/ca.pem".to_string();
listen(&t, 500, fx.hub_info(Some(env)));
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
stale()
);
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_unreadable_environment_is_stale() {
let fx = hub_fx();
let t = fx.fake();
listen(&t, 500, fx.hub_info(None));
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
stale()
);
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_older_app_is_stale() {
let fx = hub_fx_at("0.0.34");
let t = fx.fake();
listen(&t, 500, fx.hub_info(Some(clean_env())));
seed_record(&fx, 500, 1, "0.0.33", RELAY);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Stale {
pid: 500,
why: StaleWhy::OlderApp {
hub: "0.0.33".into(),
installed: "0.0.34".into()
}
}
);
seed_record(&fx, 500, 1, "0.0.34", RELAY);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Ours { pid: 500 }
);
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_state_moved_relay_is_stale() {
let fx = hub_fx();
let t = fx.fake();
listen(&t, 500, fx.hub_info(Some(clean_env())));
seed_record(&fx, 500, 1, "0.0.34", RELAY);
assert_eq!(
hub_state_with(
&t,
fx.port,
Some(&fx.app),
&captured("http://127.0.0.1:7601")
),
HubState::Stale {
pid: 500,
why: StaleWhy::ProxyChanged
}
);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Ours { pid: 500 }
);
}
#[cfg(feature = "model-relay")]
fn plugin_env(root: &Path) -> PluginEnv {
PluginEnv {
wrapper_path: root.join("cline-plugin-bootstrap").join("wrapper"),
runtime: root.join("bin").join("cline-js-runtime-hub"),
}
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_env_signature_is_order_independent() {
let fx = hub_fx();
let capture = ProxyEnvVars::for_relay(7600);
let plugin = plugin_env(fx.root.path());
let both = Desired {
capture: Some(capture.clone()),
plugin: Some(plugin.clone()),
};
let forward = serde_json::to_value(&both).expect("serialize");
let reversed: serde_json::Map<String, serde_json::Value> = forward
.as_object()
.expect("an object")
.iter()
.rev()
.map(|(k, v)| {
let v = match v.as_object() {
Some(inner) => serde_json::Value::Object(
inner
.iter()
.rev()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
),
None => v.clone(),
};
(k.clone(), v)
})
.collect();
let reordered: Desired =
serde_json::from_value(serde_json::Value::Object(reversed)).expect("deserialize");
assert_eq!(signature(&reordered), signature(&both));
assert_eq!(signature(&both.clone()), signature(&both), "stable");
let capture_only = cap(&capture);
let plugin_only = Desired {
capture: None,
plugin: Some(plugin),
};
let sigs = [
signature(&both),
signature(&capture_only),
signature(&plugin_only),
signature(&Desired::default()),
];
for (i, a) in sigs.iter().enumerate() {
for b in &sigs[i + 1..] {
assert_ne!(a, b);
}
}
let (env_a, sig_a) = {
let _v = EnvOverride::apply([("NO_PROXY", Some("corp.internal".into()))]);
(hub_env(fx.port, &both), signature(&both))
};
let (env_b, sig_b) = {
let _v = EnvOverride::apply([("NO_PROXY", Some("other.internal,10.0.0.0/8".into()))]);
(hub_env(fx.port, &both), signature(&both))
};
assert_ne!(
env_a, env_b,
"the merged env carries the inherited NO_PROXY"
);
assert_eq!(sig_a, sig_b);
assert_eq!(sig_a, signature(&both));
}
#[cfg(feature = "model-relay")]
#[test]
fn classify_customer_proxy_is_never_ours() {
let fx = hub_fx();
let t = fx.fake();
let customer = vec![
("HTTPS_PROXY".to_string(), "http://corp:3128".to_string()),
(
"NODE_EXTRA_CA_CERTS".to_string(),
"/corp/ca.pem".to_string(),
),
("LANG".to_string(), "C".to_string()),
];
listen(&t, 500, fx.hub_info(Some(customer)));
let capture = ProxyEnvVars::for_relay(7600);
let plugin = plugin_env(fx.root.path());
let combos = [
Desired::default(),
Desired {
capture: None,
plugin: Some(plugin.clone()),
},
cap(&capture),
Desired {
capture: Some(capture),
plugin: Some(plugin),
},
];
for d in &combos {
let state = hub_state_with(&t, fx.port, Some(&fx.app), d);
assert_eq!(state, stale(), "desired {d:?}");
assert_ne!(
crate::daemon::cline_hub::decide(&state, d),
crate::daemon::cline_hub::HubAction::RestartNow,
"desired {d:?}"
);
}
assert!(!test_fixture::record_exists(), "classify is pure");
}
#[cfg(feature = "model-relay")]
#[test]
fn classify_capture_removed_is_proxy_changed() {
let fx = hub_fx();
let t = fx.fake();
listen(&t, 500, fx.hub_info(Some(clean_env())));
let capture = ProxyEnvVars::for_relay(7600);
let plugin = plugin_env(fx.root.path());
let proxy_changed = HubState::Stale {
pid: 500,
why: StaleWhy::ProxyChanged,
};
for recorded_plugin in [None, Some(plugin.clone())] {
seed_record_for(
&fx,
500,
1,
"0.0.34",
&Desired {
capture: Some(capture.clone()),
plugin: recorded_plugin.clone(),
},
);
for desired_plugin in [None, Some(plugin.clone())] {
let d = Desired {
capture: None,
plugin: desired_plugin,
};
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &d),
proxy_changed,
"recorded plugin {recorded_plugin:?}, desired {d:?}"
);
}
}
let plugin_only = Desired {
capture: None,
plugin: Some(plugin.clone()),
};
seed_record_for(&fx, 500, 1, "0.0.34", &plugin_only);
let rec = read_record().expect("a plugin-only record reads back");
assert_eq!(rec.proxy_url, None);
assert_eq!(rec.ca_pem, None);
assert!(rec.plugin);
assert_eq!(rec.env_sig, signature(&plugin_only));
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &plugin_only),
HubState::Ours { pid: 500 }
);
for with_plugin in [None, Some(plugin.clone())] {
let d = Desired {
capture: Some(capture.clone()),
plugin: with_plugin,
};
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &d),
HubState::Stale {
pid: 500,
why: StaleWhy::EnvChanged
},
"desired {d:?}"
);
}
std::fs::write(
record_path(),
serde_json::json!({
"pid": 500, "start": 1, "exe": fx.app.sidecar, "port": fx.port,
"app_version": "0.0.34", "proxy_url": RELAY, "ca_pem": this_install_ca_pem(),
})
.to_string(),
)
.expect("write an I-4 record");
let rec = read_record().expect("an I-4 record reads back");
assert_eq!(rec.proxy_url.as_deref(), Some(RELAY));
assert_eq!(rec.env_sig, "");
assert!(!rec.plugin);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &cap(&capture)),
HubState::Stale {
pid: 500,
why: StaleWhy::EnvChanged
}
);
}
#[cfg(feature = "model-relay")]
#[test]
fn start_hub_passes_exactly_the_wrapper_variables() {
let fx = hub_fx();
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(900);
t.procs
.lock()
.unwrap()
.insert(900, fx.hub_info(Some(clean_env())));
let env = ProxyEnvVars::for_relay(7600);
assert_eq!(
start_hub_with(&t, &fx.app, &cap(&env), fx.port, Duration::ZERO).expect("started"),
900
);
let s = spawns(&t);
assert_eq!(s.len(), 1, "one spawn");
let (exe, args, _cwd, spawn_env) = &s[0];
assert_eq!(exe, &fx.app.sidecar);
assert_eq!(args, &hub_args(fx.port));
assert_eq!(args.len(), 9);
assert_eq!(&spawn_env[..3], hub_mode_env(fx.port).as_slice());
assert_eq!(names(&spawn_env[3..]), WRAPPER_VARS.to_vec());
for (k, v) in &spawn_env[3..7] {
assert_eq!(v, RELAY, "{k}");
}
assert_eq!(spawn_env[7].1, env.ca_pem.display().to_string());
assert!(spawn_env[8].1.ends_with(&env.no_proxy));
let rec = read_record().expect("the record is written");
assert_eq!(rec.pid, 900);
assert_eq!(rec.start, 1);
assert_eq!(rec.exe, fx.app.sidecar);
assert_eq!(rec.proxy_url.as_deref(), Some(RELAY));
}
#[cfg(feature = "model-relay")]
#[test]
fn overrides_merge_the_inherited_no_proxy() {
let env = ProxyEnvVars {
proxy_url: RELAY.to_string(),
ca_pem: PathBuf::from("/x/ca.pem"),
no_proxy: crate::hooks::proxy_env_file::relay_no_proxy(7600),
};
assert!(env.no_proxy.starts_with("127.0.0.1:7600,"));
assert!(env.no_proxy.ends_with(",127.0.0.1:7632"));
let get = |o: &[(String, String)], k: &str| {
o.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
};
let o = env.overrides(Some("corp.internal"), None);
assert_eq!(names(&o), WRAPPER_VARS.to_vec());
assert_eq!(
get(&o, "NO_PROXY"),
Some(format!("corp.internal,{}", env.no_proxy))
);
assert_eq!(get(&o, "no_proxy"), Some(env.no_proxy.clone()));
let o = env.overrides(None, Some("lower.internal"));
assert_eq!(get(&o, "NO_PROXY"), Some(env.no_proxy.clone()));
assert_eq!(
get(&o, "no_proxy"),
Some(format!("lower.internal,{}", env.no_proxy))
);
let o = env.overrides(Some(""), Some(""));
assert_eq!(get(&o, "NO_PROXY"), Some(env.no_proxy.clone()));
assert_eq!(get(&o, "no_proxy"), Some(env.no_proxy.clone()));
}
#[cfg(feature = "model-relay")]
#[test]
fn start_hub_claims_another_listener_only_by_our_environment() {
let fx = hub_fx();
let env = ProxyEnvVars::for_relay(7600);
let exactly_ours = vec![
(
"NODE_EXTRA_CA_CERTS".to_string(),
env.ca_pem.display().to_string(),
),
("HTTPS_PROXY".to_string(), env.proxy_url.clone()),
];
let run = |listener: u32, info_env: Option<Vec<(String, String)>>| {
remove_record();
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(listener);
t.procs
.lock()
.unwrap()
.insert(listener, fx.hub_info(info_env));
let r = start_hub_with(&t, &fx.app, &cap(&env), fx.port, Duration::ZERO);
(r, terminated(&t))
};
let (r, term) = run(901, Some(exactly_ours.clone()));
assert_eq!(r.expect("claimed by environment"), 901);
assert!(term.is_empty());
assert_eq!(record_pid(), Some(901));
for info_env in [Some(clean_env()), None] {
let (r, term) = run(901, info_env.clone());
let e = r.expect_err("not ours");
assert_eq!(e.code, ERR_CLINE_APP_NOT_CAPTURED);
assert!(e.message.contains("startup contention"), "{}", e.message);
assert_eq!(term, vec![900], "our child only, never 901 ({info_env:?})");
assert!(!record_exists());
}
let (r, term) = run(900, None);
assert_eq!(r.expect("claimed by pid"), 900);
assert!(term.is_empty());
assert_eq!(record_pid(), Some(900));
}
#[cfg(feature = "model-relay")]
#[test]
fn start_hub_times_out_and_records_nothing() {
let fx = hub_fx();
let t = fx.fake();
let e = start_hub_with(&t, &fx.app, &captured(RELAY), fx.port, Duration::ZERO)
.expect_err("nothing listened");
assert_eq!(e.code, ERR_CLINE_APP_NOT_CAPTURED);
assert_eq!(terminated(&t), vec![900]);
assert!(!record_exists());
}
#[cfg(feature = "model-relay")]
#[test]
fn wrapper_variables_match_the_env_file() {
let _fx = hub_fx();
wrapper_at(RELAY, false);
let sh = std::fs::read_to_string(crate::hooks::proxy_env_file::env_sh_path(
&crate::config::openlatch_dir(),
))
.expect("env.sh is written");
let mut in_block = false;
let mut found = std::collections::BTreeSet::new();
for line in sh.lines() {
let line = line.trim();
if line.starts_with("HTTPS_PROXY=") {
in_block = true;
}
if !in_block {
continue;
}
if line.starts_with("command ") {
break;
}
for token in line.split_whitespace() {
if let Some((name, _)) = token.split_once('=') {
if !name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
found.insert(name.to_string());
}
}
}
}
let want: std::collections::BTreeSet<String> =
WRAPPER_VARS.iter().map(|s| (*s).to_string()).collect();
assert_eq!(found, want, "env.sh:\n{sh}");
}
#[cfg(feature = "model-relay")]
fn restart(
fx: &test_fixture::HubFx,
t: &FakeTable,
env: Option<&ProxyEnvVars>,
when: RestartWhen,
) -> Result<RestartOutcome, OlError> {
restart_hub_with(
t,
&fx.app,
&Desired {
capture: env.cloned(),
plugin: None,
},
when,
fx.port,
Duration::ZERO,
Duration::ZERO,
)
}
#[cfg(feature = "model-relay")]
fn fake_that_starts(fx: &test_fixture::HubFx) -> FakeTable {
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(900);
t.procs
.lock()
.unwrap()
.insert(900, fx.hub_info(Some(clean_env())));
t
}
#[cfg(feature = "model-relay")]
#[test]
fn restart_if_idle_defers_while_a_client_is_attached() {
let fx = hub_fx();
let t = fx.fake();
listen(&t, 500, fx.hub_info(Some(clean_env())));
*t.clients.lock().unwrap() = Ok(vec![500, 4242]);
let env = ProxyEnvVars::for_relay(7600);
assert_eq!(
restart(&fx, &t, Some(&env), RestartWhen::IfIdle).expect("ok"),
RestartOutcome::DeferredBusy { clients: 1 }
);
assert!(terminated(&t).is_empty());
assert!(spawns(&t).is_empty());
}
#[cfg(feature = "model-relay")]
#[test]
fn restart_if_idle_restarts_an_idle_hub_with_capture() {
let fx = hub_fx();
let t = fake_that_starts(&fx);
listen(&t, 500, fx.hub_info(Some(clean_env())));
*t.clients.lock().unwrap() = Ok(vec![500]);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
stale()
);
let env = ProxyEnvVars::for_relay(7600);
assert_eq!(
restart(&fx, &t, Some(&env), RestartWhen::IfIdle).expect("ok"),
RestartOutcome::Restarted {
old: 500,
new: Some(900)
}
);
assert_eq!(terminated(&t), vec![500]);
let s = spawns(&t);
assert_eq!(s.len(), 1);
assert_eq!(names(&s[0].3[3..]), WRAPPER_VARS.to_vec());
}
#[cfg(feature = "model-relay")]
#[test]
fn restart_now_takes_capture_off_a_busy_hub() {
let fx = hub_fx();
let t = fx.fake();
seed_record(&fx, 500, 1, "0.0.34", RELAY);
listen(&t, 500, fx.hub_info(Some(clean_env())));
*t.clients.lock().unwrap() = Ok(vec![500, 4242]);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Ours { pid: 500 }
);
assert_eq!(
restart(&fx, &t, None, RestartWhen::Now).expect("ok"),
RestartOutcome::Restarted {
old: 500,
new: Some(900)
}
);
assert_eq!(terminated(&t), vec![500]);
let s = spawns(&t);
assert_eq!(s.len(), 1);
assert_eq!(s[0].3, hub_mode_env(fx.port));
assert!(!s[0]
.3
.iter()
.any(|(k, _)| WRAPPER_VARS.contains(&k.as_str())));
assert!(!record_exists());
}
#[cfg(feature = "model-relay")]
#[test]
fn restart_now_with_no_client_starts_nothing() {
let fx = hub_fx();
let t = fx.fake();
seed_record(&fx, 500, 1, "0.0.34", RELAY);
listen(&t, 500, fx.hub_info(Some(clean_env())));
*t.clients.lock().unwrap() = Ok(vec![500]);
assert_eq!(
restart(&fx, &t, None, RestartWhen::Now).expect("ok"),
RestartOutcome::Restarted {
old: 500,
new: None
}
);
assert_eq!(terminated(&t), vec![500]);
assert!(spawns(&t).is_empty());
}
#[cfg(feature = "model-relay")]
#[test]
fn restart_never_touches_a_foreign_hub() {
let fx = hub_fx();
let env = ProxyEnvVars::for_relay(7600);
for when in [RestartWhen::IfIdle, RestartWhen::Now] {
let t = fx.fake();
let mut info = fx.hub_info(Some(clean_env()));
info.args = vec!["node".to_string(), "server.js".to_string()];
listen(&t, 500, info);
*t.clients.lock().unwrap() = Ok(vec![500]);
assert_eq!(
restart(&fx, &t, Some(&env), when).expect("ok"),
RestartOutcome::LeftForeign { pid: 500 }
);
assert!(terminated(&t).is_empty(), "{when:?}");
assert!(spawns(&t).is_empty(), "{when:?}");
}
}
#[cfg(feature = "model-relay")]
#[test]
fn restart_never_touches_a_hub_of_another_sidecar() {
let fx = hub_fx();
let env = ProxyEnvVars::for_relay(7600);
let other = fx.other_exe("other/code-sidecar");
for when in [RestartWhen::IfIdle, RestartWhen::Now] {
let t = fx.fake();
let mut info = fx.hub_info(Some(clean_env()));
info.exe = Some(other.clone());
listen(&t, 500, info);
*t.clients.lock().unwrap() = Ok(vec![500]);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Foreign { pid: 500 }
);
assert_eq!(
restart(&fx, &t, Some(&env), when).expect("ok"),
RestartOutcome::LeftForeign { pid: 500 }
);
assert!(terminated(&t).is_empty(), "{when:?}");
assert!(spawns(&t).is_empty(), "{when:?}");
}
let t = fake_that_starts(&fx);
let mut info = fx.hub_info(Some(clean_env()));
info.exe = Some(
fx.app
.bundle
.join("Contents")
.join("MacOS")
.join("..")
.join("MacOS")
.join("code-sidecar"),
);
listen(&t, 500, info);
*t.clients.lock().unwrap() = Ok(vec![500]);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
stale()
);
assert_eq!(
restart(&fx, &t, Some(&env), RestartWhen::IfIdle).expect("ok"),
RestartOutcome::Restarted {
old: 500,
new: Some(900)
}
);
}
#[cfg(feature = "model-relay")]
#[test]
fn start_hub_refuses_an_unreadable_start_identity() {
let fx = hub_fx();
let env = ProxyEnvVars::for_relay(7600);
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(900);
let mut info = fx.hub_info(Some(clean_env()));
info.start = None;
t.procs.lock().unwrap().insert(900, info);
let e = start_hub_with(&t, &fx.app, &cap(&env), fx.port, Duration::ZERO)
.expect_err("no start identity");
assert_eq!(e.code, ERR_CLINE_APP_NOT_CAPTURED);
assert_eq!(terminated(&t), vec![900]);
assert!(!record_exists());
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(900);
let mut info = fx.hub_info(Some(clean_env()));
info.start = Some(7);
t.procs.lock().unwrap().insert(900, info);
assert_eq!(
start_hub_with(&t, &fx.app, &cap(&env), fx.port, Duration::ZERO).expect("started"),
900
);
assert_eq!(read_record().expect("recorded").start, 7);
}
#[cfg(feature = "model-relay")]
#[test]
fn restart_if_idle_does_nothing_when_idleness_is_unknown() {
let fx = hub_fx();
let t = fx.fake();
listen(&t, 500, fx.hub_info(Some(clean_env())));
*t.clients.lock().unwrap() = Err("lsof failed".into());
let env = ProxyEnvVars::for_relay(7600);
assert!(matches!(
restart(&fx, &t, Some(&env), RestartWhen::IfIdle).expect("ok"),
RestartOutcome::IdleUnknown(_)
));
assert!(terminated(&t).is_empty());
assert!(spawns(&t).is_empty());
}
#[cfg(feature = "model-relay")]
#[test]
fn idle_ignores_the_hubs_own_sockets() {
let fx = hub_fx();
let t = fake_that_starts(&fx);
listen(&t, 500, fx.hub_info(Some(clean_env())));
*t.clients.lock().unwrap() = Ok(vec![500, 500]);
let env = ProxyEnvVars::for_relay(7600);
assert_eq!(
restart(&fx, &t, Some(&env), RestartWhen::IfIdle).expect("ok"),
RestartOutcome::Restarted {
old: 500,
new: Some(900)
}
);
assert_eq!(terminated(&t), vec![500]);
}
#[cfg(feature = "model-relay")]
#[test]
#[should_panic(expected = "a test reached the real process table")]
fn the_real_process_table_is_unreachable_under_test() {
let _lock = process::HUB_SEAM_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ = process::table();
}
#[cfg(feature = "model-relay")]
#[test]
fn an_env_carrying_hub_off_the_sidecar_renders_captured_and_is_never_signalled() {
let fx = hub_fx();
let cli = fx.other_exe("cli/cline");
let env = ProxyEnvVars::for_relay(7600);
let off_path = |e: Option<Vec<(String, String)>>| process::ProcInfo {
exe: Some(cli.clone()),
args: hub_args(fx.port),
env: e,
start: Some(1),
ppid: None,
};
wrapper_at(RELAY, true);
listen(&fx.table, 500, off_path(Some(our_env(RELAY))));
assert_eq!(
hub_state_with(&*fx.table, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Foreign { pid: 500 }
);
assert_eq!(
hub_finding(true, &captured(RELAY)),
Some(HubFinding::Captured { pid: 500 })
);
assert_eq!(
hub_finding(false, &captured(RELAY)),
Some(HubFinding::Captured { pid: 500 })
);
for when in [RestartWhen::IfIdle, RestartWhen::Now] {
assert_eq!(
restart(&fx, &fx.table, Some(&env), when).expect("ok"),
RestartOutcome::LeftForeign { pid: 500 }
);
}
assert!(terminated(&fx.table).is_empty());
assert!(spawns(&fx.table).is_empty());
let foreign = Some(HubFinding::Foreign {
pid: 500,
port: fx.port,
});
listen(&fx.table, 500, off_path(None));
assert_eq!(hub_finding(true, &captured(RELAY)), foreign);
listen(&fx.table, 500, off_path(Some(our_env(RELAY))));
wrapper_at("http://127.0.0.1:7601", true);
assert_eq!(hub_finding(true, &captured(RELAY)), foreign);
wrapper_at(RELAY, false);
assert_eq!(hub_finding(true, &captured(RELAY)), foreign);
assert_eq!(
hub_finding(false, &captured(RELAY)),
Some(HubFinding::NotLive)
);
}
#[cfg(feature = "model-relay")]
#[test]
fn a_table_error_never_forgets_the_record() {
let fx = hub_fx();
let t = fake_that_starts(&fx);
start_hub_with(&t, &fx.app, &captured(RELAY), fx.port, Duration::ZERO).expect("started");
assert_eq!(record_pid(), Some(900));
let t = fx.fake();
*t.listener.lock().unwrap() = Err("lsof failed".into());
let state = hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY));
assert_eq!(state, HubState::Foreign { pid: 0 });
forget_record_unless(&state);
assert!(record_exists(), "a failed read keeps the record");
let t = fx.fake();
listen(&t, 900, fx.hub_info(Some(clean_env())));
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Ours { pid: 900 }
);
let t = fx.fake();
let state = hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY));
assert_eq!(state, HubState::Absent);
forget_record_unless(&state);
assert!(!record_exists());
seed_record(&fx, 900, 1, "0.0.34", RELAY);
let t = fx.fake();
listen(&t, 901, fx.hub_info(Some(clean_env())));
let state = hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY));
forget_record_unless(&state);
assert!(!record_exists());
}
#[cfg(feature = "model-relay")]
#[test]
fn hub_finding_maps_every_state() {
let t = FakeTable::new(34567);
let older = HubState::Stale {
pid: 5,
why: StaleWhy::OlderApp {
hub: "0.0.33".into(),
installed: "0.0.34".into(),
},
};
let older_finding = HubFinding::OlderApp {
pid: 5,
hub: "0.0.33".into(),
installed: "0.0.34".into(),
};
let no_proxy = HubState::Stale {
pid: 5,
why: StaleWhy::NoOurEnv,
};
let moved = HubState::Stale {
pid: 5,
why: StaleWhy::ProxyChanged,
};
let rows = [
(
HubState::Ours { pid: 5 },
true,
HubFinding::Captured { pid: 5 },
),
(
HubState::Ours { pid: 5 },
false,
HubFinding::Captured { pid: 5 },
),
(older.clone(), true, older_finding.clone()),
(older, false, older_finding),
(HubState::Absent, true, HubFinding::NoHub),
(HubState::Absent, false, HubFinding::NotLive),
(
no_proxy.clone(),
true,
HubFinding::WaitingForRestart { pid: 5 },
),
(no_proxy, false, HubFinding::NotLive),
(
moved.clone(),
true,
HubFinding::WaitingForRestart { pid: 5 },
),
(moved, false, HubFinding::NotLive),
(
HubState::Foreign { pid: 5 },
true,
HubFinding::Foreign {
pid: 5,
port: 34567,
},
),
(HubState::Foreign { pid: 5 }, false, HubFinding::NotLive),
];
for (state, live, want) in rows {
assert_eq!(
finding_of(&t, state.clone(), 34567, live),
want,
"{state:?} live={live}"
);
}
}
}
#[cfg(all(test, feature = "model-relay"))]
mod live_check_tests {
use super::test_fixture::{cap, captured, clean_env, hub_fx, listen};
use super::*;
const RELAY: &str = "http://127.0.0.1:7600";
#[test]
fn every_spawned_hub_runs_in_hub_daemon_mode_on_its_port() {
let fx = hub_fx();
let want = [
("CLINE_RUN_AS_HUB_DAEMON", "1".to_string()),
("CLINE_HUB_PORT", fx.port.to_string()),
("CLINE_HUB_HOST", "127.0.0.1".to_string()),
];
let pinned = |env: &[(String, String)]| {
for (k, v) in &want {
assert_eq!(
env.iter()
.find(|(n, _)| n == k)
.map(|(_, val)| val.as_str()),
Some(v.as_str()),
"{k} in {env:?}"
);
}
};
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(900);
t.procs
.lock()
.unwrap()
.insert(900, fx.hub_info(Some(clean_env())));
start_hub_with(&t, &fx.app, &captured(RELAY), fx.port, Duration::ZERO).expect("started");
let s = t.spawns.lock().unwrap().clone();
pinned(&s[0].3);
assert!(s[0].1.iter().any(|a| a == HUB_ARG));
assert!(s[0]
.1
.windows(2)
.any(|w| w[0] == "--port" && w[1] == fx.port.to_string()));
let t = fx.fake();
listen(&t, 900, fx.hub_info(Some(clean_env())));
*t.clients.lock().unwrap() = Ok(vec![900, 4242]);
restart_hub_with(
&t,
&fx.app,
&Desired::default(),
RestartWhen::Now,
fx.port,
Duration::ZERO,
Duration::ZERO,
)
.expect("restarted");
let s = t.spawns.lock().unwrap().clone();
assert_eq!(s.len(), 1);
pinned(&s[0].3);
}
#[test]
fn start_hub_never_spawns_while_the_port_is_held_or_unknown() {
let fx = hub_fx();
for listener in [Ok(Some(500)), Err("lsof failed".to_string())] {
let t = fx.fake();
*t.listener.lock().unwrap() = listener.clone();
let e = start_hub_with(&t, &fx.app, &captured(RELAY), fx.port, Duration::ZERO)
.expect_err("never a second hub");
assert_eq!(e.code, ERR_CLINE_APP_NOT_CAPTURED);
assert!(t.spawns.lock().unwrap().is_empty(), "{listener:?}");
assert!(t.terminated.lock().unwrap().is_empty(), "{listener:?}");
}
}
#[test]
fn a_handed_off_listener_is_recorded_by_its_own_pid() {
let fx = hub_fx();
let env = ProxyEnvVars::for_relay(7600);
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(26725);
let mut info = fx.hub_info(Some(env.overrides(None, None)));
info.start = Some(42);
t.procs.lock().unwrap().insert(26725, info);
assert_eq!(
start_hub_with(&t, &fx.app, &cap(&env), fx.port, Duration::ZERO).expect("claimed"),
26725
);
let rec = read_record().expect("recorded");
assert_eq!((rec.pid, rec.start), (26725, 42));
let t = fx.fake();
let mut info = fx.hub_info(None);
info.start = Some(42);
listen(&t, 26725, info);
assert_eq!(
hub_state_with(&t, fx.port, Some(&fx.app), &captured(RELAY)),
HubState::Ours { pid: 26725 }
);
}
}
#[cfg(all(test, feature = "model-relay"))]
mod review_tests {
use std::collections::VecDeque;
use super::test_fixture::{cap, clean_env, hub_fx, listen, record_exists};
use super::*;
const RELAY: &str = "http://127.0.0.1:7600";
#[test]
fn a_pid_reused_during_the_idle_wait_is_never_signalled() {
let fx = hub_fx();
let t = fx.fake();
let hub = fx.hub_info(Some(clean_env()));
listen(&t, 500, hub.clone());
*t.clients.lock().unwrap() = Ok(vec![500]);
let mut reused = hub.clone();
reused.exe = Some(fx.other_exe("other/editor"));
reused.start = Some(99);
t.inspect_script
.lock()
.unwrap()
.insert(500, VecDeque::from([Some(hub), Some(reused)]));
let env = ProxyEnvVars::for_relay(7600);
let _ = restart_hub_with(
&t,
&fx.app,
&cap(&env),
RestartWhen::IfIdle,
fx.port,
Duration::ZERO,
Duration::ZERO,
);
assert!(t.terminated.lock().unwrap().is_empty(), "no SIGTERM");
assert!(t.killed.lock().unwrap().is_empty(), "no SIGKILL");
assert!(
t.spawns.lock().unwrap().is_empty(),
"the port is still held"
);
}
#[test]
fn a_release_never_signals_a_reused_pid() {
let fx = hub_fx();
let t = fx.fake();
super::test_fixture::seed_record(&fx, 500, 1, "0.0.34", "http://127.0.0.1:7600");
let hub = fx.hub_info(Some(clean_env()));
listen(&t, 500, hub.clone());
t.inspect_script
.lock()
.unwrap()
.insert(500, VecDeque::from([Some(hub), None]));
let (state, info) =
hub_state_inspected(&t, fx.port, None, &super::test_fixture::captured(RELAY));
assert_eq!(state, HubState::Ours { pid: 500 });
let id = process::ProcIdentity::of(info.as_ref().expect("inspected"));
process::terminate(&t, 500, Some(&id)).expect("gone is Ok");
assert!(t.terminated.lock().unwrap().is_empty());
}
#[test]
fn a_claimed_listener_with_an_unreadable_start_is_stopped() {
let fx = hub_fx();
let env = ProxyEnvVars::for_relay(7600);
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(26725);
let mut info = fx.hub_info(Some(env.overrides(None, None)));
info.start = None;
t.procs.lock().unwrap().insert(26725, info);
let e = start_hub_with(&t, &fx.app, &cap(&env), fx.port, Duration::ZERO)
.expect_err("no start identity");
assert_eq!(e.code, ERR_CLINE_APP_NOT_CAPTURED);
assert_eq!(*t.terminated.lock().unwrap(), vec![26725, 900]);
assert!(!record_exists());
}
#[test]
fn a_claimed_listener_is_stopped_when_the_record_cannot_be_written() {
let fx = hub_fx();
let env = ProxyEnvVars::for_relay(7600);
std::fs::write(crate::config::openlatch_dir().join("model-relay"), "")
.expect("block the record directory");
let t = fx.fake();
*t.on_spawn_listen.lock().unwrap() = Some(26725);
let mut info = fx.hub_info(Some(env.overrides(None, None)));
info.start = Some(42);
t.procs.lock().unwrap().insert(26725, info);
let e = start_hub_with(&t, &fx.app, &cap(&env), fx.port, Duration::ZERO)
.expect_err("the record cannot be written");
assert_eq!(e.code, ERR_CLINE_APP_NOT_CAPTURED);
assert_eq!(*t.terminated.lock().unwrap(), vec![26725, 900]);
}
}