forked from wrenn/wrenn
Fix file browser crash on non-regular files and connection leaks
- envd: reject non-regular files (devices, pipes, sockets) in GetFiles to prevent infinite reads from /dev/zero, /dev/urandom etc. - host agent: add context cancellation check in ReadFileStream loop with proper Connect error codes - frontend: abort in-flight file reads on file switch, directory navigation, and component teardown via AbortController - frontend: guard against abort errors surfacing in UI, use try/finally for fileLoading state
This commit is contained in:
@ -72,7 +72,11 @@ export async function listDir(capsuleId: string, path: string, depth = 1): Promi
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFile(capsuleId: string, path: string): Promise<ApiResult<string>> {
|
||||
export async function readFile(
|
||||
capsuleId: string,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApiResult<string>> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||
@ -81,6 +85,7 @@ export async function readFile(capsuleId: string, path: string): Promise<ApiResu
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ path }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@ -95,12 +100,20 @@ export async function readFile(capsuleId: string, path: string): Promise<ApiResu
|
||||
const blob = await res.blob();
|
||||
const text = await blob.text();
|
||||
return { ok: true, data: text };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
return { ok: false, error: 'Request aborted' };
|
||||
}
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadFile(capsuleId: string, path: string, filename: string): Promise<void> {
|
||||
export async function downloadFile(
|
||||
capsuleId: string,
|
||||
path: string,
|
||||
filename: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||
|
||||
@ -108,6 +121,7 @@ export async function downloadFile(capsuleId: string, path: string, filename: st
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ path }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
listDir,
|
||||
readFile,
|
||||
@ -37,6 +38,14 @@
|
||||
let dirGeneration = 0;
|
||||
let fileGeneration = 0;
|
||||
|
||||
// AbortController for in-flight file reads — aborted when the user
|
||||
// selects a different file or the component is torn down.
|
||||
let fileAbort: AbortController | null = null;
|
||||
|
||||
onDestroy(() => {
|
||||
fileAbort?.abort();
|
||||
});
|
||||
|
||||
const MAX_PREVIEW_LINES = 5000;
|
||||
const MAX_HIGHLIGHT_LINES = 2000; // Don't tokenize huge files — diminishing returns
|
||||
|
||||
@ -83,6 +92,10 @@
|
||||
const canGoUp = $derived(currentPath !== '/' && currentPath.startsWith('/'));
|
||||
|
||||
async function navigateTo(path: string) {
|
||||
// Abort any in-flight file read and invalidate stale generation so the
|
||||
// abort error isn't surfaced in the UI.
|
||||
fileAbort?.abort();
|
||||
++fileGeneration;
|
||||
currentPath = normalizePath(path);
|
||||
pathInput = currentPath;
|
||||
selectedFile = null;
|
||||
@ -146,6 +159,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any in-flight file read before starting a new one.
|
||||
fileAbort?.abort();
|
||||
|
||||
selectedFile = entry;
|
||||
fileContent = null;
|
||||
fileError = null;
|
||||
@ -159,26 +175,31 @@
|
||||
|
||||
fileLoading = true;
|
||||
const gen = ++fileGeneration;
|
||||
const result = await readFile(capsuleId, entry.path);
|
||||
if (gen !== fileGeneration) return; // stale response — user clicked another file
|
||||
if (result.ok) {
|
||||
if (looksLikeBinary(result.data)) {
|
||||
fileContent = null;
|
||||
} else {
|
||||
fileContent = result.data;
|
||||
// Kick off highlighting in the background — preview shows plain text immediately.
|
||||
// Only tokenize up to MAX_HIGHLIGHT_LINES to avoid freezing on large files.
|
||||
const linesToHighlight = result.data.split('\n').length > MAX_HIGHLIGHT_LINES
|
||||
? result.data.split('\n').slice(0, MAX_HIGHLIGHT_LINES).join('\n')
|
||||
: result.data;
|
||||
tokenize(linesToHighlight, entry.name).then((tokens) => {
|
||||
if (gen === fileGeneration) highlightedTokens = tokens;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
fileAbort = controller;
|
||||
try {
|
||||
const result = await readFile(capsuleId, entry.path, controller.signal);
|
||||
if (gen !== fileGeneration) return; // stale response — user clicked another file
|
||||
if (result.ok) {
|
||||
if (looksLikeBinary(result.data)) {
|
||||
fileContent = null;
|
||||
} else {
|
||||
fileContent = result.data;
|
||||
// Kick off highlighting in the background — preview shows plain text immediately.
|
||||
// Only tokenize up to MAX_HIGHLIGHT_LINES to avoid freezing on large files.
|
||||
const linesToHighlight = result.data.split('\n').length > MAX_HIGHLIGHT_LINES
|
||||
? result.data.split('\n').slice(0, MAX_HIGHLIGHT_LINES).join('\n')
|
||||
: result.data;
|
||||
tokenize(linesToHighlight, entry.name).then((tokens) => {
|
||||
if (gen === fileGeneration) highlightedTokens = tokens;
|
||||
});
|
||||
}
|
||||
} else if (result.error !== 'Request aborted') {
|
||||
fileError = result.error;
|
||||
}
|
||||
} else {
|
||||
fileError = result.error;
|
||||
} finally {
|
||||
if (gen === fileGeneration) fileLoading = false;
|
||||
}
|
||||
fileLoading = false;
|
||||
}
|
||||
|
||||
function looksLikeBinary(text: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user