fix(desktop): plug memory leaks from log reads, stale caches, and unbounded maps (#377)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-21 08:44:58 -06:00
committed by GitHub
parent 60b51806dd
commit 818b4885e2
8 changed files with 92 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
use std::{
fs::{self, File, OpenOptions},
io::{BufRead, BufReader, Write},
io::{Read as _, Seek, SeekFrom, Write},
path::{Path, PathBuf},
};
@@ -58,7 +58,25 @@ pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> R
fs::write(&path, payload).map_err(|error| format!("failed to write agent store: {error}"))
}
/// Maximum log file size before rotation (10 MB).
const MAX_LOG_FILE_SIZE: u64 = 10 * 1024 * 1024;
/// If `path` exceeds [`MAX_LOG_FILE_SIZE`], rotate it to `<path>.1`.
fn maybe_rotate_log(path: &Path) {
let size = match fs::metadata(path) {
Ok(m) => m.len(),
Err(_) => return,
};
if size <= MAX_LOG_FILE_SIZE {
return;
}
let mut rotated = path.as_os_str().to_owned();
rotated.push(".1");
let _ = fs::rename(path, &rotated);
}
pub(crate) fn open_log_file(path: &Path) -> Result<File, String> {
maybe_rotate_log(path);
OpenOptions::new()
.create(true)
.append(true)
@@ -118,13 +136,49 @@ pub fn read_log_tail(path: &Path, max_lines: usize) -> Result<String, String> {
return Ok(String::new());
}
let file = File::open(path)
let mut file = File::open(path)
.map_err(|error| format!("failed to read log file {}: {error}", path.display()))?;
let reader = BufReader::new(file);
let lines = reader
.lines()
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("failed to read log lines: {error}"))?;
let file_len = file
.seek(SeekFrom::End(0))
.map_err(|error| format!("failed to seek log file: {error}"))?;
if file_len == 0 {
return Ok(String::new());
}
// Read backward in chunks to find enough newlines.
const CHUNK_SIZE: u64 = 8 * 1024;
let mut buf = Vec::new();
let mut remaining = file_len;
let mut newline_count: usize = 0;
// We need max_lines + 1 newlines to delimit max_lines lines (the trailing
// newline of the last line counts as one).
let target_newlines = max_lines + 1;
while remaining > 0 && newline_count < target_newlines {
let chunk = remaining.min(CHUNK_SIZE);
remaining -= chunk;
file.seek(SeekFrom::Start(remaining))
.map_err(|error| format!("failed to seek log file: {error}"))?;
let mut tmp = vec![0u8; chunk as usize];
file.read_exact(&mut tmp)
.map_err(|error| format!("failed to read log chunk: {error}"))?;
// Prepend this chunk so buf always has the tail of the file.
tmp.append(&mut buf);
buf = tmp;
newline_count = bytecount_newlines(&buf);
}
let text = String::from_utf8_lossy(&buf);
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(max_lines);
Ok(lines[start..].join("\n"))
}
fn bytecount_newlines(buf: &[u8]) -> usize {
buf.iter().filter(|&&b| b == b'\n').count()
}

View File

@@ -5,6 +5,8 @@ import {
useRouterState,
} from "@tanstack/react-router";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
type RouterHistoryState = {
__TSR_index?: number;
__TSR_key?: string;
@@ -53,6 +55,7 @@ export function useBackForwardControls() {
}
keysByIndex.set(locationIndex, locationKey);
trimMapToSize(keysByIndex, 200);
setMaxIndex((current: number) => {
if (currentKey && currentKey !== locationKey) {
return locationIndex;

View File

@@ -484,7 +484,7 @@ export function useManagedAgentLogQuery(
enabled: pubkey !== null,
retry: false,
staleTime: 3_000,
refetchInterval: pubkey ? 5_000 : false,
refetchInterval: pubkey ? 30_000 : false,
});
}

View File

@@ -139,7 +139,7 @@ export function useChannelMessagesQuery(channel: Channel | null) {
return mergedHistory;
},
staleTime: Number.POSITIVE_INFINITY,
staleTime: 5 * 60 * 1_000,
gcTime: 5 * 60 * 1_000,
});
}

View File

@@ -1,5 +1,7 @@
import * as React from "react";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
export type DraftState = {
content: string;
selectionStart: number;
@@ -14,7 +16,9 @@ export function useDrafts() {
if (draft.content.trim().length === 0) {
return;
}
draftsRef.current.set(channelId, draft);
const drafts = draftsRef.current;
drafts.set(channelId, draft);
trimMapToSize(drafts, 50);
},
[],
);

View File

@@ -7,6 +7,7 @@ import {
import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { hasMention } from "./hasMention";
const MENTION_DEBOUNCE_MS = 120;
@@ -173,7 +174,9 @@ export function useMentions(channelId: string | null) {
const nextContent = `${before}${inserted}${after}`;
const nextCursor = before.length + inserted.length;
mentionMapRef.current.set(displayName, suggestion.pubkey);
const mentions = mentionMapRef.current;
mentions.set(displayName, suggestion.pubkey);
trimMapToSize(mentions, 200);
setMentionQuery(null);
setMentionSelectedIndex(0);

View File

@@ -793,8 +793,8 @@ export class RelayClient {
if (this.wsId !== null) {
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
() => {
return;
(err) => {
console.warn("[RelayClientSession] disconnect failed:", err);
},
);
}

View File

@@ -0,0 +1,15 @@
/**
* Trims a Map to at most `maxSize` entries by deleting the oldest
* (earliest-inserted) keys. Maps iterate in insertion order, so the
* first keys are the oldest.
*/
export function trimMapToSize<K, V>(map: Map<K, V>, maxSize: number): void {
if (map.size <= maxSize) return;
const excess = map.size - maxSize;
let removed = 0;
for (const key of map.keys()) {
if (removed >= excess) break;
map.delete(key);
removed++;
}
}