mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
Merge pull request #6924: separate Runtime stores and workspace drivers
Preserve @gaord's original store/workspace ownership split, commits and regression tests, with reviewed lease lifetime, contended-inode and current-main reconciliation repairs. Exact own-head CI: macOS19057 passed;0failed;33skipped. Ubuntu19054 passed;0failed;34skipped. Windows18310 passed(one existing browser-launcher leak);0failed;27skipped. Named Windows owner recovery passed. Actual CI-flags Clippy9m35 and all ratchets passed. Source/ref/review/dependent guards and the predicted combined tree were checked; source branch remains. This is Runtime-API workspace admission and Unix derived endpoints. Native-app/installed-package acceptance and the resulting combined main/full final release QA remain separate gates. 0.10.2 has not shipped. Refs #6924
This commit is contained in:
14 files changed
+1722
-15
No files matched your search
@@ -33,6 +33,9 @@ tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
uuid.workspace = true
|
||||
# The store-derived control endpoint hashes the runtime store root
|
||||
# (`daemon_socket::store_selected_socket_path`).
|
||||
sha2.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rusqlite.workspace = true
|
||||
@@ -40,5 +43,4 @@ tempfile.workspace = true
|
||||
tower = "0.5"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
sha2.workspace = true
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_Pipes"] }
|
||||
@@ -198,6 +198,67 @@ pub fn default_socket_path() -> Result<PathBuf, DaemonSocketError> {
|
||||
resolve_socket_path(&SocketPathInputs::from_environment(None)?)
|
||||
}
|
||||
|
||||
/// Digest bytes that name a derived socket. Six bytes (twelve hex characters,
|
||||
/// the same digest length the runtime-boundary id uses) make the basename a
|
||||
/// fixed 23 bytes with the `store-` prefix and the `.sock` suffix, which keeps
|
||||
/// the hard kernel path limit reachable only for an already-close default
|
||||
/// endpoint instead of consuming the remaining headroom.
|
||||
#[cfg(unix)]
|
||||
const DERIVED_STORE_HASH_BYTES: usize = 6;
|
||||
|
||||
/// The control endpoint for a selected runtime store: the well-known default
|
||||
/// socket when the store is the default selection, and a private sibling
|
||||
/// derived from the store root otherwise.
|
||||
///
|
||||
/// One control endpoint per runtime store, as a pure function of the store
|
||||
/// selection. A client that selects a non-default store (`CODEWHALE_RUNTIME_DIR`,
|
||||
/// e.g. the VS Code extension's per-workspace store) elects its own owner
|
||||
/// instead of colliding with the default store's owner on the shared socket;
|
||||
/// the default store keeps the historical path, so discovery, `doctor` and
|
||||
/// single-engine setups are unchanged.
|
||||
///
|
||||
/// `store_root` is the canonicalized runtime store root the caller selected;
|
||||
/// `None` means "the default selection" (no override in effect).
|
||||
///
|
||||
/// Unix only: a Windows owner endpoint is a named pipe, and its derived-name
|
||||
/// form is follow-up work. Windows keeps the well-known endpoint it has today.
|
||||
#[cfg(unix)]
|
||||
pub fn store_selected_socket_path(
|
||||
store_root: Option<&Path>,
|
||||
inputs: &SocketPathInputs,
|
||||
) -> Result<PathBuf, DaemonSocketError> {
|
||||
use anyhow::Context as _;
|
||||
use sha2::Digest as _;
|
||||
|
||||
let base = resolve_socket_path(inputs)?;
|
||||
let Some(store_root) = store_root else {
|
||||
return Ok(base);
|
||||
};
|
||||
let mut digest = sha2::Sha256::new();
|
||||
// The tag keeps this name from colliding with any other use of the store
|
||||
// root's digest in the same directory.
|
||||
digest.update(b"Codewhale/store-socket/v1\0");
|
||||
digest.update(store_root.as_os_str().as_encoded_bytes());
|
||||
let suffix: String = digest.finalize()[..DERIVED_STORE_HASH_BYTES]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let parent = base
|
||||
.parent()
|
||||
.context("default daemon socket has no parent directory")
|
||||
.map_err(DaemonSocketError::State)?;
|
||||
let derived = parent.join(format!("store-{suffix}.sock"));
|
||||
let len = derived.as_os_str().len();
|
||||
if len > MAX_SOCKET_PATH_BYTES {
|
||||
return Err(DaemonSocketError::PathTooLong {
|
||||
path: derived,
|
||||
len,
|
||||
max: MAX_SOCKET_PATH_BYTES,
|
||||
});
|
||||
}
|
||||
Ok(derived)
|
||||
}
|
||||
|
||||
/// The socket path this host would use with no explicit override.
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub fn default_socket_path() -> Result<PathBuf, DaemonSocketError> {
|
||||
@@ -1789,6 +1850,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn default_store_keeps_the_well_known_socket() {
|
||||
let resolved = store_selected_socket_path(None, &inputs()).expect("resolve");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn distinct_stores_derive_distinct_sibling_sockets() {
|
||||
let one = store_selected_socket_path(
|
||||
Some(Path::new("/home/whale/.local/state/one/runtime")),
|
||||
&inputs(),
|
||||
)
|
||||
.expect("resolve");
|
||||
let two = store_selected_socket_path(
|
||||
Some(Path::new("/home/whale/.local/state/two/runtime")),
|
||||
&inputs(),
|
||||
)
|
||||
.expect("resolve");
|
||||
assert_eq!(
|
||||
one.parent().unwrap(),
|
||||
Path::new("/home/whale/.codewhale/run")
|
||||
);
|
||||
assert_eq!(one.parent().unwrap(), two.parent().unwrap());
|
||||
assert_ne!(one, two);
|
||||
let name = one.file_name().unwrap().to_str().unwrap();
|
||||
assert!(name.starts_with("store-"), "derived name: {name}");
|
||||
assert!(name.ends_with(".sock"), "derived name: {name}");
|
||||
assert_eq!(name.len(), 23, "derived name: {name}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_derived_socket_is_a_pure_function_of_the_store_root() {
|
||||
let store = Path::new("/home/whale/.local/state/one/runtime");
|
||||
let first = store_selected_socket_path(Some(store), &inputs()).expect("resolve");
|
||||
let second = store_selected_socket_path(Some(store), &inputs()).expect("resolve");
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn store_sockets_stay_within_the_kernel_path_budget() {
|
||||
// The longest realistic parent (macOS app support) plus a long store
|
||||
// path must still resolve: the derived name is a fixed 23 bytes.
|
||||
let macos = SocketPathInputs {
|
||||
user_home: Some(PathBuf::from("/Users/whale")),
|
||||
macos: true,
|
||||
..inputs()
|
||||
};
|
||||
let store = PathBuf::from(format!(
|
||||
"/Users/whale/Library/Application Support/Code/User/globalStorage/ext/runtime/ws-{}",
|
||||
"a".repeat(16)
|
||||
));
|
||||
let resolved = store_selected_socket_path(Some(&store), &macos).expect("resolve");
|
||||
assert!(resolved.as_os_str().len() <= MAX_SOCKET_PATH_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_home_is_a_typed_error() {
|
||||
let err = resolve_socket_path(&SocketPathInputs {
|
||||
|
||||
@@ -626,6 +626,15 @@ impl PrivateDirectory {
|
||||
)),
|
||||
};
|
||||
}
|
||||
let identity = windows_file_identity(&file)?;
|
||||
drop(file);
|
||||
let file = self
|
||||
.open_windows_file(name, true, true)?
|
||||
.context("new xAI OAuth lifecycle lock disappeared after securing")?;
|
||||
anyhow::ensure!(
|
||||
windows_file_identity(&file)? == identity,
|
||||
"new xAI OAuth lifecycle lock identity changed after securing"
|
||||
);
|
||||
return Ok(file);
|
||||
}
|
||||
bail!("xAI OAuth lifecycle lock changed repeatedly while opening")
|
||||
|
||||
@@ -163,6 +163,7 @@ mod voice;
|
||||
mod work_graph;
|
||||
mod worker_profile;
|
||||
mod working_set;
|
||||
mod workspace_lease;
|
||||
mod workspace_trust;
|
||||
|
||||
use crate::config::{
|
||||
|
||||
@@ -243,6 +243,11 @@ struct RuntimeWorkspaceScope {
|
||||
lsp: std::sync::OnceLock<Arc<crate::lsp::LspManager>>,
|
||||
owner: SharedRuntimeThreadManager,
|
||||
cleanup_runtime: tokio::runtime::Handle,
|
||||
/// The workspace drive lease held for this scope's lifetime: at most one
|
||||
/// driving engine per workspace, across stores and clients
|
||||
/// (`workspace_lease`). Watch-only observers never reach scope admission.
|
||||
/// Underscored because it is held for its `Drop` side effect, never read.
|
||||
_drive_lease: Option<crate::workspace_lease::SharedWorkspaceDriveLease>,
|
||||
}
|
||||
|
||||
pub(crate) fn open_workspace_directory(workspace: &FsPath) -> Result<(PathBuf, std::fs::File)> {
|
||||
@@ -294,6 +299,7 @@ impl Drop for RuntimeWorkspaceScope {
|
||||
fn drop(&mut self) {
|
||||
let mcp = self.mcp.get_mut().take().map(|(_, pool)| pool);
|
||||
let lsp = self.lsp.take();
|
||||
let drive_lease = self._drive_lease.take();
|
||||
// The canonical writer lease and held directory survive the actual
|
||||
// transport cleanup, including waiter cancellation and guest detach.
|
||||
let owner = self.owner.clone();
|
||||
@@ -301,6 +307,7 @@ impl Drop for RuntimeWorkspaceScope {
|
||||
self.cleanup_runtime.spawn(async move {
|
||||
let _owner = owner;
|
||||
let _directory = directory;
|
||||
let _drive_lease = drive_lease;
|
||||
if let Some(pool) = mcp {
|
||||
pool.lock().await.shutdown_all().await;
|
||||
}
|
||||
@@ -320,6 +327,52 @@ struct RuntimeWorkspaceScopes {
|
||||
cleanup_runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
/// A legible refusal when the workspace drive lease is held elsewhere: it
|
||||
/// names the recorded holder so the second client can offer a real choice
|
||||
/// (stay watch-only, or take the lease over) instead of an opaque conflict.
|
||||
#[derive(Debug)]
|
||||
struct WorkspaceDriveLeaseRefusal {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl WorkspaceDriveLeaseRefusal {
|
||||
fn named_holder(workspace: &FsPath) -> Option<Self> {
|
||||
let holder = crate::workspace_lease::WorkspaceDriveLease::read_holder(workspace)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let held_for = holder.held_for();
|
||||
Some(Self {
|
||||
message: format!(
|
||||
"another client is already driving this workspace: pid={} client={} store={} held_for={held_for:?}; \
|
||||
close it first or attach watch-only to its existing owner",
|
||||
holder.pid,
|
||||
holder.client.as_deref().unwrap_or("unknown"),
|
||||
holder
|
||||
.store_root
|
||||
.as_ref()
|
||||
.map_or_else(|| "unknown".to_string(), |root| root.display().to_string()),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn unreadable() -> Self {
|
||||
Self {
|
||||
message:
|
||||
"another client is already driving this workspace (holder record unreadable); \
|
||||
close it first or attach watch-only to its existing owner"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorkspaceDriveLeaseRefusal {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorkspaceDriveLeaseRefusal {}
|
||||
|
||||
impl RuntimeWorkspaceScopes {
|
||||
fn new(owner: SharedRuntimeThreadManager, workers: SharedSubAgentManager) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
@@ -350,6 +403,38 @@ impl RuntimeWorkspaceScopes {
|
||||
scopes.len() < MAX_RUNTIME_WORKSPACE_SCOPES,
|
||||
"Runtime workspace scope limit reached; restart the owner to retire unused scopes"
|
||||
);
|
||||
// One driving engine per workspace, process-wide. The lease is held
|
||||
// for as long as this engine drives the workspace and releases when
|
||||
// the scope (and its engine) goes away. A contending client gets the
|
||||
// recorded holder's facts in the refusal instead of an opaque
|
||||
// conflict. Watch-only observers attach to the owner that holds the
|
||||
// lease, so they never reach this point.
|
||||
let client = std::env::var("CODEWHALE_CLIENT_LABEL")
|
||||
.ok()
|
||||
.filter(|label| !label.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("TERM_PROGRAM")
|
||||
.ok()
|
||||
.filter(|program| program.to_ascii_lowercase().contains("vscode"))
|
||||
})
|
||||
.map(|label| label.replace(['\n', '='], " "));
|
||||
let store_root = owner.owner.session_store_binding().data_dir;
|
||||
let drive_lease = crate::workspace_lease::SharedWorkspaceDriveLease::acquire(
|
||||
&lexical,
|
||||
client.as_deref(),
|
||||
Some(&store_root),
|
||||
)
|
||||
.map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"workspace drive lease check failed for {}: {error:#}",
|
||||
lexical.display()
|
||||
)
|
||||
})?;
|
||||
let Some(drive_lease) = drive_lease else {
|
||||
let refusal = WorkspaceDriveLeaseRefusal::named_holder(&lexical)
|
||||
.unwrap_or_else(WorkspaceDriveLeaseRefusal::unreadable);
|
||||
return Err(anyhow::Error::new(refusal));
|
||||
};
|
||||
owner
|
||||
.workers
|
||||
.blocking_write()
|
||||
@@ -363,6 +448,7 @@ impl RuntimeWorkspaceScopes {
|
||||
lsp: std::sync::OnceLock::new(),
|
||||
owner: owner.owner.clone(),
|
||||
cleanup_runtime: owner.cleanup_runtime.clone(),
|
||||
_drive_lease: Some(drive_lease),
|
||||
});
|
||||
scopes.insert(lexical, scope.clone());
|
||||
Ok(scope)
|
||||
@@ -1744,9 +1830,18 @@ pub async fn run_http_server(
|
||||
Some(options.workers),
|
||||
);
|
||||
#[cfg(any(unix, windows))]
|
||||
let selected_store =
|
||||
RuntimeThreadManagerConfig::from_task_data_dir(task_cfg.data_dir.clone()).data_dir;
|
||||
#[cfg(unix)]
|
||||
let control_socket = Some(
|
||||
store_selected_control_socket(selected_control_socket(&options), &selected_store).await?,
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let control_socket = selected_control_socket(&options);
|
||||
#[cfg(any(unix, windows))]
|
||||
let admission = admit_runtime_host(
|
||||
options.config_path.clone(),
|
||||
selected_control_socket(&options),
|
||||
control_socket.clone(),
|
||||
RuntimeThreadManagerConfig::from_task_data_dir(task_cfg.data_dir.clone()).data_dir,
|
||||
)
|
||||
.await?;
|
||||
@@ -1765,9 +1860,7 @@ pub async fn run_http_server(
|
||||
let existing_binding: Option<crate::runtime_threads::RuntimeStoreBinding> = None;
|
||||
#[cfg(any(unix, windows))]
|
||||
if let Some(control) = published {
|
||||
let selected_store =
|
||||
RuntimeThreadManagerConfig::from_task_data_dir(task_cfg.data_dir.clone()).data_dir;
|
||||
validate_selected_owner(&control, selected_store).await?;
|
||||
validate_selected_owner(&control, selected_store.clone()).await?;
|
||||
let observed_owner = control.receipt().clone();
|
||||
let client = match options.control_frontend.as_ref() {
|
||||
Some(codewhale_app_server::RuntimeControlFrontend::Acp { model }) => {
|
||||
@@ -1775,7 +1868,7 @@ pub async fn run_http_server(
|
||||
let client =
|
||||
codewhale_app_server::daemon_client::connect_selected_acp_if_published(
|
||||
options.config_path.clone(),
|
||||
selected_control_socket(&options),
|
||||
control_socket.clone(),
|
||||
codewhale_app_server::RuntimeFrontendScope {
|
||||
workers: options.workers,
|
||||
workspace: workspace.clone(),
|
||||
@@ -1803,7 +1896,7 @@ pub async fn run_http_server(
|
||||
drop(control);
|
||||
codewhale_app_server::daemon_client::connect_scoped_control_if_published(
|
||||
options.config_path.clone(),
|
||||
selected_control_socket(&options),
|
||||
control_socket.clone(),
|
||||
codewhale_app_server::RuntimeFrontendScope {
|
||||
workers: options.workers,
|
||||
workspace: workspace.clone(),
|
||||
@@ -1843,7 +1936,7 @@ pub async fn run_http_server(
|
||||
drop(control);
|
||||
codewhale_app_server::daemon_client::connect_listener_if_published(
|
||||
options.config_path.clone(),
|
||||
selected_control_socket(&options),
|
||||
control_socket.clone(),
|
||||
selection,
|
||||
observed_owner,
|
||||
)
|
||||
@@ -1974,7 +2067,7 @@ pub async fn run_http_server(
|
||||
#[cfg(any(unix, windows))]
|
||||
let (owner_frontend, control_state) = bind_captured_runtime_frontends(
|
||||
&state,
|
||||
selected_control_socket(&options),
|
||||
control_socket.clone(),
|
||||
match options.control_frontend.as_ref() {
|
||||
Some(codewhale_app_server::RuntimeControlFrontend::Acp { model }) => model.clone(),
|
||||
_ => task_default_model.clone(),
|
||||
@@ -2115,6 +2208,68 @@ fn selected_control_socket(options: &RuntimeApiOptions) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The control endpoint this `serve` run uses: an explicit `Socket { path }`
|
||||
/// selection, else the socket derived from the selected runtime store.
|
||||
///
|
||||
/// One endpoint per store, as a pure function of the store selection. The
|
||||
/// default store keeps the well-known socket (discovery and single-engine
|
||||
/// setups unchanged), while a non-default store — a client-scoped
|
||||
/// `CODEWHALE_RUNTIME_DIR`, like the VS Code extension's per-workspace store —
|
||||
/// derives its own private sibling socket. A store-scoped engine then elects
|
||||
/// its own owner instead of colliding with the default store's owner, and the
|
||||
/// cross-store attach refusal drops out of the topology.
|
||||
///
|
||||
/// `selected_store` is the store root this run resolved, the same value the
|
||||
/// thread manager will open.
|
||||
///
|
||||
/// Unix only, with `daemon_socket::store_selected_socket_path`: a Windows
|
||||
/// owner endpoint is a named pipe whose derived-name form is follow-up work,
|
||||
/// so Windows keeps the well-known endpoint and the explicit selection.
|
||||
#[cfg(unix)]
|
||||
async fn store_selected_control_socket(
|
||||
explicit: Option<PathBuf>,
|
||||
selected_store: &FsPath,
|
||||
) -> Result<PathBuf> {
|
||||
let selected_store = selected_store.to_path_buf();
|
||||
codewhale_app_server::daemon_socket::owner_work(move || {
|
||||
let store_override = crate::runtime_threads::canonical_runtime_dir_override()?;
|
||||
let inputs = codewhale_app_server::daemon_socket::SocketPathInputs::from_environment(None)?;
|
||||
store_selected_control_socket_with(
|
||||
explicit,
|
||||
&selected_store,
|
||||
store_override.as_deref(),
|
||||
&inputs,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The environment-free core of [`store_selected_control_socket`].
|
||||
///
|
||||
/// The environment reads stay in the caller so this decision — the part the
|
||||
/// endpoint depends on — can be proven without a test mutating process-wide
|
||||
/// environment, which would race every other test in this binary.
|
||||
#[cfg(unix)]
|
||||
fn store_selected_control_socket_with(
|
||||
explicit: Option<PathBuf>,
|
||||
selected_store: &FsPath,
|
||||
store_override: Option<&FsPath>,
|
||||
inputs: &codewhale_app_server::daemon_socket::SocketPathInputs,
|
||||
) -> Result<PathBuf> {
|
||||
if let Some(path) = explicit {
|
||||
return Ok(path);
|
||||
}
|
||||
// Canonicalize the store root before comparing: the override is canonical
|
||||
// (`canonical_runtime_dir_override`), and the store selection is whatever
|
||||
// the store chain resolved. Comparing them in different forms would let a
|
||||
// symlinked or relative override silently keep the default endpoint.
|
||||
let selected =
|
||||
crate::runtime_threads::checked_runtime_store_root(selected_store.to_path_buf())?;
|
||||
let derived_from = store_override.filter(|dir| *dir == selected);
|
||||
codewhale_app_server::daemon_socket::store_selected_socket_path(derived_from, inputs)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) async fn validate_selected_owner(
|
||||
client: &codewhale_app_server::daemon_client::OwnerClient,
|
||||
|
||||
@@ -15,6 +15,8 @@ use tokio::time::sleep;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod command_catalog;
|
||||
#[cfg(unix)]
|
||||
mod control_socket_selection;
|
||||
mod headless_catalog;
|
||||
#[cfg(unix)]
|
||||
mod owner_bootstrap;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! The control endpoint is a pure function of the selected runtime store.
|
||||
//!
|
||||
//! These cover the decision, not the transport: `daemon_socket`'s own tests
|
||||
//! cover the path derivation, and the owner tests cover the socket being
|
||||
//! bound. What is pinned here is which endpoint this `serve` run picks, and
|
||||
//! that it picks the same one however the store was spelled.
|
||||
use super::*;
|
||||
use crate::test_support::{EnvVarGuard, lock_test_env};
|
||||
|
||||
fn inputs() -> codewhale_app_server::daemon_socket::SocketPathInputs {
|
||||
codewhale_app_server::daemon_socket::SocketPathInputs {
|
||||
explicit: None,
|
||||
codewhale_home_override: None,
|
||||
xdg_runtime_dir: None,
|
||||
user_home: Some(PathBuf::from("/home/whale")),
|
||||
macos: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_socket() -> PathBuf {
|
||||
PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
|
||||
}
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("cw-control-socket-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).expect("fixture root");
|
||||
root
|
||||
}
|
||||
|
||||
fn derived_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_socket_selection_always_wins() -> Result<()> {
|
||||
let resolved = store_selected_control_socket_with(
|
||||
Some(PathBuf::from("/tmp/explicit.sock")),
|
||||
Path::new("/tmp/opened-store"),
|
||||
Some(Path::new("/tmp/opened-store")),
|
||||
&inputs(),
|
||||
)?;
|
||||
assert_eq!(resolved, PathBuf::from("/tmp/explicit.sock"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_selection_keeps_the_well_known_socket() -> Result<()> {
|
||||
let resolved =
|
||||
store_selected_control_socket_with(None, Path::new("/tmp/opened-store"), None, &inputs())?;
|
||||
assert_eq!(resolved, default_socket());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_store_derives_its_own_sibling_endpoint() -> Result<()> {
|
||||
let root = temp_root("derived");
|
||||
let store = root.join("runtime-store");
|
||||
std::fs::create_dir_all(&store)?;
|
||||
let canonical = store.canonicalize()?;
|
||||
|
||||
let resolved = store_selected_control_socket_with(None, &store, Some(&canonical), &inputs())?;
|
||||
assert_eq!(
|
||||
resolved.parent().unwrap(),
|
||||
default_socket().parent().unwrap()
|
||||
);
|
||||
let name = derived_name(&resolved);
|
||||
assert!(name.starts_with("store-"), "derived endpoint: {name}");
|
||||
assert!(name.ends_with(".sock"), "derived endpoint: {name}");
|
||||
|
||||
// Same store, same endpoint — and a different store cannot share it.
|
||||
let again = store_selected_control_socket_with(None, &store, Some(&canonical), &inputs())?;
|
||||
assert_eq!(resolved, again);
|
||||
let other = root.join("other-store");
|
||||
std::fs::create_dir_all(&other)?;
|
||||
let other_canonical = other.canonicalize()?;
|
||||
let elsewhere =
|
||||
store_selected_control_socket_with(None, &other, Some(&other_canonical), &inputs())?;
|
||||
assert_ne!(resolved, elsewhere);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_the_override_does_not_name_falls_back_to_the_default_endpoint() -> Result<()> {
|
||||
// The opened store is not the one the override selected (the selection
|
||||
// changed under us, or the store came from the fallback chain). The
|
||||
// default endpoint is the conservative answer: the strict receipt
|
||||
// comparison there still refuses a wrong-store attachment.
|
||||
let resolved = store_selected_control_socket_with(
|
||||
None,
|
||||
Path::new("/tmp/some-other-store"),
|
||||
Some(Path::new("/tmp/the-override-store")),
|
||||
&inputs(),
|
||||
)?;
|
||||
assert_eq!(resolved, default_socket());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_aliased_store_selection_still_derives_its_own_endpoint() -> Result<()> {
|
||||
// The store chain resolves the override as written; the control plane
|
||||
// canonicalizes it. Comparing the two in different forms would let a
|
||||
// symlinked (or relative, or `/var`-shaped on macOS) override keep the
|
||||
// default endpoint, which is exactly the collision this derivation exists
|
||||
// to remove.
|
||||
let root = temp_root("alias");
|
||||
let real = root.join("real");
|
||||
std::fs::create_dir_all(real.join("store"))?;
|
||||
let link = root.join("link");
|
||||
std::os::unix::fs::symlink(&real, &link)?;
|
||||
let aliased_store = link.join("store");
|
||||
let canonical_override = aliased_store.canonicalize()?;
|
||||
assert_ne!(aliased_store, canonical_override, "fixture must alias");
|
||||
|
||||
let resolved = store_selected_control_socket_with(
|
||||
None,
|
||||
&aliased_store,
|
||||
Some(&canonical_override),
|
||||
&inputs(),
|
||||
)?;
|
||||
let name = derived_name(&resolved);
|
||||
assert!(
|
||||
name.starts_with("store-"),
|
||||
"an aliased selection must still derive its own endpoint, got {name}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn the_runtime_dir_override_reaches_the_control_endpoint() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
// A short root on purpose: unix socket paths have a hard kernel limit, and
|
||||
// a fixture under the (long) macOS temp dir cannot hold a derived name.
|
||||
let root = std::env::var_os("CODEWHALE_TEST_SHORT_TMPDIR")
|
||||
.map_or_else(|| PathBuf::from("/tmp"), PathBuf::from)
|
||||
.join(format!("cw-control-socket-env-{}", std::process::id()));
|
||||
let home = root.join("home");
|
||||
let store = root.join("runtime-store");
|
||||
std::fs::create_dir_all(&home)?;
|
||||
std::fs::create_dir_all(&store)?;
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let _runtime_dir = EnvVarGuard::set("CODEWHALE_RUNTIME_DIR", &store);
|
||||
|
||||
let resolved = store_selected_control_socket(None, &store).await?;
|
||||
let name = derived_name(&resolved);
|
||||
assert!(
|
||||
name.starts_with("store-"),
|
||||
"a selected store must reach the derived endpoint, got {}",
|
||||
resolved.display()
|
||||
);
|
||||
assert!(
|
||||
resolved.starts_with(&home),
|
||||
"the derived endpoint stays beside the well-known one, got {}",
|
||||
resolved.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -4861,6 +4861,23 @@ fn runtime_dir_override() -> Option<PathBuf> {
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// The canonicalized runtime-dir override, when one is set.
|
||||
///
|
||||
/// The control plane derives its endpoint from the same selection the store
|
||||
/// uses (`daemon_socket::store_selected_socket_path`), so a non-default store
|
||||
/// never shares the default owner's socket. Canonicalized through
|
||||
/// [`checked_runtime_store_root`] so an aliased, relative or `..`-bearing
|
||||
/// override cannot fork a second owner silently, and the same path the store
|
||||
/// is compared against later. An unresolvable override is an error, not a
|
||||
/// silent `None`: falling back would hand the caller the default endpoint for
|
||||
/// a store that is not the default one.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn canonical_runtime_dir_override() -> Result<Option<PathBuf>> {
|
||||
runtime_dir_override()
|
||||
.map(checked_runtime_store_root)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn default_runtime_store_root(task_data_dir: &Path, session_id: Option<&str>) -> PathBuf {
|
||||
match session_id
|
||||
.map(str::trim)
|
||||
@@ -19591,7 +19608,7 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_runtime_store_root(root: PathBuf) -> Result<PathBuf> {
|
||||
pub(crate) fn checked_runtime_store_root(root: PathBuf) -> Result<PathBuf> {
|
||||
if root.as_os_str().is_empty() {
|
||||
bail!("Runtime store root cannot be empty");
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn ensure_snapshot_dir(workspace: &Path) -> io::Result<PathBuf> {
|
||||
/// Strip a trailing `.worktrees/<name>` segment so all worktrees of the
|
||||
/// same checkout share a `project_hash`. If the path doesn't look like a
|
||||
/// worktree it's returned unchanged.
|
||||
fn strip_worktree_suffix(path: &Path) -> PathBuf {
|
||||
pub(crate) fn strip_worktree_suffix(path: &Path) -> PathBuf {
|
||||
let mut components: Vec<_> = path.components().collect();
|
||||
if components.len() >= 2
|
||||
&& let Some(parent) = components.get(components.len() - 2)
|
||||
@@ -86,7 +86,11 @@ fn strip_worktree_suffix(path: &Path) -> PathBuf {
|
||||
|
||||
/// Hex-encoded deterministic FNV-1a digest. This is only a directory tag, not
|
||||
/// a security boundary, but it must remain stable across process launches.
|
||||
fn stable_hex(path: &Path) -> String {
|
||||
///
|
||||
/// `pub(crate)` because the workspace drive lease derives its own file layout
|
||||
/// from the same project/worktree split (`workspace_lease::WorkspaceDriveLease::lease_path`),
|
||||
/// and two copies of this hash would be two things to keep in step.
|
||||
pub(crate) fn stable_hex(path: &Path) -> String {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for byte in path.to_string_lossy().as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
//! Workspace drive lease: at most one driving engine per workspace, process-wide.
|
||||
//!
|
||||
//! The store process-owner lock serializes writers of one *store*, but a
|
||||
//! workspace is shared across stores — a per-workspace store (the VS Code
|
||||
//! extension), a per-session store (the interactive TUI) and the user-level
|
||||
//! default store can all point at the same working tree, the same snapshot
|
||||
//! side-repo (keyed by canonical workspace path) and the same git index, with
|
||||
//! nothing serializing them across processes. The lease lifts the exclusivity
|
||||
//! unit to the workspace itself.
|
||||
//!
|
||||
//! Drive vs watch: only a *driving* engine takes the lease. Watch-only
|
||||
//! observers never do — their token intent already forbids mutation, and they
|
||||
//! attach to the owner that holds the lease rather than admitting their own
|
||||
//! workspace scope.
|
||||
//!
|
||||
//! The mechanism mirrors the store process-owner lock: an fd lock the kernel
|
||||
//! releases when the holder dies, plus a small holder record a contender can
|
||||
//! read to name the holder in a legible refusal. The kernel lock is the
|
||||
//! authority; recorded holder facts never authorize replacing its file.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use codewhale_config::private_directory::PrivateDirectory;
|
||||
|
||||
/// Upper bound on the bytes read back from a lease holder record. Also the
|
||||
/// offset the Windows byte-range lock starts at, so a contender can still read
|
||||
/// the record of the holder it is contending with.
|
||||
const HOLDER_RECORD_MAX_BYTES: u64 = 512;
|
||||
|
||||
/// Facts about who holds a workspace drive lease.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceLeaseHolder {
|
||||
pub pid: u32,
|
||||
/// Liveness token of the holder process (`unix_process_start` shape), when
|
||||
/// recorded. Absent on Windows and on records written before it existed.
|
||||
pub process_start: Option<String>,
|
||||
/// Human-readable client label, e.g. `vscode`, `desktop`, `tui`.
|
||||
pub client: Option<String>,
|
||||
/// Store root of the holding engine, for legible refusal messages.
|
||||
pub store_root: Option<PathBuf>,
|
||||
/// Wall-clock acquisition time (ms since UNIX_EPOCH).
|
||||
pub since_ms: u64,
|
||||
}
|
||||
|
||||
impl WorkspaceLeaseHolder {
|
||||
fn to_record(&self) -> String {
|
||||
let mut record = format!("version=1\npid={}\nsince_ms={}\n", self.pid, self.since_ms);
|
||||
if let Some(process_start) = &self.process_start {
|
||||
record.push_str(&format!("process_start={process_start}\n"));
|
||||
}
|
||||
if let Some(client) = &self.client {
|
||||
record.push_str(&format!("client={}\n", client.replace(['\n', '='], " ")));
|
||||
}
|
||||
if let Some(store_root) = &self.store_root {
|
||||
record.push_str(&format!(
|
||||
"store_root={}\n",
|
||||
store_root.to_string_lossy().replace(['\n', '='], " ")
|
||||
));
|
||||
}
|
||||
record
|
||||
}
|
||||
|
||||
fn from_record(text: &str) -> Option<Self> {
|
||||
let mut version = None;
|
||||
let mut pid = None;
|
||||
let mut process_start = None;
|
||||
let mut client = None;
|
||||
let mut store_root = None;
|
||||
let mut since_ms = None;
|
||||
for line in text.lines() {
|
||||
match line.split_once('=') {
|
||||
Some(("version", value)) => version = value.parse::<u32>().ok(),
|
||||
Some(("pid", value)) => pid = value.parse::<u32>().ok(),
|
||||
Some(("process_start", value)) => process_start = Some(value.to_string()),
|
||||
Some(("client", value)) => client = Some(value.to_string()),
|
||||
Some(("store_root", value)) => store_root = Some(PathBuf::from(value)),
|
||||
Some(("since_ms", value)) => since_ms = value.parse::<u64>().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if version != Some(1) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
pid: pid?,
|
||||
process_start,
|
||||
client,
|
||||
store_root,
|
||||
since_ms: since_ms?,
|
||||
})
|
||||
}
|
||||
|
||||
/// How long the lease has been held, best effort.
|
||||
pub fn held_for(&self) -> Duration {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH + Duration::from_millis(self.since_ms))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared process-wide drive lease handle.
|
||||
///
|
||||
/// The first admission for a workspace acquires the OS lease; later admissions
|
||||
/// in the *same process* (a successor manager, a second engine in one test
|
||||
/// process) share it by counted acquisition instead of refusing each other.
|
||||
/// The last admitted handle drop releases it.
|
||||
pub struct SharedWorkspaceDriveLease {
|
||||
inner: std::sync::Arc<LeaseInner>,
|
||||
}
|
||||
|
||||
struct LeaseInner {
|
||||
/// The OS lease plus the live shared-admission count, or nothing when a
|
||||
/// foreign process held it at (first) acquire time.
|
||||
state: std::sync::Mutex<Option<(WorkspaceDriveLease, usize)>>,
|
||||
}
|
||||
|
||||
impl SharedWorkspaceDriveLease {
|
||||
/// Acquire (or join) this process's drive lease for the workspace.
|
||||
///
|
||||
/// `None` means a *foreign process* holds it — the caller refuses with the
|
||||
/// holder's facts from [`WorkspaceDriveLease::read_holder`]. Same-process
|
||||
/// joins never fail and never re-lock.
|
||||
pub fn acquire(
|
||||
workspace: &Path,
|
||||
client: Option<&str>,
|
||||
store_root: Option<&Path>,
|
||||
) -> Result<Option<Self>> {
|
||||
let lease_key = WorkspaceDriveLease::lease_path(workspace)?;
|
||||
let slot = {
|
||||
let mut registry = registry()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(slot) = registry.get(&lease_key) {
|
||||
slot.clone()
|
||||
} else {
|
||||
let Some(lease) = WorkspaceDriveLease::try_acquire(workspace, client, store_root)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let inner = std::sync::Arc::new(LeaseInner {
|
||||
state: std::sync::Mutex::new(Some((lease, 1))),
|
||||
});
|
||||
registry.insert(lease_key, inner.clone());
|
||||
return Ok(Some(Self { inner }));
|
||||
}
|
||||
};
|
||||
// The returned handle is an owned clone, so moving it out never
|
||||
// borrows the guard: the state guard stays held across `try_acquire`,
|
||||
// which serializes two threads of one process racing for the same
|
||||
// workspace and makes the slot — not the fd lock — the in-process
|
||||
// authority.
|
||||
let inner = slot.clone();
|
||||
let mut state = slot
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some((_lease, count)) = state.as_mut() {
|
||||
*count += 1;
|
||||
return Ok(Some(Self { inner }));
|
||||
}
|
||||
let Some(lease) = WorkspaceDriveLease::try_acquire(workspace, client, store_root)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
*state = Some((lease, 1));
|
||||
Ok(Some(Self { inner }))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SharedWorkspaceDriveLease {
|
||||
fn drop(&mut self) {
|
||||
// The decrement and the removal of the OS lease happen in *one*
|
||||
// critical section. Deciding under the lock and taking afterwards
|
||||
// leaves a window in which a concurrent `acquire` observes the zero
|
||||
// count, joins the handle, and then loses the OS lease out from under
|
||||
// itself — two engines driving one workspace, which is the whole
|
||||
// failure this lease exists to prevent.
|
||||
let Ok(mut state) = self.inner.state.lock() else {
|
||||
return;
|
||||
};
|
||||
let Some((_lease, count)) = state.as_mut() else {
|
||||
return;
|
||||
};
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
// Drop the OS lease *inside* this section. Deciding here and
|
||||
// releasing after the guard is gone leaves a window in which a
|
||||
// same-process `acquire` finds the slot empty while the OS lock is
|
||||
// still held, and is refused — two engine scopes of one process
|
||||
// refusing each other, which is precisely what the shared slot
|
||||
// exists to prevent.
|
||||
state.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One slot per workspace, kept for the life of the process.
|
||||
///
|
||||
/// Removing a slot on release would race its owner: a contender that read the
|
||||
/// map before the removal could populate the slot that is about to be deleted,
|
||||
/// and the next contender would then create a second slot for the same
|
||||
/// workspace. A free slot holds nothing but an empty `Option`, so keeping it
|
||||
/// costs one map entry per workspace this process has successfully acquired.
|
||||
/// Refused first acquisitions do not enter the registry.
|
||||
type LeaseRegistry =
|
||||
std::sync::Mutex<std::collections::HashMap<PathBuf, std::sync::Arc<LeaseInner>>>;
|
||||
|
||||
fn registry() -> &'static LeaseRegistry {
|
||||
static REGISTRY: std::sync::OnceLock<LeaseRegistry> = std::sync::OnceLock::new();
|
||||
REGISTRY.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// A held workspace drive lease. Dropping releases the kernel lock.
|
||||
pub struct WorkspaceDriveLease {
|
||||
file: File,
|
||||
_base: PrivateDirectory,
|
||||
_directory: PrivateDirectory,
|
||||
}
|
||||
|
||||
impl WorkspaceDriveLease {
|
||||
/// The lease path for a workspace: alongside the snapshot side-repo, using
|
||||
/// the same canonical-path hashing (`snapshot::paths`), so worktrees of one
|
||||
/// checkout share a project bucket and one worktree maps to exactly one
|
||||
/// lease.
|
||||
pub fn lease_path(workspace: &Path) -> Result<PathBuf> {
|
||||
let base = lease_state_base()?;
|
||||
let canonical = workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace.to_path_buf());
|
||||
let project = crate::snapshot::paths::strip_worktree_suffix(&canonical);
|
||||
Ok(base
|
||||
.join(crate::snapshot::paths::stable_hex(&project))
|
||||
.join(crate::snapshot::paths::stable_hex(&canonical))
|
||||
.with_extension("lease"))
|
||||
}
|
||||
|
||||
/// Try once to acquire the drive lease for this process.
|
||||
///
|
||||
/// `None` on contention: the kernel lock is held. Use
|
||||
/// [`Self::read_holder`] to name the recorded holder.
|
||||
pub fn try_acquire(
|
||||
workspace: &Path,
|
||||
client: Option<&str>,
|
||||
store_root: Option<&Path>,
|
||||
) -> Result<Option<Self>> {
|
||||
let path = Self::lease_path(workspace)?;
|
||||
let parent = path.parent().context("Workspace lease has no parent")?;
|
||||
let base = parent
|
||||
.parent()
|
||||
.context("Workspace lease has no state root")?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("Workspace lease name is invalid")?;
|
||||
let base = PrivateDirectory::admit(base)?;
|
||||
let directory = PrivateDirectory::admit(parent)?;
|
||||
let file = directory.open_internal_file(name)?;
|
||||
#[cfg(unix)]
|
||||
let contention = {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
// SAFETY: `file` owns a valid descriptor retained by this guard.
|
||||
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
Some(std::io::Error::last_os_error())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let contention = {
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::LockFile;
|
||||
let (offset_low, offset_high, length_low, length_high) =
|
||||
(HOLDER_RECORD_MAX_BYTES as u32, 0, u32::MAX, 0x7FFF_FFFF);
|
||||
// SAFETY: `file` owns a valid handle retained by this guard.
|
||||
if unsafe {
|
||||
LockFile(
|
||||
file.as_raw_handle() as _,
|
||||
offset_low,
|
||||
offset_high,
|
||||
length_low,
|
||||
length_high,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
Some(std::io::Error::last_os_error())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let contention: Option<std::io::Error> = None;
|
||||
if let Some(error) = contention {
|
||||
if !is_contention(&error) {
|
||||
return Err(error).context("Failed to lock workspace lease");
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(any(unix, windows))]
|
||||
anyhow::ensure!(
|
||||
base.is_at_selected_path()? && directory.is_at_selected_path()?,
|
||||
"Workspace lease directory identity changed"
|
||||
);
|
||||
let selected = directory.open_internal_file(name)?;
|
||||
anyhow::ensure!(
|
||||
PrivateDirectory::same_file_identity(&file, &selected)?,
|
||||
"Workspace lease file identity changed"
|
||||
);
|
||||
let holder = WorkspaceLeaseHolder {
|
||||
pid: std::process::id(),
|
||||
process_start: current_process_start(),
|
||||
client: client.map(ToString::to_string),
|
||||
store_root: store_root.map(Path::to_path_buf),
|
||||
since_ms: SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| elapsed.as_millis() as u64),
|
||||
};
|
||||
let mut lease = Self {
|
||||
file,
|
||||
_base: base,
|
||||
_directory: directory,
|
||||
};
|
||||
// Best effort: holder diagnostics must never fail the lease holder.
|
||||
let _ = lease
|
||||
.file
|
||||
.set_len(0)
|
||||
.and_then(|()| lease.file.seek(SeekFrom::Start(0)).map(|_| ()))
|
||||
.and_then(|()| lease.file.write_all(holder.to_record().as_bytes()));
|
||||
Ok(Some(lease))
|
||||
}
|
||||
|
||||
/// Read the recorded holder, if any. Missing or malformed data is `None`;
|
||||
/// filesystem errors remain explicit, and the record may be stale.
|
||||
pub fn read_holder(workspace: &Path) -> Result<Option<WorkspaceLeaseHolder>> {
|
||||
let path = Self::lease_path(workspace)?;
|
||||
let parent = path.parent().context("Workspace lease has no parent")?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("Workspace lease name is invalid")?;
|
||||
let directory = match PrivateDirectory::inspect(parent) {
|
||||
Ok(directory) => directory,
|
||||
Err(error)
|
||||
if error.chain().any(|cause| {
|
||||
cause
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
|
||||
}) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error).context("Failed to inspect workspace lease directory"),
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let bytes =
|
||||
match directory.read_private_receipt(name, HOLDER_RECORD_MAX_BYTES as usize - 1)? {
|
||||
Some((bytes, _file)) => bytes,
|
||||
None => return Ok(None),
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let bytes = {
|
||||
use std::io::Read as _;
|
||||
let Some(file) = directory.open_owned_file_for_read(name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(HOLDER_RECORD_MAX_BYTES).read_to_end(&mut bytes)?;
|
||||
bytes
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
anyhow::bail!("Workspace drive lease is unsupported on this platform");
|
||||
#[cfg(any(unix, windows))]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
directory.is_at_selected_path()?,
|
||||
"Workspace lease directory identity changed"
|
||||
);
|
||||
let text = std::str::from_utf8(&bytes)?;
|
||||
Ok(WorkspaceLeaseHolder::from_record(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkspaceDriveLease {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsRawFd as _;
|
||||
// SAFETY: Drop runs only while `file` still owns this descriptor.
|
||||
unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::UnlockFile;
|
||||
let (offset_low, offset_high, length_low, length_high) =
|
||||
(HOLDER_RECORD_MAX_BYTES as u32, 0, u32::MAX, 0x7FFF_FFFF);
|
||||
// SAFETY: Drop runs only while `file` still owns this handle.
|
||||
unsafe {
|
||||
UnlockFile(
|
||||
self.file.as_raw_handle() as _,
|
||||
offset_low,
|
||||
offset_high,
|
||||
length_low,
|
||||
length_high,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a lock error means "held by someone else". The two raw codes cover
|
||||
/// the platforms whose `WouldBlock` mapping is not enough; kept in step with
|
||||
/// `RuntimeProcessOwnerLock::is_contention`, the store lock this mirrors.
|
||||
fn is_contention(error: &std::io::Error) -> bool {
|
||||
error.kind() == std::io::ErrorKind::WouldBlock || matches!(error.raw_os_error(), Some(32 | 33))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
fn holder_is_dead(path: &Path) -> bool {
|
||||
use codewhale_config::private_directory::{UnixProcessStatus, unix_process_status};
|
||||
|
||||
let Some(holder) =
|
||||
WorkspaceLeaseHolder::from_record(&std::fs::read_to_string(path).unwrap_or_default())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(recorded_start) = holder.process_start.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
if holder.pid == std::process::id() {
|
||||
// Same-process contention: the record is either ours (a live sibling
|
||||
// scope holds the lock — alive by definition) or a same-pid
|
||||
// predecessor. Distinguish by creation token: a different one means
|
||||
// the pid was recycled and the previous holder is gone.
|
||||
return current_process_start().as_deref() != Some(recorded_start);
|
||||
}
|
||||
match unix_process_status(holder.pid) {
|
||||
Ok(UnixProcessStatus::Present { start }) => start != recorded_start,
|
||||
Ok(UnixProcessStatus::Absent) => true,
|
||||
// Identity or liveness is uncertain (EPERM, a foreign principal, a
|
||||
// pid we cannot probe). Never convert uncertainty into death.
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// This process's liveness token, best effort.
|
||||
#[cfg(unix)]
|
||||
fn current_process_start() -> Option<String> {
|
||||
use codewhale_config::private_directory::{UnixProcessStatus, unix_process_status};
|
||||
|
||||
match unix_process_status(std::process::id()) {
|
||||
Ok(UnixProcessStatus::Present { start }) => Some(start),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows records no creation token; the byte-range lock itself is the
|
||||
/// liveness mechanism.
|
||||
#[cfg(not(unix))]
|
||||
fn current_process_start() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The base for lease files. Test isolation mirrors `snapshot::paths`: an
|
||||
/// unsealed test gets a private base, so a lease taken by a fixture never
|
||||
/// lands in the developer's real state dir.
|
||||
fn lease_state_base() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Some(base) = crate::test_support::unsealed_state_dir("workspace-leases") {
|
||||
PrivateDirectory::open(&base)?;
|
||||
return Ok(base);
|
||||
}
|
||||
}
|
||||
codewhale_config::resolve_state_dir("workspace-leases")
|
||||
.context("Failed to resolve workspace lease state dir")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_workspace(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("cw-ws-lease-{}-{}", tag, std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create workspace");
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_record(workspace: &Path, holder: &WorkspaceLeaseHolder) -> PathBuf {
|
||||
let path = WorkspaceDriveLease::lease_path(workspace).expect("lease path");
|
||||
let parent = path.parent().expect("lease dir");
|
||||
let _base = PrivateDirectory::open(parent.parent().expect("lease state root"))
|
||||
.expect("private lease state root");
|
||||
let directory = PrivateDirectory::open(parent).expect("private lease dir");
|
||||
let mut file = directory
|
||||
.open_internal_file(path.file_name().unwrap().to_str().unwrap())
|
||||
.expect("private lease file");
|
||||
file.set_len(0).expect("truncate holder record");
|
||||
file.write_all(holder.to_record().as_bytes())
|
||||
.expect("write holder record");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_then_contend_reports_holder() {
|
||||
let workspace = temp_workspace("contend");
|
||||
let first =
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("tui"), None).expect("first acquire");
|
||||
assert!(first.is_some(), "first drive is admitted");
|
||||
let second = WorkspaceDriveLease::try_acquire(&workspace, Some("vscode"), None)
|
||||
.expect("contend probe");
|
||||
assert!(second.is_none(), "second drive must be refused");
|
||||
let holder = WorkspaceDriveLease::read_holder(&workspace)
|
||||
.expect("read holder")
|
||||
.expect("holder recorded");
|
||||
assert_eq!(holder.pid, std::process::id());
|
||||
assert_eq!(holder.client.as_deref(), Some("tui"));
|
||||
assert_eq!(holder.process_start, current_process_start());
|
||||
drop(first);
|
||||
let third =
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("vscode"), None).expect("reacquire");
|
||||
assert!(third.is_some(), "lease frees after drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_lease_reenters_within_a_process() {
|
||||
let workspace = temp_workspace("shared");
|
||||
let first = SharedWorkspaceDriveLease::acquire(&workspace, Some("tui"), None)
|
||||
.expect("first shared acquire")
|
||||
.expect("lease taken");
|
||||
// A second engine in the same process joins by refcount instead of
|
||||
// refusing — the lease's unit is the process.
|
||||
let second = SharedWorkspaceDriveLease::acquire(&workspace, Some("vscode"), None)
|
||||
.expect("second shared acquire")
|
||||
.expect("same-process join");
|
||||
drop(first);
|
||||
// Still held by the second handle: a raw probe must be refused.
|
||||
let refused =
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("probe"), None).expect("probe");
|
||||
assert!(refused.is_none(), "lease still held while shared");
|
||||
drop(second);
|
||||
let after = SharedWorkspaceDriveLease::acquire(&workspace, Some("next"), None)
|
||||
.expect("reacquire after full release")
|
||||
.expect("lease freed after last handle");
|
||||
drop(after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_handles_survive_concurrent_acquire_and_release() {
|
||||
// The registry slot is shared by every handle for one workspace, so a
|
||||
// wrong lock order here shows up as a deadlock or a lost OS lease. Both
|
||||
// are matters of the slot being taken and cleared under the lock that
|
||||
// guards the count.
|
||||
let workspace = temp_workspace("churn");
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let workspace = workspace.clone();
|
||||
workers.push(std::thread::spawn(move || {
|
||||
for _ in 0..25 {
|
||||
let lease = SharedWorkspaceDriveLease::acquire(&workspace, Some("churn"), None)
|
||||
.expect("acquire")
|
||||
.expect("same-process join");
|
||||
drop(lease);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("churn worker");
|
||||
}
|
||||
// Every handle is gone: the lease is free and a fresh holder records
|
||||
// itself rather than joining a slot nothing released.
|
||||
let lease = SharedWorkspaceDriveLease::acquire(&workspace, Some("after"), None)
|
||||
.expect("acquire after churn")
|
||||
.expect("the lease is free after the last handle");
|
||||
let holder = WorkspaceDriveLease::read_holder(&workspace)
|
||||
.expect("read holder")
|
||||
.expect("holder recorded");
|
||||
assert_eq!(holder.client.as_deref(), Some("after"));
|
||||
drop(lease);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lease_path_is_stable_and_worktree_aware() {
|
||||
let workspace = temp_workspace("paths");
|
||||
let one = WorkspaceDriveLease::lease_path(&workspace).expect("path");
|
||||
let two = WorkspaceDriveLease::lease_path(&workspace).expect("path");
|
||||
assert_eq!(one, two);
|
||||
assert_eq!(one.extension().and_then(|ext| ext.to_str()), Some("lease"));
|
||||
let worktree = workspace.join(".worktrees").join("feat");
|
||||
std::fs::create_dir_all(&worktree).expect("create worktree");
|
||||
let sibling = workspace.join(".worktrees").join("other");
|
||||
std::fs::create_dir_all(&sibling).expect("create sibling");
|
||||
let a = WorkspaceDriveLease::lease_path(&worktree).expect("path");
|
||||
let b = WorkspaceDriveLease::lease_path(&sibling).expect("path");
|
||||
assert_ne!(a, b, "distinct worktrees get distinct leases");
|
||||
assert_eq!(
|
||||
a.parent().unwrap(),
|
||||
b.parent().unwrap(),
|
||||
"worktrees of one project share a bucket"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holder_record_round_trips_and_needs_a_version() {
|
||||
let holder = WorkspaceLeaseHolder {
|
||||
pid: 4242,
|
||||
process_start: Some("macos:1:2".into()),
|
||||
client: Some("vscode".into()),
|
||||
store_root: Some(PathBuf::from("/tmp/store")),
|
||||
since_ms: 1_700_000_000_000,
|
||||
};
|
||||
assert_eq!(
|
||||
WorkspaceLeaseHolder::from_record(&holder.to_record()),
|
||||
Some(holder)
|
||||
);
|
||||
// A record without a version is not a record we trust.
|
||||
assert_eq!(
|
||||
WorkspaceLeaseHolder::from_record("pid=1\nsince_ms=0\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_or_malformed_records_read_none() {
|
||||
let workspace = temp_workspace("records");
|
||||
assert_eq!(
|
||||
WorkspaceDriveLease::read_holder(&workspace).expect("absent"),
|
||||
None
|
||||
);
|
||||
let path = write_record(
|
||||
&workspace,
|
||||
&WorkspaceLeaseHolder {
|
||||
pid: 7,
|
||||
process_start: None,
|
||||
client: None,
|
||||
store_root: None,
|
||||
since_ms: 5,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
WorkspaceDriveLease::read_holder(&workspace)
|
||||
.expect("present")
|
||||
.is_some()
|
||||
);
|
||||
std::fs::write(&path, "not a record\n").expect("write malformed");
|
||||
assert_eq!(
|
||||
WorkspaceDriveLease::read_holder(&workspace).expect("malformed"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_provably_dead_holder_record_does_not_replace_a_locked_lease() {
|
||||
/// Spawn a child, report its pid and creation token while it is alive,
|
||||
/// then reap it. Returns the recorded facts and the lease path.
|
||||
fn holder_of_a_reaped_child(workspace: &Path) -> PathBuf {
|
||||
use codewhale_config::private_directory::{UnixProcessStatus, unix_process_status};
|
||||
|
||||
let mut child = std::process::Command::new("/bin/sh")
|
||||
.args(["-c", "exit 0"])
|
||||
.spawn()
|
||||
.expect("spawn child");
|
||||
let pid = child.id();
|
||||
let start = match unix_process_status(pid) {
|
||||
Ok(UnixProcessStatus::Present { start }) => Some(start),
|
||||
other => panic!("live child must be present: {other:?}"),
|
||||
};
|
||||
child.wait().expect("reap child");
|
||||
let path = write_record(
|
||||
workspace,
|
||||
&WorkspaceLeaseHolder {
|
||||
pid,
|
||||
process_start: start,
|
||||
client: Some("crashed".into()),
|
||||
store_root: None,
|
||||
since_ms: 0,
|
||||
},
|
||||
);
|
||||
assert!(holder_is_dead(&path), "a reaped holder is provably dead");
|
||||
path
|
||||
}
|
||||
|
||||
let workspace = temp_workspace("dead");
|
||||
holder_of_a_reaped_child(&workspace);
|
||||
let first = WorkspaceDriveLease::try_acquire(&workspace, Some("next"), None)
|
||||
.expect("acquire")
|
||||
.expect("an unlocked lease remains acquirable despite a stale record");
|
||||
holder_of_a_reaped_child(&workspace);
|
||||
assert!(
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("contender"), None)
|
||||
.expect("contend")
|
||||
.is_none(),
|
||||
"a stale holder record must not replace a held kernel lock"
|
||||
);
|
||||
drop(first);
|
||||
assert!(
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("after"), None)
|
||||
.expect("reacquire")
|
||||
.is_some(),
|
||||
"the same lease is available after its holder releases it"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_live_holder_with_a_matching_token_is_never_dead() {
|
||||
use codewhale_config::private_directory::{UnixProcessStatus, unix_process_status};
|
||||
|
||||
let workspace = temp_workspace("live");
|
||||
let mut child = std::process::Command::new("/bin/sh")
|
||||
.args(["-c", "exec sleep 30"])
|
||||
.spawn()
|
||||
.expect("spawn child");
|
||||
let pid = child.id();
|
||||
let start = match unix_process_status(pid) {
|
||||
Ok(UnixProcessStatus::Present { start }) => start,
|
||||
other => panic!("live child must be present: {other:?}"),
|
||||
};
|
||||
let path = write_record(
|
||||
&workspace,
|
||||
&WorkspaceLeaseHolder {
|
||||
pid,
|
||||
process_start: Some(start),
|
||||
client: Some("driving".into()),
|
||||
store_root: None,
|
||||
since_ms: 0,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
!holder_is_dead(&path),
|
||||
"a live holder's recorded token must never read dead"
|
||||
);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_record_without_a_liveness_token_is_never_taken_over() {
|
||||
let workspace = temp_workspace("no-token");
|
||||
let path = write_record(
|
||||
&workspace,
|
||||
&WorkspaceLeaseHolder {
|
||||
// A pid that cannot be probed at all.
|
||||
pid: u32::MAX,
|
||||
process_start: None,
|
||||
client: None,
|
||||
store_root: None,
|
||||
since_ms: 0,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
!holder_is_dead(&path),
|
||||
"without a liveness token, only a live-holder conclusion is safe"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
# Runtime Ownership and Frontend Lifecycle Design
|
||||
|
||||
**Status:** Partially implemented (Phase A: store-derived control sockets) +
|
||||
workspace drive-lease design below (§11). Decision record: per-client topology
|
||||
("Plan C" — A's mechanism, client-specific defaults), not single-owner-everywhere
|
||||
("Plan B"). Written against `wave/0.10.1-next` (`5cf09da77` and descendants).
|
||||
|
||||
|
||||
This is a design record, not a claim that every phase is implemented. This PR
|
||||
adds Unix control sockets for an explicit canonical runtime-directory override
|
||||
and a shared workspace lease at Runtime API scope admission. Existing store,
|
||||
principal, config, worker and workspace checks remain authoritative. Detached
|
||||
owner election, scope retirement, standalone TUI/turn/CLI lease admission and
|
||||
store-derived Windows pipe names remain follow-up work. Existing Windows named
|
||||
pipe transport and owner recovery are retained.
|
||||
|
||||
**Audience:** Engine runtime maintainers, desktop client, web client, VS Code
|
||||
extension owners.
|
||||
|
||||
**Problem in one line:** 0.10.1 gave the engine an authenticated single-owner
|
||||
control plane, but left every client spawning self-contained engines — so two
|
||||
legitimate clients of the same user now refuse each other
|
||||
(`authenticated Runtime owner belongs to another selected store`).
|
||||
|
||||
---
|
||||
|
||||
## 1. What actually broke, precisely
|
||||
|
||||
The 0.10.1 owner model resolved two identities from **disjoint inputs**:
|
||||
|
||||
| Identity | Resolved from | Code |
|
||||
|---|---|---|
|
||||
| Runtime store (data_dir, execution_scope) | `CODEWHALE_RUNTIME_DIR` / `DEEPSEEK_RUNTIME_DIR`, else `default_tasks_dir()/runtime` | `runtime_threads.rs:runtime_dir_override`, `default_runtime_store_root` |
|
||||
| Control plane (socket path) | explicit flag > `CODEWHALE_HOME/run` > `XDG_RUNTIME_DIR` > OS app-support default | `daemon_socket.rs:resolve_socket_path` |
|
||||
|
||||
A `serve` that finds a live owner on the socket compares the owner's receipt
|
||||
store against its **own** store selection
|
||||
(`runtime_api.rs:validate_selected_owner`). Same store → attach as a frontend.
|
||||
Different store → exit 1. Store choice is client input; socket choice is not a
|
||||
function of it; therefore two clients of the same user on one machine can
|
||||
trivially construct a mismatch. The refusal is the correct *security* outcome
|
||||
of an inconsistent *topology* model — the engine can't tell "another user's
|
||||
engine" from "my user's other window", because nothing relates the two
|
||||
identities.
|
||||
|
||||
The second structural flaw: **the owner is a child of whichever client won the
|
||||
election.** The VS Code extension stops its child when the window closes
|
||||
("Stopping the engine started by this window"). Under a shared-owner model
|
||||
that kills the engine hosting every other window's listeners. Ownership that
|
||||
outlives frontends cannot live inside a frontend's process tree.
|
||||
|
||||
Everything needed to fix this already exists in 0.10.1 and is merely
|
||||
under-composed:
|
||||
|
||||
- workspace scopes inside one owner (≤64, canonical-path verified, per-scope
|
||||
MCP/LSP/worker admission) — `RuntimeWorkspaceScopes`;
|
||||
- guest-requested listeners bound inside the owner with the **guest's own**
|
||||
auth token, workspace, CORS, web/mobile flags — `RuntimeListenerSelection`;
|
||||
- the attach stdout compatibility line (`Runtime API listening on …`) so a
|
||||
guest child is indistinguishable from a standalone engine to a spawning
|
||||
client — `run_attached_frontend`;
|
||||
- stale-socket reclaim, peer-uid checks, receipt re-validation, version-skew
|
||||
guard, lease generations.
|
||||
|
||||
## 2. Design invariants
|
||||
|
||||
1. **One owner process per runtime boundary.** Not per window, not per
|
||||
workspace, not per user-machine (see §3 for when a user legitimately has
|
||||
several).
|
||||
2. **Control-plane identity is a pure function of runtime identity.** The
|
||||
socket path is derived from the same inputs that select the store. Same
|
||||
boundary ⇒ same socket ⇒ the store always matches; different boundary ⇒
|
||||
different socket ⇒ different owner, and the mismatch refusal becomes
|
||||
structurally unreachable except for genuine races.
|
||||
3. **The owner is nobody's child.** Election daemonizes the owner; every
|
||||
client — including the one that triggered election — attaches as a guest.
|
||||
No client's exit can take the engine down.
|
||||
4. **Clients attach; they never demand a store.** `CODEWHALE_RUNTIME_DIR`
|
||||
stops being a per-window workaround and becomes an explicit boundary
|
||||
selector (isolation for CI, tests, experiments).
|
||||
5. **Workspace multiplicity lives inside the owner.** A window is a frontend
|
||||
scope over one workspace; threads carry their workspace; one store serves
|
||||
all of them (this is already how the shared store filters per workspace).
|
||||
6. **Engine lifetime is decoupled from any single client**: idle retirement
|
||||
with a grace period, blocked by active turns/tasks/automations or an
|
||||
explicit `linger` holder.
|
||||
7. **Refusals are typed protocol outcomes** reserved for true races, version
|
||||
skew, and trust denials — never for legitimate topology.
|
||||
|
||||
## 3. The runtime boundary
|
||||
|
||||
A **boundary** is the tuple of everything that must be identical for two
|
||||
engine processes to be interchangeable hosts of the same store. The key
|
||||
insight: hash the **resolved store root**, not the env vars that produced it —
|
||||
otherwise any input the store chain reads but the tuple omits (`DEEPSEEK_TASKS_DIR`
|
||||
in the fallback chain) silently forks a second owner:
|
||||
|
||||
| Input | Canonicalization |
|
||||
|---|---|
|
||||
| Home root (`CODEWHALE_HOME` / default) | resolved absolute path |
|
||||
| **Resolved runtime store root** (env overrides `CODEWHALE_RUNTIME_DIR`/`DEEPSEEK_RUNTIME_DIR`, else the tasks-dir fallback chain, all canonicalized) | canonical absolute path |
|
||||
| Config source (`--config` / loaded path) | `canonical_runtime_config_source` |
|
||||
| Config profile (`--profile` / env) | literal |
|
||||
| ACP-only base (`serve --acp`) | boolean |
|
||||
|
||||
`boundary_id = sha256(canonical(serialization))[0..12]`.
|
||||
|
||||
Socket layout (unix; macOS path budget is 103 bytes — verified
|
||||
`MAX_SOCKET_PATH_BYTES` — and this fits with room to spare):
|
||||
|
||||
- Default boundary (no overrides, default profile): **today's well-known
|
||||
path stays** (`~/Library/Application Support/codewhale/daemon.sock` on
|
||||
macOS, `$XDG_RUNTIME_DIR/codewhale/daemon.sock`, …). Discovery and doctor
|
||||
keep one obvious place to look.
|
||||
- Non-default boundaries: a **sibling** of the default socket in the same
|
||||
0700 parent — `<default socket's parent>/b-<boundary_id>.sock`
|
||||
(macOS ≈ 56 bytes, well inside the 103-byte kernel budget).
|
||||
Receipts continue to ride beside their socket (`<name>.owner.json`), so
|
||||
publication, re-validation, and stale reclaim work unchanged per socket.
|
||||
- Windows (named-pipe transport exists; store-derived naming is deferred): the derived pipe
|
||||
name is `\\.\pipe\codewhale-daemon-<boundary_id>`; follow-up work.
|
||||
|
||||
Consequences for today's refusal paths:
|
||||
|
||||
| Today's check | Becomes |
|
||||
|---|---|
|
||||
| `authenticated Runtime owner belongs to another selected store` | Unreachable: store and socket derive from the same tuple |
|
||||
| `selected config profile differs from the captured owner scope` | Unreachable on the same socket: profile is part of the boundary; a different profile lands on a different socket (a deliberate second engine, not a refusal) |
|
||||
| `selected worker setting differs from the held scheduler` | A typed control error (`owner_scheduler_mismatch`) carrying the owner's setting; the receipt advertises `workers`; clients retry with it. The scheduler is per-owner, first election wins |
|
||||
| `stale owner receipt belongs to a different selected store` | Unreachable: a receipt at a given socket can only belong to that boundary |
|
||||
| Version skew, owner-died-mid-attach, trust denial | Remain — these are genuine races/skew |
|
||||
|
||||
Scope cap: `MAX_RUNTIME_WORKSPACE_SCOPES = 64` stays, but scopes stop being
|
||||
"retained until owner retirement" (today's wart: *restart the owner to retire
|
||||
unused scopes*). See §4.
|
||||
|
||||
## 4. Owner and frontend lifecycle
|
||||
|
||||
### States
|
||||
|
||||
```
|
||||
absent → electing → ready ⇄ serving → draining → retired
|
||||
```
|
||||
|
||||
- **electing:** bind the boundary socket (kernel-atomic election; losers
|
||||
probe, find the winner, and attach). Stale reclaim (dead socket, refused
|
||||
connection) is existing behavior.
|
||||
- **ready:** store opened under its process-owner lock, receipt published,
|
||||
scheduler and task manager started, first workspace scope admitted.
|
||||
- **serving:** frontends attach/detach; each attachment gets
|
||||
`daemon/frontend_ready` with its endpoint (reused listener or a
|
||||
guest-requested one carrying the guest's token).
|
||||
- **draining:** stop admitting; let accepted turns finish (existing
|
||||
`shutdown_and_wait` semantics); release the store lock; retire socket and
|
||||
receipt.
|
||||
- **retired:** nothing on disk claims ownership; the next client re-elects.
|
||||
|
||||
### Election daemonizes
|
||||
|
||||
The client that wins election does not *stay* the owner as a child of the
|
||||
client's process tree. The elected `serve` re-execs a detached supervisor
|
||||
(single child, reparented to the OS, stdio closed) which opens the store and
|
||||
publishes the receipt; the original child then attaches like any guest. Every
|
||||
frontend is therefore killable by its own client with zero engine-wide
|
||||
blast radius. The VS Code extension's "stop the engine started by this
|
||||
window" keeps meaning exactly "stop *my* frontend".
|
||||
|
||||
### Idle retirement
|
||||
|
||||
The owner retires when, for a configurable grace (default 60 s):
|
||||
|
||||
- zero live frontend connections,
|
||||
- no accepted turns, running tasks, or due/running automations,
|
||||
- no `linger` holder.
|
||||
|
||||
`linger` is an attach-mode property, not a flag only the desktop knows:
|
||||
a desktop app attaches a pure control connection with linger for
|
||||
app-lifetime; an account-owned remote-control session (mobile/web remote)
|
||||
holds linger while active; automations due within the grace block retirement
|
||||
on their own. Retirement is graceful drain, never SIGKILL.
|
||||
|
||||
### Crash and recovery
|
||||
|
||||
An owner crash EOFs every guest's control connection. Guest children
|
||||
re-run election (one winner), re-attach, and re-request their listeners.
|
||||
Accepted turns in flight at crash are lost — that is the honest cost of one
|
||||
owner; stores, events, and receipts are durable, so recovery is
|
||||
re-elect → re-attach → reload thread state, all idempotent (scope admission
|
||||
re-canonicalizes the workspace; listener admission is a fresh bind).
|
||||
Guest children may print a **new** `Runtime API listening on …` line after
|
||||
re-election; clients treat every matching line as the current endpoint, not
|
||||
just the first (extension change, §5.3).
|
||||
|
||||
### Upgrades
|
||||
|
||||
Version skew already refuses cross-version attach. Add one control
|
||||
operation, `daemon/drain_restart`: the desktop (or CLI) tells the owner to
|
||||
drain and exit, then starts the new binary and lets frontends re-attach.
|
||||
No orphaned sockets: drain is the same path as retirement.
|
||||
|
||||
### Scope retirement
|
||||
|
||||
Workspace scopes refcount live frontend attachments; on last detach the
|
||||
scope's MCP/LSP pools close and the scope slot frees after the same grace.
|
||||
Disk state (items, events) stays; re-admission is cheap. The 64-cap now
|
||||
bounds *concurrent* workspaces, not lifetime workspaces.
|
||||
|
||||
## 5. Client contracts
|
||||
|
||||
All clients speak the same three verbs: **find** (connect boundary socket),
|
||||
**elect** (spawn detached owner if absent), **attach** (frontend scope +
|
||||
listener selection). Nothing else.
|
||||
|
||||
### 5.1 Desktop (canonical consumer)
|
||||
|
||||
Attaches over the control socket directly — JSON-RPC control transport, no
|
||||
TCP hop, one connection per window, one linger connection for the app.
|
||||
Orchestrates upgrades via `daemon/drain_restart`. Surfaces owner facts
|
||||
(pid, version, boundary, scopes, frontends, uptime) in its status UI from
|
||||
the receipt plus a `daemon/status` read.
|
||||
|
||||
### 5.2 Web (`codewhale web`)
|
||||
|
||||
Finds/elects the default boundary, attaches a web frontend. Bootstrap-nonce
|
||||
URLs are per-attachment (existing `web_bootstrap_url`). Multi-tab is N SSE
|
||||
connections to one listener — never N engines.
|
||||
|
||||
### 5.3 VS Code extension
|
||||
|
||||
Keeps its child-per-window shape — the compat line makes a guest child look
|
||||
identical to a standalone engine — but the child attaches to the default
|
||||
boundary instead of demanding a private store:
|
||||
|
||||
- stop setting `CODEWHALE_RUNTIME_DIR` **and** `DEEPSEEK_TASKS_DIR` on
|
||||
engines ≥ 0.10 — the runtime-dir override was the isolation workaround,
|
||||
and the tasks-dir env forks the resolved store root (hence the boundary);
|
||||
tasks land in the user-level tasks dir, which is what `codewhale doctor`
|
||||
and receipts already read;
|
||||
- one-time version probe (`codewhale --version`) chooses the strategy:
|
||||
≥ 0.10 → attach; < 0.10 → legacy per-workspace store (current 0.8.3
|
||||
behavior, which is correct for the old one-process-per-store model);
|
||||
- accept repeated `Runtime API listening on` lines (re-election) and update
|
||||
the base URL;
|
||||
- surface typed attach failures verbatim instead of
|
||||
`Engine exited before becoming ready`;
|
||||
- add a "Codewhale: engine status" command (doctor owner panel: boundary,
|
||||
socket, pid, scopes, frontends) and "Codewhale: restart engine"
|
||||
(`daemon/drain_restart`).
|
||||
|
||||
Window close kills only the guest child; its listener drains in the owner;
|
||||
other windows are untouched. Same-workspace-in-two-windows now works (same
|
||||
scope, two listeners, two tokens).
|
||||
|
||||
### 5.4 ACP (`serve --acp`)
|
||||
|
||||
An ACP base owner is immutable by design (`acp_only`); in this design it is
|
||||
simply its own boundary (the acp-only bit is in the tuple), so Zed sessions
|
||||
never collide with the interactive owner and never attach into it.
|
||||
|
||||
### 5.5 Mobile / remote control
|
||||
|
||||
Account-owned remote control attaches to the default boundary with
|
||||
watch/drive device-token intents (existing) and holds linger while a remote
|
||||
session is live. Nothing new.
|
||||
|
||||
### 5.6 Interactive TUI, `exec`, MCP stdio — explicit non-goals
|
||||
|
||||
The interactive TUI keeps its per-session store (`for_session`), `exec`
|
||||
stays a one-shot worker, `serve --mcp` stays stdio-scoped. They are not
|
||||
frontends of the owner and must not become so implicitly; the boundary model
|
||||
governs the `app-server`/`serve` daemon family only. (A future where the
|
||||
TUI attaches as a frontend is possible but is not this design.)
|
||||
|
||||
## 6. Security
|
||||
|
||||
Kept unchanged: peer-uid checks, 0600/0700, per-connection receipts that are
|
||||
never bearers, loopback-only default binds, per-listener guest tokens,
|
||||
constant-time token comparison, canonical workspace verification
|
||||
(`O_DIRECTORY|O_NOFOLLOW` + same-file identity), device-token intents.
|
||||
|
||||
Added or sharpened:
|
||||
|
||||
- **Boundary hashing uses canonical inputs only** — a symlinked config path
|
||||
or non-canonical runtime dir must not fork a second owner silently.
|
||||
- **Workspace admission is an engine-side trust decision**: scope admission
|
||||
consults project trust (`[projects]` in config); an untrusted workspace's
|
||||
first attach surfaces an approval (the engine already runs approvals; the
|
||||
extension's VS Code-side trust is a client precondition, not the
|
||||
engine's).
|
||||
- **Audit events** for `owner_elected`, `scope_admitted`, `frontend_attached`,
|
||||
`frontend_detached`, `owner_retired` land in the boundary store's event
|
||||
log — the owner election stops being observable only through
|
||||
`engine.log`.
|
||||
- Derived sockets live under the same 0700 parent as the default socket; no
|
||||
new world-writable surface. Hash collision on a derived socket shows up as
|
||||
a receipt mismatch → refuse (the true-race class).
|
||||
|
||||
## 7. Mechanism changes (touchpoints)
|
||||
|
||||
Phase 1 — engine, identity + typed errors (no client changes required):
|
||||
|
||||
- `daemon_socket.rs`: extend `SocketPathInputs` with the runtime-dir override
|
||||
and config profile/source; derive non-default socket names from
|
||||
`boundary_id`; keep the default path for the default boundary.
|
||||
- `runtime_api.rs`: `validate_selected_owner` loses the store-compare (now
|
||||
structurally guaranteed) and keeps pid/lease/principal checks; worker
|
||||
mismatch becomes a typed control error; scheduler setting joins the
|
||||
receipt.
|
||||
- Election daemonization: a detached re-exec path in `serve` startup
|
||||
(`--supervise-owner` internal flag); guest children always attach.
|
||||
- Scope refcounting + grace retirement in `RuntimeWorkspaceScopes`; idle
|
||||
retirement loop honoring turns/tasks/automations/linger.
|
||||
- `doctor`: owner panel (boundary, socket, receipt, scopes, frontends).
|
||||
|
||||
Phase 2 — extension migration (version probe, drop per-workspace runtime
|
||||
dir, repeated-line handling, status/restart commands, typed error surface).
|
||||
|
||||
Phase 3 — desktop/web speak control-protocol attach directly; linger;
|
||||
`daemon/drain_restart`; `daemon/status`.
|
||||
|
||||
Phase 4 (optional, only if a real requirement appears) — per-scope worker
|
||||
pools, or per-workspace store partitions inside a boundary.
|
||||
|
||||
## 8. Rollout and compatibility
|
||||
|
||||
| Client | Engine 0.8.x | Engine ≥ 0.10 (phase 1) | After phase 2/3 |
|
||||
|---|---|---|---|
|
||||
| Extension ≤ 0.8.3 | works (old model) | **broken today** (the reported error) | works: version probe keeps per-workspace stores on old engines |
|
||||
| Extension ≥ phase 2 | works via probe | attaches; multi-window works | same |
|
||||
| `codewhale web` | standalone | unchanged behavior | attaches to default boundary |
|
||||
| Desktop socket client | n/a | attach works when boundary matches | canonical; linger; upgrades |
|
||||
| TUI / exec / MCP | unchanged | unchanged | unchanged |
|
||||
|
||||
No transport changes, no wire-format changes beyond additive typed errors and
|
||||
control ops; the stdout readiness line contract is preserved for every
|
||||
spawn-and-scan client.
|
||||
|
||||
## 9. Rejected alternatives
|
||||
|
||||
- **Socket-per-store only (keep N independent engines).** Smallest diff, but
|
||||
leaves N provider catalogs, N schedulers contending over the shared
|
||||
automations/tasks locations (`AutomationManager::default_location`,
|
||||
`default_tasks_dir` are per-user, not per-store), and N heavy processes.
|
||||
The 0.10.1 owner model exists to end exactly this.
|
||||
- **HTTP-first discovery (port files as the control plane).** Port files
|
||||
already exist per workspace; HTTP cannot authenticate the local peer the
|
||||
way the socket + receipt do. The socket stays the control plane.
|
||||
- **OS supervision (launchd/systemd user units).** Installs state per
|
||||
machine for a problem self-election + idle retirement solves with zero
|
||||
install; desktop *may* supervise, but nothing requires it.
|
||||
- **Owner stays inside the electing client, extensions learn "shared"
|
||||
semantics.** Makes every client's stop path a protocol negotiation about
|
||||
other clients' needs; fragile under SIGTERM. Daemonized election is
|
||||
simpler and total.
|
||||
- **Store-per-workspace partitions inside the owner.** Unnecessary today —
|
||||
threads are workspace-filtered in one shared store, and the extension
|
||||
already depends on shared saved sessions. Revisit only with a concrete
|
||||
isolation requirement.
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
1. Grace default (60 s?) and whether linger is per-connection or
|
||||
per-boundary with a count.
|
||||
2. Should the automations scheduler run in the owner unconditionally, or
|
||||
only when at least one frontend has ever attached (headless-schedule
|
||||
mode)?
|
||||
3. `daemon/drain_restart` authorization: any attached frontend, or only
|
||||
linger holders / an explicit operator path?
|
||||
4. Whether the desktop wants a *named* multi-boundary story (e.g., one
|
||||
boundary per account profile) — the tuple supports it; the UX does not
|
||||
have to yet.
|
||||
5. Windows store-derived named-pipe naming timing (the existing transport is retained).
|
||||
|
||||
---
|
||||
|
||||
## 11. Workspace drive lease (single-writer per workspace)
|
||||
|
||||
**Status:** Partial implementation at Runtime API workspace scope admission.
|
||||
Turn admission and standalone TUI/CLI ownership are not added in this PR.
|
||||
|
||||
### The invariant
|
||||
|
||||
One process holds the workspace kernel lease across stores that use the same
|
||||
private lease-state root. Scopes in that process share counted acquisitions.
|
||||
This PR does not serialize distinct in-process engines or add leases to
|
||||
standalone TUI/CLI drivers. Existing watchers of an admitted owner share its
|
||||
held scope and retain their token-intent restrictions.
|
||||
|
||||
### Why the lease unit is the workspace, not the store
|
||||
|
||||
Three engine facts make the workspace the real contended resource:
|
||||
|
||||
1. **The workspace is a shared write surface.** Two engines with different
|
||||
stores editing the same working tree is *worse* than store double-writes:
|
||||
it corrupts user code, not history.
|
||||
2. **The snapshot side-repo is keyed by workspace, store-independent**
|
||||
(`snapshot/paths.rs` — user-level state dir hashed by canonical workspace
|
||||
path; the 500 MB cap warning in the wild lands there). Two engines on one
|
||||
workspace write the same snapshot repo with no serialization today.
|
||||
3. **The in-process git-write mutex only covers windows on the same server**
|
||||
(`git_writes`, #6647). Cross-process it does not exist.
|
||||
|
||||
The existing exclusivity unit — the store process-owner lock — is scoped to
|
||||
one store. Per-workspace stores (extension), per-session stores (TUI), and
|
||||
the user-level default store never contend on it, yet may share a
|
||||
workspace. The lease lifts the unit to where the actual conflict is.
|
||||
|
||||
### Drive vs watch
|
||||
|
||||
Existing device tokens separate watch and drive intent. This PR takes the
|
||||
lease when a Runtime API workspace scope is admitted; it does not add a
|
||||
separate lease-free scope admission API. Existing observers attach to the
|
||||
owner's held scope and do not acquire another operating-system lease.
|
||||
|
||||
### Mechanism
|
||||
|
||||
- **Lease file**: one per workspace under the private `workspace-leases` state directory —
|
||||
`<lease_state_base()>/<project_hash>/<worktree_hash>.lease` —
|
||||
reusing the same canonical-path hashing as the snapshot side-repo, so worktrees
|
||||
of one checkout share a project bucket and a worktree lease is exact.
|
||||
- **Acquire**: open-create + a kernel `flock`/Windows byte-range lock. Write holder facts: pid,
|
||||
`process_start` (the existing liveness token), client label, store root,
|
||||
timestamp.
|
||||
- **Liveness**: the kernel lock releases when its holder dies, so a crashed
|
||||
holder does not prevent the next acquisition. The lease file keeps a stable
|
||||
identity. Releasing the lock leaves diagnostic metadata on disk; a successor
|
||||
overwrites it on acquisition. The record alone never establishes a live holder.
|
||||
A contender never deletes or replaces it based on recorded pid or
|
||||
creation-time metadata: those fields describe a holder but do not grant
|
||||
takeover authority. An old record can remain between a successor's lock
|
||||
acquisition and its metadata write. A live but wedged holder must release
|
||||
the kernel lease or exit before another process can drive the workspace.
|
||||
- **Refusal is legible**: on contention the engine reports the recorded
|
||||
holder (pid, client label, store root, age). The holder must close or release
|
||||
its scope before another process can drive; existing observers may attach
|
||||
watch-only to that owner. This PR does not add a forced takeover command.
|
||||
- **Where it lives**: one small module in the TUI crate beside the store
|
||||
process-owner lock, taking only `&Path` (workspace) + holder facts.
|
||||
|
||||
### Admission points
|
||||
|
||||
`RuntimeWorkspaceScopes::admit` is the implemented admission point. It retains
|
||||
canonical directory identity checks, acquires the lease before coordination
|
||||
registration, and holds the guard through asynchronous MCP/LSP cleanup.
|
||||
|
||||
Independent turn admission and standalone TUI/CLI resume/fork ownership are
|
||||
follow-up work. They do not acquire this lease merely by opening a
|
||||
`RuntimeThreadManager`. Existing watch requests continue to use the admitted
|
||||
owner scope and existing token checks.
|
||||
|
||||
### Costs and caveats (honest list)
|
||||
|
||||
- Refusal UX is a product surface: the second client must present holder
|
||||
facts and a takeover path, or this becomes a support burden.
|
||||
- fd-lock semantics weaken on NFS/network volumes; the lease degrades to
|
||||
advisory there (same as every existing lock in the engine).
|
||||
- pid namespaces in containers make `process_start` checks conservative:
|
||||
refuse takeover rather than false-positive "dead".
|
||||
- Test matrix cost: client-pair mutex behavior (VS Code × desktop, TUI ×
|
||||
VS Code, …) is a durable maintenance cost.
|
||||
- Rollout: engines older than the lease have no lease; the lease binds only
|
||||
when both sides run the new engine (first-ready-wins, no migration).
|
||||
|
||||
### Relationship to the phases
|
||||
|
||||
The lease is orthogonal to the topology phases: A/C decide *where history
|
||||
lives*, the lease decides *who may write*, B's lifecycle machinery remains
|
||||
optional. Combined semantics: anyone can watch; history converges by
|
||||
domain; one driver per workspace at a time.
|
||||
+1
-1
@@ -468,7 +468,7 @@ owner = "crates/tui/src/lsp"
|
||||
[[feature]]
|
||||
id = "runtime-api"
|
||||
name = "Runtime API"
|
||||
summary = "Local HTTP API: threads, events, approvals, persisted workspace trust, scoped device tokens and account-bound turns."
|
||||
summary = "Local API: threads, events, approvals, project trust, workspace leases, Unix store sockets and scoped device tokens."
|
||||
status = "stable"
|
||||
surfaces = { api = "stable" }
|
||||
docs = "docs/RUNTIME_API.md"
|
||||
|
||||
@@ -517,6 +517,9 @@
|
||||
"path_canonicalize": 1,
|
||||
"std_fs": 1
|
||||
},
|
||||
"crates/tui/src/workspace_lease.rs": {
|
||||
"path_canonicalize": 1
|
||||
},
|
||||
"crates/tui/src/workspace_trust.rs": {
|
||||
"path_canonicalize": 1,
|
||||
"std_fs": 2
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"format": "codewhale.features.v1",
|
||||
"source": {
|
||||
"path": "docs/features.toml",
|
||||
"sha256": "d94c2c1b0bc5da626c2ea5637d4b79b26b9a208f2f62e7e189b489a6d9ff5d79"
|
||||
"sha256": "2f91995d1103bbbade5761a51bc29fbcc3a0bc355d62fb1cf3ccafa3cb8d7a16"
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
@@ -594,7 +594,7 @@
|
||||
{
|
||||
"id": "runtime-api",
|
||||
"name": "Runtime API",
|
||||
"summary": "Local HTTP API: threads, events, approvals, persisted workspace trust, scoped device tokens and account-bound turns.",
|
||||
"summary": "Local API: threads, events, approvals, project trust, workspace leases, Unix store sockets and scoped device tokens.",
|
||||
"status": "stable",
|
||||
"surfaces": {
|
||||
"api": "stable"
|
||||
|
||||
Reference in new issue
Block a user