Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 76 additions & 8 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ fn pair_command(args: &[String], client_host: &NativeClientHost) -> Result<Strin

/// Start the in-process host and put the mux in front of it. This window is
/// then one ordinary client among every link attached to that mux.
fn start_local(store: SessionStore) -> (SpawnedHost, HostMux) {
fn start_local(store: SessionStore) -> std::io::Result<(SpawnedHost, HostMux)> {
let mut host_services = HostServices {
background_startup_probes: true,
ai_title_generation: true,
Expand All @@ -204,9 +204,71 @@ fn start_local(store: SessionStore) -> (SpawnedHost, HostMux) {
}
Err(error) => log::warn!("MCP host failed to bind: {error}"),
}
let host = spawn_host(store, host_services).expect("failed to start tcode host thread");
let host = spawn_host(store, host_services)?;
let mux = HostMux::new(host.to_host.clone(), host.from_host.clone());
(host, mux)
Ok((host, mux))
}

/// The window-less stand-in for a host that could not start: an alert that
/// says why, after which the app quits.
struct StartupFailure;

impl gpui::Render for StartupFailure {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
gpui::Empty
}
}

fn report_startup_failure(error: &std::io::Error, data_dir: &std::path::Path) {
eprintln!("tcode: {error}");
let (title, detail) = if error.kind() == std::io::ErrorKind::ResourceBusy {
(
tcode_ui::tr!("startup.index_in_use_title").into_owned(),
tcode_ui::tr!("startup.index_in_use", dir = data_dir.display()).into_owned(),
)
} else {
(
tcode_ui::tr!("startup.failed_title").into_owned(),
error.to_string(),
)
};
let quit = tcode_ui::tr!("quit.confirm").into_owned();
gpui_platform::application().run(move |cx| {
// On macOS `cx.quit()` ends in `-[NSApplication terminate:]`, which
// calls `exit(0)` right after gpui's shutdown has run its quit
// handlers, so the failure status has to be set from one of them.
cx.on_app_quit(|_| async { std::process::exit(1) }).detach();
cx.activate(true);
let window = cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::centered(size(px(420.), px(160.)), cx)),
..Default::default()
},
|_, cx| gpui::AppContext::new(cx, |_| StartupFailure),
);
let Ok(window) = window else {
cx.quit();
return;
};
let _ = window.update(cx, |_, window, cx| {
let answer = window.prompt(
gpui::PromptLevel::Critical,
&title,
Some(&detail),
&[quit.as_str()],
cx,
);
cx.spawn(async move |_, cx| {
let _ = answer.await;
cx.update(|cx| cx.quit());
})
.detach();
});
});
}

