Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,45 @@ UltraLog embeds an MCP (Model Context Protocol) HTTP server so Claude Desktop ca

**Load-bearing contract:** the IPC server wakes the GUI via a repaint callback (`request_repaint()`), not a polling timer — see `IpcServer::start_with_repaint` in `src/ipc/server.rs` and its wiring in `UltraLogApp::new`. Incoming commands are drained in `UltraLogApp::process_ipc_commands`, which caps processing at **10 commands per frame** to avoid blocking the UI thread; if more are queued, it requests another repaint to continue next frame.

**Response size contract (load-bearing):** every MCP tool result travels as a single
Server-Sent Event, and streamable-HTTP MCP clients cap one SSE event at **1 MiB**
(`DEFAULT_MAX_EVENT_SIZE_BYTES` in the reference client). An event over that cap is
discarded inside the client's SSE decoder — the caller receives no value *and* no error,
so the tool call simply never returns, even past the IPC layer's own 30s timeout. That
is issue #80's cousin for the MCP path: issue #88, where `evaluate_formula` worked up to
~22,000 rows and hung on everything larger.

Two defenses keep that from recurring, and both must stay in place:

1. **Sample budget** — `UltraLogApp::limit_samples` (`src/ipc/handler.rs`) reduces any
per-record series to `DEFAULT_MAX_POINTS` (2000), clamped to `MAX_POINTS_LIMIT`
(10,000), using the chart's LTTB so peaks and dropouts survive. `GetChannelData` and
`EvaluateFormula` carry an optional `max_points`; responses report `total_samples` and
`downsampled` so a caller can tell what it got. `FindPeaks` is capped at `MAX_PEAKS`
(500) because peak count scales with channel noise rather than with anything the
caller asked for; it *selects* by prominence but *returns* chronologically, and
reports `total_peaks`/`truncated` — a bare truncated list is indistinguishable from a
complete one, so a caller counting events would read exactly 500 and believe it.
2. **Byte guard** — `UltraLogMcpServer::json_result` (`src/mcp/server.rs`) serializes
compactly (never `to_string_pretty`: one array element per line roughly doubles the
payload for no benefit) and refuses anything over `MAX_RESPONSE_BYTES` (512 KiB) with
an error naming `max_points` and the time range. An honest error beats silence.

**Corollary:** anything needing exact aggregates must read the *full* series, not a
downsampled response. `UltraLogApp::channel_series` is that accessor, and
`handle_get_channel_stats`, `handle_find_peaks` and `handle_correlate_channels` all go
through it. `handle_evaluate_formula` does not use it — it has already evaluated its own
series — but it holds the same invariant by calling `compute_stats` *before*
`limit_samples`. Either way the rule is the same: routing an aggregate through a
downsampled series would silently compute statistics over 2000 samples instead of 178,000.

`channel_series` also enforces that `times` and `values` are the same length
(`require_aligned`). `Log::get_channel_data` is a `filter_map` that drops a row missing
the column, and an analysis-derived `cached_data` is only as long as the algorithm made
it, so a ragged log yields a pair that is *misaligned*, not merely short — and
`downsample_lttb` indexes `values` off `times.len()`, which panics on the GUI thread.
`src/ui/chart.rs` refuses to plot that case; the data API refuses to serve it.

### UI Modules (src/ui/)

UI rendering is split into focused modules that implement methods on `UltraLogApp`. The current layout is a VS Code-style activity bar + side panel; a couple of pre-activity-bar modules remain in the tree but are superseded (noted below):
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ultralog"
version = "2.14.0"
version = "2.14.1"
edition = "2024"
# egui/eframe 0.36 is the binding constraint on the minimum supported Rust
# version; edition 2024 itself only needs 1.85.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust.

