Skip to content
2 changes: 1 addition & 1 deletion crates/fractal-daemon/src/terminal_fabric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 46 additions & 1 deletion crates/fractal-fabric-adapter/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,31 @@ fn default_st_socket() -> Result<PathBuf> {
.join("st3.sock"))
}

fn default_state_dir(home: &Path) -> Result<PathBuf> {
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<bool> {
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};
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
}
Expand Down
14 changes: 14 additions & 0 deletions crates/fractal-kernel/src/fabric_gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion crates/fractal-terminal-direct/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
49 changes: 49 additions & 0 deletions crates/fractal-terminal-direct/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -183,3 +201,34 @@ fn handshake(socket: &Path, line: &str, protocol: &str) -> Result<StdUnixStream,
.map_err(|error| unreachable("configure", error))?;
Ok(stream)
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn mixed_advertisements_preserve_owner_node_and_exact_fleet() {
let mut member: MemberView = serde_json::from_value(json!({
"name":"owner", "member_key":"key", "state":"current", "mode":"peer", "start":1,
"endpoints":[
{"transport":"fabric","node":"other","protocol":format!("{RENAMED_PTY_PREFIX}fleet")},
{"transport":"fabric","node":"node","protocol":format!("{RENAMED_PTY_PREFIX}fleet-extra")},
{"transport":"fabric","node":"node","protocol":"fractal/fleet/fleet"}
]
})).unwrap();
for name in [protocol("fleet"), format!("{RENAMED_PTY_PREFIX}fleet")] {
member.endpoints.push(json!({"transport":"fabric","node":"node","protocol":name}));
assert_eq!(owner_protocol(&[member.clone()], "owner", "node", "fleet"), name);
assert_eq!(owner_protocol(&[member.clone()], "other", "node", "fleet"), "st3/pty/fleet");
member.state = "ended".into();
assert_eq!(owner_protocol(&[member.clone()], "owner", "node", "fleet"), "st3/pty/fleet");
member.state = "current".into();
member.endpoints.pop();
}
assert_eq!(owner_protocol(&[member], "owner", "node", "fleet"), "st3/pty/fleet");
let request = RouteRequest::new("runtime", "agent/worker", "pid:created");
let decoded: RouteRequest = serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap();
assert_eq!(decoded, request);
}
}
2 changes: 1 addition & 1 deletion crates/fractal-tui/src/ui/direct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ async fn over_fabric(
"{owner} advertises no Fabric node, and no Fabric peer of this machine has its name"
))
})?;
let protocol = fractal_terminal_direct::protocol(&fleet_id);
let protocol = fractal_terminal_direct::owner_protocol(&members, owner, &peer, &fleet_id);
let target = FabricTarget {
fabric,
peer,
Expand Down
3 changes: 2 additions & 1 deletion crates/fractal/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8501,11 +8501,12 @@ async fn fabric_route(
.with_context(|| {
format!("`{owner}` advertises no Fabric node, and no Fabric peer of this machine has its name")
})?;
let protocol = fractal_daemon::terminal_fabric::owner_protocol(&view.members, owner, &peer, &fleet_id);
Ok((
fractal_daemon::terminal_fabric::FabricTarget {
fabric,
peer,
protocol: fractal_daemon::terminal_fabric::protocol(&fleet_id),
protocol,
},
fractal_daemon::terminal_fabric::RouteRequest::new(runtime_id, subject, incarnation),
))
Expand Down
11 changes: 11 additions & 0 deletions release-notes/fabric-pty-adapter-state-accepting-readers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"summary": "Read renamed PTY advertisements and existing Fabric adapter state without changing exposures",
"replay": {"status": "none", "detail": "No replay, state rewrite or backfill."},
"schema": {"status": "none", "detail": "Gateway settings, route handshakes and adapter state retain their existing formats."},
"rules": {"status": "none", "detail": "Terminal capability checks, owner and node selection, and runtime incarnation handshakes remain unchanged. The adapter still grants no PTY access."},
"compatibility": {"status": "changed", "detail": "CLI and TUI direct terminal readers select an exact st3/pty/<fleet> or fractal/pty/<fleet> advertisement from the current selected owner and node. Without such an advertisement they retain st3/pty/<fleet>. 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."}
}
Loading