diff --git a/crates/fractal-daemon/src/terminal_fabric.rs b/crates/fractal-daemon/src/terminal_fabric.rs index feb2ae05b3e..224d5ae78ec 100644 --- a/crates/fractal-daemon/src/terminal_fabric.rs +++ b/crates/fractal-daemon/src/terminal_fabric.rs @@ -21,7 +21,7 @@ use pty_client::{ClientIo, Reconnect, RouteRefusedError}; pub use fractal_terminal_direct::{ FabricTarget, ROUTE_LINE_LIMIT, ROUTE_TIMEOUT, RouteAnswer, RouteError, RouteRequest, - open_route, owner_peer, protocol, + open_route, owner_peer, owner_protocol, protocol, }; use crate::model::LocalTerminal; diff --git a/crates/fractal-fabric-adapter/src/main.rs b/crates/fractal-fabric-adapter/src/main.rs index 8046d478093..5d90e19fa94 100644 --- a/crates/fractal-fabric-adapter/src/main.rs +++ b/crates/fractal-fabric-adapter/src/main.rs @@ -89,6 +89,31 @@ fn default_st_socket() -> Result { .join("st3.sock")) } +fn default_state_dir(home: &Path) -> Result { + let legacy = home.join(".local/state/st-fabric-adapter"); + let renamed = home.join(".local/state/fractal-fabric-adapter"); + // Retain the existing authority; never combine two independently populated homes. + let populated = |path: &Path| -> Result { + match std::fs::read_dir(path) { + Ok(mut entries) => Ok(entries.next().transpose()?.is_some()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } + }; + let legacy_populated = populated(&legacy)?; + let renamed_populated = populated(&renamed)?; + ensure!( + !(legacy_populated && renamed_populated), + "conflicting adapter state in {} and {}; pass --state-dir explicitly", + legacy.display(), renamed.display() + ); + Ok(if renamed_populated || (!legacy.try_exists()? && renamed.try_exists()?) { + renamed + } else { + legacy + }) +} + /// Durable before any external mutation, including ownership discovery on first use. fn save(path: &Path, value: &impl Serialize) -> Result<()> { use std::{io::Write, os::unix::fs::OpenOptionsExt}; @@ -266,7 +291,7 @@ async fn main() -> Result<()> { let args = Args::parse(); let state = match args.state_dir { Some(path) => path, - None => home()?.join(".local/state/st-fabric-adapter"), + None => default_state_dir(&home()?)?, }; if args.status { let status: Status = serde_json::from_slice( @@ -337,6 +362,26 @@ async fn main() -> Result<()> { mod tests { use super::*; + #[test] + fn state_home_reads_both_names_and_refuses_conflicting_authority() { + let root = tempfile::tempdir().unwrap(); + let legacy = root.path().join(".local/state/st-fabric-adapter"); + let renamed = root.path().join(".local/state/fractal-fabric-adapter"); + assert_eq!(default_state_dir(root.path()).unwrap(), legacy); + std::fs::create_dir_all(&renamed).unwrap(); + assert_eq!(default_state_dir(root.path()).unwrap(), renamed); + for path in [&legacy, &renamed] { + std::fs::create_dir_all(path).unwrap(); + } + assert_eq!(default_state_dir(root.path()).unwrap(), legacy); + std::fs::write(renamed.join("owners.json"), b"[\"fleet\"]").unwrap(); + assert_eq!(default_state_dir(root.path()).unwrap(), renamed); + std::fs::write(legacy.join("owners.json"), b"[\"fleet\"]").unwrap(); + assert!(default_state_dir(root.path()).is_err()); + std::fs::remove_file(renamed.join("owners.json")).unwrap(); + assert_eq!(default_state_dir(root.path()).unwrap(), legacy); + } + pub(crate) fn envelope(value: serde_json::Value) -> serde_json::Value { serde_json::to_value(smallclaims::sync::PeerResponse::new("fixture", 127, value)).unwrap() } diff --git a/crates/fractal-kernel/src/fabric_gateway.rs b/crates/fractal-kernel/src/fabric_gateway.rs index 5a8527db931..23890dbf37d 100644 --- a/crates/fractal-kernel/src/fabric_gateway.rs +++ b/crates/fractal-kernel/src/fabric_gateway.rs @@ -253,6 +253,20 @@ mod tests { fs::read_to_string(log).unwrap_or_default() } + #[test] + fn gateway_settings_read_both_service_names_without_rewriting() { + let root = tempfile::tempdir().unwrap(); + for service in ["st3-client-v0", "fractal-client-v0"] { + let text = format!("service = '{service}'\nfabric = '/opt/fabric'\n"); + fs::write(GatewayExposure::path(root.path()), &text).unwrap(); + let exposure = GatewayExposure::load(root.path()).unwrap().unwrap(); + assert_eq!(exposure.service, service); + assert_eq!(exposure.fabric, PathBuf::from("/opt/fabric")); + assert_eq!(fs::read_to_string(GatewayExposure::path(root.path())).unwrap(), text); + } + assert_eq!(DEFAULT_SERVICE, "st3-client-v0"); + } + #[test] fn node_ids_and_peer_names() { assert!(valid_node_id(PHONE)); diff --git a/crates/fractal-terminal-direct/src/lib.rs b/crates/fractal-terminal-direct/src/lib.rs index d31df64bea6..ba58ab7dd87 100644 --- a/crates/fractal-terminal-direct/src/lib.rs +++ b/crates/fractal-terminal-direct/src/lib.rs @@ -12,5 +12,5 @@ mod route; pub use local::{LocalTerminal, connect_pty_session, open_local_terminal}; pub use route::{ FabricTarget, ROUTE_LINE_LIMIT, ROUTE_TIMEOUT, RouteAnswer, RouteError, RouteRequest, - open_route, owner_peer, protocol, + open_route, owner_peer, owner_protocol, protocol, }; diff --git a/crates/fractal-terminal-direct/src/route.rs b/crates/fractal-terminal-direct/src/route.rs index 7afbe30f68b..bcf1c59872e 100644 --- a/crates/fractal-terminal-direct/src/route.rs +++ b/crates/fractal-terminal-direct/src/route.rs @@ -13,12 +13,30 @@ pub const ROUTE_TIMEOUT: Duration = Duration::from_secs(10); /// The longest route line or answer either side reads. pub const ROUTE_LINE_LIMIT: usize = 4_096; +const RENAMED_PTY_PREFIX: &str = concat!("fractal", "/pty/"); + /// The Fabric protocol that serves a fleet's PTY sessions. Each fleet has its own, so an isolated /// fleet on the same machine never replaces another fleet's exposure. pub fn protocol(fleet_id: &str) -> String { format!("st3/pty/{fleet_id}") } +/// Read either exact PTY spelling advertised by this current owner at the selected node. +/// An owner with no PTY advertisement still uses the existing exposure default. +pub fn owner_protocol(members: &[MemberView], owner: &str, peer: &str, fleet: &str) -> String { + members + .iter() + .filter(|member| member.name == owner && member.state == "current") + .flat_map(|member| &member.endpoints) + .filter(|endpoint| endpoint["transport"] == "fabric" && endpoint["node"] == peer) + .filter_map(|endpoint| endpoint["protocol"].as_str()) + .find(|name| { + name.strip_prefix("st3/pty/") == Some(fleet) + || name.strip_prefix(RENAMED_PTY_PREFIX) == Some(fleet) + }) + .map_or_else(|| protocol(fleet), str::to_owned) +} + /// Attach to PTY session `name`, which must be `subject`'s terminal at runtime incarnation /// `incarnation` (`PTY_DAEMON_PID:CREATED_AT`). #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -183,3 +201,34 @@ fn handshake(socket: &Path, line: &str, protocol: &str) -> Result or fractal/pty/ advertisement from the current selected owner and node. Without such an advertisement they retain st3/pty/. Gateway settings already accept both client service names; a fixture now locks in that behavior. Adapter default state discovery reads an existing ~/.local/state/st-fabric-adapter or ~/.local/state/fractal-fabric-adapter home and refuses two populated homes rather than merging authority. Explicit --state-dir bypasses discovery. Fresh installs retain the legacy default; no producer or exposure changes."}, + "downtime": {"status": "none", "detail": "No host actions or migration are required."}, + "manual": {"status": "none", "detail": "Only conflicting populated adapter homes require choosing the authoritative directory explicitly with --state-dir."}, + "recovery": {"status": "none", "detail": "No stored paths or services are rewritten. Older binaries still require their existing spellings. Retain accepting readers until the rename ledger's admitted-site inventory proves old advertisements and state homes absent."} +}