Skip to content

feat: speed test and health check for desktop app#1659

Open
CelebrityPunks wants to merge 1 commit intoCapSoftware:mainfrom
CelebrityPunks:feat/speed-test-health-check
Open

feat: speed test and health check for desktop app#1659
CelebrityPunks wants to merge 1 commit intoCapSoftware:mainfrom
CelebrityPunks:feat/speed-test-health-check

Conversation

@CelebrityPunks
Copy link

@CelebrityPunks CelebrityPunks commented Mar 14, 2026

Summary

Implements the speed test and health check system requested in #73.

  • Speed test module (speed_test.rs): Measures upload bandwidth by sending a 1MB test payload to a new /api/desktop/health-check endpoint. Maps speed results to quality presets (Full/High/Medium/Low) that cap the recording resolution accordingly.
  • Health check: Runs automatically on startup (3s delay) to verify server reachability, authentication validity, and upload functionality. Results are surfaced via events to the frontend.
  • Quality adjustment: When starting an instant recording, the max output resolution is capped to the lower of the user's setting and the speed-test-recommended resolution.
  • Recording guard: Speed tests are blocked during active recordings to avoid bandwidth contention.
  • UI indicator (NetworkStatus.tsx): Shows health status (OK/Issue) and upload speed in the bottom of the main window. Both are clickable to manually re-run checks. Tooltips provide details.
  • Web API endpoint (health-check.ts): Lightweight Hono route under /api/desktop/health-check that handles GET (health/auth check) and POST (speed test/upload test) requests.

Files changed

  • apps/desktop/src-tauri/src/speed_test.rs — new Rust module with speed test, health check, network state, and Tauri commands/events
  • apps/desktop/src-tauri/src/lib.rs — register module, commands, events, managed state, and startup health check
  • apps/desktop/src-tauri/src/recording.rs — integrate recording-active flag and speed-based resolution capping
  • apps/desktop/src/components/NetworkStatus.tsx — new SolidJS component for health/speed indicators
  • apps/desktop/src/routes/(window-chrome)/new-main/index.tsx — mount NetworkStatus in main window
  • apps/web/app/api/desktop/[...route]/health-check.ts — new health check API endpoint
  • apps/web/app/api/desktop/[...route]/route.ts — register health-check route

Test plan

  • Verify health check runs on app startup and shows green indicator when server is reachable and user is authenticated
  • Verify health check shows warning when user is not signed in
  • Verify speed test button triggers a test and displays the upload speed in Mbps
  • Verify speed test cannot be triggered during an active recording
  • Verify instant recordings use the speed-test-recommended resolution cap
  • Verify the /api/desktop/health-check endpoint returns correct responses for GET and POST
  • Verify tooltips show detailed status information

Closes #73

🤖 Generated with Claude Code

Greptile Summary

Adds a speed test and health check system for the desktop app. A new Rust module (speed_test.rs) measures upload bandwidth via a 1MB POST to a new /api/desktop/health-check Hono endpoint, maps results to quality presets that cap instant recording resolution, and runs an automatic health check on startup. A SolidJS NetworkStatus component surfaces health and speed status in the main window.

  • Recording flag bug: is_recording is set to true when recording starts but is never reset on recording failure/crash (only on explicit stop_recording), which permanently blocks speed tests after a crash.
  • Premature flag reset: set_recording_active(false) is called at the top of stop_recording before validating a recording exists or successfully stopping it.
  • Unauthenticated POST endpoint: The health-check POST handler accepts arbitrary-size payloads without authentication or rate limiting, which could be abused.
  • Indentation issue: <NetworkStatus /> in the main window JSX has incorrect indentation.

Confidence Score: 2/5

  • This PR has logic bugs in recording state management that could leave the speed test permanently blocked after a recording crash.
  • The recording-active flag is never cleared on recording failure, creating a stuck state. Additionally, the flag is cleared prematurely in stop_recording before validation. These are real bugs that will manifest in production when recordings crash or stop_recording is called unexpectedly.
  • apps/desktop/src-tauri/src/recording.rs needs the most attention — both the error path in spawn_actor and the ordering in stop_recording have issues with the is_recording flag lifecycle.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/speed_test.rs New module implementing speed test, health check, and network state management. Well-structured with proper async/locking patterns. Minor: label() method is unused (dead code).
