tests: share the environment barrier through Runtime test support

Move the UI-free half of crates/tui/src/test_support.rs and test_env_lock.rs into the existing codewhale-runtime test-support feature. Keep TUI resolver fences, sandbox fixture placement, JWT/trust helpers and App fixtures in their existing owner for this packet. TUI and the included shell integration harness now use the same barrier. Migrate the shell and two client private environment locks to that barrier; retain stream_entry's existing shared lock and remove only its extra private lock. Shell fixtures reuse EnvVarGuard so failures restore the variables. No new test functions, crate or code comments.

Visibility review: only the opt-in test-support feature (or Runtime unit tests) exposes these helpers. Previously pub(crate) helper APIs become pub so the TUI shim and included harness can share one owner; environment scope internals, ticket generation fields, seal fields and guarded-key bookkeeping remain private. EnvVarGuard set/remove retain the existing safe-function contract and debug assertions requiring the process barrier before unsafe environment mutation; restore/drop order and stale-generation refusal are unchanged. The public constructor intentionally has no implicit Default that would acquire a non-reentrant process lock. Production builds do not enable the feature by default. The moved barrier body is identical apart from visibility and the corrected existing module header.

Baseline existing guards: "test result: ok. 12 passed; 0 failed" plus2barrier/3private-mutex cases passed0failed. After source inventory15316TUI cases ->15311, with five moved to Runtime. Runtime: "Summary [   0.013s] 5 tests run: 5 passed, 188 skipped". Full TUI: "Summary [ 199.151s] 15286 tests run: 15283 passed (1 slow), 3 failed, 25 skipped". Those three nested-Cargo fixtures inherited the outer build directory outside their workspace sandbox; removing that outer test-launch setting and reusing the unchanged compiled source gave "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 15308 filtered out; finished in 1.94s". Original failures retained; no outstanding failures among the15286TUI cases. Shell denial integration: "Summary [   2.881s] 3 tests run: 3 passed, 217 skipped". Initial missing OnceLock import compile retained and fixed; unchanged Runtime evidence reused.

Strict Runtime/TUI all-targets/all-features clippy passes8m25 with the existing CI -Dwarnings and three allowances; all18 frozen hashes unchanged. Feature-only check passes, package list includes both Runtime feature files and excludes TUI test helpers; Cargo.lock unchanged. Blocking checker34passed0failed; blocking538sites/165files unchanged; dead-code221 ->220 (139allow/81expect) after removing the old module mount suppression. Boundary production0/test13/late1/uilib0/doc0 unchanged. Required npm/web gate1312passed0failed7skipped and check:webcompleted. Shared-process qualification and final hosted release acceptance remain separate.

Refs CW-7160
Refs #6938
This commit is contained in:
CodeWhale Bot committed 2026-10-12 04:29:40 -07:00
1 parent 441fe2d1e7
commit 44f7f6bbca
17 files changed
+611 -640

No files matched your search

