Skip to content

Commit c91b982

Browse files
committed
docs: add SAFETY comments and improve MCP code documentation
- Add SAFETY comments to all unsafe mmap operations in streaming_filter - Add module-level documentation explaining MCP blocking behavior - Create error_response() helper to reduce boilerplate in MCP tools - Add detailed documentation for get_lines_content() design rationale - Add aur/ to .gitignore
1 parent 439383b commit c91b982

File tree

3 files changed

+72
-66
lines changed

3 files changed

+72
-66
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ AGENTS.md
88
pkg/
99
PKGBUILD
1010
.SRCINFO
11+
aur/

src/filter/streaming_filter.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ fn stream_filter_impl(
8080
return Ok(());
8181
}
8282

83+
// SAFETY: File handle remains valid for mmap lifetime. Read-only access.
8384
let mmap = unsafe { Mmap::map(&file)? };
8485
let data = &mmap[..];
8586

@@ -204,6 +205,7 @@ fn stream_filter_grep_style(
204205
return Ok(());
205206
}
206207

208+
// SAFETY: File handle remains valid for mmap lifetime. Read-only access.
207209
let mmap = unsafe { Mmap::map(&file)? };
208210
let data = &mmap[..];
209211

@@ -303,6 +305,7 @@ fn stream_filter_fast_impl(
303305
return Ok(());
304306
}
305307

308+
// SAFETY: File handle remains valid for mmap lifetime. Read-only access.
306309
let mmap = unsafe { Mmap::map(&file)? };
307310
let data = &mmap[..];
308311

@@ -428,6 +431,7 @@ fn stream_filter_range_impl(
428431
return Ok(());
429432
}
430433

434+
// SAFETY: File handle remains valid for mmap lifetime. Read-only access.
431435
let mmap = unsafe { Mmap::map(&file)? };
432436
let data = &mmap[..];
433437

src/mcp/tools.rs

Lines changed: 67 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
//! MCP tool implementations for log file analysis.
2+
//!
3+
//! Note on blocking: Tool handlers are synchronous as required by rmcp. The `search` tool
4+
//! spawns a filter thread and blocks waiting for results. This is acceptable because:
5+
//! 1. MCP stdio transport processes requests sequentially (one at a time)
6+
//! 2. The actual filtering work runs on a dedicated thread, not blocking the tokio runtime
7+
//! 3. Only the channel recv() blocks, which is waiting on real work
8+
//!
9+
//! If concurrent request handling is needed in the future, consider wrapping heavy
10+
//! operations in `tokio::task::spawn_blocking`.
211
312
use super::types::*;
413
use crate::filter::{cancel::CancelToken, engine::FilterProgress, streaming_filter};
@@ -10,8 +19,15 @@ use memmap2::Mmap;
1019
use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
1120
use rmcp::{tool, tool_box, ServerHandler};
1221
use std::fs::File;
22+
use std::path::Path;
1323
use std::sync::Arc;
1424

