26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
256 lines
9.5 KiB
Rust
256 lines
9.5 KiB
Rust
//! Tests verifying that `ctx_read` never hangs indefinitely due to session lock contention.
|
|
//! Covers the Windows deadlock scenario: shell-hook holds session write-lock while
|
|
//! `ctx_read` waits on session read-lock, causing circular dependency.
|
|
|
|
use lean_ctx::core::pathutil::safe_canonicalize_bounded;
|
|
use std::path::Path;
|
|
use std::time::{Duration, Instant};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 1. safe_canonicalize_bounded — timeout behavior
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn canonicalize_existing_path_succeeds_quickly() {
|
|
let start = Instant::now();
|
|
let result = safe_canonicalize_bounded(Path::new("/tmp"), 2000);
|
|
let elapsed = start.elapsed();
|
|
|
|
assert!(elapsed < Duration::from_secs(1));
|
|
// On macOS /tmp -> /private/tmp
|
|
assert!(
|
|
result.to_str().unwrap().contains("tmp"),
|
|
"should resolve to a path containing 'tmp', got: {}",
|
|
result.display()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_nonexistent_path_returns_original() {
|
|
let fake = Path::new("/nonexistent/path/that/does/not/exist/xyzzy123");
|
|
let start = Instant::now();
|
|
let result = safe_canonicalize_bounded(fake, 2000);
|
|
let elapsed = start.elapsed();
|
|
|
|
assert!(elapsed < Duration::from_secs(1));
|
|
assert_eq!(result, fake.to_path_buf());
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_bounded_respects_timeout() {
|
|
// We can't easily simulate a hanging canonicalize on non-Windows,
|
|
// but we verify the function doesn't panic and completes quickly
|
|
// for various path types
|
|
let paths = &[
|
|
"/tmp",
|
|
"/var",
|
|
"/usr/bin",
|
|
"/this/does/not/exist",
|
|
"relative/path/here",
|
|
];
|
|
|
|
for p in paths {
|
|
let start = Instant::now();
|
|
let _ = safe_canonicalize_bounded(Path::new(p), 500);
|
|
assert!(
|
|
start.elapsed() < Duration::from_millis(600),
|
|
"canonicalize took too long for {p}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 2. Session lock timeout — simulated contention
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
mod session_lock_timeout {
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::RwLock;
|
|
|
|
#[test]
|
|
fn read_lock_succeeds_when_uncontested() {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let lock = Arc::new(RwLock::new(42u32));
|
|
|
|
rt.block_on(async {
|
|
let result = tokio::time::timeout(Duration::from_secs(5), lock.read()).await;
|
|
assert!(result.is_ok());
|
|
assert_eq!(*result.unwrap(), 42);
|
|
});
|
|
}
|
|
|
|
/// Upper bound for "the timeout fired promptly, we did not deadlock".
|
|
/// Generous on purpose: loaded CI runners (Windows especially) can stall a
|
|
/// timer by hundreds of ms, and the assertion only needs to distinguish
|
|
/// "timed out" from "waited for the writer / hung forever".
|
|
const PROMPT: Duration = Duration::from_secs(5);
|
|
|
|
#[test]
|
|
fn read_lock_times_out_when_write_held() {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let lock = Arc::new(RwLock::new(42u32));
|
|
let lock2 = lock.clone();
|
|
|
|
rt.block_on(async {
|
|
// Write lock held for the whole test — a read can never succeed.
|
|
let _guard = lock2.write().await;
|
|
|
|
let start = Instant::now();
|
|
let result = tokio::time::timeout(Duration::from_millis(200), lock.read()).await;
|
|
|
|
assert!(result.is_err(), "should have timed out");
|
|
assert!(start.elapsed() < PROMPT, "timeout did not fire promptly");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn write_lock_times_out_when_read_held() {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let lock = Arc::new(RwLock::new(42u32));
|
|
let lock2 = lock.clone();
|
|
|
|
rt.block_on(async {
|
|
let _guard = lock2.read().await;
|
|
|
|
let start = Instant::now();
|
|
let result = tokio::time::timeout(Duration::from_millis(200), lock.write()).await;
|
|
|
|
assert!(result.is_err(), "should have timed out");
|
|
assert!(start.elapsed() < PROMPT, "timeout did not fire promptly");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn block_in_place_timeout_pattern_works() {
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let lock = Arc::new(RwLock::new(String::from("hello")));
|
|
let lock2 = lock.clone();
|
|
|
|
rt.block_on(async {
|
|
// Simulate a held write lock (like shell-hook holding session)
|
|
let _guard = lock2.write().await;
|
|
|
|
// This is the exact pattern used in ctx_read.rs
|
|
let result = tokio::task::block_in_place(|| {
|
|
let rt = tokio::runtime::Handle::current();
|
|
rt.block_on(tokio::time::timeout(
|
|
Duration::from_millis(300),
|
|
lock.read(),
|
|
))
|
|
});
|
|
|
|
assert!(result.is_err(), "should timeout, not deadlock");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_readers_succeed_concurrently() {
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let lock = Arc::new(RwLock::new(String::from("data")));
|
|
|
|
rt.block_on(async {
|
|
let mut handles = Vec::new();
|
|
for _ in 0..10 {
|
|
let l = lock.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let result = tokio::time::timeout(Duration::from_secs(1), l.read()).await;
|
|
assert!(result.is_ok());
|
|
}));
|
|
}
|
|
for h in handles {
|
|
h.await.unwrap();
|
|
}
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn graceful_degradation_on_write_timeout() {
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let lock = Arc::new(RwLock::new(String::from("session_data")));
|
|
let lock2 = lock.clone();
|
|
|
|
rt.block_on(async {
|
|
let _guard = lock2.read().await;
|
|
|
|
// Simulate the post-read session update (write lock)
|
|
let start = Instant::now();
|
|
let session_guard = tokio::task::block_in_place(|| {
|
|
let rt = tokio::runtime::Handle::current();
|
|
rt.block_on(tokio::time::timeout(
|
|
Duration::from_millis(200),
|
|
lock.write(),
|
|
))
|
|
});
|
|
|
|
// Should timeout gracefully, not hang
|
|
assert!(session_guard.is_err());
|
|
assert!(start.elapsed() < PROMPT, "timeout did not fire promptly");
|
|
// In production, we'd fall back to project_root from ctx
|
|
});
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 3. Integration: read_file_lossy doesn't hang on nonexistent/weird paths
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
mod read_file_safety {
|
|
use lean_ctx::tools::ctx_read::read_file_lossy;
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[test]
|
|
fn nonexistent_file_errors_quickly() {
|
|
let start = Instant::now();
|
|
let result = read_file_lossy("/this/path/does/not/exist/file.rs");
|
|
assert!(result.is_err());
|
|
assert!(
|
|
start.elapsed() < Duration::from_secs(3),
|
|
"should fail fast, not hang"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn existing_file_reads_within_timeout() {
|
|
let start = Instant::now();
|
|
let result = read_file_lossy("/tmp/.lean-ctx-test-read");
|
|
// May error (file doesn't exist) or succeed, but must not hang
|
|
let _ = result;
|
|
assert!(
|
|
start.elapsed() < Duration::from_secs(3),
|
|
"should complete within timeout"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deeply_nested_nonexistent_path_errors_quickly() {
|
|
let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/file.txt";
|
|
let start = Instant::now();
|
|
let result = read_file_lossy(deep);
|
|
assert!(result.is_err());
|
|
assert!(start.elapsed() < Duration::from_secs(3));
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_gemini_path_doesnt_hang() {
|
|
// Simulates the exact path pattern from the bug report
|
|
let path = r"C:\Users\USER\.gemini\some_config.json";
|
|
let start = Instant::now();
|
|
let _ = read_file_lossy(path);
|
|
assert!(
|
|
start.elapsed() < Duration::from_secs(5),
|
|
"Windows .gemini path must not hang"
|
|
);
|
|
}
|
|
}
|