+2 -1
View File
@@ -22,7 +22,7 @@ workspace = true
# effect only with debug assertions, so no optimized build ever carries it,
# even under `--all-features`.
[features]
test-support = []
test-support = ["dep:tempfile"]
test-thread-scoped-state = []
[lib]
@@ -51,6 +51,7 @@ regex = "1.11"
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
sha2.workspace = true
tempfile = { workspace = true, optional = true }
thiserror.workspace = true
tokio.workspace = true
tokio-util = { version = "0.7.16", features = ["io"] }
+2
View File
@@ -36,6 +36,8 @@ pub mod session_tree;
pub mod skill_state;
pub mod sleep_guard;
pub mod startup_trace;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod tool_history_repair;
pub mod workspace_discovery;
+552
View File
@@ -0,0 +1,552 @@
//! Shared test-only helpers.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
mod test_env_lock;
pub use test_env_lock::{
EnvScopeMembership, EnvScopeTicket, TestEnvLock, current_env_scope_generation,
current_thread_holds_test_env_lock, env_scope_ticket, join_env_scope, lock_test_env,
with_test_env_lock, with_test_env_lock_if_uncontended,
};
/// Process-wide state root for unit tests that do not intentionally provide an
/// explicit config/settings path.
///
/// The production fallback is the user's real home. That is useful at runtime
/// and unsafe in a parallel test binary: an unguarded save can otherwise read
/// or overwrite the developer's config. Tests that exercise path precedence
/// still hold [`lock_test_env`] and provide explicit temporary environment
/// values; every other test is confined here — enforced by
/// [`guarded_environment_provides_state_paths`], not assumed.
pub fn isolated_test_state_root() -> &'static Path {
static ROOT: OnceLock<PathBuf> = OnceLock::new();
ROOT.get_or_init(|| {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"codewhale-tui-test-state-{}-{nonce}",
std::process::id()
));
std::fs::create_dir_all(&root).unwrap_or_else(|error| {
panic!(
"failed to create isolated unit-test state root {}: {error}",
root.display()
)
});
// Match resolvers that canonicalize their root (macOS aliases /var to
// /private/var). Every fence must name the same physical test store.
root.canonicalize().expect("canonical test state root")
})
}
/// Where the calling test's state should live when it has not sealed the
/// environment itself.
///
/// Two different callers land here. A test that never took [`lock_test_env`]
/// gets the shared root, exactly as before — those tests already coexist there
/// under [`with_test_state_io_lock`]. A test that *holds* the lock but sealed
/// nothing gets a private directory instead: before #5359 it resolved the
/// developer's real home, so it has never shared the process root, and several
/// such tests run full settings transactions. Adding that traffic to the shared
/// root pushed the transaction lock past its deadline and hung unrelated
/// `config_command_*` tests. Keep them isolated from the developer *and* from
/// each other.
pub fn unsealed_test_state_root() -> PathBuf {
let shared = isolated_test_state_root();
if !current_thread_holds_test_env_lock() {
return shared.to_path_buf();
}
// libtest runs each test in a fresh thread. Keep one root for that thread:
// a settings save resolves its path more than once, while different tests
// must not inherit each other's files.
HOLDER_ROOT.with(|cached| {
cached
.get_or_init(|| {
let root = shared.join(format!("env-holder-{:?}", std::thread::current().id()));
std::fs::create_dir_all(&root).unwrap_or_else(|error| {
panic!(
"failed to create per-holder test state root {}: {error}",
root.display()
)
});
root
})
.clone()
})
}
thread_local! {
static HOLDER_ROOT: OnceLock<PathBuf> = const { OnceLock::new() };
}
/// Where a state resolver must put `name` instead of the user's home: the
/// isolated test root when no test has sealed the home (see [`home_is_sealed`]),
/// `None` when one has and the resolver should follow the environment as
/// production does.
///
/// Every resolver that reaches `~/.codewhale` and is reachable from a test
/// needs this fence, because incidental callers (an `App` constructor, a
/// command dispatch) never think about the home at all. Call it first, under
/// `#[cfg(test)]`, so the production path stays byte-for-byte unchanged.
///
/// The directory is under the shared isolated root, not the per-holder one
/// [`unsealed_test_state_root`] hands to lock holders: these resolvers are
/// reached from worker threads as well as the test thread, and a test must see
/// one directory from both. The directory exists when this returns, so callers
/// need no filesystem call of their own on the fenced branch.
pub fn unsealed_state_dir(name: impl AsRef<Path>) -> Option<PathBuf> {
if home_is_sealed() {
return None;
}
let dir = isolated_test_state_root().join(name);
std::fs::create_dir_all(&dir).unwrap_or_else(|error| {
panic!(
"failed to create the unsealed test state dir {}: {error}",
dir.display()
)
});
Some(dir)
}
fn state_io_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Serialize read/merge/write operations against the process-wide isolated
/// test state root.
///
/// Path isolation protects the developer's files, but parallel tests still
/// share the same temporary files. Settings persistence is a multi-step
/// operation, so it needs this second barrier around the complete I/O
/// transaction rather than only around path resolution.
pub fn with_test_state_io_lock<T>(operation: impl FnOnce() -> T) -> T {
let _guard = match state_io_lock().lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
operation()
}
/// Build a test phase's future inside this call and box it (#6362).
///
/// In debug builds every inline `async {}` value gets a stack slot in the
/// enclosing poll frame the size of that future's whole state machine, and
/// the slots are never reused, so a body that awaits four phases inline
/// carries all four state machines on its own frame at once (measured at
/// 806 KiB for the runtime-store binding test). Constructing the phase here
/// leaves the caller holding a pointer, and the phase's own temporaries die
/// with its poll frame.
pub fn boxed_phase<'a, T, M, F>(
make: M,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>
where
M: FnOnce() -> F,
F: std::future::Future<Output = T> + 'a,
{
Box::pin(make())
}
/// Drive a test future on a thread with libtest's default 2 MiB stack,
/// whatever `RUST_MIN_STACK` says (#6362).
///
/// CI exports a 16 MiB `RUST_MIN_STACK` for every test thread, so a test
/// that only fits because of that export never learns it overflowed the
/// stack a contributor's plain `cargo test` gives it. The future is built on
/// the spawned thread (so it need not be `Send`) and pinned before
/// `block_on`, exactly as `#[tokio::test]` drives a current-thread runtime;
/// a panic inside propagates to the caller unchanged. An overflow still
/// aborts the process with "has overflowed its stack": that is the reported
/// symptom, not something this helper can turn into a panic.
pub fn block_on_default_test_stack<M, F, T>(make: M) -> T
where
M: FnOnce() -> F + Send + 'static,
F: std::future::Future<Output = T>,
T: Send + 'static,
{
const DEFAULT_TEST_THREAD_STACK: usize = 2 * 1024 * 1024;
std::thread::Builder::new()
.name("default-test-stack".into())
.stack_size(DEFAULT_TEST_THREAD_STACK)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread test runtime");
let future = make();
tokio::pin!(future);
runtime.block_on(future)
})
.expect("spawn the default-stack test thread")
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}
/// Restore one environment variable when dropped.
///
/// Callers that mutate process-global environment variables must hold
/// [`lock_test_env`] until after this guard is dropped.
///
/// Every live guard is also recorded in [`guarded_env_keys`], so path
/// resolution can distinguish a test that deliberately redirected `HOME`
/// from one that merely holds the lock to serialize unrelated env access —
/// see [`guarded_environment_provides_state_paths`].
pub struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
fn guarded_env_keys() -> &'static Mutex<std::collections::HashMap<&'static str, usize>> {
static KEYS: OnceLock<Mutex<std::collections::HashMap<&'static str, usize>>> = OnceLock::new();
KEYS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
fn register_guarded_env_key(key: &'static str) {
let mut keys = match guarded_env_keys().lock() {
Ok(keys) => keys,
Err(poisoned) => poisoned.into_inner(),
};
*keys.entry(key).or_insert(0) += 1;
}
fn unregister_guarded_env_key(key: &'static str) {
let mut keys = match guarded_env_keys().lock() {
Ok(keys) => keys,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(count) = keys.get_mut(key) {
*count -= 1;
if *count == 0 {
keys.remove(key);
}
}
}
/// Whether some live [`EnvVarGuard`] currently covers `key`.
pub fn env_var_currently_guarded(key: &str) -> bool {
match guarded_env_keys().lock() {
Ok(keys) => keys.contains_key(key),
Err(poisoned) => poisoned.into_inner().contains_key(key),
}
}
/// Whether the calling test actually provided the state-path environment it
/// is about to resolve.
///
/// Holding [`lock_test_env`] alone is not that: many tests hold the lock only
/// to serialize access to unrelated variables (`TERM_PROGRAM`, API keys) and
/// have provided no temporary paths at all. Trusting the lock routed those
/// tests to the developer's real `~/.codewhale` state, which is exactly the
/// leak the isolated root exists to prevent (#5359). A test earns environment
/// resolution by holding the lock *and* either setting one of the explicit
/// override variables or redirecting `HOME`/`USERPROFILE` through
/// [`EnvVarGuard`].
pub fn guarded_environment_provides_state_paths() -> bool {
if !current_thread_holds_test_env_lock() {
return false;
}
let guarded_path_is_present = |var: &str| {
env_var_currently_guarded(var)
&& std::env::var_os(var)
.is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
};
[
"CODEWHALE_HOME",
"CODEWHALE_CONFIG_PATH",
"DEEPSEEK_CONFIG_PATH",
"HOME",
"USERPROFILE",
]
.iter()
.any(|var| guarded_path_is_present(var))
}
/// Whether the calling test's live seal pins the user's *home* — the root that
/// user state (sessions, snapshots, plugin bundles, logs) resolves under — as
/// opposed to merely redirecting a config file.
///
/// The owner and workers enrolled through [`join_env_scope`] follow the seal.
/// Unrelated parallel tests and workers without that scope use the isolated
/// root: following another test's environment would race its restoration to
/// the developer's real profile. Pass [`env_scope_ticket`] to workers that need
/// their owner's home.
///
/// Stricter than [`guarded_environment_provides_state_paths`], which also
/// accepts a guarded config-path override: such a test still resolves
/// `~/.codewhale` for everything else. `CODEWHALE_HOME` outranks `HOME` when it
/// is set, so a guarded `HOME` seals nothing while an unguarded
/// `CODEWHALE_HOME` is in the ambient environment.
pub fn home_is_sealed() -> bool {
if !current_thread_holds_test_env_lock() {
return false;
}
let present = |var: &str| {
std::env::var_os(var)
.is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
};
if present("CODEWHALE_HOME") {
return env_var_currently_guarded("CODEWHALE_HOME");
}
["HOME", "USERPROFILE"]
.iter()
.any(|var| env_var_currently_guarded(var) && present(var))
}
impl EnvVarGuard {
pub fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
debug_assert!(
current_thread_holds_test_env_lock(),
"EnvVarGuard::set({key}) requires lock_test_env()"
);
let previous = std::env::var_os(key);
// SAFETY: callers hold the process-wide test env mutex.
unsafe { std::env::set_var(key, value) };
register_guarded_env_key(key);
Self { key, previous }
}
pub fn remove(key: &'static str) -> Self {
debug_assert!(
current_thread_holds_test_env_lock(),
"EnvVarGuard::remove({key}) requires lock_test_env()"
);
let previous = std::env::var_os(key);
// SAFETY: callers hold the process-wide test env mutex.
unsafe { std::env::remove_var(key) };
register_guarded_env_key(key);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
// Withdraw the claim *before* restoring the value: a reader on another
// thread that still saw the key as guarded would otherwise follow the
// restored — ambient, possibly real — path for the instant in between.
unregister_guarded_env_key(self.key);
// SAFETY: callers hold the process-wide test env mutex until after this
// guard is dropped.
unsafe {
if let Some(value) = self.previous.take() {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
/// Seal the user's home onto a directory the test owns, for the life of one
/// test.
///
/// This is the one way a test takes the user's state out of play. It pins
/// `HOME`, `USERPROFILE` and `CODEWHALE_HOME` through [`EnvVarGuard`] — so
/// [`guarded_environment_provides_state_paths`] recognises the seal — and
/// removes the aliases that would otherwise route around them
/// (`HOMEDRIVE`/`HOMEPATH`, `CODEWHALE_CONFIG_PATH`, `DEEPSEEK_CONFIG_PATH`,
/// `DEEPSEEK_HOME`). Pinning `HOME` alone is not a seal: an ambient
/// `CODEWHALE_HOME` takes precedence over it, so a test that "scoped" only
/// `HOME` still wrote the developer's real profile.
///
/// It holds the process-wide environment lock, which is not reentrant: keep
/// one seal per test, and build any fixture that takes the lock itself
/// (settings, diagnostics harnesses) after it. Anything that reads or writes
/// the user's state — instructions, skills, sessions, snapshots, settings,
/// credentials — then lands in the seal.
pub struct SealedHome {
// Drop order matters: restore the environment, then delete the directory,
// then release the lock.
_vars: Vec<EnvVarGuard>,
_dir: Option<tempfile::TempDir>,
home: PathBuf,
codewhale_home: PathBuf,
_lock: TestEnvLock,
}
#[expect(clippy::new_without_default)]
impl SealedHome {
/// Seal onto fresh, empty directories: `<tmp>/home` and
/// `<tmp>/codewhale-home`.
pub fn new() -> Self {
let lock = lock_test_env();
let dir = tempfile::TempDir::new().expect("sealed home tempdir");
let home = dir.path().join("home");
let codewhale_home = dir.path().join("codewhale-home");
std::fs::create_dir_all(&home).expect("sealed home dir");
std::fs::create_dir_all(&codewhale_home).expect("sealed codewhale home dir");
Self::pin(lock, Some(dir), home, codewhale_home)
}
/// Seal onto a home the test already owns: `HOME` is `home` and the
/// Codewhale home is `home/.codewhale`, the layout an unset
/// `CODEWHALE_HOME` resolves to. For tests whose fixtures are built under
/// one tempdir that also plays the home.
pub fn at(home: &Path) -> Self {
let lock = lock_test_env();
let codewhale_home = home.join(".codewhale");
Self::pin(lock, None, home.to_path_buf(), codewhale_home)
}
fn pin(
lock: TestEnvLock,
dir: Option<tempfile::TempDir>,
home: PathBuf,
codewhale_home: PathBuf,
) -> Self {
// `CODEWHALE_HOME` first: it is dropped first, so the seal never
// outlives the override that outranks `HOME`.
let vars = vec![
EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home),
EnvVarGuard::set("HOME", &home),
EnvVarGuard::set("USERPROFILE", &home),
EnvVarGuard::remove("HOMEDRIVE"),
EnvVarGuard::remove("HOMEPATH"),
EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
EnvVarGuard::remove("DEEPSEEK_HOME"),
];
Self {
_vars: vars,
_dir: dir,
home,
codewhale_home,
_lock: lock,
}
}
/// The sealed `HOME` / `USERPROFILE`.
pub fn home(&self) -> &Path {
&self.home
}
/// The sealed `CODEWHALE_HOME`.
pub fn codewhale_home(&self) -> &Path {
&self.codewhale_home
}
}
/// Find the byte position of the first divergence between two strings,
/// returning a windowed view (`±32 bytes` around the divergence) so failures
/// in cache-prefix-stability tests show *which* bytes drifted, not just that
/// they did. Returns `None` when the strings are byte-identical.
pub fn first_divergence(a: &str, b: &str) -> Option<(usize, String, String)> {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let max = a_bytes.len().min(b_bytes.len());
for i in 0..max {
if a_bytes[i] != b_bytes[i] {
let lo = i.saturating_sub(32);
let a_hi = (i + 32).min(a_bytes.len());
let b_hi = (i + 32).min(b_bytes.len());
let a_ctx = String::from_utf8_lossy(&a_bytes[lo..a_hi]).into_owned();
let b_ctx = String::from_utf8_lossy(&b_bytes[lo..b_hi]).into_owned();
return Some((i, a_ctx, b_ctx));
}
}
if a_bytes.len() != b_bytes.len() {
return Some((
max,
format!("(len={})", a_bytes.len()),
format!("(len={})", b_bytes.len()),
));
}
None
}
/// Assert two strings are byte-identical, panicking with a windowed diff
/// around the first divergence when they aren't. Used by the prefix-cache
/// stability harness (#263, #280) to pin construction surfaces that land in
/// DeepSeek's KV cache prefix.
#[track_caller]
pub fn assert_byte_identical(label: &str, a: &str, b: &str) {
if let Some((pos, a_ctx, b_ctx)) = first_divergence(a, b) {
panic!(
"{label}: prompt construction is non-deterministic — first diff at byte {pos}\n\
── side A (±32B) ──\n{a_ctx:?}\n── side B (±32B) ──\n{b_ctx:?}",
);
}
}
pub fn read_shell_sentinel(path: &Path) -> String {
let bytes = std::fs::read(path).unwrap_or_default();
let text = if bytes.starts_with(&[0xFF, 0xFE]) {
let units: Vec<u16> = bytes[2..]
.as_chunks::<2>()
.0
.iter()
.map(|pair| u16::from_le_bytes(*pair))
.collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(&bytes).into_owned()
};
text.trim_start_matches('\u{feff}')
.replace("\r\n", "\n")
.replace('"', "")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ambient_codewhale_home_is_not_a_test_seal() {
let _lock = lock_test_env();
let _ambient = EnvVarGuard::set("CODEWHALE_HOME", "/tmp/ambient-codewhale-home");
unregister_guarded_env_key("CODEWHALE_HOME");
let sealed = guarded_environment_provides_state_paths();
register_guarded_env_key("CODEWHALE_HOME");
assert!(!sealed, "ambient developer state must remain confined");
}
#[test]
fn a_config_path_guard_or_a_home_shadowed_by_the_ambient_override_is_not_a_home_seal() {
let _lock = lock_test_env();
let dir = tempfile::tempdir().expect("tempdir");
{
// Redirecting the config file leaves `~/.codewhale` in play.
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", dir.path().join("config.toml"));
let _no_home_override = EnvVarGuard::remove("CODEWHALE_HOME");
assert!(guarded_environment_provides_state_paths());
assert!(!home_is_sealed());
}
{
// `CODEWHALE_HOME` outranks `HOME`: guarding only `HOME` while an
// ambient `CODEWHALE_HOME` is set seals nothing.
let _ambient = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("ambient"));
unregister_guarded_env_key("CODEWHALE_HOME");
let _home = EnvVarGuard::set("HOME", dir.path());
let sealed = home_is_sealed();
register_guarded_env_key("CODEWHALE_HOME");
assert!(!sealed, "an ambient CODEWHALE_HOME shadows a guarded HOME");
}
{
let _no_home_override = EnvVarGuard::remove("CODEWHALE_HOME");
let _home = EnvVarGuard::set("HOME", dir.path());
assert!(home_is_sealed());
}
}
#[test]
fn removing_overrides_does_not_seal_the_ambient_home() {
let _lock = lock_test_env();
let _codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
assert!(
!guarded_environment_provides_state_paths(),
"removing an override must not expose the developer's HOME"
);
}
}
@@ -1,9 +1,4 @@
//! Process-wide test-environment barrier owned by shell dispatch.
//!
//! `shell_dispatcher.rs` is also compiled directly by integration harnesses,
//! outside the main binary crate. Keeping the barrier under that module makes
//! shell detection self-contained while `crate::test_support` re-exports the
//! same instance to its existing environment-mutating callers in the main crate.
//! Process-wide test-environment barrier.
use std::sync::{Mutex, MutexGuard, OnceLock, TryLockError};
use std::thread::ThreadId;
@@ -59,7 +54,7 @@ fn current_thread_owns_contended_env_lock() -> bool {
/// Owners and adopted workers can pass the same live generation to child
/// workers. Foreign threads cannot mint a ticket for a test they did not join.
#[derive(Clone, Copy, Debug)]
pub(crate) struct EnvScopeTicket {
pub struct EnvScopeTicket {
generation: u64,
}
@@ -68,7 +63,7 @@ impl EnvScopeTicket {
/// disk writes on a live scope key their bookkeeping by this value, so a
/// straggler from generation N can never be mistaken for work belonging to
/// generation N+1.
pub(crate) fn generation(&self) -> u64 {
pub fn generation(&self) -> u64 {
self.generation
}
}
@@ -80,7 +75,7 @@ impl EnvScopeTicket {
/// This is the authorization primitive for anything that must only touch disk
/// on behalf of a test that actually sealed `HOME`. A process-global "writes
/// are enabled" flag cannot distinguish unrelated parallel tests.
pub(crate) fn current_env_scope_generation() -> Option<u64> {
pub fn current_env_scope_generation() -> Option<u64> {
let scope = lock_env_scope();
let current = std::thread::current().id();
if scope.owner == Some(current) || scope.adopted.contains(&current) {
@@ -90,7 +85,7 @@ pub(crate) fn current_env_scope_generation() -> Option<u64> {
}
}
pub(crate) fn env_scope_ticket() -> Option<EnvScopeTicket> {
pub fn env_scope_ticket() -> Option<EnvScopeTicket> {
let scope = lock_env_scope();
let current = std::thread::current().id();
(scope.owner == Some(current) || scope.adopted.contains(&current)).then_some(EnvScopeTicket {
@@ -104,7 +99,7 @@ pub(crate) fn env_scope_ticket() -> Option<EnvScopeTicket> {
/// [`with_test_env_lock`] stops a foreign test from resolving another test's
/// temporary `HOME`. A helper thread doing work for the sealing test must see
/// that same environment without blocking on the mutex its owner holds.
pub(crate) fn join_env_scope(ticket: Option<EnvScopeTicket>) -> Option<EnvScopeMembership> {
pub fn join_env_scope(ticket: Option<EnvScopeTicket>) -> Option<EnvScopeMembership> {
let ticket = ticket?;
let mut scope = lock_env_scope();
if scope.owner.is_none() || scope.generation != ticket.generation {
@@ -120,7 +115,7 @@ pub(crate) fn join_env_scope(ticket: Option<EnvScopeTicket>) -> Option<EnvScopeM
})
}
pub(crate) struct EnvScopeMembership {
pub struct EnvScopeMembership {
generation: u64,
thread: ThreadId,
}
@@ -139,7 +134,7 @@ impl Drop for EnvScopeMembership {
/// Clearing the owner before the underlying mutex unlocks keeps re-entrant
/// reader detection exact. Closing the scope also evicts adopted workers, so
/// enrollment cannot outlive the test that granted it.
pub(crate) struct TestEnvLock {
pub struct TestEnvLock {
_guard: MutexGuard<'static, ()>,
}
@@ -157,7 +152,7 @@ impl Drop for TestEnvLock {
///
/// If a prior test panicked while holding the lock, recover the guard instead
/// of cascading failures across unrelated tests.
pub(crate) fn lock_test_env() -> TestEnvLock {
pub fn lock_test_env() -> TestEnvLock {
let guard = match env_lock().lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
@@ -170,7 +165,7 @@ pub(crate) fn lock_test_env() -> TestEnvLock {
///
/// The owner check makes the barrier re-entrant for a test that reads its own
/// guarded override.
pub(crate) fn with_test_env_lock<T>(read: impl FnOnce() -> T) -> T {
pub fn with_test_env_lock<T>(read: impl FnOnce() -> T) -> T {
if current_thread_owns_contended_env_lock() {
return read();
}
@@ -194,7 +189,7 @@ pub(crate) fn with_test_env_lock<T>(read: impl FnOnce() -> T) -> T {
/// serialized, and a contended one reads a variable mid-mutation at worst.
/// Callers must therefore only use this for values whose staleness is
/// tolerable, never to gate a disk write.
pub(crate) fn with_test_env_lock_if_uncontended<T>(read: impl FnOnce() -> T) -> T {
pub fn with_test_env_lock_if_uncontended<T>(read: impl FnOnce() -> T) -> T {
if current_thread_owns_contended_env_lock() {
return read();
}
@@ -209,7 +204,7 @@ pub(crate) fn with_test_env_lock_if_uncontended<T>(read: impl FnOnce() -> T) ->
}
}
pub(crate) fn current_thread_holds_test_env_lock() -> bool {
pub fn current_thread_holds_test_env_lock() -> bool {
match env_lock().try_lock() {
Ok(guard) => {
drop(guard);
-1
View File
@@ -24,7 +24,6 @@ exclude = [
"src/**/*acceptance.rs",
"src/conformance.rs",
"src/conformance/**",
"src/test_env_lock.rs",
"src/lib/terminal_mode_test_cases_*.rs",
"src/llm_client/mock.rs",
"src/plugins/test_fixture.rs",
-1
View File
@@ -500,7 +500,6 @@ mod tests {
#[test]
fn configured_http1_pin_controls_open_attempts_and_survives_clone() {
let _pin_env = super::super::tests::FORCE_HTTP1_ENV_LOCK.lock().unwrap();
let _env = crate::test_support::lock_test_env();
let _codewhale = crate::test_support::EnvVarGuard::remove("CODEWHALE_FORCE_HTTP1");
let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_FORCE_HTTP1");
+9 -17
View File
@@ -539,7 +539,7 @@
// Scenario consolidation of: base_url_security_rejects_insecure_non_local_http, base_url_security_errors_redact_sensitive_url_parts, base_url_security_allows_localhost_http, base_url_security_allows_non_local_http_with_explicit_opt_in
// from base_url_security_rejects_insecure_non_local_http
{
let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = AllowInsecureHttpEnvGuard::capture();
unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) };
@@ -549,7 +549,7 @@
}
// from base_url_security_errors_redact_sensitive_url_parts
{
let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = AllowInsecureHttpEnvGuard::capture();
unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) };
@@ -566,7 +566,7 @@
}
// from base_url_security_allows_localhost_http
{
let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = AllowInsecureHttpEnvGuard::capture();
unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) };
@@ -596,7 +596,7 @@
}
// from base_url_security_allows_non_local_http_with_explicit_opt_in
{
let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = AllowInsecureHttpEnvGuard::capture();
unsafe { std::env::set_var(ALLOW_INSECURE_HTTP_ENV, "1") };
@@ -606,7 +606,7 @@
// use a plain-HTTP base URL without any env var. This is the
// 0.9.11-and-earlier behavior the key silently stopped providing.
{
let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = AllowInsecureHttpEnvGuard::capture();
unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) };
@@ -618,10 +618,6 @@
}
}
/// Serialize tests that mutate `DEEPSEEK_ALLOW_INSECURE_HTTP`; env vars are
/// process-global and would otherwise leak across security checks.
static ALLOW_INSECURE_HTTP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct AllowInsecureHttpEnvGuard {
prior: Option<std::ffi::OsString>,
prior_legacy: Option<std::ffi::OsString>,
@@ -692,10 +688,6 @@
// === #103 Phase 2: HTTP/1 escape hatch ===================================
/// Serialize tests that mutate `DEEPSEEK_FORCE_HTTP1` so they don't race
/// against each other — env vars are process-global.
pub(super) static FORCE_HTTP1_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct ForceHttp1EnvGuard {
prior: Option<std::ffi::OsString>,
}
@@ -784,17 +776,17 @@
// Scenario consolidation of: force_http1_unset_is_false, force_http1_truthy_values, force_http1_falsy_values
// from force_http1_unset_is_false
{
let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = ForceHttp1EnvGuard::capture();
unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") };
assert!(!force_http1_from_env());
}
// from force_http1_truthy_values
{
let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = ForceHttp1EnvGuard::capture();
for value in ["1", "true", "True", "YES", "on", " 1 "] {
// Safety: serialized by FORCE_HTTP1_ENV_LOCK; reverted by guard.
// Safety: serialized by lock_test_env; reverted by guard.
unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
assert!(
force_http1_from_env(),
@@ -804,7 +796,7 @@
}
// from force_http1_falsy_values
{
let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap();
let _lock = crate::test_support::lock_test_env();
let _guard = ForceHttp1EnvGuard::capture();
for value in ["0", "false", "no", "off", "", "garbage", "2"] {
unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
+2 -2
View File
@@ -4890,10 +4890,10 @@ async fn list_commands(
.ok_or_else(|| ApiError::internal("command catalog is not installed"))?;
let workspace = state.workspace.clone();
#[cfg(test)]
let env_ticket = crate::shell_dispatcher::test_env_lock::env_scope_ticket();
let env_ticket = crate::test_support::env_scope_ticket();
let commands = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::shell_dispatcher::test_env_lock::join_env_scope(env_ticket);
let _membership = crate::test_support::join_env_scope(env_ticket);
command_catalog(
&catalog.builtins(),
&catalog.user_commands(workspace.as_path()),
+2 -3
View File
@@ -25,11 +25,10 @@ pub(super) async fn get_constitution(
}
None => {
#[cfg(test)]
let env_ticket = crate::shell_dispatcher::test_env_lock::env_scope_ticket();
let env_ticket = crate::test_support::env_scope_ticket();
let rendered = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership =
crate::shell_dispatcher::test_env_lock::join_env_scope(env_ticket);
let _membership = crate::test_support::join_env_scope(env_ticket);
crate::prompts::load_active_user_constitution()
.map(|document| document.render_body())
})
+3 -6
View File
@@ -43,11 +43,6 @@ use std::sync::Mutex;
static LOG_MUTEX: Mutex<()> = Mutex::new(());
#[cfg(test)]
#[allow(dead_code)] // Direct integration-harness inclusion only needs the read barrier.
#[path = "test_env_lock.rs"]
pub(crate) mod test_env_lock;
// ---------------------------------------------------------------------------
// Shell kind
// ---------------------------------------------------------------------------
@@ -489,7 +484,9 @@ impl ShellDispatcher {
// wedges the whole test binary with no libtest timeout to end it.
// `$SHELL` is the only variable read, and the two tests that set it
// set it to a fixed value, so an unsynchronized read is safe.
test_env_lock::with_test_env_lock_if_uncontended(Self::detect_shell_unlocked)
codewhale_runtime::test_support::with_test_env_lock_if_uncontended(
Self::detect_shell_unlocked,
)
}
#[cfg(not(test))]
{
+6 -540
View File
@@ -1,116 +1,13 @@
//! Shared test-only helpers.
use std::ffi::{OsStr, OsString};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(unix)]
use std::sync::OnceLock;
#[cfg(unix)]
use std::time::UNIX_EPOCH;
pub(crate) use crate::shell_dispatcher::test_env_lock::{
EnvScopeMembership, EnvScopeTicket, TestEnvLock, current_env_scope_generation,
current_thread_holds_test_env_lock, env_scope_ticket, join_env_scope, lock_test_env,
with_test_env_lock,
};
/// Process-wide state root for unit tests that do not intentionally provide an
/// explicit config/settings path.
///
/// The production fallback is the user's real home. That is useful at runtime
/// and unsafe in a parallel test binary: an unguarded save can otherwise read
/// or overwrite the developer's config. Tests that exercise path precedence
/// still hold [`lock_test_env`] and provide explicit temporary environment
/// values; every other test is confined here — enforced by
/// [`guarded_environment_provides_state_paths`], not assumed.
pub(crate) fn isolated_test_state_root() -> &'static Path {
static ROOT: OnceLock<PathBuf> = OnceLock::new();
ROOT.get_or_init(|| {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"codewhale-tui-test-state-{}-{nonce}",
std::process::id()
));
std::fs::create_dir_all(&root).unwrap_or_else(|error| {
panic!(
"failed to create isolated unit-test state root {}: {error}",
root.display()
)
});
// Match resolvers that canonicalize their root (macOS aliases /var to
// /private/var). Every fence must name the same physical test store.
root.canonicalize().expect("canonical test state root")
})
}
/// Where the calling test's state should live when it has not sealed the
/// environment itself.
///
/// Two different callers land here. A test that never took [`lock_test_env`]
/// gets the shared root, exactly as before — those tests already coexist there
/// under [`with_test_state_io_lock`]. A test that *holds* the lock but sealed
/// nothing gets a private directory instead: before #5359 it resolved the
/// developer's real home, so it has never shared the process root, and several
/// such tests run full settings transactions. Adding that traffic to the shared
/// root pushed the transaction lock past its deadline and hung unrelated
/// `config_command_*` tests. Keep them isolated from the developer *and* from
/// each other.
pub(crate) fn unsealed_test_state_root() -> PathBuf {
let shared = isolated_test_state_root();
if !current_thread_holds_test_env_lock() {
return shared.to_path_buf();
}
// libtest runs each test in a fresh thread. Keep one root for that thread:
// a settings save resolves its path more than once, while different tests
// must not inherit each other's files.
HOLDER_ROOT.with(|cached| {
cached
.get_or_init(|| {
let root = shared.join(format!("env-holder-{:?}", std::thread::current().id()));
std::fs::create_dir_all(&root).unwrap_or_else(|error| {
panic!(
"failed to create per-holder test state root {}: {error}",
root.display()
)
});
root
})
.clone()
})
}
thread_local! {
static HOLDER_ROOT: OnceLock<PathBuf> = const { OnceLock::new() };
}
/// Where a state resolver must put `name` instead of the user's home: the
/// isolated test root when no test has sealed the home (see [`home_is_sealed`]),
/// `None` when one has and the resolver should follow the environment as
/// production does.
///
/// Every resolver that reaches `~/.codewhale` and is reachable from a test
/// needs this fence, because incidental callers (an `App` constructor, a
/// command dispatch) never think about the home at all. Call it first, under
/// `#[cfg(test)]`, so the production path stays byte-for-byte unchanged.
///
/// The directory is under the shared isolated root, not the per-holder one
/// [`unsealed_test_state_root`] hands to lock holders: these resolvers are
/// reached from worker threads as well as the test thread, and a test must see
/// one directory from both. The directory exists when this returns, so callers
/// need no filesystem call of their own on the fenced branch.
pub(crate) fn unsealed_state_dir(name: impl AsRef<Path>) -> Option<PathBuf> {
if home_is_sealed() {
return None;
}
let dir = isolated_test_state_root().join(name);
std::fs::create_dir_all(&dir).unwrap_or_else(|error| {
panic!(
"failed to create the unsealed test state dir {}: {error}",
dir.display()
)
});
Some(dir)
}
pub(crate) use codewhale_runtime::test_support::*;
/// A fixture workspace an OS sandbox can still see (#6305).
///
@@ -175,364 +72,6 @@ pub(crate) fn future_test_jwt(label: &str) -> String {
format!("test.{payload}.{label}")
}
fn state_io_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Serialize read/merge/write operations against the process-wide isolated
/// test state root.
///
/// Path isolation protects the developer's files, but parallel tests still
/// share the same temporary files. Settings persistence is a multi-step
/// operation, so it needs this second barrier around the complete I/O
/// transaction rather than only around path resolution.
pub(crate) fn with_test_state_io_lock<T>(operation: impl FnOnce() -> T) -> T {
let _guard = match state_io_lock().lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
operation()
}
/// Build a test phase's future inside this call and box it (#6362).
///
/// In debug builds every inline `async {}` value gets a stack slot in the
/// enclosing poll frame the size of that future's whole state machine, and
/// the slots are never reused, so a body that awaits four phases inline
/// carries all four state machines on its own frame at once (measured at
/// 806 KiB for the runtime-store binding test). Constructing the phase here
/// leaves the caller holding a pointer, and the phase's own temporaries die
/// with its poll frame.
pub(crate) fn boxed_phase<'a, T, M, F>(
make: M,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>
where
M: FnOnce() -> F,
F: std::future::Future<Output = T> + 'a,
{
Box::pin(make())
}
/// Drive a test future on a thread with libtest's default 2 MiB stack,
/// whatever `RUST_MIN_STACK` says (#6362).
///
/// CI exports a 16 MiB `RUST_MIN_STACK` for every test thread, so a test
/// that only fits because of that export never learns it overflowed the
/// stack a contributor's plain `cargo test` gives it. The future is built on
/// the spawned thread (so it need not be `Send`) and pinned before
/// `block_on`, exactly as `#[tokio::test]` drives a current-thread runtime;
/// a panic inside propagates to the caller unchanged. An overflow still
/// aborts the process with "has overflowed its stack": that is the reported
/// symptom, not something this helper can turn into a panic.
pub(crate) fn block_on_default_test_stack<M, F, T>(make: M) -> T
where
M: FnOnce() -> F + Send + 'static,
F: std::future::Future<Output = T>,
T: Send + 'static,
{
const DEFAULT_TEST_THREAD_STACK: usize = 2 * 1024 * 1024;
std::thread::Builder::new()
.name("default-test-stack".into())
.stack_size(DEFAULT_TEST_THREAD_STACK)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread test runtime");
let future = make();
tokio::pin!(future);
runtime.block_on(future)
})
.expect("spawn the default-stack test thread")
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}
/// Restore one environment variable when dropped.
///
/// Callers that mutate process-global environment variables must hold
/// [`lock_test_env`] until after this guard is dropped.
///
/// Every live guard is also recorded in [`guarded_env_keys`], so path
/// resolution can distinguish a test that deliberately redirected `HOME`
/// from one that merely holds the lock to serialize unrelated env access —
/// see [`guarded_environment_provides_state_paths`].
pub(crate) struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
fn guarded_env_keys() -> &'static Mutex<std::collections::HashMap<&'static str, usize>> {
static KEYS: OnceLock<Mutex<std::collections::HashMap<&'static str, usize>>> = OnceLock::new();
KEYS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
fn register_guarded_env_key(key: &'static str) {
let mut keys = match guarded_env_keys().lock() {
Ok(keys) => keys,
Err(poisoned) => poisoned.into_inner(),
};
*keys.entry(key).or_insert(0) += 1;
}
fn unregister_guarded_env_key(key: &'static str) {
let mut keys = match guarded_env_keys().lock() {
Ok(keys) => keys,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(count) = keys.get_mut(key) {
*count -= 1;
if *count == 0 {
keys.remove(key);
}
}
}
/// Whether some live [`EnvVarGuard`] currently covers `key`.
pub(crate) fn env_var_currently_guarded(key: &str) -> bool {
match guarded_env_keys().lock() {
Ok(keys) => keys.contains_key(key),
Err(poisoned) => poisoned.into_inner().contains_key(key),
}
}
/// Whether the calling test actually provided the state-path environment it
/// is about to resolve.
///
/// Holding [`lock_test_env`] alone is not that: many tests hold the lock only
/// to serialize access to unrelated variables (`TERM_PROGRAM`, API keys) and
/// have provided no temporary paths at all. Trusting the lock routed those
/// tests to the developer's real `~/.codewhale` state, which is exactly the
/// leak the isolated root exists to prevent (#5359). A test earns environment
/// resolution by holding the lock *and* either setting one of the explicit
/// override variables or redirecting `HOME`/`USERPROFILE` through
/// [`EnvVarGuard`].
pub(crate) fn guarded_environment_provides_state_paths() -> bool {
if !current_thread_holds_test_env_lock() {
return false;
}
let guarded_path_is_present = |var: &str| {
env_var_currently_guarded(var)
&& std::env::var_os(var)
.is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
};
[
"CODEWHALE_HOME",
"CODEWHALE_CONFIG_PATH",
"DEEPSEEK_CONFIG_PATH",
"HOME",
"USERPROFILE",
]
.iter()
.any(|var| guarded_path_is_present(var))
}
/// Whether the calling test's live seal pins the user's *home* — the root that
/// user state (sessions, snapshots, plugin bundles, logs) resolves under — as
/// opposed to merely redirecting a config file.
///
/// The owner and workers enrolled through [`join_env_scope`] follow the seal.
/// Unrelated parallel tests and workers without that scope use the isolated
/// root: following another test's environment would race its restoration to
/// the developer's real profile. Pass [`env_scope_ticket`] to workers that need
/// their owner's home.
///
/// Stricter than [`guarded_environment_provides_state_paths`], which also
/// accepts a guarded config-path override: such a test still resolves
/// `~/.codewhale` for everything else. `CODEWHALE_HOME` outranks `HOME` when it
/// is set, so a guarded `HOME` seals nothing while an unguarded
/// `CODEWHALE_HOME` is in the ambient environment.
pub(crate) fn home_is_sealed() -> bool {
if !current_thread_holds_test_env_lock() {
return false;
}
let present = |var: &str| {
std::env::var_os(var)
.is_some_and(|value| value.to_str().is_none_or(|text| !text.trim().is_empty()))
};
if present("CODEWHALE_HOME") {
return env_var_currently_guarded("CODEWHALE_HOME");
}
["HOME", "USERPROFILE"]
.iter()
.any(|var| env_var_currently_guarded(var) && present(var))
}
impl EnvVarGuard {
pub(crate) fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
debug_assert!(
current_thread_holds_test_env_lock(),
"EnvVarGuard::set({key}) requires lock_test_env()"
);
let previous = std::env::var_os(key);
// SAFETY: callers hold the process-wide test env mutex.
unsafe { std::env::set_var(key, value) };
register_guarded_env_key(key);
Self { key, previous }
}
pub(crate) fn remove(key: &'static str) -> Self {
debug_assert!(
current_thread_holds_test_env_lock(),
"EnvVarGuard::remove({key}) requires lock_test_env()"
);
let previous = std::env::var_os(key);
// SAFETY: callers hold the process-wide test env mutex.
unsafe { std::env::remove_var(key) };
register_guarded_env_key(key);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
// Withdraw the claim *before* restoring the value: a reader on another
// thread that still saw the key as guarded would otherwise follow the
// restored — ambient, possibly real — path for the instant in between.
unregister_guarded_env_key(self.key);
// SAFETY: callers hold the process-wide test env mutex until after this
// guard is dropped.
unsafe {
if let Some(value) = self.previous.take() {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
/// Seal the user's home onto a directory the test owns, for the life of one
/// test.
///
/// This is the one way a test takes the user's state out of play. It pins
/// `HOME`, `USERPROFILE` and `CODEWHALE_HOME` through [`EnvVarGuard`] — so
/// [`guarded_environment_provides_state_paths`] recognises the seal — and
/// removes the aliases that would otherwise route around them
/// (`HOMEDRIVE`/`HOMEPATH`, `CODEWHALE_CONFIG_PATH`, `DEEPSEEK_CONFIG_PATH`,
/// `DEEPSEEK_HOME`). Pinning `HOME` alone is not a seal: an ambient
/// `CODEWHALE_HOME` takes precedence over it, so a test that "scoped" only
/// `HOME` still wrote the developer's real profile.
///
/// It holds the process-wide environment lock, which is not reentrant: keep
/// one seal per test, and build any fixture that takes the lock itself
/// (settings, diagnostics harnesses) after it. Anything that reads or writes
/// the user's state — instructions, skills, sessions, snapshots, settings,
/// credentials — then lands in the seal.
pub(crate) struct SealedHome {
// Drop order matters: restore the environment, then delete the directory,
// then release the lock.
_vars: Vec<EnvVarGuard>,
_dir: Option<tempfile::TempDir>,
home: PathBuf,
codewhale_home: PathBuf,
_lock: TestEnvLock,
}
impl SealedHome {
/// Seal onto fresh, empty directories: `<tmp>/home` and
/// `<tmp>/codewhale-home`.
pub(crate) fn new() -> Self {
let lock = lock_test_env();
let dir = tempfile::TempDir::new().expect("sealed home tempdir");
let home = dir.path().join("home");
let codewhale_home = dir.path().join("codewhale-home");
std::fs::create_dir_all(&home).expect("sealed home dir");
std::fs::create_dir_all(&codewhale_home).expect("sealed codewhale home dir");
Self::pin(lock, Some(dir), home, codewhale_home)
}
/// Seal onto a home the test already owns: `HOME` is `home` and the
/// Codewhale home is `home/.codewhale`, the layout an unset
/// `CODEWHALE_HOME` resolves to. For tests whose fixtures are built under
/// one tempdir that also plays the home.
pub(crate) fn at(home: &Path) -> Self {
let lock = lock_test_env();
let codewhale_home = home.join(".codewhale");
Self::pin(lock, None, home.to_path_buf(), codewhale_home)
}
fn pin(
lock: TestEnvLock,
dir: Option<tempfile::TempDir>,
home: PathBuf,
codewhale_home: PathBuf,
) -> Self {
// `CODEWHALE_HOME` first: it is dropped first, so the seal never
// outlives the override that outranks `HOME`.
let vars = vec![
EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home),
EnvVarGuard::set("HOME", &home),
EnvVarGuard::set("USERPROFILE", &home),
EnvVarGuard::remove("HOMEDRIVE"),
EnvVarGuard::remove("HOMEPATH"),
EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
EnvVarGuard::remove("DEEPSEEK_HOME"),
];
Self {
_vars: vars,
_dir: dir,
home,
codewhale_home,
_lock: lock,
}
}
/// The sealed `HOME` / `USERPROFILE`.
pub(crate) fn home(&self) -> &Path {
&self.home
}
/// The sealed `CODEWHALE_HOME`.
pub(crate) fn codewhale_home(&self) -> &Path {
&self.codewhale_home
}
}
/// Find the byte position of the first divergence between two strings,
/// returning a windowed view (`±32 bytes` around the divergence) so failures
/// in cache-prefix-stability tests show *which* bytes drifted, not just that
/// they did. Returns `None` when the strings are byte-identical.
pub(crate) fn first_divergence(a: &str, b: &str) -> Option<(usize, String, String)> {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let max = a_bytes.len().min(b_bytes.len());
for i in 0..max {
if a_bytes[i] != b_bytes[i] {
let lo = i.saturating_sub(32);
let a_hi = (i + 32).min(a_bytes.len());
let b_hi = (i + 32).min(b_bytes.len());
let a_ctx = String::from_utf8_lossy(&a_bytes[lo..a_hi]).into_owned();
let b_ctx = String::from_utf8_lossy(&b_bytes[lo..b_hi]).into_owned();
return Some((i, a_ctx, b_ctx));
}
}
if a_bytes.len() != b_bytes.len() {
return Some((
max,
format!("(len={})", a_bytes.len()),
format!("(len={})", b_bytes.len()),
));
}
None
}
/// Assert two strings are byte-identical, panicking with a windowed diff
/// around the first divergence when they aren't. Used by the prefix-cache
/// stability harness (#263, #280) to pin construction surfaces that land in
/// DeepSeek's KV cache prefix.
#[track_caller]
pub(crate) fn assert_byte_identical(label: &str, a: &str, b: &str) {
if let Some((pos, a_ctx, b_ctx)) = first_divergence(a, b) {
panic!(
"{label}: prompt construction is non-deterministic — first diff at byte {pos}\n\
── side A (±32B) ──\n{a_ctx:?}\n── side B (±32B) ──\n{b_ctx:?}",
);
}
}
// ── Shared App/TuiOptions fixtures (#3923) ──────────────────────────────
//
// Before this module owned them, `create_test_app` was copy-pasted across 28
@@ -555,26 +94,6 @@ pub(crate) fn trust_workspace(workspace: &Path) {
crate::config::save_workspace_trust(workspace).expect("trust test workspace");
}
pub(crate) fn read_shell_sentinel(path: &Path) -> String {
let bytes = std::fs::read(path).unwrap_or_default();
let text = if bytes.starts_with(&[0xFF, 0xFE]) {
let units: Vec<u16> = bytes[2..]
.as_chunks::<2>()
.0
.iter()
.map(|pair| u16::from_le_bytes(*pair))
.collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(&bytes).into_owned()
};
text.trim_start_matches('\u{feff}')
.replace("\r\n", "\n")
.replace('"', "")
.trim()
.to_string()
}
pub(crate) fn test_tui_options(workspace: impl AsRef<Path>) -> crate::tui::app::TuiOptions {
let workspace = workspace.as_ref().to_path_buf();
crate::tui::app::TuiOptions {
@@ -935,59 +454,6 @@ mod tests {
);
}
#[test]
fn ambient_codewhale_home_is_not_a_test_seal() {
let _lock = lock_test_env();
let _ambient = EnvVarGuard::set("CODEWHALE_HOME", "/tmp/ambient-codewhale-home");
unregister_guarded_env_key("CODEWHALE_HOME");
let sealed = guarded_environment_provides_state_paths();
register_guarded_env_key("CODEWHALE_HOME");
assert!(!sealed, "ambient developer state must remain confined");
}
#[test]
fn a_config_path_guard_or_a_home_shadowed_by_the_ambient_override_is_not_a_home_seal() {
let _lock = lock_test_env();
let dir = tempfile::tempdir().expect("tempdir");
{
// Redirecting the config file leaves `~/.codewhale` in play.
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", dir.path().join("config.toml"));
let _no_home_override = EnvVarGuard::remove("CODEWHALE_HOME");
assert!(guarded_environment_provides_state_paths());
assert!(!home_is_sealed());
}
{
// `CODEWHALE_HOME` outranks `HOME`: guarding only `HOME` while an
// ambient `CODEWHALE_HOME` is set seals nothing.
let _ambient = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("ambient"));
unregister_guarded_env_key("CODEWHALE_HOME");
let _home = EnvVarGuard::set("HOME", dir.path());
let sealed = home_is_sealed();
register_guarded_env_key("CODEWHALE_HOME");
assert!(!sealed, "an ambient CODEWHALE_HOME shadows a guarded HOME");
}
{
let _no_home_override = EnvVarGuard::remove("CODEWHALE_HOME");
let _home = EnvVarGuard::set("HOME", dir.path());
assert!(home_is_sealed());
}
}
#[test]
fn removing_overrides_does_not_seal_the_ambient_home() {
let _lock = lock_test_env();
let _codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
let _codewhale_config = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
assert!(
!guarded_environment_provides_state_paths(),
"removing an override must not expose the developer's HOME"
);
}
#[test]
fn removing_home_variables_does_not_seal_a_missing_path() {
let _lock = lock_test_env();
+8 -42
View File
@@ -2,6 +2,8 @@ use super::*;
use crate::tools::spec::ToolContext;
use serde_json::{Value, json};
#[cfg(unix)]
use std::sync::OnceLock;
use tempfile::tempdir;
#[cfg(windows)]
@@ -9,16 +11,6 @@ use windows::Win32::Foundation::{DUPLICATE_HANDLE_OPTIONS, DuplicateHandle, HAND
#[cfg(windows)]
use windows::Win32::System::Threading::GetCurrentProcess;
// `env_lock` serializes tests that mutate the process environment.
#[cfg(any(unix, windows))]
use std::sync::{Mutex, OnceLock};
#[cfg(any(unix, windows))]
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;
#[test]
@@ -2368,11 +2360,9 @@ fn completion_evidence_preserves_arbitrary_stream_bytes() {
#[test]
#[cfg(unix)]
fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() {
let _guard = env_lock().lock().expect("env lock");
let previous = std::env::var_os("DEEPSEEK_CHILD_ENV_SHELL_SECRET");
unsafe {
std::env::set_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET", "parent-secret");
}
let _lock = crate::test_support::lock_test_env();
let _secret =
crate::test_support::EnvVarGuard::set("DEEPSEEK_CHILD_ENV_SHELL_SECRET", "parent-secret");
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
@@ -2395,15 +2385,6 @@ fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() {
)
.expect("execute");
match previous {
Some(value) => unsafe {
std::env::set_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET", value);
},
None => unsafe {
std::env::remove_var("DEEPSEEK_CHILD_ENV_SHELL_SECRET");
},
}
assert_eq!(result.status, ShellStatus::Completed);
assert_eq!(result.stdout, "unset\nexplicit-value\n");
}
@@ -2411,13 +2392,9 @@ fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() {
#[test]
#[cfg(windows)]
fn shell_execution_preserves_custom_windows_sdk_root_env() {
let _guard = env_lock().lock().expect("env lock");
let previous_sdk = std::env::var_os("BIMRV_SDK_ROOT");
let previous_secret = std::env::var_os("MY_SECRET_ROOT");
unsafe {
std::env::set_var("BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
std::env::set_var("MY_SECRET_ROOT", r"F:\Secrets");
}
let _lock = crate::test_support::lock_test_env();
let _sdk = crate::test_support::EnvVarGuard::set("BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
let _secret = crate::test_support::EnvVarGuard::set("MY_SECRET_ROOT", r"F:\Secrets");
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
@@ -2434,17 +2411,6 @@ fn shell_execution_preserves_custom_windows_sdk_root_env() {
let result = execute_shell(&mut manager, &command, None, 5000, false).expect("execute");
unsafe {
match previous_sdk {
Some(value) => std::env::set_var("BIMRV_SDK_ROOT", value),
None => std::env::remove_var("BIMRV_SDK_ROOT"),
}
match previous_secret {
Some(value) => std::env::set_var("MY_SECRET_ROOT", value),
None => std::env::remove_var("MY_SECRET_ROOT"),
}
}
assert_eq!(result.status, ShellStatus::Completed);
assert!(
result.stdout.contains(r"F:\Lib\BimRv27.5"),
+2 -2
View File
@@ -112,10 +112,10 @@ impl ToolSpec for TuiHelpTool {
.ok_or_else(|| ToolError::execution_failed("command catalog is not installed"))?;
let workspace = context.workspace.clone();
#[cfg(test)]
let env_ticket = crate::shell_dispatcher::test_env_lock::env_scope_ticket();
let env_ticket = crate::test_support::env_scope_ticket();
let help = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::shell_dispatcher::test_env_lock::join_env_scope(env_ticket);
let _membership = crate::test_support::join_env_scope(env_ticket);
build_help(catalog.as_ref(), ui_locale(), &workspace, query.as_deref())
})
.await
+1 -1
View File
@@ -233,7 +233,7 @@ Record at every release, on one machine, at a named source commit with a dirty-f
- **0.10.5**: A/B decision: RS-14 atomic move vs slice-only path (record in 0.10.5). Decide whether 0.11.0 is the atomic move of the remaining runtime SCC (Option A) or the design doc's slice-only path (Option B). Evidence: with A, X1/X4/X7/X8/P1/P2/settings are not prerequisites; only A moves tui below 9.0 MiB (proxy UI 2.78 MiB / runtime 6.89 MiB). Needs prod/test/late/uilib all 0 first. Linear only, not mirrored. Reuse the #6941 issue if it covers this.
- **0.10.2**: 0.10.2: ship candidate with no structural move; record per-crate tarball table and ratatui 0.1.2 receipt. No move in the candidate. Add real per-crate tarball sizes to the release notes (baseline for later releases) and the codewhale-ratatui 0.1.2 publication receipt (out of band, not in crates.sh). Gate: publish-crates.sh dry-run, validate-crate-publish-order.py, hosted CI green on the SHA.
- **0.10.2 (founder, 2026-10-12)**: X2 finishes RS-13 (#6940) through the shared command_catalog leaf; every direct consumer is migrated. Tests capture a catalog instead of mutating a global authority. Ratchet production 11 -> 0, tests 32 -> 30; RS-11 lowers the remaining test edges through the existing runtime test-support feature. Linear remains the claim owner.
- **0.10.4**: 0.10.4 W0-S2: codewhale-test-support (publish=false), env half. Move test_env_lock.rs (300) and the env half of test_support.rs (657 non-test lines) to crates/test/test-support; version.workspace=true; add to root members; keep tui shim and App fixtures; remove shell_dispatcher cfg(test) mount and the tests/integration #[path] include. Critical path for settings and credentials tests. Gate: equal before/after pass counts, one process-global lock copy, crates.sh stays 29.
- **0.10.2 RS-11 environment helpers**: supersedes the former 0.10.4 test-support crate proposal. The UI-free helpers and process environment barrier move into codewhale-runtime's existing test-support feature; the TUI shim retains its resolver fences and App fixtures. One barrier serves both TUI tests and the directly included shell harness. No new crate or workspace member.
- **0.10.5**: 0.10.5 Test-edge rehoming: test 32 -> 0, late 1 -> 0, uilib 1 -> 0. Rehome tests naming crate::tui, commands or lib.rs (tools|tui 12, client|tui 3, session_manager|tui 3, config|tui 2, core|tui 2, prompts|tui 1), plus test_cases_04.rs:1197 (exec_agent) and roster_routes.rs:798 (crossterm). Test-only; equal pass counts before and after per directory. Prerequisite for any new runtime crate that must not depend on tui or crossterm.
- **0.10.6**: 0.10.6 X1 [Track B]: fslock into crates/runtime. Move try_lock_file_exclusive plus RuntimeProcessOwnerLock::try_lock_exclusive/is_contention (runtime_threads.rs:6592, 6855) to crates/runtime. Seam-introducing, not pure rename. Blocked until runtime_threads.rs and its tests are committed. Visibility review in the commit message; revert immediately on any lock regression.
- **0.10.7**: 0.10.7 X7 [Track B]: tool rules into execpolicy. Reconcile the moved set (design table: tool_denied, tool_matches_any_rule, requires_raw_shell, policy_tool_aliases; AutoReviewPolicy lives in core/authority/auto_review) then move to crates/execpolicy with re-exports. Approval trust boundary: no matching or visibility change. Blocked by dirty core/authority.rs.
+1 -1
View File
@@ -382,7 +382,7 @@ part of the slice.
| **X8 agent-contract** | `crates/agent-contract/` (new), `fleet/role.rs`, `fleet/worker_profile.rs`, `tools/subagent/mod.rs` | M | **Corrected:** the inherent `impl FleetRole` at `tools/subagent/mod.rs:639` becomes illegal after the move; convert to free functions first; resolve the two different `ChildAuthority` types | X7 | removes `tools -> fleet` (62 refs) |
| **X9 sandbox pilot** | `crates/sandbox/` (new), `tui/src/sandbox/` | S then M | **Corrected:** do the two edge cuts first (`sandbox -> config` 1, `sandbox -> oauth` 1); `shell_dispatcher` cannot move yet (`tests/integration/main.rs` includes it by `#[path]`, `test_env_lock` is `cfg(test)`) | the cuts only | second feature leaf after `tools-web` |
| **W0-S1 runtime residents** | `crates/runtime/` ← `features`, `logging`, `startup_trace`, `resource_telemetry`, `runtime_policy`, `lane_control` | S, landed 2026-10-10 (8aea0265c) | **Landed.** `python3 scripts/split/move-modules.py` dry run is read-only and safe | none | six fewer tui modules; no `test_support` needed |
| **W0-S2.. test-support and the rest of wave 0** | the existing `codewhale-runtime` `test-support` feature, then remaining leaves | M each | **Planned:** one process environment lock in the Runtime test-support owner; no new test-support crate | W0-S1 | replaces the test-support half of RS-11; remaining residents follow RS-10 |
| **W0-S2.. test-support and the rest of wave 0** | the existing `codewhale-runtime` `test-support` feature, then remaining leaves | M each | **Environment helpers moved in 0.10.2:** one process environment lock in the Runtime test-support owner; no new test-support crate | W0-S1 | the TUI shim retains its resolver fences and App fixtures; remaining residents follow RS-10 |
| **B billing** | `crates/billing/` | L | **Not confirmed.** Five production callers remain, it must not depend on the runtime tier, and the engine files it touches are in flight | a host port for the usage store | deferred |
| **S0 closure report** | `scripts/split/closure_report.py` | S (report), M (sandbox generator) | Held as a tool; deletes no edges | decide where sandbox third-party sources come from | the numbers the sandbox contract needs |
+6 -3
View File
@@ -264,7 +264,7 @@ def _rust_path_literal(text: str) -> tuple[str, int] | None:
def _test_module_edges(path: Path, text: str, *, crate_root: bool = False) -> list[tuple[Path, bool]]:
"""Literal item edges and their actual lexical cfg(test) module scope."""
"""Literal item edges and their test-only module scope."""
code = strip_comments_and_strings(text, module_literals=True)
closes: dict[int, int] = {}
braces: list[int] = []
@@ -280,8 +280,11 @@ def _test_module_edges(path: Path, text: str, *, crate_root: bool = False) -> li
))
inline = [entry for entry in modules if entry.group("end") == "{" and entry.end() - 1 in closes]
def test_attribute(entry: re.Match[str]) -> bool:
return any(re.fullmatch(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]", attr.group())
for attr in re.finditer(r"#\s*\[[^\]]*\]", entry.group("attrs")))
attrs = text[entry.start("attrs"):entry.end("attrs")]
return any(re.fullmatch(
r'#\s*\[\s*cfg\s*\(\s*(?:test|any\s*\(\s*test\s*,\s*feature\s*=\s*"test-support"\s*\))\s*\)\s*\]',
attr.group(),
) for attr in re.finditer(r"#\s*\[[^\]]*\]", attrs))
def ancestors(position: int) -> list[re.Match[str]]:
return [entry for entry in inline if entry.end() <= position < closes[entry.end() - 1]]
def under_test(position: int) -> bool:
+3 -3
View File
@@ -2,8 +2,8 @@
"_comment": "Ceiling for dead-code suppressions across crates/, counting both `#[allow(dead_code)]` and `#[expect(dead_code)]`. `total` is the sum and is the ratcheted figure. It may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update",
"_issue": "https://github.com/codewhale-hq/CodeWhale/issues/4785",
"_expect_blind_spot": "Until #6241 this gate counted only the `allow` spelling, so rewriting an allow as an expect lowered the number without removing any dead code. The ceiling was re-based to the true combined count when that was fixed; it is not a regression.",
"total": 221,
"allow_total": 140,
"total": 220,
"allow_total": 139,
"expect_total": 81,
"per_crate": {
"cli": {
@@ -35,7 +35,7 @@
"expect": 0
},
"tui": {
"allow": 116,
"allow": 115,
"expect": 78
}
}