apps/desktop/src-tauri/src/lib.rs Clean integration of speed_test module — registers commands, events, managed state, and startup health check. No issues found.
apps/desktop/src-tauri/src/recording.rs Integrates recording-active flag and speed-based resolution capping. Has two logic bugs: is_recording flag is never reset on recording failure/crash, and set_recording_active(false) is called before validating a recording exists in stop_recording.
apps/desktop/src/components/NetworkStatus.tsx New SolidJS component showing health and speed indicators with tooltips. Clean reactive patterns using signals, events, and mutations.
apps/desktop/src/routes/(window-chrome)/new-main/index.tsx Mounts NetworkStatus component at bottom of main window. Has an indentation mismatch — NetworkStatus uses 2 tabs while sibling RecoveryToast uses 3.
apps/web/app/api/desktop/[...route]/health-check.ts New Hono health-check endpoint with GET (health/auth) and POST (speed/upload test). POST accepts unauthenticated arbitrary-size payloads without rate limiting.
apps/web/app/api/desktop/[...route]/route.ts Registers the new health-check route under /api/desktop/health-check. Straightforward addition matching existing patterns.

Sequence Diagram

sequenceDiagram
    participant UI as NetworkStatus UI
    participant Tauri as Tauri Commands
    participant State as NetworkState
    participant API as /api/desktop/health-check

    Note over Tauri: App startup (3s delay)
    Tauri->>API: GET /health-check
    API-->>Tauri: {ok, authenticated}
    Tauri->>API: GET /health-check (X-Auth-Check)
    API-->>Tauri: 200 / 401
    Tauri->>API: POST /health-check (1KB, X-Upload-Test)
    API-->>Tauri: {ok, bytesReceived}
    Tauri->>State: Update health_check_result
    Tauri->>UI: HealthCheckUpdate event

    UI->>Tauri: runSpeedTest()
    Tauri->>State: Check is_recording (block if true)
    Tauri->>State: Set status = Running
    Tauri->>UI: SpeedTestUpdate(Running)
    Tauri->>API: POST /health-check (1MB, X-Speed-Test)
    API-->>Tauri: {ok, bytesReceived}
    Tauri->>State: Set status = Completed(result)
    Tauri->>UI: SpeedTestUpdate(Completed)

    Note over Tauri: start_recording()
    Tauri->>State: set_recording_active(true)
    Note over Tauri: Resolution = min(settings, speed_test)

    Note over Tauri: stop_recording()
    Tauri->>State: set_recording_active(false)
Loading

Comments Outside Diff (1)

  1. apps/desktop/src-tauri/src/recording.rs, line 1257-1264 (link)

    set_recording_active(false) called before state validation

    set_recording_active(&app, false) is called unconditionally at the start of stop_recording, before checking whether a recording is actually in progress (line 1260). If stop_recording is called when no recording exists, this will set is_recording = false even though it was never true — which is benign but incorrect. More importantly, if stop_recording fails partway through (e.g., at line 1264), the flag is already cleared, so the speed test thinks no recording is active while a partially-stopped recording may still be in progress.

    Consider moving this call after successfully stopping the recording:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src-tauri/src/recording.rs
    Line: 1257-1264
    
    Comment:
    **`set_recording_active(false)` called before state validation**
    
    `set_recording_active(&app, false)` is called unconditionally at the start of `stop_recording`, before checking whether a recording is actually in progress (line 1260). If `stop_recording` is called when no recording exists, this will set `is_recording = false` even though it was never `true` — which is benign but incorrect. More importantly, if `stop_recording` fails partway through (e.g., at line 1264), the flag is already cleared, so the speed test thinks no recording is active while a partially-stopped recording may still be in progress.
    
    Consider moving this call after successfully stopping the recording:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 1064

Comment:
**Recording failure leaves speed test permanently blocked**

When a recording fails or crashes (the error path in `spawn_actor` at line ~1078), `set_recording_active(&app, false)` is never called. The flag is set to `true` at line 1064, but only `stop_recording` (line 1257) resets it to `false`. If the recording actor errors out via the `Err(e)` branch, `is_recording` stays `true` forever, permanently blocking the speed test with "Cannot run speed test during an active recording".

You should reset the flag in the error handler as well:

