Skip to content

Commit 526d51e

Browse files
Merge branch 'Th0rgal:master' into master
2 parents d993d2a + 519e7b2 commit 526d51e

13 files changed

Lines changed: 1137 additions & 137 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
name = "sandboxed_sh"
33
version = "0.11.1"
44
edition = "2021"
5+
rust-version = "1.91"
56
description = "Self-hosted orchestrator for AI coding agents (formerly Open Agent)"
67
authors = ["sandboxed.sh Contributors"]
78

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# ---------------------------------------------------------------------------
1212
# Stage 1: Rust builder
1313
# ---------------------------------------------------------------------------
14-
FROM rust:1.88-bookworm AS rust-builder
14+
FROM rust:1.91-bookworm AS rust-builder
1515

1616
WORKDIR /build
1717

dashboard/src/app/control/control-client.tsx

Lines changed: 353 additions & 25 deletions
Large diffs are not rendered by default.

dashboard/src/components/markdown-content.tsx

Lines changed: 160 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,35 +30,116 @@ interface MarkdownContentProps {
3030
missionId?: string;
3131
}
3232

33-
// Global cache for fetched image URLs with automatic cleanup
34-
// Uses a simple LRU-style eviction: when cache exceeds limit, oldest entries are revoked
33+
// Global, refcounted cache of fetched image blob URLs.
34+
//
35+
// Why refcounted: the previous implementation revoked the oldest URL on
36+
// LRU eviction, but that URL was often still the `src=` of a mounted
37+
// `<img>` element somewhere on the page (the URL lives in component
38+
// state, not just in this map). Revoking it under the running DOM made
39+
// the browser show its broken-image icon — but a click-through to the
40+
// preview modal triggered a fresh fetch and worked, which matched the
41+
// reported "image fails until I click the broken icon" symptom exactly.
42+
//
43+
// New contract: every consumer that uses a URL from this cache must call
44+
// `acquireCachedImageUrl` (or `cacheAndAcquireImageUrl` if it just
45+
// fetched) and pair it with a later `releaseImageUrl`. The cache only
46+
// revokes a URL when its refcount reaches zero AND the entry has been
47+
// LRU-evicted, so a URL stuck in a mounted `<img>` keeps working until
48+
// that `<img>` unmounts.
3549
const IMAGE_CACHE_LIMIT = 50;
36-
const imageUrlCache = new Map<string, string>();
3750