struct LocalKernel {
Expand All @@ -217,18 +279,18 @@ struct LocalKernel {
}

impl LocalKernel {
fn start(store: SessionStore) -> Self {
let (host, mux) = start_local(store);
fn start(store: SessionStore) -> std::io::Result<Self> {
let (host, mux) = start_local(store)?;
let connection = mux.attach();
let control_link = HostLink::new(connection.to_host, connection.from_host);
let pump_link = control_link.clone();
let control_pump = smol::spawn(async move { pump_link.pump().await });
Self {
Ok(Self {
host,
mux,
control_link,
_control_pump: control_pump,
}
})
}

/// A window's link to the local kernel. The mux keeps the kernel alive
Expand Down Expand Up @@ -360,7 +422,13 @@ fn main() {
};
// Kernel ownership is process composition, not a property of whichever
// host the window currently views.
let kernel = Rc::new(LocalKernel::start(store));
let kernel = match LocalKernel::start(store) {
Ok(kernel) => Rc::new(kernel),
Err(error) => {
report_startup_failure(&error, &data_dir);
std::process::exit(1);
}
};
let local_settings = kernel.settings();

gpui_platform::application()
Expand Down
18 changes: 9 additions & 9 deletions crates/core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,8 @@ pub fn order_sessions_with_children(sessions: Vec<SessionMeta>) -> Vec<SessionMe
output
}

/// On-disk shape of `sessions.json` (current schema). Old files were a bare
/// `Vec<SessionMeta>`; the store loader tolerates both.
/// Shape of the legacy `sessions.json` index (its last schema; older files
/// were a bare `Vec<SessionMeta>`), and of an index being migrated.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IndexFile {
#[serde(default)]
Expand All @@ -481,9 +481,11 @@ pub struct IndexFile {
}

/// Ensure every session belongs to a project, deriving implicit projects from
/// each orphan session's cwd (deduped by root). Idempotent.
/// each orphan session's cwd (deduped by root). A session whose project is in
/// `unreadable_projects` (stored, but not readable by this build) is not an
/// orphan. Idempotent.
#[cfg(feature = "process")]
pub fn migrate_index(mut file: IndexFile) -> IndexFile {
pub fn migrate_index(mut file: IndexFile, unreadable_projects: &HashSet<String>) -> IndexFile {
// Map existing project roots to their ids so derived projects dedupe.
let mut root_to_id: std::collections::HashMap<PathBuf, String> = file
.projects
Expand All @@ -492,11 +494,9 @@ pub fn migrate_index(mut file: IndexFile) -> IndexFile {
.collect();

for session in &mut file.sessions {
if session
.project_id
.as_ref()
.is_some_and(|id| file.projects.iter().any(|p| &p.id == id))
{
if session.project_id.as_ref().is_some_and(|id| {
unreadable_projects.contains(id) || file.projects.iter().any(|p| &p.id == id)
}) {
continue;
}
let root = session.cwd.clone();
Expand Down
5 changes: 2 additions & 3 deletions crates/runtime/src/app/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,8 @@ impl AppState {
},
cx,
);
// Reflect the upsert in memory instead of reloading the whole index
// from disk: `persist_metas` runs on every turn,
// where re-reading and re-parsing a large sessions.json stalls the UI.
// The host's lists are the index while it runs; the file is read
// only at startup.
self.upsert_sessions_in_memory(metas);
}

Expand Down
67 changes: 45 additions & 22 deletions crates/runtime/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ use tcode_services::import::{
use tcode_services::provider_probe::{default_program, probe_provider, run_capture_env};
use tcode_services::session_search::SessionSearch;
use tcode_services::settings::SettingsStore;
use tcode_services::store::{SessionStore, now_millis, now_secs};
use tcode_services::store::{LoadedIndex, SessionIndex, SessionStore, now_millis, now_secs};
use tcode_services::user_files;
use tcode_services::version_check::provider_updates::{
self, CheckInput as ProviderCheckInput, Installation,
Expand Down Expand Up @@ -245,7 +245,7 @@ mod subagents;
mod terminals;

#[cfg(test)]
mod test_support;
pub(crate) mod test_support;
#[cfg(test)]
mod tests;

Expand Down Expand Up @@ -309,9 +309,14 @@ impl Default for TcodeUpdateState {

pub struct AppState {
store: SessionStore,
/// The project and session index this host owns while it runs; written
/// only by the store writer once startup is over.
index: SessionIndex,
settings_store: SettingsStore,
store_writes: smol::channel::Sender<StoreWrite>,
store_write_receiver: Option<smol::channel::Receiver<StoreWrite>>,
/// The running store writer, which ends once every queued write is done.
store_writer: Option<HostTask<()>>,
store_write_failures: smol::channel::Sender<Result<RuntimeError, String>>,
store_write_failure_receiver: Option<smol::channel::Receiver<Result<RuntimeError, String>>>,
pub sessions: Vec<SessionMeta>,
Expand Down Expand Up @@ -438,20 +443,34 @@ pub(crate) fn startup_collapsed_threads(sessions: &[SessionMeta]) -> Vec<String>
.collect()
}

/// How long a host started by a relaunch waits for the instance it replaces
/// to quit and release the index. That instance may first ask whether to stop
/// its working threads.
const RELAUNCH_INDEX_WAIT: Duration = Duration::from_secs(15);

impl AppState {
pub fn new(store: SessionStore) -> Self {
Self::with_ai_titles(store, false)
#[cfg(test)]
pub(crate) fn new(store: SessionStore) -> Self {
Self::start(store, false).expect("open the session index")
}

pub(crate) fn with_ai_titles(store: SessionStore, ai_title_generation_enabled: bool) -> Self {
// Load + migrate once and persist so derived project ids stay stable.
let file = store.read_file();
if let Err(err) = store.persist_index(&file) {
log::warn!("failed to persist migrated session index: {err}");
}
let mut sessions = file.sessions;
/// Open the data dir's index and load the host's state from it. Fails
/// when another host has the index open.
pub(crate) fn start(
store: SessionStore,
ai_title_generation_enabled: bool,
) -> std::io::Result<Self> {
let index_wait = if tcode_services::relaunch::pending(store.root()) {
RELAUNCH_INDEX_WAIT
} else {
Duration::ZERO
};
let index = store.open_index(index_wait)?;
let LoadedIndex {
projects,
mut sessions,
} = index.load()?;
sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
let projects = file.projects;
let settings_store = SettingsStore::new(store.root().clone());
let mut settings = settings_store.load();
settings.collapsed_threads = startup_collapsed_threads(&sessions);
Expand Down Expand Up @@ -491,11 +510,13 @@ impl AppState {
let (store_writes, store_write_receiver) = smol::channel::unbounded();
let (store_write_failures, store_write_failure_receiver) = smol::channel::unbounded();
let session_search = Arc::new(std::sync::Mutex::new(SessionSearch::new(store.clone())));
Self {
Ok(Self {
store,
index,
settings_store,
store_writes,
store_write_receiver: Some(store_write_receiver),
store_writer: None,
store_write_failures,
store_write_failure_receiver: Some(store_write_failure_receiver),
sessions,
Expand Down Expand Up @@ -544,7 +565,7 @@ impl AppState {
external_imports: HashMap::new(),
next_import_run_id: 1,
session_search,
}
})
}

#[cfg(any(test, feature = "test-support"))]
Expand All @@ -554,20 +575,15 @@ impl AppState {

fn start_store_writer(&mut self, cx: &mut HostCx) {
if let Some(writes) = self.store_write_receiver.take() {
let mut writer = StoreWriter::new(
let writer = StoreWriter::new(
self.store.clone(),
self.index.clone(),
self.settings_store.clone(),
self.terminal_preferences_path.clone(),
);
let failures = self.store_write_failures.clone();
let host_cx = cx.clone();
HostCx::spawn_detached(cx, async move {
while let Ok(write) = writes.recv().await {
if let Some(failure) = writer.run(write, &host_cx).await {
let _ = failures.send(failure).await;
}
}
});
self.store_writer = Some(cx.spawn_background(writer.serve(writes, failures, host_cx)));
}
if let Some(failures) = self.store_write_failure_receiver.take() {
let host_cx = cx.clone();
Expand All @@ -582,6 +598,13 @@ impl AppState {
}
}

/// Stop accepting writes and hand back the writer, which finishes the
/// queued ones and then closes the index.
pub(crate) fn close_store(self) -> Option<HostTask<()>> {
let Self { store_writer, .. } = self;
store_writer
}

fn enqueue_store_write(&mut self, write: StoreWrite, cx: &mut HostCx) {
self.start_store_writer(cx);
if self.store_writes.try_send(write).is_err() {
Expand Down
8 changes: 6 additions & 2 deletions crates/runtime/src/app/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1139,10 +1139,14 @@ impl AppState {
.terminal_preferences
.remove(&draft_destination.preference_key())
.is_some();
if self.forget_sessions(&session_ids, false, cx) || draft_preferences_changed {
// Queued right behind its sessions' removal, so both leave the index
// in one commit.
let preferences_changed =
self.forget_sessions(&session_ids, false, cx) || draft_preferences_changed;
self.enqueue_store_write(StoreWrite::RemoveProject(project_id.to_string()), cx);
if preferences_changed {
self.write_terminal_preferences(cx);
}
self.enqueue_store_write(StoreWrite::RemoveProject(project_id.to_string()), cx);
self.settings
.collapsed_projects
.retain(|id| id != project_id);
Expand Down
2 changes: 1 addition & 1 deletion crates/runtime/src/app/snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ impl AppState {
statuses
}

/// `sessions` stays newest-first, matching `load_index`'s order.
/// `sessions` stays newest-first, the order the host loads it in.
pub(super) fn upsert_sessions_in_memory(
&mut self,
metas: impl IntoIterator<Item = SessionMeta>,
Expand Down
Loading
Loading