```rust
                Err(e) => {
                    let mut state = state_mtx.write().await;

                    crate::speed_test::set_recording_active(&app, false).await;

                    let _ = RecordingEvent::Failed {
                        error: e.to_string(),
                    }
                    .emit(&app);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 1257-1264

Comment:
**`set_recording_active(false)` called before state validation**

`set_recording_active(&app, false)` is called unconditionally at the start of `stop_recording`, before checking whether a recording is actually in progress (line 1260). If `stop_recording` is called when no recording exists, this will set `is_recording = false` even though it was never `true` — which is benign but incorrect. More importantly, if `stop_recording` fails partway through (e.g., at line 1264), the flag is already cleared, so the speed test thinks no recording is active while a partially-stopped recording may still be in progress.

Consider moving this call after successfully stopping the recording:

```suggestion
    let mut state = state.write().await;
    let Some(current_recording) = state.clear_current_recording() else {
        return Err("Recording not in progress".to_string())?;
    };

    let completed_recording = current_recording.stop().await.map_err(|e| e.to_string())?;
    crate::speed_test::set_recording_active(&app, false).await;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/new-main/index.tsx
Line: 1987

Comment:
**Incorrect indentation level**

`<NetworkStatus />` is indented with 2 tabs, but its sibling `<RecoveryToast />` above uses 3 tabs. Both are children of the same parent `<div>`.