![CI](https://github.com/ClassicMiniDIY/UltraLog/actions/workflows/ci.yml/badge.svg)
![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)
![Version](https://img.shields.io/badge/version-2.14.0-green.svg)
![Version](https://img.shields.io/badge/version-2.14.1-green.svg)

---

Expand Down
6 changes: 3 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@
"applicationCategory": "UtilitiesApplication",
"applicationSubCategory": "Automotive Software",
"operatingSystem": ["Windows 10", "Windows 11", "macOS", "Linux"],
"softwareVersion": "2.14.0",
"releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.14.0",
"softwareVersion": "2.14.1",
"releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.14.1",
"downloadUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest",
"installUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest",
"screenshot": [
Expand Down Expand Up @@ -1427,7 +1427,7 @@ <h1 id="hero-heading" class="tagline" itemprop="headline">Unlock Your Performanc
<div class="hero-badges">
<span class="version-badge">
<span class="new-tag">New</span>
v2.14.0
v2.14.1
</span>
<a href="https://github.com/ClassicMiniDIY/UltraLog" class="opensource-badge" target="_blank" rel="noopener noreferrer">
<i class="fa-brands fa-github" aria-hidden="true"></i> Open Source
Expand Down
2 changes: 1 addition & 1 deletion docs/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<loc>https://ultralog.co/</loc>
<lastmod>2026-08-26</lastmod>
<lastmod>2026-09-15</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
<image:image>
Expand Down
155 changes: 146 additions & 9 deletions src/ipc/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

use serde::{Deserialize, Serialize};

/// Default number of samples returned by the data-bearing commands
/// (`GetChannelData`, `EvaluateFormula`) when the caller does not ask for a
/// specific count.
///
/// This matches the chart's own LTTB budget in `src/ui/chart.rs`: 2000 points
/// is enough to see every feature of a trace and costs ~55 KB of compact JSON.
pub const DEFAULT_MAX_POINTS: usize = 2000;

/// Hard ceiling on the caller-supplied `max_points`.
///
/// Streamable-HTTP MCP clients drop any single SSE event larger than 1 MiB
/// (`DEFAULT_MAX_EVENT_SIZE_BYTES` in the reference client), and the drop is
/// silent: the caller never receives a result *or* an error, it just hangs
/// (issue #88). 10,000 samples is ~275 KB of compact JSON, which leaves ample
/// headroom under that limit for the surrounding envelope.
pub const MAX_POINTS_LIMIT: usize = 10_000;

/// Maximum number of peaks returned by `FindPeaks`.
///
/// Peak counts grow with the noise in a channel, not with anything the caller
/// controls, so this is capped for the same reason as `MAX_POINTS_LIMIT`. The
/// most prominent peaks are kept.
pub const MAX_PEAKS: usize = 500;

/// Clamp a caller-supplied sample budget into the supported range.
pub fn resolve_max_points(requested: Option<usize>) -> usize {
requested
.unwrap_or(DEFAULT_MAX_POINTS)
.clamp(1, MAX_POINTS_LIMIT)
}

/// Commands that can be sent from the MCP server to the GUI
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload")]
Expand All @@ -27,6 +58,10 @@ pub enum IpcCommand {
channel_name: String,
/// Optional time range (start, end) in seconds
time_range: Option<(f64, f64)>,
/// Maximum number of samples to return. Defaults to
/// [`DEFAULT_MAX_POINTS`] and is clamped to [`MAX_POINTS_LIMIT`].
#[serde(default)]
max_points: Option<usize>,
},

/// Get statistics for a channel
Expand Down Expand Up @@ -72,6 +107,10 @@ pub enum IpcCommand {
formula: String,
/// Optional time range
time_range: Option<(f64, f64)>,
/// Maximum number of samples to return. Defaults to
/// [`DEFAULT_MAX_POINTS`] and is clamped to [`MAX_POINTS_LIMIT`].
#[serde(default)]
max_points: Option<usize>,
},

/// Set the visible time range on the chart
Expand Down Expand Up @@ -148,17 +187,34 @@ pub enum ResponseData {
/// List of channels
Channels(Vec<ChannelInfo>),

/// Channel time series data
ChannelData { times: Vec<f64>, values: Vec<f64> },
/// Channel time series data.
///
/// `times`/`values` may be downsampled; `total_samples` is always the
/// number of records the series was drawn from.
ChannelData {
times: Vec<f64>,
values: Vec<f64>,
#[serde(default)]
total_samples: usize,
#[serde(default)]
downsampled: bool,
},

/// Channel statistics
Stats(ChannelStats),

/// Formula evaluation result
/// Formula evaluation result.
///
/// `times`/`values` may be downsampled; `stats` is always computed over
/// the full series, and `total_samples` is its length.
FormulaResult {
times: Vec<f64>,
values: Vec<f64>,
stats: ChannelStats,
#[serde(default)]
total_samples: usize,
#[serde(default)]
downsampled: bool,
},

/// Values at cursor position
Expand All @@ -167,8 +223,17 @@ pub enum ResponseData {
/// List of computed channel templates
ComputedChannels(Vec<ComputedChannelInfo>),

/// Peak detection results
Peaks(Vec<Peak>),
/// Peak detection results.
///
/// `peaks` is capped at [`MAX_PEAKS`]; `total_peaks` is how many the
/// detector actually found, and `truncated` says whether the cap bit.
Peaks {
peaks: Vec<Peak>,
#[serde(default)]
total_peaks: usize,
#[serde(default)]
truncated: bool,
},

/// Correlation result
Correlation {
Expand Down Expand Up @@ -350,18 +415,21 @@ mod tests {
file_id: "0".to_string(),
channel_name: "RPM".to_string(),
time_range: Some((10.0, 20.0)),
max_points: Some(500),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: IpcCommand = serde_json::from_str(&json).unwrap();
if let IpcCommand::GetChannelData {
file_id,
channel_name,
time_range,
max_points,
} = parsed
{
assert_eq!(file_id, "0");
assert_eq!(channel_name, "RPM");
assert_eq!(time_range, Some((10.0, 20.0)));
assert_eq!(max_points, Some(500));
} else {
panic!("Expected GetChannelData command");
}
Expand All @@ -373,18 +441,21 @@ mod tests {
file_id: "0".to_string(),
channel_name: "Boost".to_string(),
time_range: None,
max_points: None,
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: IpcCommand = serde_json::from_str(&json).unwrap();
if let IpcCommand::GetChannelData {
file_id,
channel_name,
time_range,
max_points,
} = parsed
{
assert_eq!(file_id, "0");
assert_eq!(channel_name, "Boost");
assert!(time_range.is_none());
assert!(max_points.is_none());
} else {
panic!("Expected GetChannelData command");
}
Expand Down Expand Up @@ -488,12 +559,22 @@ mod tests {
let resp = IpcResponse::ok_with_data(ResponseData::ChannelData {
times: vec![0.0, 0.1, 0.2, 0.3],
values: vec![1000.0, 1500.0, 2000.0, 2500.0],
total_samples: 4,
downsampled: false,
});
let json = serde_json::to_string(&resp).unwrap();
let parsed: IpcResponse = serde_json::from_str(&json).unwrap();
if let IpcResponse::Ok(Some(ResponseData::ChannelData { times, values })) = parsed {
if let IpcResponse::Ok(Some(ResponseData::ChannelData {
times,
values,
total_samples,
downsampled,
})) = parsed
{
assert_eq!(times, vec![0.0, 0.1, 0.2, 0.3]);
assert_eq!(values, vec![1000.0, 1500.0, 2000.0, 2500.0]);
assert_eq!(total_samples, 4);
assert!(!downsampled);
} else {
panic!("Expected ChannelData response");
}
Expand Down Expand Up @@ -596,18 +677,61 @@ mod tests {
prominence: 800.0,
},
];
let resp = IpcResponse::ok_with_data(ResponseData::Peaks(peaks));
let resp = IpcResponse::ok_with_data(ResponseData::Peaks {
peaks,
total_peaks: 2,
truncated: false,
});
let json = serde_json::to_string(&resp).unwrap();
let parsed: IpcResponse = serde_json::from_str(&json).unwrap();
if let IpcResponse::Ok(Some(ResponseData::Peaks(p))) = parsed {
if let IpcResponse::Ok(Some(ResponseData::Peaks {
peaks: p,
total_peaks,
truncated,
})) = parsed
{
assert_eq!(p.len(), 2);
assert_eq!(p[0].time, 10.5);
assert_eq!(p[1].value, 7500.0);
assert_eq!(total_peaks, 2);
assert!(!truncated);
} else {
panic!("Expected Peaks response");
}
}

#[test]
fn test_truncated_peaks_response_reports_the_true_total() {
// A truncated list must not be indistinguishable from a complete one:
// a caller counting events off `peaks.len()` alone would read exactly
// MAX_PEAKS and believe the channel had no more.
let peaks: Vec<Peak> = (0..MAX_PEAKS)
.map(|i| Peak {
time: i as f64,
value: 1000.0 + i as f64,
prominence: 50.0,
})
.collect();
let resp = IpcResponse::ok_with_data(ResponseData::Peaks {
peaks,
total_peaks: 41_337,
truncated: true,
});
let json = serde_json::to_string(&resp).unwrap();
let parsed: IpcResponse = serde_json::from_str(&json).unwrap();
let IpcResponse::Ok(Some(ResponseData::Peaks {
peaks,
total_peaks,
truncated,
})) = parsed
else {
panic!("Expected Peaks response");
};
assert_eq!(peaks.len(), MAX_PEAKS);
assert_eq!(total_peaks, 41_337);
assert!(truncated);
}

// ========================================================================
// JSON Format Compatibility Tests
// ========================================================================
Expand Down Expand Up @@ -641,20 +765,33 @@ mod tests {

#[test]
fn test_command_can_be_parsed_from_external_json() {
// Test parsing JSON that might come from an external MCP client
// Test parsing JSON that might come from an external MCP client.
// `max_points` is absent here on purpose: it is `#[serde(default)]`, so
// a payload written before the sample budget existed still parses.
let json = r#"{"type":"GetChannelData","payload":{"file_id":"0","channel_name":"RPM","time_range":[0.0,10.0]}}"#;
let cmd: IpcCommand = serde_json::from_str(json).unwrap();
if let IpcCommand::GetChannelData {
file_id,
channel_name,
time_range,
max_points,
} = cmd
{
assert_eq!(file_id, "0");
assert_eq!(channel_name, "RPM");
assert_eq!(time_range, Some((0.0, 10.0)));
assert!(max_points.is_none(), "Omitted max_points must default");
} else {
panic!("Expected GetChannelData command");
}
}

#[test]
fn test_resolve_max_points_clamps_into_supported_range() {
assert_eq!(resolve_max_points(None), DEFAULT_MAX_POINTS);
assert_eq!(resolve_max_points(Some(750)), 750);
assert_eq!(resolve_max_points(Some(0)), 1, "Zero would return no data");
assert_eq!(resolve_max_points(Some(usize::MAX)), MAX_POINTS_LIMIT);
assert_eq!(resolve_max_points(Some(MAX_POINTS_LIMIT)), MAX_POINTS_LIMIT);
}
}
Loading