25+
/// Create a JSON error response string.
26+
fn error_response(message: impl std::fmt::Display) -> String {
27+
serde_json::to_string(&serde_json::json!({ "error": message.to_string() }))
28+
.unwrap_or_else(|_| r#"{"error": "Failed to serialize error"}"#.to_string())
29+
}
30+
1531
/// LazyTail MCP server providing log file analysis tools.
1632
#[derive(Clone)]
1733
pub struct LazyTailMcp;
@@ -37,14 +53,14 @@ impl LazyTailMcp {
3753
fn get_lines(&self, #[tool(aggr)] req: GetLinesRequest) -> String {
3854
let count = req.count.min(1000);
3955

40-
let reader_result = FileReader::new(&req.file);
41-
let mut reader = match reader_result {
56+
let mut reader = match FileReader::new(&req.file) {
4257
Ok(r) => r,
4358
Err(e) => {
44-
return serde_json::to_string(&serde_json::json!({
45-
"error": format!("Failed to open file '{}': {}", req.file.display(), e)
46-
}))
47-
.unwrap_or_else(|_| "Error serializing response".to_string());
59+
return error_response(format!(
60+
"Failed to open file '{}': {}",
61+
req.file.display(),
62+
e
63+
))
4864
}
4965
};
5066

@@ -76,14 +92,14 @@ impl LazyTailMcp {
7692
fn get_tail(&self, #[tool(aggr)] req: GetTailRequest) -> String {
7793
let count = req.count.min(1000);
7894

79-
let reader_result = FileReader::new(&req.file);
80-
let mut reader = match reader_result {
95+
let mut reader = match FileReader::new(&req.file) {
8196
Ok(r) => r,
8297
Err(e) => {
83-
return serde_json::to_string(&serde_json::json!({
84-
"error": format!("Failed to open file '{}': {}", req.file.display(), e)
85-
}))
86-
.unwrap_or_else(|_| "Error serializing response".to_string());
98+
return error_response(format!(
99+
"Failed to open file '{}': {}",
100+
req.file.display(),
101+
e
102+
))
87103
}
88104
};
89105

@@ -123,27 +139,25 @@ impl LazyTailMcp {
123139
SearchMode::Plain => Arc::new(StringFilter::new(&req.pattern, req.case_sensitive)),
124140
SearchMode::Regex => match RegexFilter::new(&req.pattern, req.case_sensitive) {
125141
Ok(f) => Arc::new(f),
126-
Err(e) => {
127-
return serde_json::to_string(&serde_json::json!({
128-
"error": format!("Invalid regex pattern: {}", e)
129-
}))
130-
.unwrap_or_else(|_| "Error serializing response".to_string());
131-
}
142+
Err(e) => return error_response(format!("Invalid regex pattern: {}", e)),
132143
},
133144
};
134145

135-
// Run streaming filter (grep-like performance)
146+
// Run streaming filter (grep-like performance).
147+
// The filter runs on a dedicated thread; we block here waiting for results.
148+
// See module doc for why this is acceptable in the current MCP design.
136149
let rx = match streaming_filter::run_streaming_filter(
137150
req.file.clone(),
138151
filter,
139152
CancelToken::new(),
140153
) {
141154
Ok(rx) => rx,
142155
Err(e) => {
143-
return serde_json::to_string(&serde_json::json!({
144-
"error": format!("Failed to search file '{}': {}", req.file.display(), e)
145-
}))
146-
.unwrap_or_else(|_| "Error serializing response".to_string());
156+
return error_response(format!(
157+
"Failed to search file '{}': {}",
158+
req.file.display(),
159+
e
160+
))
147161
}
148162
};
149163

@@ -170,12 +184,7 @@ impl LazyTailMcp {
170184
FilterProgress::Processing(n) => {
171185
lines_searched = n;
172186
}
173-
FilterProgress::Error(e) => {
174-
return serde_json::to_string(&serde_json::json!({
175-
"error": format!("Search error: {}", e)
176-
}))
177-
.unwrap_or_else(|_| "Error serializing response".to_string());
178-
}
187+
FilterProgress::Error(e) => return error_response(format!("Search error: {}", e)),
179188
}
180189
}
181190

@@ -189,12 +198,7 @@ impl LazyTailMcp {
189198
} else {
190199
match Self::get_lines_content(&req.file, &matching_indices, context_lines) {
191200
Ok(m) => m,
192-
Err(e) => {
193-
return serde_json::to_string(&serde_json::json!({
194-
"error": format!("Failed to read line content: {}", e)
195-
}))
196-
.unwrap_or_else(|_| "Error serializing response".to_string());
197-
}
201+
Err(e) => return error_response(format!("Failed to read line content: {}", e)),
198202
}
199203
};
200204

@@ -209,9 +213,19 @@ impl LazyTailMcp {
209213
.unwrap_or_else(|e| format!("Error serializing response: {}", e))
210214
}
211215

212-
/// Get line content and context using mmap (no index building required)
216+
/// Fetch line content and context for search matches using a single-pass mmap scan.
217+
///
218+
/// This is a specialized batch operation that differs from `FileReader`:
219+
/// - `FileReader`: Builds a full line index, optimized for random access to any line
220+
/// - This function: Single sequential pass, only extracts specific lines + context
221+
///
222+
/// For search results with context, this approach is more efficient because:
223+
/// 1. We know exactly which lines we need upfront (matches + context)
224+
/// 2. Single pass through file up to the last needed line, then early exit
225+
/// 3. No index structure overhead - just a BTreeSet of needed line numbers
226+
/// 4. Handles overlapping context ranges efficiently via deduplication
213227
fn get_lines_content(
214-
path: &std::path::Path,
228+
path: &Path,
215229
line_indices: &[usize],
216230
context_lines: usize,
217231
) -> anyhow::Result<Vec<SearchMatch>> {
@@ -220,6 +234,9 @@ impl LazyTailMcp {
220234
}
221235

222236
let file = File::open(path)?;
237+
// SAFETY: The file handle is kept open for the lifetime of the mmap.
238+
// We only perform read operations on the mapped memory.
239+
// The file is opened read-only and we don't modify it.
223240
let mmap = unsafe { Mmap::map(&file)? };
224241
let data = &mmap[..];
225242

@@ -307,24 +324,24 @@ impl LazyTailMcp {
307324
let before_count = req.before.min(50);
308325
let after_count = req.after.min(50);
309326

310-
let reader_result = FileReader::new(&req.file);
311-
let mut reader = match reader_result {
327+
let mut reader = match FileReader::new(&req.file) {
312328
Ok(r) => r,
313329
Err(e) => {
314-
return serde_json::to_string(&serde_json::json!({
315-
"error": format!("Failed to open file '{}': {}", req.file.display(), e)
316-
}))
317-
.unwrap_or_else(|_| "Error serializing response".to_string());
330+
return error_response(format!(
331+
"Failed to open file '{}': {}",
332+
req.file.display(),
333+
e
334+
))
318335
}
319336
};
320337

321338
let total = reader.total_lines();
322339

323340
if req.line_number >= total {
324-
return serde_json::to_string(&serde_json::json!({
325-
"error": format!("Line {} does not exist (file has {} lines)", req.line_number, total)
326-
}))
327-
.unwrap_or_else(|_| "Error serializing response".to_string());
341+
return error_response(format!(
342+
"Line {} does not exist (file has {} lines)",
343+
req.line_number, total
344+
));
328345
}
329346

330347
// Get before lines
@@ -342,12 +359,7 @@ impl LazyTailMcp {
342359
// Get target line
343360
let target_content = match reader.get_line(req.line_number) {
344361
Ok(Some(c)) => c,
345-
_ => {
346-
return serde_json::to_string(&serde_json::json!({
347-
"error": "Failed to read target line"
348-
}))
349-
.unwrap_or_else(|_| "Error serializing response".to_string());
350-
}
362+
_ => return error_response("Failed to read target line"),
351363
};
352364
let target_line = LineInfo {
353365
line_number: req.line_number,
@@ -384,24 +396,13 @@ impl LazyTailMcp {
384396
fn list_sources(&self, #[tool(aggr)] _req: ListSourcesRequest) -> String {
385397
let data_dir = match source::data_dir() {
386398
Some(dir) => dir,
387-
None => {
388-
return serde_json::to_string(&serde_json::json!({
389-
"error": "Could not determine data directory"
390-
}))
391-
.unwrap_or_else(|_| "Error serializing response".to_string());
392-
}
399+
None => return error_response("Could not determine data directory"),
393400
};
394401

395402
// Get discovered sources
396403
let discovered = match source::discover_sources() {
397404
Ok(sources) => sources,
398-
Err(e) => {
399-
return serde_json::to_string(&serde_json::json!({
400-
"error": format!("Failed to discover sources: {}", e),
401-
"data_directory": data_dir.display().to_string()
402-
}))
403-
.unwrap_or_else(|_| "Error serializing response".to_string());
404-
}
405+
Err(e) => return error_response(format!("Failed to discover sources: {}", e)),
405406
};
406407

407408
let mut sources = Vec::new();

0 commit comments

Comments
 (0)