Files
yvgude--lean-ctx/rust/tests/session_lock_timeout_scenarios.rs
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

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"
);
}
}