mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
15
desktop/src/shared/lib/trimMapToSize.ts
Normal file
15
desktop/src/shared/lib/trimMapToSize.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user