1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex};
3
4use rmcp::ErrorData;
5use rmcp::model::Tool;
6use serde_json::{Map, Value, json};
7
8use crate::server::tool_trait::{
9 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
10};
11use crate::tool_defs::tool_def;
12
13fn per_file_lock(path: &str) -> Arc<Mutex<()>> {
24 crate::core::path_locks::per_file_lock(path)
25}
26
27pub struct CtxReadTool;
28
29impl McpTool for CtxReadTool {
30 fn name(&self) -> &'static str {
31 "ctx_read"
32 }
33
34 fn tool_def(&self) -> Tool {
35 tool_def(
36 "ctx_read",
37 "Read a file. Prefer over native Read/cat/head/tail (cached, compressed).\n\
38 Omit mode to auto-select (recommended); use full only right before editing. \
39 Re-reads ~13 tokens. fresh=true forces a disk re-read.",
40 json!({
41 "type": "object",
42 "properties": {
43 "path": { "type": "string", "description": "Absolute file path" },
44 "mode": {
45 "type": "string",
46 "default": "auto",
47 "description": "Omit to auto-select (recommended). auto(default)|full (for editing)|raw|map (large files)|signatures|diff|task|reference|aggressive|entropy|lines:N-M|density:0.X"
48 },
49 "start_line": { "type": "integer", "description": "First line, 1-based (alias: offset)" },
50 "offset": { "type": "integer", "description": "Alias for start_line" },
51 "limit": { "type": "integer", "description": "Max lines to read" },
52 "fresh": { "type": "boolean", "description": "Bypass cache, force disk re-read" }
53 },
54 "required": ["path"]
55 }),
56 )
57 }
58
59 fn handle(
60 &self,
61 args: &Map<String, Value>,
62 ctx: &ToolContext,
63 ) -> Result<ToolOutput, ErrorData> {
64 let path = require_resolved_path(ctx, args, "path")?;
65
66 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
67 self.handle_inner(args, ctx, &path)
68 })) {
69 Ok(result) => result,
70 Err(_) => Err(ErrorData::internal_error(
71 format!(
72 "ctx_read panicked while processing '{path}'. This is a bug — please report it."
73 ),
74 None,
75 )),
76 }
77 }
78}
79
80impl CtxReadTool {
81 #[allow(clippy::unused_self)]
82 fn handle_inner(
83 &self,
84 args: &Map<String, Value>,
85 ctx: &ToolContext,
86 path: &str,
87 ) -> Result<ToolOutput, ErrorData> {
88 let session_lock = ctx
89 .session
90 .as_ref()
91 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
92 let cache_lock = ctx
93 .cache
94 .as_ref()
95 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
96
97 let current_task = {
98 let rt = tokio::runtime::Handle::current();
99 let mut attempt = 0u32;
100 loop {
101 if let Ok(session) = rt.block_on(tokio::time::timeout(
102 std::time::Duration::from_secs(5),
103 session_lock.read(),
104 )) {
105 break session.task.as_ref().map(|t| t.description.clone());
106 }
107 attempt += 1;
108 if attempt >= 3 {
109 tracing::warn!(
110 "session read-lock timeout after {attempt} attempts in ctx_read for {path}"
111 );
112 return Err(ErrorData::internal_error(
113 "session lock timeout — another tool may be holding it. Retry in a moment.",
114 None,
115 ));
116 }
117 tracing::debug!(
118 "session read-lock attempt {attempt}/3 timed out for {path}, retrying"
119 );
120 std::thread::sleep(std::time::Duration::from_millis(100 * u64::from(attempt)));
121 }
122 };
123 let task_ref = current_task.as_deref();
124
125 let profile = crate::core::profiles::active_profile();
126 let explicit_mode_arg = get_str(args, "mode");
127 let explicit_mode = explicit_mode_arg.is_some();
128 let mut mode = if let Some(m) = explicit_mode_arg {
129 m
130 } else if profile.read.default_mode_effective() == "auto" {
131 if let Ok(cache) = cache_lock.try_read() {
132 crate::tools::ctx_smart_read::select_mode_with_task(&cache, path, task_ref)
133 } else {
134 tracing::debug!(
135 "cache lock contested during auto-mode selection for {path}; \
136 falling back to full"
137 );
138 "full".to_string()
139 }
140 } else {
141 profile.read.default_mode_effective().to_string()
142 };
143 let mut fresh = get_bool(args, "fresh").unwrap_or(false);
144 let cache_policy = crate::server::compaction_sync::effective_cache_policy();
145 if cache_policy == "off" {
146 fresh = true;
147 }
148 apply_line_window(
153 &mut mode,
154 &mut fresh,
155 explicit_mode,
156 get_int(args, "start_line"),
157 get_int(args, "offset"),
158 get_int(args, "limit"),
159 );
160
161 let pressure_action = ctx.pressure_snapshot.as_ref().map(|p| &p.recommendation);
162 let resolved_agent_id = ctx.agent_id.as_ref().and_then(|a| match a.try_read() {
163 Ok(guard) => guard.clone(),
164 Err(_) => None,
165 });
166 let gate_result = crate::server::context_gate::pre_dispatch_read_for_agent(
167 path,
168 &mode,
169 task_ref,
170 Some(&ctx.project_root),
171 pressure_action,
172 resolved_agent_id.as_deref(),
173 );
174 if gate_result.budget_blocked {
175 let msg = gate_result
176 .budget_warning
177 .unwrap_or_else(|| "Agent token budget exceeded".to_string());
178 return Err(ErrorData::invalid_params(msg, None));
179 }
180 let budget_warning = gate_result.budget_warning.clone();
181 if let Some(overridden) = gate_result.overridden_mode {
182 mode = overridden;
183 }
184
185 let (mode, degrade_warning) = if crate::tools::ctx_read::is_instruction_file(path) {
186 ("full".to_string(), None)
187 } else {
188 auto_degrade_read_mode(&mode)
189 };
190
191 if mode.starts_with("lines:") {
192 fresh = true;
193 }
194
195 if crate::core::binary_detect::is_binary_file(path) {
196 let msg = crate::core::binary_detect::binary_file_message(path);
197 return Err(ErrorData::invalid_params(msg, None));
198 }
199 {
200 let cap = crate::core::limits::max_read_bytes() as u64;
201 if let Ok(meta) = std::fs::metadata(path)
202 && meta.len() > cap
203 {
204 let msg = format!(
205 "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
206 Use mode=\"lines:1-100\" for partial reads or increase the limit.",
207 meta.len(),
208 cap
209 );
210 return Err(ErrorData::invalid_params(msg, None));
211 }
212 }
213
214 if !fresh
217 && let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir()
218 && let Ok(mut cache) = cache_lock.try_write()
219 {
220 crate::server::compaction_sync::sync_if_compacted(&mut cache, &data_dir);
221 }
222
223 let read_timeout = std::time::Duration::from_secs(30);
227 let cancelled = Arc::new(AtomicBool::new(false));
228 let (output, resolved_mode, original, is_cache_hit, file_ref, cache_stats) = {
229 let crp_mode = ctx.crp_mode;
230 let task_ref = current_task.as_deref();
231
232 let fast_result = 'fast: {
233 let file_lock = per_file_lock(path);
234 let Some(_file_guard) = file_lock.try_lock().ok() else {
235 break 'fast None;
236 };
237
238 if !fresh
243 && mode == "full"
244 && let Ok(cache) = cache_lock.try_read()
245 && let Some(read_output) =
246 crate::tools::ctx_read::try_stub_hit_readonly(&cache, path)
247 {
248 let content = read_output.content;
249 let rmode = read_output.resolved_mode;
250 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
251 let hit = content.contains(" cached ")
252 || content.contains("[unchanged")
253 || content.contains("[delta:");
254 let fref = cache.file_ref_map().get(path).cloned();
255 let stats = cache.get_stats();
256 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
257 break 'fast Some((content, rmode, orig, hit, fref, stats_snapshot));
258 }
259
260 let Some(mut cache) = cache_lock.try_write().ok() else {
263 break 'fast None;
264 };
265 let read_output = if fresh {
266 crate::tools::ctx_read::handle_fresh_with_task_resolved(
267 &mut cache, path, &mode, crp_mode, task_ref,
268 )
269 } else {
270 crate::tools::ctx_read::handle_with_task_resolved(
271 &mut cache, path, &mode, crp_mode, task_ref,
272 )
273 };
274 let content = read_output.content;
275 let rmode = read_output.resolved_mode;
276 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
277 let hit = content.contains(" cached ")
278 || content.contains("[unchanged")
279 || content.contains("[delta:");
280 let fref = cache.file_ref_map().get(path).cloned();
281 let stats = cache.get_stats();
282 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
283 Some((content, rmode, orig, hit, fref, stats_snapshot))
284 };
285
286 if let Some(result) = fast_result {
287 result
288 } else {
289 let cache_lock = cache_lock.clone();
291 let mode = mode.clone();
292 let task_owned = current_task.clone();
293 let path_owned = path.to_string();
294 let cancel_flag = cancelled.clone();
295 let (tx, rx) = std::sync::mpsc::sync_channel(1);
296 std::thread::spawn(move || {
297 let file_lock = per_file_lock(&path_owned);
298
299 let _file_guard = {
302 let deadline =
303 std::time::Instant::now() + std::time::Duration::from_secs(25);
304 loop {
305 if cancel_flag.load(Ordering::Relaxed) {
306 return;
307 }
308 if let Ok(guard) = file_lock.try_lock() {
309 break guard;
310 }
311 if std::time::Instant::now() >= deadline {
312 tracing::error!(
313 "ctx_read: per-file lock timeout after 25s for {path_owned}"
314 );
315 let _ = tx.send((
316 format!("per-file lock contention for {path_owned} — retry in a moment"),
317 "error".to_string(), 0, false, None, (0, 0),
318 ));
319 return;
320 }
321 std::thread::sleep(std::time::Duration::from_millis(50));
322 }
323 };
324
325 if cancel_flag.load(Ordering::Relaxed) {
326 return;
327 }
328
329 let mut cache = {
332 let deadline =
333 std::time::Instant::now() + std::time::Duration::from_secs(25);
334 loop {
335 if cancel_flag.load(Ordering::Relaxed) {
336 return;
337 }
338 if let Ok(guard) = cache_lock.try_write() {
339 break guard;
340 }
341 if std::time::Instant::now() >= deadline {
342 tracing::error!(
343 "ctx_read: cache write-lock timeout after 25s for {path_owned}"
344 );
345 let _ = tx.send((
346 format!(
347 "cache lock contention for {path_owned} — retry in a moment"
348 ),
349 "error".to_string(),
350 0,
351 false,
352 None,
353 (0, 0),
354 ));
355 return;
356 }
357 std::thread::sleep(std::time::Duration::from_millis(50));
358 }
359 };
360
361 let task_ref = task_owned.as_deref();
362 let read_output = if fresh {
363 crate::tools::ctx_read::handle_fresh_with_task_resolved(
364 &mut cache,
365 &path_owned,
366 &mode,
367 crp_mode,
368 task_ref,
369 )
370 } else {
371 crate::tools::ctx_read::handle_with_task_resolved(
372 &mut cache,
373 &path_owned,
374 &mode,
375 crp_mode,
376 task_ref,
377 )
378 };
379 let content = read_output.content;
380 let rmode = read_output.resolved_mode;
381 let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
382 let hit = content.contains(" cached ");
383 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
384 let stats = cache.get_stats();
385 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
386 let _ = tx.send((content, rmode, orig, hit, fref, stats_snapshot));
387 });
388 if let Ok(result) = rx.recv_timeout(read_timeout) {
389 result
390 } else {
391 cancelled.store(true, Ordering::Relaxed);
392 tracing::error!("ctx_read timed out after {read_timeout:?} for {path}");
393 let msg = format!(
394 "ERROR: ctx_read timed out after {}s reading {path}. \
395 The file may be very large or a blocking I/O issue occurred. \
396 Try mode=\"lines:1-100\" for a partial read.",
397 read_timeout.as_secs()
398 );
399 return Err(ErrorData::internal_error(msg, None));
400 }
401 } };
403
404 if resolved_mode == "error" {
406 return Err(ErrorData::invalid_params(output, None));
407 }
408
409 let output_tokens = crate::core::tokens::count_tokens(&output);
410 let saved = original.saturating_sub(output_tokens);
411
412 let mut ensured_root: Option<String> = None;
414 let mut traversal_working_set: Vec<String> = Vec::new();
415 let project_root_snapshot;
416 {
417 let rt = tokio::runtime::Handle::current();
418 let session_guard = rt.block_on(tokio::time::timeout(
419 std::time::Duration::from_secs(10),
420 session_lock.write(),
421 ));
422 if let Ok(mut session) = session_guard {
423 session.touch_file(path, file_ref.as_deref(), &resolved_mode, original);
424 traversal_working_set =
427 crate::core::tool_lifecycle::recent_working_set(&session, path);
428 let file_summary = extract_file_summary(&output, path);
430 if !file_summary.is_empty() {
431 session.set_file_summary(path, &file_summary);
432 }
433 if is_cache_hit {
434 session.record_cache_hit();
435 }
436 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
437 let touched: Vec<String> = session
438 .files_touched
439 .iter()
440 .map(|f| f.path.clone())
441 .collect();
442 let inferred =
443 crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
444 if inferred.confidence >= 0.4 {
445 session.active_structured_intent = Some(inferred);
446 }
447 }
448 if session.task.is_none() && session.stats.files_read % 5 == 0 {
450 session.auto_infer_task();
451 }
452 let root_missing = session
453 .project_root
454 .as_deref()
455 .is_none_or(|r| r.trim().is_empty());
456 if root_missing && let Some(root) = crate::core::protocol::detect_project_root(path)
457 {
458 session.project_root = Some(root.clone());
459 ensured_root = Some(root);
460 }
461 project_root_snapshot = session
462 .project_root
463 .clone()
464 .unwrap_or_else(|| ".".to_string());
465 } else {
466 tracing::warn!(
467 "session write-lock timeout (5s) in ctx_read post-update for {path}"
468 );
469 project_root_snapshot = ctx.project_root.clone();
470 }
471 }
472
473 if let Some(root) = ensured_root.as_deref() {
474 crate::core::index_orchestrator::ensure_all_background(root);
475 }
476
477 {
483 let path_bg = path.to_string();
484 let resolved_mode_bg = resolved_mode.clone();
485 let project_root_bg = project_root_snapshot.clone();
486 let (turns, hits) = cache_stats;
487 std::thread::spawn(move || {
488 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
491 crate::core::heatmap::record_file_access(&path_bg, original, saved);
492
493 if let Some(root) =
496 crate::core::tool_lifecycle::usable_root(Some(project_root_bg.as_str()))
497 {
498 crate::core::cooccurrence::record_focus_access(
499 root,
500 &path_bg,
501 &traversal_working_set,
502 );
503 }
504
505 let sig =
506 crate::core::mode_predictor::FileSignature::from_path(&path_bg, original);
507 let density = if output_tokens > 0 {
508 original as f64 / output_tokens as f64
509 } else {
510 1.0
511 };
512 let outcome = crate::core::mode_predictor::ModeOutcome {
513 mode: resolved_mode_bg,
514 tokens_in: original,
515 tokens_out: output_tokens,
516 density: density.min(1.0),
517 };
518 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
519 predictor.set_project_root(&project_root_bg);
520 predictor.record(sig, outcome);
521 predictor.save();
522
523 let ext = std::path::Path::new(&path_bg)
524 .extension()
525 .and_then(|e| e.to_str())
526 .unwrap_or("")
527 .to_string();
528 let thresholds =
529 crate::core::adaptive_thresholds::thresholds_for_path(&path_bg);
530 let feedback_outcome = crate::core::feedback::CompressionOutcome {
531 session_id: format!("{}", std::process::id()),
532 language: ext,
533 entropy_threshold: thresholds.bpe_entropy,
534 jaccard_threshold: thresholds.jaccard,
535 total_turns: turns as u32,
536 tokens_saved: saved as u64,
537 tokens_original: original as u64,
538 cache_hits: hits as u32,
539 total_reads: turns as u32,
540 task_completed: crate::core::bounce_tracker::global()
548 .lock()
549 .ok()
550 .and_then(|bt| bt.bounce_rate_for_extension(&path_bg))
551 .is_none_or(|rate| rate < 0.30),
552 timestamp: chrono::Local::now().to_rfc3339(),
553 };
554 let mut store = crate::core::feedback::FeedbackStore::load();
555 store.project_root = Some(project_root_bg);
556 store.record_outcome(feedback_outcome);
557 }));
558 });
559 }
560
561 if let Some(aid) = resolved_agent_id.as_deref() {
562 crate::core::agent_budget::record_consumption(aid, output_tokens);
563 }
564
565 let hints_suffix = {
569 if let Some(index) = crate::core::graph_index::ProjectIndex::load(&ctx.project_root) {
570 let hints = crate::core::cross_source_hints::hints_for_file(
571 path,
572 &index.edges,
573 &ctx.project_root,
574 );
575 if hints.is_empty() {
576 String::new()
577 } else {
578 crate::core::cross_source_hints::format_hints(&hints)
579 }
580 } else {
581 String::new()
582 }
583 };
584
585 let mut warnings = Vec::new();
586 if let Some(ref w) = budget_warning {
587 warnings.push(w.as_str());
588 }
589 if let Some(ref w) = degrade_warning {
590 warnings.push(w.as_str());
591 }
592 let final_output = if !warnings.is_empty() {
593 format!("{output}{hints_suffix}\n\n{}", warnings.join("\n"))
594 } else if hints_suffix.is_empty() {
595 output
596 } else {
597 format!("{output}{hints_suffix}")
598 };
599
600 Ok(ToolOutput {
601 text: final_output,
602 original_tokens: original,
603 saved_tokens: saved,
604 mode: Some(resolved_mode),
605 path: Some(path.to_string()),
606 changed: false,
607 shell_outcome: None,
608 })
609 }
610}
611
612fn resolve_line_window(
619 start_line: Option<i64>,
620 offset: Option<i64>,
621 limit: Option<i64>,
622) -> Option<(i64, Option<i64>)> {
623 let start = start_line.or(offset).map(|v| v.max(1));
624 let limit = limit.filter(|&l| l > 0);
625 match (start, limit) {
626 (Some(s), l) => Some((s, l)),
627 (None, Some(_)) => Some((1, limit)),
628 (None, None) => None,
629 }
630}
631
632fn lines_mode(start: i64, limit: Option<i64>) -> String {
635 match limit {
636 Some(l) => format!("lines:{start}-{}", start + l - 1),
637 None => format!("lines:{start}-999999"),
638 }
639}
640
641fn apply_line_window(
645 mode: &mut String,
646 fresh: &mut bool,
647 explicit_mode: bool,
648 start_line: Option<i64>,
649 offset: Option<i64>,
650 limit: Option<i64>,
651) {
652 let Some((start, limit)) = resolve_line_window(start_line, offset, limit) else {
653 return;
654 };
655 if start <= 1 && limit.is_none() {
656 return;
657 }
658 *fresh = true;
659 if !explicit_mode || mode.starts_with("lines") {
660 *mode = lines_mode(start, limit);
661 }
662}
663
664fn apply_verdict(
665 mode: &str,
666 verdict: crate::core::degradation_policy::DegradationVerdictV1,
667) -> (String, bool) {
668 use crate::core::degradation_policy::DegradationVerdictV1;
669 match verdict {
670 DegradationVerdictV1::Ok => (mode.to_string(), false),
671 DegradationVerdictV1::Warn => match mode {
672 "full" => ("map".to_string(), true),
673 other => (other.to_string(), false),
674 },
675 DegradationVerdictV1::Throttle => match mode {
676 "full" | "map" => ("signatures".to_string(), true),
677 other => (other.to_string(), false),
678 },
679 DegradationVerdictV1::Block => {
680 if mode == "signatures" {
681 ("signatures".to_string(), false)
682 } else {
683 ("signatures".to_string(), true)
684 }
685 }
686 }
687}
688
689fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
690 if crate::core::config::Config::load().no_degrade_effective() {
691 return (mode.to_string(), None);
692 }
693 let profile = crate::core::profiles::active_profile();
694 if !profile.degradation.enforce_effective() {
695 return (mode.to_string(), None);
696 }
697 let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
698 let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
699 let warning = if degraded {
700 Some(format!(
701 "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
702 (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
703 policy.decision.verdict
704 ))
705 } else {
706 None
707 };
708 (new_mode, warning)
709}
710
711fn extract_file_summary(output: &str, path: &str) -> String {
712 let hint = crate::core::auto_findings::extract_content_hint(output);
713 if !hint.is_empty() {
714 return hint;
715 }
716 let ext = std::path::Path::new(path)
717 .extension()
718 .and_then(|e| e.to_str())
719 .unwrap_or("");
720 let line_count = output.lines().count();
721 if line_count > 5 {
722 format!("{ext} file, {line_count} lines")
723 } else {
724 String::new()
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use std::sync::atomic::{AtomicUsize, Ordering};
732
733 #[test]
734 fn per_file_lock_same_path_returns_same_mutex() {
735 let lock_a1 = per_file_lock("/tmp/test_same_path.txt");
736 let lock_a2 = per_file_lock("/tmp/test_same_path.txt");
737 assert!(Arc::ptr_eq(&lock_a1, &lock_a2));
738 }
739
740 #[test]
741 fn per_file_lock_different_paths_return_different_mutexes() {
742 let lock_a = per_file_lock("/tmp/test_path_a.txt");
743 let lock_b = per_file_lock("/tmp/test_path_b.txt");
744 assert!(!Arc::ptr_eq(&lock_a, &lock_b));
745 }
746
747 #[test]
748 fn per_file_lock_serializes_concurrent_access() {
749 let counter = Arc::new(AtomicUsize::new(0));
750 let max_concurrent = Arc::new(AtomicUsize::new(0));
751 let path = "/tmp/test_concurrent_serialization.txt";
752 let mut handles = Vec::new();
753
754 for _ in 0..5 {
755 let counter = counter.clone();
756 let max_concurrent = max_concurrent.clone();
757 let path = path.to_string();
758 handles.push(std::thread::spawn(move || {
759 let lock = per_file_lock(&path);
760 let _guard = lock.lock().unwrap();
761 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
762 max_concurrent.fetch_max(active, Ordering::SeqCst);
763 std::thread::sleep(std::time::Duration::from_millis(10));
764 counter.fetch_sub(1, Ordering::SeqCst);
765 }));
766 }
767
768 for h in handles {
769 h.join().unwrap();
770 }
771
772 assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
773 }
774
775 #[test]
776 fn per_file_lock_allows_parallel_different_paths() {
777 let counter = Arc::new(AtomicUsize::new(0));
778 let max_concurrent = Arc::new(AtomicUsize::new(0));
779 let mut handles = Vec::new();
780
781 for i in 0..4 {
782 let counter = counter.clone();
783 let max_concurrent = max_concurrent.clone();
784 let path = format!("/tmp/test_parallel_{i}.txt");
785 handles.push(std::thread::spawn(move || {
786 let lock = per_file_lock(&path);
787 let _guard = lock.lock().unwrap();
788 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
789 max_concurrent.fetch_max(active, Ordering::SeqCst);
790 std::thread::sleep(std::time::Duration::from_millis(50));
791 counter.fetch_sub(1, Ordering::SeqCst);
792 }));
793 }
794
795 for h in handles {
796 h.join().unwrap();
797 }
798
799 assert!(max_concurrent.load(Ordering::SeqCst) > 1);
800 }
801
802 #[test]
806 fn zombie_thread_does_not_block_subsequent_cache_access() {
807 let cache: Arc<tokio::sync::RwLock<u32>> = Arc::new(tokio::sync::RwLock::new(0));
808
809 let zombie_lock = cache.clone();
811 let _zombie = std::thread::spawn(move || {
812 let _guard = zombie_lock.blocking_write();
813 std::thread::sleep(std::time::Duration::from_secs(2));
814 });
815 std::thread::sleep(std::time::Duration::from_millis(50));
816
817 assert!(cache.try_read().is_err());
819
820 let cancel = Arc::new(AtomicBool::new(false));
822 let cancel2 = cancel.clone();
823 let lock2 = cache.clone();
824 let waiter = std::thread::spawn(move || {
825 let start = std::time::Instant::now();
826 loop {
827 if cancel2.load(Ordering::Relaxed) {
828 return (false, start.elapsed());
829 }
830 if let Ok(_guard) = lock2.try_write() {
831 return (true, start.elapsed());
832 }
833 std::thread::sleep(std::time::Duration::from_millis(50));
834 }
835 });
836
837 std::thread::sleep(std::time::Duration::from_millis(200));
839 cancel.store(true, Ordering::Relaxed);
840
841 let (acquired, elapsed) = waiter.join().unwrap();
842 assert!(
843 !acquired,
844 "should not have acquired lock while zombie holds it"
845 );
846 assert!(
847 elapsed < std::time::Duration::from_secs(1),
848 "cancellation should have stopped the loop promptly"
849 );
850 }
851
852 fn apply_start_line(
856 mode: &mut String,
857 fresh: &mut bool,
858 explicit_mode: bool,
859 start_line: Option<i64>,
860 ) {
861 super::apply_line_window(mode, fresh, explicit_mode, start_line, None, None);
862 }
863
864 #[test]
865 fn start_line_1_does_not_override_mode() {
866 let mut mode = "auto".to_string();
867 let mut fresh = false;
868 apply_start_line(&mut mode, &mut fresh, false, Some(1));
869 assert_eq!(mode, "auto", "start_line=1 should not change mode");
870 assert!(!fresh, "start_line=1 should not force fresh=true");
871 }
872
873 #[test]
874 fn start_line_gt1_overrides_implicit_mode() {
875 let mut mode = "auto".to_string();
876 let mut fresh = false;
877 apply_start_line(&mut mode, &mut fresh, false, Some(50));
878 assert_eq!(mode, "lines:50-999999");
879 assert!(fresh);
880 }
881
882 #[test]
883 fn start_line_gt1_does_not_override_explicit_map() {
884 let mut mode = "map".to_string();
886 let mut fresh = false;
887 apply_start_line(&mut mode, &mut fresh, true, Some(50));
888 assert_eq!(
889 mode, "map",
890 "explicit mode=map must not be clobbered by start_line"
891 );
892 assert!(fresh, "start_line>1 should still force fresh");
893 }
894
895 #[test]
896 fn start_line_gt1_does_not_override_explicit_signatures() {
897 let mut mode = "signatures".to_string();
898 let mut fresh = false;
899 apply_start_line(&mut mode, &mut fresh, true, Some(100));
900 assert_eq!(mode, "signatures");
901 assert!(fresh);
902 }
903
904 #[test]
905 fn start_line_gt1_honors_explicit_lines_mode() {
906 let mut mode = "lines:1-50".to_string();
907 let mut fresh = false;
908 apply_start_line(&mut mode, &mut fresh, true, Some(30));
909 assert_eq!(
910 mode, "lines:30-999999",
911 "explicit lines mode should accept start_line override"
912 );
913 assert!(fresh);
914 }
915
916 #[test]
917 fn start_line_none_does_nothing() {
918 let mut mode = "map".to_string();
919 let mut fresh = false;
920 apply_start_line(&mut mode, &mut fresh, true, None);
921 assert_eq!(mode, "map");
922 assert!(!fresh);
923 }
924
925 #[test]
926 fn start_line_1_with_explicit_mode_preserves_it() {
927 let mut mode = "map".to_string();
929 let mut fresh = false;
930 apply_start_line(&mut mode, &mut fresh, true, Some(1));
931 assert_eq!(mode, "map");
932 assert!(!fresh);
933 }
934
935 #[test]
938 fn offset_is_alias_for_start_line() {
939 let mut mode = "auto".to_string();
940 let mut fresh = false;
941 super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), None);
942 assert_eq!(mode, "lines:40-999999");
943 assert!(fresh);
944 }
945
946 #[test]
947 fn offset_and_limit_make_bounded_window() {
948 let mut mode = "auto".to_string();
949 let mut fresh = false;
950 super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), Some(20));
951 assert_eq!(mode, "lines:40-59", "20 inclusive lines starting at 40");
952 assert!(fresh);
953 }
954
955 #[test]
956 fn limit_alone_reads_from_first_line() {
957 let mut mode = "auto".to_string();
958 let mut fresh = false;
959 super::apply_line_window(&mut mode, &mut fresh, false, None, None, Some(25));
960 assert_eq!(mode, "lines:1-25");
961 assert!(fresh);
962 }
963
964 #[test]
965 fn start_line_wins_over_offset_when_both_present() {
966 assert_eq!(
967 super::resolve_line_window(Some(10), Some(99), None),
968 Some((10, None))
969 );
970 }
971
972 #[test]
973 fn resolve_clamps_start_and_drops_nonpositive_limit() {
974 assert_eq!(
976 super::resolve_line_window(Some(-5), None, Some(0)),
977 Some((1, None))
978 );
979 assert_eq!(super::resolve_line_window(None, None, Some(-3)), None);
981 assert_eq!(super::resolve_line_window(None, None, None), None);
982 }
983
984 #[test]
985 fn lines_mode_bounds_are_inclusive() {
986 assert_eq!(super::lines_mode(40, Some(20)), "lines:40-59");
987 assert_eq!(super::lines_mode(5, None), "lines:5-999999");
988 }
989
990 #[test]
991 fn explicit_map_not_clobbered_by_offset_limit() {
992 let mut mode = "map".to_string();
994 let mut fresh = false;
995 super::apply_line_window(&mut mode, &mut fresh, true, None, Some(40), Some(20));
996 assert_eq!(mode, "map", "explicit mode wins over offset/limit");
997 assert!(fresh);
998 }
999
1000 #[test]
1005 fn schema_advertises_line_window_aliases() {
1006 let tool = CtxReadTool.tool_def();
1007 let props = tool
1008 .input_schema
1009 .get("properties")
1010 .and_then(|p| p.as_object())
1011 .expect("ctx_read schema has a properties object");
1012 for key in ["path", "mode", "start_line", "offset", "limit", "fresh"] {
1013 assert!(props.contains_key(key), "ctx_read schema missing '{key}'");
1014 }
1015 }
1016
1017 use crate::core::degradation_policy::DegradationVerdictV1;
1021
1022 #[test]
1023 fn verdict_ok_does_not_degrade() {
1024 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Ok);
1025 assert_eq!(mode, "full");
1026 assert!(!degraded);
1027 }
1028
1029 #[test]
1030 fn verdict_warn_degrades_full_to_map() {
1031 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1032 assert_eq!(mode, "map");
1033 assert!(degraded, "full→map must be flagged as degraded");
1034 }
1035
1036 #[test]
1037 fn verdict_warn_keeps_map() {
1038 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Warn);
1039 assert_eq!(mode, "map");
1040 assert!(!degraded, "map is not degraded under Warn");
1041 }
1042
1043 #[test]
1044 fn verdict_warn_keeps_signatures() {
1045 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Warn);
1046 assert_eq!(mode, "signatures");
1047 assert!(!degraded);
1048 }
1049
1050 #[test]
1051 fn verdict_throttle_degrades_full_to_signatures() {
1052 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Throttle);
1053 assert_eq!(mode, "signatures");
1054 assert!(degraded);
1055 }
1056
1057 #[test]
1058 fn verdict_throttle_degrades_map_to_signatures() {
1059 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Throttle);
1060 assert_eq!(mode, "signatures");
1061 assert!(degraded);
1062 }
1063
1064 #[test]
1065 fn verdict_throttle_keeps_lines() {
1066 let (mode, degraded) = super::apply_verdict("lines:1-50", DegradationVerdictV1::Throttle);
1067 assert_eq!(mode, "lines:1-50");
1068 assert!(!degraded, "lines mode bypasses degradation");
1069 }
1070
1071 #[test]
1072 fn verdict_block_degrades_full_to_signatures() {
1073 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Block);
1074 assert_eq!(mode, "signatures");
1075 assert!(degraded);
1076 }
1077
1078 #[test]
1079 fn verdict_block_does_not_degrade_signatures() {
1080 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Block);
1081 assert_eq!(mode, "signatures");
1082 assert!(!degraded, "already at signatures — no degradation needed");
1083 }
1084
1085 #[test]
1086 fn degrade_warning_message_contains_mode_info() {
1087 let (new_mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1088 assert!(degraded);
1089 let warning = format!(
1090 "⚠ Context pressure: mode=full was downgraded to mode={new_mode} (verdict: {:?}).",
1091 DegradationVerdictV1::Warn
1092 );
1093 assert!(warning.contains("mode=full"));
1094 assert!(warning.contains("mode=map"));
1095 assert!(warning.contains("Warn"));
1096 }
1097
1098 #[test]
1103 fn auto_degrade_preserves_full_when_default_config() {
1104 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1105 return;
1106 }
1107 let (mode, warning) = super::auto_degrade_read_mode("full");
1108 assert_eq!(mode, "full");
1109 assert!(warning.is_none());
1110 }
1111
1112 #[test]
1113 fn auto_degrade_preserves_map_when_default_config() {
1114 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1115 return;
1116 }
1117 let (mode, warning) = super::auto_degrade_read_mode("map");
1118 assert_eq!(mode, "map");
1119 assert!(warning.is_none());
1120 }
1121
1122 #[test]
1123 fn auto_degrade_preserves_signatures_when_default_config() {
1124 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1125 return;
1126 }
1127 let (mode, warning) = super::auto_degrade_read_mode("signatures");
1128 assert_eq!(mode, "signatures");
1129 assert!(warning.is_none());
1130 }
1131
1132 #[test]
1133 fn auto_degrade_preserves_diff_always() {
1134 let (mode, warning) = super::auto_degrade_read_mode("diff");
1135 assert_eq!(mode, "diff");
1136 assert!(warning.is_none());
1137 }
1138
1139 #[test]
1140 fn auto_degrade_preserves_lines_mode_always() {
1141 let (mode, warning) = super::auto_degrade_read_mode("lines:10-50");
1142 assert_eq!(mode, "lines:10-50");
1143 assert!(warning.is_none());
1144 }
1145
1146 #[test]
1147 fn auto_degrade_preserves_aggressive_when_default_config() {
1148 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1149 return;
1150 }
1151 let (mode, warning) = super::auto_degrade_read_mode("aggressive");
1152 assert_eq!(mode, "aggressive");
1153 assert!(warning.is_none());
1154 }
1155
1156 #[test]
1157 fn auto_degrade_preserves_entropy_when_default_config() {
1158 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1159 return;
1160 }
1161 let (mode, warning) = super::auto_degrade_read_mode("entropy");
1162 assert_eq!(mode, "entropy");
1163 assert!(warning.is_none());
1164 }
1165
1166 #[test]
1167 fn auto_degrade_preserves_auto_when_default_config() {
1168 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1169 return;
1170 }
1171 let (mode, warning) = super::auto_degrade_read_mode("auto");
1172 assert_eq!(mode, "auto");
1173 assert!(warning.is_none());
1174 }
1175
1176 #[test]
1179 fn verdict_warn_does_not_degrade_diff() {
1180 let (mode, degraded) = super::apply_verdict("diff", DegradationVerdictV1::Warn);
1181 assert_eq!(mode, "diff");
1182 assert!(!degraded);
1183 }
1184
1185 #[test]
1186 fn verdict_throttle_does_not_degrade_signatures() {
1187 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Throttle);
1188 assert_eq!(mode, "signatures");
1189 assert!(!degraded);
1190 }
1191
1192 #[test]
1193 fn verdict_ok_preserves_map() {
1194 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Ok);
1195 assert_eq!(mode, "map");
1196 assert!(!degraded);
1197 }
1198
1199 #[test]
1200 fn verdict_ok_preserves_signatures() {
1201 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Ok);
1202 assert_eq!(mode, "signatures");
1203 assert!(!degraded);
1204 }
1205
1206 #[test]
1207 fn verdict_ok_preserves_lines() {
1208 let (mode, degraded) = super::apply_verdict("lines:1-100", DegradationVerdictV1::Ok);
1209 assert_eq!(mode, "lines:1-100");
1210 assert!(!degraded);
1211 }
1212
1213 #[test]
1214 fn verdict_block_degrades_map_to_signatures() {
1215 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Block);
1216 assert_eq!(mode, "signatures");
1217 assert!(degraded);
1218 }
1219}