38-
function cacheImageUrl(path: string, url: string): void {
39-
// If already cached, revoke the duplicate URL and update access order
40-
if (imageUrlCache.has(path)) {
41-
// Revoke the incoming duplicate URL to prevent memory leak from concurrent fetches
51+
interface ImageCacheEntry {
52+
url: string;
53+
refCount: number;
54+
/** Marked when LRU-evicted but kept alive because refCount > 0. The
55+
* next `releaseImageUrl` that drops refCount to 0 will revoke and
56+
* drop the entry instead of leaving it indefinitely. */
57+
evicted: boolean;
58+
}
59+
60+
const imageUrlCache = new Map<string, ImageCacheEntry>();
61+
62+
/** Try to read a cached URL for `path` and acquire a reference. Returns
63+
* null when not cached. Callers must pair every successful return with
64+
* exactly one `releaseImageUrl(path)` on unmount. */
65+
function acquireCachedImageUrl(path: string): string | null {
66+
const entry = imageUrlCache.get(path);
67+
if (!entry) return null;
68+
entry.refCount++;
69+
// Re-acquired by a new consumer, so the entry is back to useful —
70+
// clear any stale eviction mark from a prior overflow that didn't
71+
// actually need to revoke this URL after all.
72+
entry.evicted = false;
73+
return entry.url;
74+
}
75+
76+
/** Insert a freshly-fetched blob URL and acquire one reference. Caller
77+
* owns the reference and must release it on unmount. Concurrent fetches
78+
* for the same path collapse onto the existing entry — the duplicate
79+
* URL is revoked here so the caller's later `releaseImageUrl` decrements
80+
* the canonical entry's refCount instead of leaking. */
81+
function cacheAndAcquireImageUrl(path: string, url: string): string {
82+
const existing = imageUrlCache.get(path);
83+
if (existing) {
4284
URL.revokeObjectURL(url);
43-
const existingUrl = imageUrlCache.get(path)!;
44-
imageUrlCache.delete(path);
45-
imageUrlCache.set(path, existingUrl);
46-
return;
85+
existing.refCount++;
86+
// The entry is being acquired again, so it's clearly still useful —
87+
// clear any prior eviction mark so a subsequent release doesn't
88+
// revoke a URL we're actively re-using.
89+
existing.evicted = false;
90+
return existing.url;
4791
}
4892

49-
// Evict oldest entries if at limit
93+
// LRU eviction strategy:
94+
// 1. Drop refCount=0 entries first — they're safe to revoke immediately.
95+
// 2. If everything is referenced, mark only the OLDEST entry (first
96+
// in insertion order) as `evicted`, so its last consumer's
97+
// `releaseImageUrl` revokes it. We don't mark more than one —
98+
// that would needlessly destroy cache effectiveness for entries
99+
// that callers might still re-acquire. If the working set really
100+
// exceeds the cap, future inserts will mark additional entries
101+
// one-by-one as they overflow.
102+
// 3. The cache may temporarily exceed `IMAGE_CACHE_LIMIT` when all
103+
// entries are referenced; that's fine and self-corrects as
104+
// consumers unmount.
50105
while (imageUrlCache.size >= IMAGE_CACHE_LIMIT) {
106+
let dropped = false;
107+
for (const [key, entry] of imageUrlCache) {
108+
if (entry.refCount === 0) {
109+
URL.revokeObjectURL(entry.url);
110+
imageUrlCache.delete(key);
111+
dropped = true;
112+
break;
113+
}
114+
}
115+
if (dropped) continue;
116+
117+
// No droppable entries — every URL is live. Mark only the oldest
118+
// for revoke-on-last-release and stop.
51119
const oldestKey = imageUrlCache.keys().next().value;
52120
if (oldestKey) {
53-
const oldUrl = imageUrlCache.get(oldestKey);
54-
if (oldUrl) {
55-
URL.revokeObjectURL(oldUrl);
121+
const oldest = imageUrlCache.get(oldestKey);
122+
if (oldest && !oldest.evicted) {
123+
oldest.evicted = true;
56124
}
57-
imageUrlCache.delete(oldestKey);
58125
}
126+
break;
59127
}
60128

61-
imageUrlCache.set(path, url);
129+
imageUrlCache.set(path, { url, refCount: 1, evicted: false });
130+
return url;
131+
}
132+
133+
/** Decrement the refcount for `path`. If it reaches zero AND the entry
134+
* was previously LRU-evicted, revoke the URL and drop it. */
135+
function releaseImageUrl(path: string): void {
136+
const entry = imageUrlCache.get(path);
137+
if (!entry) return;
138+
entry.refCount = Math.max(0, entry.refCount - 1);
139+
if (entry.refCount === 0 && entry.evicted) {
140+
URL.revokeObjectURL(entry.url);
141+
imageUrlCache.delete(path);
142+
}
62143
}
63144

64145
function isFilePath(str: string): boolean {
@@ -155,8 +236,13 @@ function FilePreviewModalContent({
155236
const FileIcon = getFileIcon(path);
156237
const fileName = path.split("/").pop() || "file";
157238

158-
const [imageUrl, setImageUrl] = useState<string | null>(imageUrlCache.get(resolvedPath) || null);
159-
const [loading, setLoading] = useState(!imageUrl && isImage);
239+
// imageUrl initial state is null on purpose — the effect below acquires
240+
// (and refcounts) the cached URL on mount instead of reading the cache
241+
// directly here. Reading cache without acquiring would let LRU eviction
242+
// revoke the URL out from under this component's `<img src>`, which is
243+
// the bug this refcount fix is closing.
244+
const [imageUrl, setImageUrl] = useState<string | null>(null);
245+
const [loading, setLoading] = useState(isImage);
160246
const [error, setError] = useState<string | null>(null);
161247
const [fileSize, setFileSize] = useState<number | null>(null);
162248
const [downloading, setDownloading] = useState(false);
@@ -167,9 +253,22 @@ function FilePreviewModalContent({
167253

168254
// Fetch image on mount
169255
useEffect(() => {
170-
if (!isImage || imageUrl) return;
256+
if (!isImage) return;
171257

172258
let cancelled = false;
259+
let acquired = false;
260+
261+
const cached = acquireCachedImageUrl(resolvedPath);
262+
if (cached) {
263+
setImageUrl(cached);
264+
setLoading(false);
265+
acquired = true;
266+
return () => {
267+
cancelled = true;
268+
if (acquired) releaseImageUrl(resolvedPath);
269+
};
270+
}
271+
173272
const fetchImage = async () => {
174273
const API_BASE = getRuntimeApiBase();
175274
const params = new URLSearchParams({ path: resolvedPath });
@@ -187,8 +286,15 @@ function FilePreviewModalContent({
187286
const blob = await res.blob();
188287
if (!cancelled) setFileSize(blob.size);
189288
const url = URL.createObjectURL(blob);
190-
cacheImageUrl(resolvedPath, url);
191-
if (!cancelled) setImageUrl(url);
289+
if (cancelled) {
290+
// Component unmounted before fetch landed — don't put this URL
291+
// into the cache where it would leak; just revoke it directly.
292+
URL.revokeObjectURL(url);
293+
return;
294+
}
295+
const stored = cacheAndAcquireImageUrl(resolvedPath, url);
296+
acquired = true;
297+
setImageUrl(stored);
192298
} catch (err) {
193299
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load");
194300
} finally {
@@ -197,8 +303,11 @@ function FilePreviewModalContent({
197303
};
198304

199305
fetchImage();
200-
return () => { cancelled = true; };
201-
}, [isImage, imageUrl, resolvedPath]);
306+
return () => {
307+
cancelled = true;
308+
if (acquired) releaseImageUrl(resolvedPath);
309+
};
310+
}, [isImage, resolvedPath, workspaceId, missionId]);
202311

203312
// Fetch text preview on mount
204313
useEffect(() => {
@@ -546,13 +655,25 @@ function InlineImagePreview({
546655
missionId?: string;
547656
}) {
548657
const resolvedPath = resolvePath(path, basePath);
549-
const [imageUrl, setImageUrl] = useState<string | null>(imageUrlCache.get(resolvedPath) || null);
550-
const [loading, setLoading] = useState(!imageUrl);
658+
const [imageUrl, setImageUrl] = useState<string | null>(null);
659+
const [loading, setLoading] = useState(true);
551660
const [error, setError] = useState<string | null>(null);
552661

553662
useEffect(() => {
554-
if (imageUrl) return;
555663
let cancelled = false;
664+
let acquired = false;
665+
666+
const cached = acquireCachedImageUrl(resolvedPath);
667+
if (cached) {
668+
setImageUrl(cached);
669+
setLoading(false);
670+
acquired = true;
671+
return () => {
672+
cancelled = true;
673+
if (acquired) releaseImageUrl(resolvedPath);
674+
};
675+
}
676+
556677
const fetchImage = async () => {
557678
const API_BASE = getRuntimeApiBase();
558679
const params = new URLSearchParams({ path: resolvedPath });
@@ -569,17 +690,25 @@ function InlineImagePreview({
569690
}
570691
const blob = await res.blob();
571692
const url = URL.createObjectURL(blob);
572-
cacheImageUrl(resolvedPath, url);
573-
if (!cancelled) setImageUrl(url);
693+
if (cancelled) {
694+
URL.revokeObjectURL(url);
695+
return;
696+
}
697+
const stored = cacheAndAcquireImageUrl(resolvedPath, url);
698+
acquired = true;
699+
setImageUrl(stored);
574700
} catch (err) {
575701
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load");
576702
} finally {
577703
if (!cancelled) setLoading(false);
578704
}
579705
};
580706
fetchImage();
581-
return () => { cancelled = true; };
582-
}, [imageUrl, resolvedPath, workspaceId, missionId]);
707+
return () => {
708+
cancelled = true;
709+
if (acquired) releaseImageUrl(resolvedPath);
710+
};
711+
}, [resolvedPath, workspaceId, missionId]);
583712

584713
if (error) {
585714
return (

dashboard/src/lib/api/missions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ export async function getMissionEvents(
177177
/** When set, request only events with `sequence > sinceSeq`.
178178
* Takes precedence over `offset`/`latest` on the server. */
179179
sinceSeq?: number;
180+
/** When set, request only events with `sequence < beforeSeq`,
181+
* returned ASC. Takes precedence over `sinceSeq`/`offset`/`latest`.
182+
* Used for backwards pagination. */
183+
beforeSeq?: number;
180184
}
181185
): Promise<StoredEvent[]> {
182186
const { events } = await getMissionEventsWithMeta(id, options);
@@ -208,6 +212,7 @@ export async function getMissionEventsWithMeta(
208212
offset?: number;
209213
latest?: boolean;
210214
sinceSeq?: number;
215+
beforeSeq?: number;
211216
}
212217
): Promise<{ events: StoredEvent[]; meta: MissionEventsMeta }> {
213218
const params = new URLSearchParams();
@@ -216,6 +221,7 @@ export async function getMissionEventsWithMeta(
216221
if (options?.offset) params.set("offset", String(options.offset));
217222
if (options?.latest) params.set("latest", "true");
218223
if (options?.sinceSeq !== undefined) params.set("since_seq", String(options.sinceSeq));
224+
if (options?.beforeSeq !== undefined) params.set("before_seq", String(options.beforeSeq));
219225
const query = params.toString();
220226
const res = await apiFetch(
221227
`/api/control/missions/${id}/events${query ? `?${query}` : ""}`

0 commit comments

Comments
 (0)