```suggestion
			<NetworkStatus />
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/web/app/api/desktop/[...route]/health-check.ts
Line: 21-39

Comment:
**Unauthenticated POST endpoint accepts arbitrary payloads**

The POST handler uses `withOptionalAuth` and reads the entire request body into memory with `c.req.arrayBuffer()` without any size limit or authentication requirement. Since the speed test sends 1MB payloads and there's no rate limiting on this route, this endpoint could be abused to consume server memory/bandwidth. Consider either:
- Requiring authentication (`withAuth`) for POST requests, or
- Adding a body size limit (e.g., rejecting bodies > 2MB), or
- Adding rate limiting to this endpoint

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: eb8e7b2

Greptile also left 3 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Implements upload speed testing with automatic quality adjustment and
a startup health check that verifies server reachability, auth, and
upload functionality. Addresses issue CapSoftware#73.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment on lines +222 to +235
{
let state = network_state.read().await;
if state.is_recording {
return Err("Cannot run speed test during an active recording".to_string());
}
if matches!(state.speed_test_status, SpeedTestStatus::Running) {
return Err("Speed test is already running".to_string());
}
}

{
let mut state = network_state.write().await;
state.speed_test_status = SpeedTestStatus::Running;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read() pre-check + later write() update has a race where two callers can both pass the pre-check and run concurrently. Doing check+set under a single write() lock closes that window.

Suggested change
{
let state = network_state.read().await;
if state.is_recording {
return Err("Cannot run speed test during an active recording".to_string());
}
if matches!(state.speed_test_status, SpeedTestStatus::Running) {
return Err("Speed test is already running".to_string());
}
}
{
let mut state = network_state.write().await;
state.speed_test_status = SpeedTestStatus::Running;
}
{
let mut state = network_state.write().await;
if state.is_recording {
return Err("Cannot run speed test during an active recording".to_string());
}
if matches!(state.speed_test_status, SpeedTestStatus::Running) {
return Err("Speed test is already running".to_string());
}
state.speed_test_status = SpeedTestStatus::Running;
}

Comment on lines +3 to +9
import {
createEffect,
createSignal,
onCleanup,
onMount,
Show,
} from "solid-js";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like createEffect is unused here (should trip lint/ts). Easy cleanup.

Suggested change
import {
createEffect,
createSignal,
onCleanup,
onMount,
Show,
} from "solid-js";
import { createSignal, onCleanup, onMount, Show } from "solid-js";

Comment on lines +25 to +35
if (isSpeedTest || isUploadTest) {
try {
const body = await c.req.arrayBuffer();
return c.json({
ok: true,
bytesReceived: body.byteLength,
timestamp: Date.now(),
});
} catch {
return c.json({ ok: false, message: "Failed to process upload" }, 500);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this endpoint reads the entire request body into memory, it’d be good to cap payload size (even if callers are expected to send ~1MB) to avoid someone posting an arbitrarily large body.

Suggested change
if (isSpeedTest || isUploadTest) {
try {
const body = await c.req.arrayBuffer();
return c.json({
ok: true,
bytesReceived: body.byteLength,
timestamp: Date.now(),
});
} catch {
return c.json({ ok: false, message: "Failed to process upload" }, 500);
}
if (isSpeedTest || isUploadTest) {
const maxBytes = 2 * 1024 * 1024;
const contentLength = Number(c.req.header("content-length") ?? "0");
if (contentLength && contentLength > maxBytes) {
return c.json({ ok: false, message: "Payload too large" }, 413);
}
try {
const body = await c.req.arrayBuffer();
if (body.byteLength > maxBytes) {
return c.json({ ok: false, message: "Payload too large" }, 413);
}
return c.json({
ok: true,
bytesReceived: body.byteLength,
timestamp: Date.now(),
});
} catch {
return c.json({ ok: false, message: "Failed to process upload" }, 500);
}
}

#[specta::specta]
#[instrument(skip(app, state))]
pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> {
crate::speed_test::set_recording_active(&app, false).await;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor logic bug: setting is_recording = false at the top of stop_recording opens a window where run_speed_test can run while current_recording.stop().await is still in progress. I’d move the flag flip to after stop() completes (or right before returning) so the speed-test guard matches reality.

let _ = RecordingEvent::Started.emit(&app);
let _ = RecordingStarted.emit(&app);

crate::speed_test::set_recording_active(&app, true).await;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recording failure leaves speed test permanently blocked

When a recording fails or crashes (the error path in spawn_actor at line ~1078), set_recording_active(&app, false) is never called. The flag is set to true at line 1064, but only stop_recording (line 1257) resets it to false. If the recording actor errors out via the Err(e) branch, is_recording stays true forever, permanently blocking the speed test with "Cannot run speed test during an active recording".

You should reset the flag in the error handler as well:

                Err(e) => {
                    let mut state = state_mtx.write().await;

                    crate::speed_test::set_recording_active(&app, false).await;

                    let _ = RecordingEvent::Failed {
                        error: e.to_string(),
                    }
                    .emit(&app);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 1064

Comment:
**Recording failure leaves speed test permanently blocked**

When a recording fails or crashes (the error path in `spawn_actor` at line ~1078), `set_recording_active(&app, false)` is never called. The flag is set to `true` at line 1064, but only `stop_recording` (line 1257) resets it to `false`. If the recording actor errors out via the `Err(e)` branch, `is_recording` stays `true` forever, permanently blocking the speed test with "Cannot run speed test during an active recording".

You should reset the flag in the error handler as well:

```rust
                Err(e) => {
                    let mut state = state_mtx.write().await;

                    crate::speed_test::set_recording_active(&app, false).await;

                    let _ = RecordingEvent::Failed {
                        error: e.to_string(),
                    }
                    .emit(&app);
```

How can I resolve this? If you propose a fix, please make it concise.

</Show>
</div>
<RecoveryToast />
<NetworkStatus />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect indentation level

<NetworkStatus /> is indented with 2 tabs, but its sibling <RecoveryToast /> above uses 3 tabs. Both are children of the same parent <div>.

Suggested change
<NetworkStatus />
<NetworkStatus />
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/new-main/index.tsx
Line: 1987

Comment:
**Incorrect indentation level**

`<NetworkStatus />` is indented with 2 tabs, but its sibling `<RecoveryToast />` above uses 3 tabs. Both are children of the same parent `<div>`.

```suggestion
			<NetworkStatus />
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +21 to +39
app.post("/", withOptionalAuth, async (c) => {
const isSpeedTest = c.req.header("X-Speed-Test") === "true";
const isUploadTest = c.req.header("X-Upload-Test") === "true";

if (isSpeedTest || isUploadTest) {
try {
const body = await c.req.arrayBuffer();
return c.json({
ok: true,
bytesReceived: body.byteLength,
timestamp: Date.now(),
});
} catch {
return c.json({ ok: false, message: "Failed to process upload" }, 500);
}
}

return c.json({ ok: true, timestamp: Date.now() });
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unauthenticated POST endpoint accepts arbitrary payloads

The POST handler uses withOptionalAuth and reads the entire request body into memory with c.req.arrayBuffer() without any size limit or authentication requirement. Since the speed test sends 1MB payloads and there's no rate limiting on this route, this endpoint could be abused to consume server memory/bandwidth. Consider either:

  • Requiring authentication (withAuth) for POST requests, or
  • Adding a body size limit (e.g., rejecting bodies > 2MB), or
  • Adding rate limiting to this endpoint
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/desktop/[...route]/health-check.ts
Line: 21-39

Comment:
**Unauthenticated POST endpoint accepts arbitrary payloads**

The POST handler uses `withOptionalAuth` and reads the entire request body into memory with `c.req.arrayBuffer()` without any size limit or authentication requirement. Since the speed test sends 1MB payloads and there's no rate limiting on this route, this endpoint could be abused to consume server memory/bandwidth. Consider either:
- Requiring authentication (`withAuth`) for POST requests, or
- Adding a body size limit (e.g., rejecting bodies > 2MB), or
- Adding rate limiting to this endpoint

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement speed test and health check for Cap desktop app

2 participants