Skip to content
Merged
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
11 changes: 10 additions & 1 deletion crates/okena-app/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ mod notifications;
pub use detached_overlays::open_detached_overlay;

use crate::remote_client::manager::{RemoteConnectionManager, RemoteManagerEvent};
use crate::views::window::{TerminalsRegistry, WindowView};
use crate::views::window::{
TerminalsRegistry, WindowView, content_pane_registry, notify_registered_panes,
};
use crate::workspace::state::{GlobalWorkspace, WindowId, Workspace, WorkspaceData};
use gpui::*;
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -359,6 +361,13 @@ impl Okena {
cx.subscribe(&remote_manager, |this, _rm, event, cx| match event {
RemoteManagerEvent::TerminalActivity(terminal_ids) => {
if !terminal_ids.is_empty() {
// One app-wide fan-out refreshes panes in every main, extra,
// and detached window. Keeping this out of WindowView avoids
// duplicate notifications and does not depend on a specific
// OS window entity remaining alive.
let mut registry = content_pane_registry().lock();
notify_registered_panes(&mut registry, terminal_ids, cx);
drop(registry);
this.process_terminal_notifications(terminal_ids, cx);
// Answer (or, when disabled, drop) OSC 52 clipboard *read*
// requests for remote terminals. The clipboard physically
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ impl ProfileManager {
}

pub(super) fn delete_profile(&mut self, id: &str, cx: &mut Context<Self>) {
match okena_core::profiles::delete_profile(id) {
match okena_core::profiles::delete_profile_with_cleanup(id, || {
okena_terminal::session_backend::reap_dtach_profile_sessions(id)
.map(|_| ())
.map_err(anyhow::Error::from)
}) {
Ok(()) => {
self.show_delete_confirmation = None;
self.refresh_profiles();
Expand Down
64 changes: 60 additions & 4 deletions crates/okena-app/src/views/window/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,31 @@ pub fn notify_pane_weaks<T: 'static>(weaks: &mut Vec<WeakEntity<T>>, cx: &mut Ap
any_alive
}

/// Notify panes for terminals whose remote content actually advanced. Empty
/// registrations are removed so repeated activity cannot grow stale weak lists.
pub fn notify_registered_panes<T: 'static>(
registry: &mut HashMap<String, Vec<WeakEntity<T>>>,
terminal_ids: &[String],
cx: &mut App,
) -> usize {
let mut notified = 0;
let mut empty = Vec::new();
for terminal_id in terminal_ids {
if let Some(weaks) = registry.get_mut(terminal_id) {
if notify_pane_weaks(weaks, cx) {
notified += 1;
}
if weaks.is_empty() {
empty.push(terminal_id.clone());
}
}
}
for terminal_id in empty {
registry.remove(&terminal_id);
}
notified
}

/// Per-window view of the application: one instance per OS window.
///
/// Owns the per-window UI state (sidebar, overlays, toasts, scroll handles,
Expand Down Expand Up @@ -569,9 +594,8 @@ impl WindowView {
// observer above.
let sidebar_for_activity = self.sidebar.clone();
cx.subscribe(&manager, move |_this, _rm, event, cx| match event {
// The payload (advanced terminal ids) is for `Okena`'s
// notification drain; the sidebar re-reads every terminal's
// bell/idle flags, so it just repaints.
// The app-wide manager subscription performs targeted terminal
// pane fan-out once; each WindowView only owns its sidebar repaint.
RemoteManagerEvent::TerminalActivity(_) => {
sidebar_for_activity.update(cx, |_, cx| cx.notify());
}
Expand Down Expand Up @@ -883,11 +907,43 @@ impl EventEmitter<WindowViewEvent> for WindowView {}

#[cfg(test)]
mod tests {
use super::notify_pane_weaks;
use super::{notify_pane_weaks, notify_registered_panes};
use gpui::AppContext as _;
use std::collections::HashMap;

struct Stub;

#[gpui::test]
fn activity_notifies_only_registered_terminal_panes(cx: &mut gpui::TestAppContext) {
let (target, other, mut registry) = cx.update(|cx| {
let target = cx.new(|_| Stub);
let other = cx.new(|_| Stub);
let registry = HashMap::from([
("target".to_string(), vec![target.downgrade()]),
("other".to_string(), vec![other.downgrade()]),
]);
(target, other, registry)
});

cx.update(|cx| {
assert_eq!(
notify_registered_panes(&mut registry, &["target".to_string()], cx),
1
);
assert_eq!(registry.len(), 2, "unrelated registrations stay intact");
});

drop(target);
cx.update(|cx| {
assert_eq!(
notify_registered_panes(&mut registry, &["target".to_string()], cx),
0
);
assert!(!registry.contains_key("target"), "dead pane key is pruned");
});
drop(other);
}

#[gpui::test]
fn fans_out_to_every_alive_weak_and_prunes_dead(cx: &mut gpui::TestAppContext) {
let (a, b, mut weaks) = cx.update(|cx| {
Expand Down
117 changes: 113 additions & 4 deletions crates/okena-core/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,88 @@ pub fn all_profiles() -> Result<Vec<ProfileEntry>> {
Ok(ProfileIndex::load(&root)?.profiles)
}

/// Delete a profile. Refuses to delete the active profile, the default profile, or a
/// profile whose `remote.json` points to a live PID. Removes the profile directory and
/// updates `profiles.json` (index written first so partial FS failure leaves index clean).
/// Claude credentials at `~/.claude-<id>/` are intentionally preserved.
/// Runtime root used by persistent dtach sessions.
pub fn dtach_socket_base_dir() -> PathBuf {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(runtime_dir).join("okena")
} else {
#[cfg(unix)]
{
// SAFETY: `getuid(2)` takes no arguments, dereferences no pointers,
// and is documented as never failing.
let uid = unsafe { libc::getuid() };
PathBuf::from(format!("/tmp/okena-{uid}"))
}
#[cfg(not(unix))]
{
std::env::temp_dir().join("okena")
}
}
}

/// Profile-isolated dtach directory. The default retains the legacy root for
/// backward compatibility; named profiles use a nested runtime directory.
pub fn dtach_socket_dir_for_profile(profile_id: &str) -> PathBuf {
let base = dtach_socket_base_dir();
if profile_id == "default" {
base
} else {
base.join("profiles").join(profile_id)
}
}

#[cfg(unix)]
fn ensure_profile_runtime_removable(runtime_dir: &Path, profile_id: &str) -> Result<()> {
let Ok(entries) = std::fs::read_dir(runtime_dir) else {
return Ok(());
};
for path in entries.flatten().map(|entry| entry.path()) {
let is_dtach_socket = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("tm-") && name.ends_with(".sock"));
if !is_dtach_socket {
continue;
}
match std::os::unix::net::UnixStream::connect(&path) {
Ok(_) => {
bail!(
"Cannot delete profile '{profile_id}' while persistent terminal sessions are still running; open the profile and close its terminals first"
);
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
) =>
{
let _ = std::fs::remove_file(path);
}
Err(error) => {
bail!("Cannot verify terminal cleanup for profile '{profile_id}': {error}");
}
}
}
Ok(())
}

#[cfg(not(unix))]
fn ensure_profile_runtime_removable(_runtime_dir: &Path, _profile_id: &str) -> Result<()> {
Ok(())
}

/// Delete a profile. Refuses to delete the active profile, the default profile, a
/// profile whose `remote.json` points to a live PID, or one with live persistent
/// terminal sessions. Removes the profile directory and updates `profiles.json`
/// (index written first so partial FS failure leaves index clean). Claude
/// credentials at `~/.claude-<id>/` are intentionally preserved.
pub fn delete_profile(id: &str) -> Result<()> {
delete_profile_with_cleanup(id, || Ok(()))
}

/// Delete a profile after running a caller-provided terminal cleanup, but only
/// after the usual default/active/running guards have succeeded.
pub fn delete_profile_with_cleanup(id: &str, cleanup: impl FnOnce() -> Result<()>) -> Result<()> {
let root = config_root();
let mut index = ProfileIndex::load(&root)?;

Expand All @@ -335,6 +412,9 @@ pub fn delete_profile(id: &str) -> Result<()> {
if is_profile_running(&paths) {
bail!("Profile '{id}' is currently in use by another Okena instance");
}
cleanup()?;
let runtime_dir = dtach_socket_dir_for_profile(id);
ensure_profile_runtime_removable(&runtime_dir, id)?;

index.profiles.retain(|p| p.id != id);
if index.last_used.as_deref() == Some(id) {
Expand All @@ -343,6 +423,7 @@ pub fn delete_profile(id: &str) -> Result<()> {
index.save(&root)?;

let _ = std::fs::remove_dir_all(&paths.root);
let _ = std::fs::remove_dir_all(runtime_dir);
Ok(())
}

Expand Down Expand Up @@ -1153,6 +1234,34 @@ mod tests {
assert_eq!(loaded.profiles.len(), idx.profiles.len());
}

#[cfg(unix)]
#[test]
fn profile_runtime_guard_refuses_live_sessions_and_removes_dead_sockets() {
let dir = temp_root();
let socket_path = dir.path().join("tm-live.sock");
let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap();

let error = ensure_profile_runtime_removable(dir.path(), "work").unwrap_err();
assert!(error.to_string().contains("persistent terminal sessions"));
assert!(socket_path.exists());

drop(listener);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
loop {
match ensure_profile_runtime_removable(dir.path(), "work") {
Ok(()) => break,
Err(error)
if error.to_string().contains("persistent terminal sessions")
&& std::time::Instant::now() < deadline =>
{
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(error) => panic!("dead socket did not become removable: {error}"),
}
}
assert!(!socket_path.exists());
}

#[test]
fn test_delete_profile_refuses_default() {
let dir = temp_root();
Expand Down
41 changes: 36 additions & 5 deletions crates/okena-daemon-core/src/command_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use okena_app_core::remote_snapshot::build_state_response;
#[cfg(test)]
Expand Down Expand Up @@ -520,10 +521,15 @@ async fn flush_project_runtime_teardown(
for teardown in &teardown_sessions {
backend.kill_session(teardown);
}
backend.flush_teardown();
if backend.flush_teardown_with_timeout(Duration::from_secs(5)) {
Ok(())
} else {
Err("terminal teardown did not release project paths in time; checkout preserved")
}
})
.await
.map_err(|error| format!("terminal teardown task failed: {error}"))
.map_err(|error| format!("terminal teardown task failed: {error}"))?
.map_err(str::to_string)
}

#[allow(clippy::too_many_arguments)]
Expand Down Expand Up @@ -1901,9 +1907,16 @@ pub(crate) fn spawn_background_worktree_removal(
let monitor = hook_monitor.clone();
let teardown_backend = backend.clone();
let outcome = tokio::task::spawn_blocking(move || {
// `kill` is asynchronous for local PTYs. Wait off-reactor until the
// queued handles and persistent sessions release their checkout CWD.
teardown_backend.flush_teardown();
// `kill` is asynchronous for local PTYs. Do not race destructive
// removal with a process that may still own the checkout CWD: a
// bounded failure restores the project instead of deleting it.
if !teardown_backend.flush_teardown_with_timeout(Duration::from_secs(5)) {
return (
plan,
Err("terminal teardown did not release the worktree in time; checkout preserved".to_string()),
None,
);
}
let worktree_path = plan.worktree_path.clone();
// force_remove = is_dirty && !did_stash — same condition the sync
// close_worktree path uses to fire the dirty-close safety net. Runs
Expand Down Expand Up @@ -6503,6 +6516,7 @@ mod tests {
killed: std::sync::atomic::AtomicBool,
flush_started: std::sync::atomic::AtomicBool,
release: Mutex<std::sync::mpsc::Receiver<()>>,
timeout_result: Option<bool>,
}

struct RenameRecordingBackend {
Expand Down Expand Up @@ -6671,6 +6685,21 @@ mod tests {
.expect("test releases teardown barrier");
}

fn flush_teardown_with_timeout(&self, _timeout: Duration) -> bool {
if let Some(result) = self.timeout_result {
assert!(
self.killed.load(std::sync::atomic::Ordering::SeqCst),
"project PTYs must be killed before bounded teardown verification"
);
self.flush_started
.store(true, std::sync::atomic::Ordering::SeqCst);
result
} else {
self.flush_teardown();
true
}
}

fn capture_buffer(&self, _terminal_id: &str) -> Option<std::path::PathBuf> {
None
}
Expand Down Expand Up @@ -7099,6 +7128,7 @@ mod tests {
killed: std::sync::atomic::AtomicBool::new(false),
flush_started: std::sync::atomic::AtomicBool::new(false),
release: Mutex::new(teardown_release_rx),
timeout_result: None,
});
let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new()));
let service_manager = Arc::new(Mutex::new(ServiceManager::new(
Expand Down Expand Up @@ -8201,6 +8231,7 @@ mod tests {
killed: std::sync::atomic::AtomicBool::new(false),
flush_started: std::sync::atomic::AtomicBool::new(false),
release: Mutex::new(release_rx),
timeout_result: None,
});
let backend: Arc<dyn TerminalBackend> = barrier_backend.clone();
let terminals: TerminalsRegistry = Arc::new(Mutex::new(Default::default()));
Expand Down
Loading
Loading