diff --git a/Cargo.lock b/Cargo.lock index 3968146..7af2172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,6 +1243,7 @@ dependencies = [ "gpui-pre-apple", "image", "raw-window-handle", + "rusqlite", "serde_json", ] diff --git a/README.md b/README.md index 0ca0b4a..8f9499a 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,12 @@ Hover the dot for every signal behind it. A status's age never greys a dot. is the newest open top-level thread of the T3 project whose folder is the checkout, a running one first. T3 offers no other way to ask, so at startup and after every refresh the dashboard reads the state database in T3's `userdata` folder, read-only. It looks in `t3_home` from `config.toml`, then T3's own `T3CODE_HOME`, then `~/.t3` - and `~/.t3-server`, preferring the one a server is running from. When there is nothing to open, the button is - disabled and its tooltip says why: T3 isn't installed or running, has no project for the checkout, or has no open - thread. The T3 desktop app has no way to open a thread from outside, so the browser it is. + and `~/.t3-server`, preferring the one a server is running from. A folder's server counts as running only when + the server at the address its `server-runtime.json` records answers T3's `/.well-known/t3/environment` with + that folder's `environment-id`. Every T3 home defaults to port 3773, so a file left by a crashed server is + skipped even when another T3 server now listens on its port. When there is nothing to open, the button is + disabled and its tooltip says why: T3 isn't installed or running, has no project for the checkout, has no open + thread, or something other than that folder's T3 server answers at its address. The T3 desktop app has no way to open a thread from outside, so the browser it is. - **Refresh one** repository with its row's button. It reloads the same pmd read endpoints. - **Automatic refresh** runs every `refresh_minutes` (default 5). Connection and timeout failures retry after 5, 10, 20, then 60 seconds until recovery, even when automatic refresh is off. At startup the title reads "Connecting to pmd…". Later network failures keep the last good data and show one offline indicator with the last successful read time. HTTP and authentication errors remain visible. - **Config changes apply while it runs.** Save `config.toml` and, within a couple of seconds, added repositories diff --git a/crates/dashboard-app/Cargo.toml b/crates/dashboard-app/Cargo.toml index d7ba1dd..6b6cd0b 100644 --- a/crates/dashboard-app/Cargo.toml +++ b/crates/dashboard-app/Cargo.toml @@ -28,6 +28,8 @@ raw-window-handle = "0.6" gpui-kit = { workspace = true, features = ["test-support"] } # Saves the headless captures (src/captures.rs) and the icon example's PNGs. image = { version = "0.25", default-features = false, features = ["png"] } +# Writes the synthetic T3 state databases the T3 button's lookup reads (ui_tests.rs `t3_homes`, #96). +rusqlite.workspace = true # macOS captures (src/captures.rs): GPUI's Mac platform must be created on the main thread, which a test never runs on, # so the captures build its text system directly. The version GPUI itself uses, already in the lock. diff --git a/crates/dashboard-app/src/captures.rs b/crates/dashboard-app/src/captures.rs index 62c8eb0..d6d5099 100644 --- a/crates/dashboard-app/src/captures.rs +++ b/crates/dashboard-app/src/captures.rs @@ -1993,3 +1993,64 @@ fn captures_projects_reread() { let listed = cx.update(|cx| manage.read(cx).projects().unwrap().visible_slugs(cx)); assert!(listed.contains(&"yahtzee".to_owned()), "{listed:?}"); } + +#[test] +#[ignore = "writes issue 96 T3 home identity states offscreen"] +fn captures_t3_home_identity() { + use crate::ui_tests::t3_homes::{CHECKOUT, Descriptor, Homes, Scratch, home}; + let out = PathBuf::from(std::env::var_os("CAPTURE_DIR").expect("CAPTURE_DIR")); + std::fs::create_dir_all(&out).unwrap(); + let scratch = Scratch::new("captures-identity"); + // One running server, for env-live, on the port both homes' runtime files record. + let server = Descriptor::serving("env-live"); + let stale = scratch.0.join(".t3"); + let live = scratch.0.join(".t3-server"); + home( + &stale, + "env-stale", + &server.origin, + ("t-stale", "Stale session from a crashed home"), + ); + home(&live, "env-live", &server.origin, ("t-live", "Live session")); + let checkout = Path::new(CHECKOUT); + let mut cx = headless(); + for (name, homes) in [ + ("t3-identity-wrong-environment", vec![stale.clone()]), + ("t3-identity-shared-port", vec![stale.clone(), live.clone()]), + ] { + let mut options = options(); + options.threads = Arc::new(Homes(homes)); + let (window, view) = open_with(&mut cx, vec![checkout.to_owned()], 1100., 420., options); + fill(&mut cx, &view, &demo::DemoSource); + // The production lookup probes the loopback server from a real background thread. + for _ in 0..400 { + if cx.update(|cx| view.read(cx).thread(checkout).is_some()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + cx.run_until_parked(); + } + let lookup = cx + .update(|cx| view.read(cx).thread(checkout).cloned()) + .expect("the lookup landed"); + match name { + "t3-identity-wrong-environment" => assert!( + dashboard_core::t3::tooltip(&lookup).contains("is another T3 Code environment"), + "{lookup:?}" + ), + _ => assert_eq!( + lookup.as_ref().map(|link| link.url.clone()), + Ok(format!("{}/env-live/t-live", server.origin)) + ), + } + save(&mut cx, window, &out, name); + cx.update_window(window, |_, window, cx| window.hover(("t3", 0usize), cx)) + .unwrap(); + for _ in 0..6 { + cx.advance_clock(std::time::Duration::from_millis(150)); + cx.run_until_parked(); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + save(&mut cx, window, &out, &format!("{name}-tooltip")); + } +} diff --git a/crates/dashboard-app/src/ui_tests.rs b/crates/dashboard-app/src/ui_tests.rs index 576e34b..88cf732 100644 --- a/crates/dashboard-app/src/ui_tests.rs +++ b/crates/dashboard-app/src/ui_tests.rs @@ -2080,6 +2080,218 @@ async fn a_row_without_a_thread_has_a_disabled_t3_button_that_says_why(cx: &mut assert!(!cx.update(|cx| view.read(cx).is_expanded(weather))); } +/// Synthetic T3 homes for the T3 button through the production lookup (#96): each home's state database, runtime +/// file and environment ID, and a loopback server answering T3's environment descriptor. No real T3 install, +/// profile or child process; the server is a thread in the test process. +pub(crate) mod t3_homes { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::{Duration, Instant}; + + /// The checkout every synthetic home has a project for. + pub(crate) const CHECKOUT: &str = "/demo/codexbar"; + + /// [`dashboard_core::t3::find_in_homes`] over `homes`, as the app's `LocalT3` runs it over the real ones. + pub(crate) struct Homes(pub(crate) Vec); + + impl dashboard_core::t3::ThreadFinder for Homes { + fn find(&self, checkouts: &[PathBuf]) -> Vec { + dashboard_core::t3::find_in_homes(&self.0, checkouts) + } + } + + /// A folder removed when dropped. + pub(crate) struct Scratch(pub(crate) PathBuf); + + impl Scratch { + pub(crate) fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("project-dashboard-ui-t3-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A running T3 server for one environment: answers `GET /.well-known/t3/environment` as T3 does, until + /// dropped (a minute at most). + pub(crate) struct Descriptor { + pub(crate) origin: String, + stop: Arc, + thread: Option>, + } + + impl Descriptor { + pub(crate) fn serving(environment: &str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let body = serde_json::json!({ + "environmentId": environment, + "label": "fixture", + "platform": { "os": "linux", "arch": "x64" }, + "serverVersion": "0.1.8", + "capabilities": { "repositoryIdentity": true }, + }) + .to_string(); + let stop = Arc::new(AtomicBool::new(false)); + let thread = std::thread::spawn({ + let stop = Arc::clone(&stop); + move || { + let deadline = Instant::now() + Duration::from_secs(60); + while !stop.load(Ordering::SeqCst) && Instant::now() < deadline { + match listener.accept() { + Ok((stream, _)) => answer(stream, &body), + Err(_) => std::thread::sleep(Duration::from_millis(5)), + } + } + } + }); + Self { + origin, + stop, + thread: Some(thread), + } + } + } + + impl Drop for Descriptor { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } + } + + fn answer(mut stream: std::net::TcpStream, body: &str) { + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let (status, body) = if request.starts_with(b"GET /.well-known/t3/environment ") { + (200, body) + } else { + (404, "") + }; + let _ = write!( + stream, + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + + /// A T3 home for `environment` whose runtime file records `origin`, with one open thread `(id, title)` in its + /// project for [`CHECKOUT`]. Tables shaped like T3's `statev2.sqlite`. + pub(crate) fn home(home: &Path, environment: &str, origin: &str, (id, title): (&str, &str)) { + let userdata = home.join("userdata"); + std::fs::create_dir_all(&userdata).unwrap(); + let db = rusqlite::Connection::open(userdata.join(dashboard_core::t3::STATE_DB)).unwrap(); + db.execute_batch( + "CREATE TABLE projection_projects (project_id TEXT PRIMARY KEY, workspace_root TEXT NOT NULL, + deleted_at TEXT); + CREATE TABLE orchestration_v2_projection_threads (thread_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, + title TEXT NOT NULL, updated_at TEXT, archived_at TEXT, deleted_at TEXT, payload_json TEXT NOT NULL); + CREATE TABLE orchestration_v2_projection_runs (run_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, + status TEXT NOT NULL);", + ) + .unwrap(); + db.execute("INSERT INTO projection_projects VALUES ('p', ?1, NULL)", [CHECKOUT]) + .unwrap(); + db.execute( + "INSERT INTO orchestration_v2_projection_threads + VALUES (?1, 'p', ?2, '2026-11-07T08:00:00.000Z', NULL, NULL, '{}')", + [id, title], + ) + .unwrap(); + let port = origin.rsplit(':').next().unwrap(); + std::fs::write( + userdata.join("server-runtime.json"), + format!( + r#"{{"version":1,"pid":4242,"port":{port},"origin":"{origin}","startedAt":"2026-11-07T08:00:00.000Z"}}"# + ), + ) + .unwrap(); + std::fs::write(userdata.join("environment-id"), format!("{environment}\n")).unwrap(); + } +} + +#[gpui_kit::test] +fn the_t3_button_links_only_the_home_whose_own_server_answers(cx: &mut TestAppContext) { + use dashboard_core::t3::tooltip; + use t3_homes::{CHECKOUT, Descriptor, Homes, Scratch, home}; + let scratch = Scratch::new("identity"); + // One running server, for env-live, on the port both homes' runtime files record (#96). + let server = Descriptor::serving("env-live"); + let stale = scratch.0.join(".t3"); + let live = scratch.0.join(".t3-server"); + home(&stale, "env-stale", &server.origin, ("t-stale", "Stale session")); + home(&live, "env-live", &server.origin, ("t-live", "Live session")); + let checkout = Path::new(CHECKOUT); + let board = |cx: &mut TestAppContext, homes: Vec| { + let mut opts = options(); + opts.threads = Arc::new(Homes(homes)); + let (handle, view) = open_board( + cx, + vec![checkout.to_owned()], + StatusCache::default(), + Arc::new(demo::DemoSource), + opts, + ); + cx.run_until_parked(); + let lookup = cx + .update(|cx| view.read(cx).thread(checkout).cloned()) + .expect("the lookup landed"); + (handle, lookup) + }; + + // Only the crashed home: another environment answers on its port, so the button is disabled with that reason. + let (handle, lookup) = board(cx, vec![stale.clone()]); + let reason = tooltip(&lookup); + assert_eq!( + reason, + format!( + "Can't read T3 Code's threads: the server at {} is another T3 Code environment, not the one whose \ + files were found", + server.origin + ) + ); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find(("t3", 0usize)).disabled(), Some(true)); + window.click(("t3", 0usize), cx); + }) + .unwrap(); + assert_eq!(cx.opened_url(), None); + + // The crashed home first, then the home the server belongs to: the running home's thread is linked. + let (handle, lookup) = board(cx, vec![stale, live]); + let link = lookup.unwrap(); + assert_eq!(link.title, "Live session"); + assert_eq!(link.url, format!("{}/env-live/t-live", server.origin)); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert_ne!(window.find(("t3", 0usize)).disabled(), Some(true)); + window.click(("t3", 0usize), cx); + }) + .unwrap(); + assert_eq!(cx.opened_url().as_deref(), Some(link.url.as_str())); +} + #[gpui_kit::test] async fn threads_are_looked_up_at_start_and_again_after_each_refresh(cx: &mut TestAppContext) { let counter = Arc::new(CountingThreads(std::sync::atomic::AtomicUsize::new(0))); diff --git a/crates/dashboard-core/src/t3.rs b/crates/dashboard-core/src/t3.rs index 3523d1f..1aa3451 100644 --- a/crates/dashboard-core/src/t3.rs +++ b/crates/dashboard-core/src/t3.rs @@ -102,6 +102,31 @@ struct Runtime { origin: String, } +/// Where a T3 server describes itself (T3's `EnvironmentMetadataHttpApi`). +const DESCRIPTOR_PATH: &str = "/.well-known/t3/environment"; +/// The descriptor is a few hundred bytes; anything far larger isn't one. +const DESCRIPTOR_LIMIT: u64 = 64 * 1024; +/// How long a server that accepted the connection gets to describe itself. +const IDENTITY_TIMEOUT: Duration = Duration::from_secs(2); + +/// The part of T3's `ExecutionEnvironmentDescriptor` that identifies the server. +#[derive(Deserialize)] +struct Descriptor { + #[serde(rename = "environmentId")] + environment_id: String, +} + +/// What answers at a home's recorded origin. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Liveness { + /// The server there reports this home's environment ID. + Running, + /// Nothing accepts connections there. + Stopped, + /// Something answers there, but not as this home's T3 server: why. + Other(String), +} + impl Server { /// Reads where the server listens and which environment it is. Doesn't check that it is running. pub fn locate(userdata: &Path) -> Result { @@ -127,18 +152,49 @@ impl Server { }) } - /// Whether something accepts connections at the origin's host and port, within `timeout`. - pub fn is_listening(&self, timeout: Duration) -> bool { - let Some(authority) = self.origin.split("://").nth(1) else { - return false; + /// Asks the server at the origin which T3 environment it is, through the unauthenticated descriptor T3's own + /// `t3 pair` reads (`GET /.well-known/t3/environment`, whose `environmentId` names the environment). Every T3 + /// home defaults to port 3773 and a crash leaves its runtime file behind, so answering on the port proves + /// nothing: only a server reporting this home's `environment-id` is running from this home. + pub fn probe(&self, timeout: Duration) -> Liveness { + let client = match reqwest::blocking::Client::builder() + .connect_timeout(timeout) + .timeout(IDENTITY_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + { + Ok(client) => client, + Err(err) => return Liveness::Other(format!("can't ask {} who it is: {err}", self.origin)), }; - let authority = authority.split('/').next().unwrap_or(authority); - use std::net::ToSocketAddrs as _; - authority - .to_socket_addrs() - .into_iter() - .flatten() - .any(|address| std::net::TcpStream::connect_timeout(&address, timeout).is_ok()) + let response = match client.get(format!("{}{DESCRIPTOR_PATH}", self.origin)).send() { + Ok(response) => response, + Err(err) if err.is_connect() => return Liveness::Stopped, + Err(_) => return Liveness::Other(format!("{} didn't say which T3 Code server it is", self.origin)), + }; + if !response.status().is_success() { + return Liveness::Other(format!( + "{} answered HTTP {} when asked which T3 Code server it is", + self.origin, + response.status().as_u16() + )); + } + let mut body = Vec::new(); + use std::io::Read as _; + let read = response.take(DESCRIPTOR_LIMIT + 1).read_to_end(&mut body); + let reported = read + .ok() + .filter(|_| body.len() as u64 <= DESCRIPTOR_LIMIT) + .and_then(|_| serde_json::from_slice::(&body).ok()) + .map(|descriptor| descriptor.environment_id); + match reported { + Some(id) if id == self.environment_id => Liveness::Running, + Some(_) => Liveness::Other(format!( + "the server at {} is another T3 Code environment, not the one whose files were found", + self.origin + )), + None => Liveness::Other(format!("{} didn't identify itself as a T3 Code server", self.origin)), + } } /// The web UI's address for a thread. The ID is percent-encoded as T3's router expects (`mcp:` is `mcp%3A`). @@ -194,34 +250,61 @@ pub fn tooltip(lookup: &Lookup) -> String { } } -/// The thread for each checkout, in the order given, from the local T3 server in `userdata`. -/// The thread for each checkout from the first of `homes` whose server is listening. A home left with a stale -/// runtime file by a crashed or stopped server is passed over for a later one that is running. With none running, -/// the reason comes from the home [`resolve_userdata`] picks. +/// The thread for each checkout from the first of `homes` whose own server is running ([`Server::probe`]). A home +/// left with a stale runtime file by a crashed or stopped server is passed over for a later one that is running, +/// even when another home's server now listens on the stale file's port. With none running, the reason comes from +/// the home [`resolve_userdata`] picks. pub fn find_in_homes(homes: &[PathBuf], checkouts: &[PathBuf]) -> Vec { + find_in_homes_with(homes, checkouts, &|server| server.probe(PROBE)) +} + +/// [`find_in_homes`] with `probe` telling whether a located server is running from its home. +pub fn find_in_homes_with( + homes: &[PathBuf], + checkouts: &[PathBuf], + probe: &dyn Fn(&Server) -> Liveness, +) -> Vec { + let mut probed: Vec<(PathBuf, Liveness)> = Vec::new(); for home in homes { - if let Ok(server) = Server::locate(&home.join("userdata")) - && server.is_listening(PROBE) - { - return lookups(&server, checkouts); + let userdata = home.join("userdata"); + if let Ok(server) = Server::locate(&userdata) { + let liveness = probe(&server); + if liveness == Liveness::Running { + return lookups(&server, checkouts); + } + probed.push((userdata, liveness)); } } - find(&resolve_userdata(homes), checkouts) + let userdata = resolve_userdata(homes); + let earlier = probed + .into_iter() + .find(|(dir, _)| *dir == userdata) + .map(|(_, liveness)| liveness); + find_with(&userdata, checkouts, &|server| { + earlier.clone().unwrap_or_else(|| probe(server)) + }) } /// How long to wait for a server to accept a connection. const PROBE: Duration = Duration::from_millis(300); +/// The thread for each checkout, in the order given, from the local T3 server in `userdata`. pub fn find(userdata: &Path, checkouts: &[PathBuf]) -> Vec { + find_with(userdata, checkouts, &|server| server.probe(PROBE)) +} + +/// [`find`] with `probe` telling whether the located server is running from `userdata`'s home. +pub fn find_with(userdata: &Path, checkouts: &[PathBuf], probe: &dyn Fn(&Server) -> Liveness) -> Vec { let all = |reason: NoThread| checkouts.iter().map(|_| Err(reason.clone())).collect(); let server = match Server::locate(userdata) { Ok(server) => server, Err(reason) => return all(reason), }; - if !server.is_listening(PROBE) { - return all(NoThread::NotRunning); + match probe(&server) { + Liveness::Running => lookups(&server, checkouts), + Liveness::Stopped => all(NoThread::NotRunning), + Liveness::Other(reason) => all(NoThread::Unreadable(reason)), } - lookups(&server, checkouts) } /// Every checkout's thread from a running server's database. diff --git a/crates/dashboard-core/tests/t3.rs b/crates/dashboard-core/tests/t3.rs index b647179..daca2ea 100644 --- a/crates/dashboard-core/tests/t3.rs +++ b/crates/dashboard-core/tests/t3.rs @@ -1,9 +1,13 @@ //! Finding a checkout's T3 Code thread in fixture state databases shaped like T3 server 0.1.1's `statev2.sqlite`. +use std::io::{Read as _, Write as _}; use std::net::TcpListener; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; -use dashboard_core::t3::{self, NoThread, STATE_DB, Server, ThreadLink}; +use dashboard_core::t3::{self, Liveness, NoThread, STATE_DB, Server, ThreadLink}; use rusqlite::Connection; struct Scratch(PathBuf); @@ -377,13 +381,112 @@ fn reading_never_writes_to_t3s_database() { assert_eq!(names, [STATE_DB]); } -fn userdata(dir: &Path, origin: &str) { +/// A home's runtime files, shaped like T3's `PersistedServerRuntimeState` and `environment-id`. +fn userdata(dir: &Path, origin: &str, environment: &str) { + let port = origin.rsplit(':').next().unwrap_or_default().trim_end_matches('/'); std::fs::write( dir.join("server-runtime.json"), - format!(r#"{{"version":1,"origin":"{origin}"}}"#), + format!( + r#"{{"version":1,"pid":4242,"port":{port},"origin":"{origin}","startedAt":"2026-11-07T08:00:00.000Z","serverVersion":"0.1.8"}}"# + ), ) .unwrap(); - std::fs::write(dir.join("environment-id"), "env-1\n").unwrap(); + std::fs::write(dir.join("environment-id"), format!("{environment}\n")).unwrap(); +} + +/// A local HTTP server standing in for a running T3 server: it answers every request with `status` and `body`, +/// on a loopback port of its own, until dropped. A thread in this test process, not a child process. +struct FakeT3 { + origin: String, + stop: Arc, + thread: Option>, +} + +impl FakeT3 { + /// A T3 server for `environment`, describing itself as T3 does. + fn running(environment: &str) -> Self { + Self::answering( + 200, + &serde_json::json!({ + "environmentId": environment, + "label": "fixture", + "platform": { "os": "windows", "arch": "x64" }, + "serverVersion": "0.1.8", + "capabilities": { "repositoryIdentity": true }, + }) + .to_string(), + ) + } + + /// Accepts connections and reads requests but never answers, like a hung server. + fn stalled() -> Self { + Self::answering(STALLED, "") + } + + fn answering(status: u16, body: &str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let stop = Arc::new(AtomicBool::new(false)); + let body = body.to_owned(); + let thread = std::thread::spawn({ + let stop = Arc::clone(&stop); + move || { + // Bounded: stops when dropped, and after a minute at most. + let deadline = Instant::now() + Duration::from_secs(60); + let mut held = Vec::new(); + while !stop.load(Ordering::SeqCst) && Instant::now() < deadline { + match listener.accept() { + Ok((stream, _)) if status == STALLED => held.push(stream), + Ok((stream, _)) => answer(stream, status, &body), + Err(_) => std::thread::sleep(Duration::from_millis(5)), + } + } + } + }); + Self { + origin, + stop, + thread: Some(thread), + } + } +} + +impl Drop for FakeT3 { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +/// [`FakeT3::answering`]'s status for a server that never answers. +const STALLED: u16 = 0; + +fn answer(mut stream: std::net::TcpStream, status: u16, body: &str) { + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let wanted = request.starts_with(b"GET /.well-known/t3/environment "); + let (status, body) = if wanted { (status, body) } else { (404, "") }; + let _ = write!( + stream, + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); +} + +fn closed_origin() -> String { + let closed = TcpListener::bind("127.0.0.1:0").unwrap(); + format!("http://{}", closed.local_addr().unwrap()) } #[test] @@ -398,15 +501,12 @@ fn find_reports_why_for_every_checkout_when_t3_is_missing_or_stopped() { // No runtime file: the server isn't running. assert_eq!(t3::find(&scratch.0, &checkouts)[0], Err(NoThread::NotRunning)); // A runtime file for a server that has gone away. - let closed = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = closed.local_addr().unwrap().port(); - drop(closed); - userdata(&scratch.0, &format!("http://127.0.0.1:{port}")); + userdata(&scratch.0, &closed_origin(), "env-1"); assert_eq!(t3::find(&scratch.0, &checkouts)[0], Err(NoThread::NotRunning)); } #[test] -fn find_links_each_checkout_while_the_server_is_listening() { +fn find_links_each_checkout_while_its_server_is_running() { let scratch = Scratch::new("listening"); database( &scratch.0, @@ -422,11 +522,11 @@ fn find_links_each_checkout_while_the_server_is_listening() { Some("running"), )], ); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - userdata(&scratch.0, &format!("http://{}/", listener.local_addr().unwrap())); + let t3 = FakeT3::running("env-1"); + userdata(&scratch.0, &format!("{}/", t3.origin), "env-1"); let found = t3::find(&scratch.0, &[PathBuf::from(DASHBOARD), PathBuf::from("C:/src/other")]); let link = found[0].as_ref().unwrap(); - assert_eq!(link.url, format!("http://{}/env-1/t1", listener.local_addr().unwrap())); + assert_eq!(link.url, format!("{}/env-1/t1", t3.origin)); assert_eq!(found[1], Err(NoThread::NoProject)); } @@ -462,23 +562,20 @@ fn the_home_with_a_running_server_wins() { // When both have a database, the one with a running server's runtime file wins, wherever it is in the order. database(&default.join("userdata"), &[], &[]); assert_eq!(t3::resolve_userdata(&homes), default.join("userdata")); - userdata(&server.join("userdata"), "http://127.0.0.1:3773"); + userdata(&server.join("userdata"), "http://127.0.0.1:3773", "env-1"); assert_eq!(t3::resolve_userdata(&homes), server.join("userdata")); // Nothing anywhere: the first home, which find() then reports as not installed. let empty = [scratch.0.join("nothing")]; assert_eq!(t3::resolve_userdata(&empty), scratch.0.join("nothing").join("userdata")); } -#[test] -fn a_stale_home_is_passed_over_for_the_running_server() { - let scratch = Scratch::new("stale"); - let stale = scratch.0.join(".t3").join("userdata"); - let live = scratch.0.join(".t3-server").join("userdata"); - std::fs::create_dir_all(&stale).unwrap(); - std::fs::create_dir_all(&live).unwrap(); - // The first home still has a database and a runtime file, but its server has gone. +/// Two homes for the same checkout: a crashed `stale` one with thread "Stale" in environment `env-stale`, and a +/// `live` one with thread "Live" in `env-live`. +fn stale_and_live(stale: &Path, live: &Path) { + std::fs::create_dir_all(stale).unwrap(); + std::fs::create_dir_all(live).unwrap(); database( - &stale, + stale, &[("p-old", DASHBOARD, false)], &[( "t-old", @@ -491,28 +588,209 @@ fn a_stale_home_is_passed_over_for_the_running_server() { None, )], ); - let gone = TcpListener::bind("127.0.0.1:0").unwrap(); - let gone_port = gone.local_addr().unwrap().port(); - drop(gone); - userdata(&stale, &format!("http://127.0.0.1:{gone_port}")); database( - &live, + live, &[("p1", DASHBOARD, false)], &[("t-live", "p1", "Live", "2026-10-21T09:00:00Z", false, false, None, None)], ); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - userdata(&live, &format!("http://{}", listener.local_addr().unwrap())); +} + +#[test] +fn a_stale_home_is_passed_over_for_the_running_server() { + let scratch = Scratch::new("stale"); + let stale = scratch.0.join(".t3").join("userdata"); + let live = scratch.0.join(".t3-server").join("userdata"); + stale_and_live(&stale, &live); + // The first home still has a database and a runtime file, but its server has gone. + userdata(&stale, &closed_origin(), "env-stale"); + let t3 = FakeT3::running("env-live"); + userdata(&live, &t3.origin, "env-live"); let homes = [scratch.0.join(".t3"), scratch.0.join(".t3-server")]; let found = t3::find_in_homes(&homes, &[PathBuf::from(DASHBOARD)]); assert_eq!(found[0].as_ref().unwrap().title, "Live"); // With no server listening anywhere, the reason is that T3 isn't running. - drop(listener); + drop(t3); assert_eq!( t3::find_in_homes(&homes, &[PathBuf::from(DASHBOARD)]), [Err(NoThread::NotRunning)] ); } +#[test] +fn a_stale_home_on_the_running_servers_port_is_passed_over() { + // #96: every T3 home defaults to port 3773, so a crashed ~/.t3's runtime file names the port the running + // ~/.t3-server now listens on. Answering there doesn't make the stale home's threads live. + let scratch = Scratch::new("shared-port"); + let stale = scratch.0.join(".t3").join("userdata"); + let live = scratch.0.join(".t3-server").join("userdata"); + stale_and_live(&stale, &live); + let t3 = FakeT3::running("env-live"); + userdata(&stale, &t3.origin, "env-stale"); + userdata(&live, &t3.origin, "env-live"); + let homes = [scratch.0.join(".t3"), scratch.0.join(".t3-server")]; + let found = t3::find_in_homes(&homes, &[PathBuf::from(DASHBOARD)]); + let link = found[0].as_ref().unwrap(); + assert_eq!(link.title, "Live"); + assert_eq!(link.url, format!("{}/env-live/t-live", t3.origin)); +} + +#[test] +fn a_stale_configured_t3_home_on_the_running_servers_port_is_passed_over() { + let scratch = Scratch::new("configured"); + let configured = scratch.0.join("configured"); + let user_home = scratch.0.join("user"); + stale_and_live( + &configured.join("userdata"), + &user_home.join(".t3-server").join("userdata"), + ); + let t3 = FakeT3::running("env-live"); + userdata(&configured.join("userdata"), &t3.origin, "env-stale"); + userdata(&user_home.join(".t3-server").join("userdata"), &t3.origin, "env-live"); + let homes = t3::candidate_homes(Some(&configured), None, &user_home); + let found = t3::find_in_homes(&homes, &[PathBuf::from(DASHBOARD)]); + let link = found[0].as_ref().unwrap(); + assert_eq!(link.title, "Live"); + assert_eq!(link.url, format!("{}/env-live/t-live", t3.origin)); +} + +#[test] +fn a_stale_home_alone_with_another_environment_on_its_port_links_nothing() { + let scratch = Scratch::new("stale-alone"); + let stale = scratch.0.join(".t3").join("userdata"); + stale_and_live(&stale, &scratch.0.join("unused")); + let t3 = FakeT3::running("env-elsewhere"); + userdata(&stale, &t3.origin, "env-stale"); + let found = t3::find_in_homes(&[scratch.0.join(".t3")], &[PathBuf::from(DASHBOARD)]); + let Err(NoThread::Unreadable(reason)) = &found[0] else { + panic!("expected a reason, got {found:?}"); + }; + assert!(reason.contains("another T3 Code environment"), "{reason}"); + assert!(reason.contains(&t3.origin), "{reason}"); +} + +#[test] +fn something_that_isnt_a_t3_server_on_the_port_is_a_reason() { + let missing_id = serde_json::json!({ "label": "x", "serverVersion": "0.1.8" }).to_string(); + let numeric_id = serde_json::json!({ "environmentId": 7 }).to_string(); + let oversized = format!(r#"{{"environmentId":"env-1","pad":"{}"}}"#, "x".repeat(70 * 1024)); + for (status, body, expected) in [ + (404, "", "answered HTTP 404"), + (500, "{}", "answered HTTP 500"), + (200, "hello", "didn't identify itself as a T3 Code server"), + (200, "", "didn't identify itself as a T3 Code server"), + (200, missing_id.as_str(), "didn't identify itself as a T3 Code server"), + (200, numeric_id.as_str(), "didn't identify itself as a T3 Code server"), + (200, oversized.as_str(), "didn't identify itself as a T3 Code server"), + ] { + let scratch = Scratch::new(&format!("not-t3-{status}-{}", body.len())); + database(&scratch.0, &[("p1", DASHBOARD, false)], &[]); + let other = FakeT3::answering(status, body); + userdata(&scratch.0, &other.origin, "env-1"); + let found = t3::find(&scratch.0, &[PathBuf::from(DASHBOARD)]); + let Err(NoThread::Unreadable(reason)) = &found[0] else { + panic!("{status} {body:.40}: expected a reason, got {found:?}"); + }; + assert!(reason.contains(expected), "{status} {body:.40}: {reason}"); + } +} + +#[test] +fn a_server_that_accepts_but_never_answers_is_a_reason() { + let scratch = Scratch::new("stalled"); + database(&scratch.0, &[("p1", DASHBOARD, false)], &[]); + let stalled = FakeT3::stalled(); + userdata(&scratch.0, &stalled.origin, "env-1"); + let started = Instant::now(); + let found = t3::find(&scratch.0, &[PathBuf::from(DASHBOARD)]); + let Err(NoThread::Unreadable(reason)) = &found[0] else { + panic!("expected a reason, got {found:?}"); + }; + assert_eq!( + *reason, + format!("{} didn't say which T3 Code server it is", stalled.origin) + ); + // Bounded by the probe's own timeout, not the stalled server. + assert!(started.elapsed() < Duration::from_secs(10), "{:?}", started.elapsed()); +} + +#[test] +fn a_runtime_file_without_the_servers_identity_is_a_reason() { + let scratch = Scratch::new("no-identity"); + database(&scratch.0, &[("p1", DASHBOARD, false)], &[]); + let t3 = FakeT3::running("env-1"); + let find = || t3::find(&scratch.0, &[PathBuf::from(DASHBOARD)]); + let unreadable = |found: Vec, mention: &str| { + let Err(NoThread::Unreadable(reason)) = &found[0] else { + panic!("expected a reason mentioning {mention}, got {found:?}"); + }; + assert!(reason.contains(mention), "{reason}"); + }; + // No origin in the runtime file. + userdata(&scratch.0, &t3.origin, "env-1"); + std::fs::write(scratch.0.join("server-runtime.json"), r#"{"version":1,"pid":4242}"#).unwrap(); + unreadable(find(), "server-runtime.json"); + // Not JSON at all. + std::fs::write(scratch.0.join("server-runtime.json"), "not json").unwrap(); + unreadable(find(), "server-runtime.json"); + // An empty or missing environment ID. + userdata(&scratch.0, &t3.origin, ""); + unreadable(find(), "unexpected server identity"); + std::fs::remove_file(scratch.0.join("environment-id")).unwrap(); + unreadable(find(), "environment-id"); + // Restored, the running server is read again: its project has no open thread. + userdata(&scratch.0, &t3.origin, "env-1"); + assert_eq!(find(), [Err(NoThread::NoOpenThread)]); +} + +#[test] +fn each_home_is_asked_once_and_its_answer_decides() { + let scratch = Scratch::new("probe"); + let first = scratch.0.join("a").join("userdata"); + let second = scratch.0.join("b").join("userdata"); + stale_and_live(&first, &second); + userdata(&first, "http://127.0.0.1:3773", "env-stale"); + userdata(&second, "http://127.0.0.1:3773", "env-live"); + let homes = [scratch.0.join("a"), scratch.0.join("b")]; + let checkouts = [PathBuf::from(DASHBOARD)]; + let asked = std::cell::RefCell::new(Vec::new()); + let probe = |answers: &'static [(&'static str, Liveness)]| { + let asked = &asked; + move |server: &Server| { + asked.borrow_mut().push(server.environment_id.clone()); + answers + .iter() + .find(|(id, _)| *id == server.environment_id) + .map(|(_, liveness)| liveness.clone()) + .unwrap() + } + }; + // The running home's thread, whatever answered first. + const LIVE_SECOND: &[(&str, Liveness)] = &[("env-stale", Liveness::Stopped), ("env-live", Liveness::Running)]; + let found = t3::find_in_homes_with(&homes, &checkouts, &probe(LIVE_SECOND)); + assert_eq!(found[0].as_ref().unwrap().title, "Live"); + assert_eq!(*asked.borrow(), ["env-stale", "env-live"]); + // Neither running: the picked home's own answer is the reason, without asking it twice. + asked.borrow_mut().clear(); + const NEITHER: &[(&str, Liveness)] = &[("env-stale", Liveness::Stopped), ("env-live", Liveness::Stopped)]; + assert_eq!( + t3::find_in_homes_with(&homes, &checkouts, &probe(NEITHER)), + [Err(NoThread::NotRunning)] + ); + assert_eq!(*asked.borrow(), ["env-stale", "env-live"]); +} + +#[test] +fn an_unidentified_answer_from_the_picked_home_is_its_reason() { + let scratch = Scratch::new("probe-other"); + let only = scratch.0.join("a").join("userdata"); + stale_and_live(&only, &scratch.0.join("unused")); + userdata(&only, "http://127.0.0.1:3773", "env-stale"); + let found = t3::find_in_homes_with(&[scratch.0.join("a")], &[PathBuf::from(DASHBOARD)], &|_| { + Liveness::Other("not T3".to_owned()) + }); + assert_eq!(found, [Err(NoThread::Unreadable("not T3".to_owned()))]); +} + #[test] fn reasons_read_as_tooltips() { assert_eq!(NoThread::NotRunning.to_string(), "T3 Code isn't running");