0.37.1: fix folder-download hang/501 on special files + add download progress

Two issues with the 0.37.0 folder zip-download:

1. Hang / server error (reported as 501) on "some folders". archive_dir
   tried to zip every entry, including non-regular files. Opening a FIFO
   blocks forever (no writer); a unix socket / unreadable file raised an
   OSError that aborted the whole archive. Now only real regular files are
   zipped — FIFOs, sockets, devices and symlinks are skipped without ever
   open()-ing them, and a per-file read error skips just that file instead
   of failing the download.

2. No feedback while a large folder is being prepared. The zip is built
   server-side before any bytes flow, so the click felt dead. The Files
   page now shows an indeterminate "Preparing <name>…" bar from click,
   switching to a real percentage during the transfer (Content-Length is
   known for the finished zip). filesApi.download forwards onDownloadProgress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-21 20:11:50 +00:00
co-authored by Claude Opus 4.8
parent f1782eca0e
commit 0dc430bb2a
6 changed files with 97 additions and 23 deletions
+18 -4
View File
@@ -187,7 +187,13 @@ def archive_dir(path: str) -> tuple[str, str]:
Returns ``(tmp_zip_path, download_filename)``. The caller is responsible
for deleting the temp file once it has been streamed to the client.
Symlinks are skipped so the archive cannot escape the sandbox or loop.
Only regular files and real subdirectories are archived. Symlinks are
skipped (no sandbox escape / loops); special files (FIFOs, sockets,
devices) are skipped too — opening a FIFO would block forever and a
socket can't be read at all. Files that can't be read (permissions, or
that vanish mid-walk) are skipped individually rather than aborting the
whole archive.
"""
real = _safe_real(path)
if not os.path.isdir(real):
@@ -207,10 +213,18 @@ def archive_dir(path: str) -> tuple[str, str]:
zf.writestr(os.path.join(name, rel_root) + "/", "")
for f in files:
full = os.path.join(root, f)
if os.path.islink(full):
# os.path.isfile follows symlinks; combined with the islink
# check it admits only real regular files (skips FIFOs,
# sockets, devices and symlinks without ever open()-ing them).
if os.path.islink(full) or not os.path.isfile(full):
continue
arc = (os.path.join(name, rel_root, f) if rel_root != "."
else os.path.join(name, f))
try:
zf.write(full, arc)
except OSError:
# Unreadable or vanished mid-walk — skip just this file.
continue
zf.write(full, os.path.join(name, rel_root, f) if rel_root != "."
else os.path.join(name, f))
except OSError:
if os.path.exists(tmp):
os.unlink(tmp)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.37.0"
APP_VERSION = "0.37.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.37.0",
"version": "0.37.1",
"type": "module",
"scripts": {
"dev": "vite",
+13 -2
View File
@@ -58,8 +58,19 @@ export const filesApi = {
.post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
download: async (path: string, filename: string, agentId?: number) => {
const res = await api.get(`${base(agentId)}/download`, { params: { path }, responseType: "blob" });
download: async (
path: string,
filename: string,
agentId?: number,
onProgress?: (loaded: number, total: number | undefined) => void,
) => {
const res = await api.get(`${base(agentId)}/download`, {
params: { path },
responseType: "blob",
onDownloadProgress: onProgress
? (e) => onProgress(e.loaded, e.total || undefined)
: undefined,
});
triggerDownload(res.data as Blob, filename);
},
+54 -15
View File
@@ -83,7 +83,12 @@ export function Files() {
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
const [clip, setClip] = useState<Clipboard | null>(null);
const [pasteConflict, setPasteConflict] = useState(false);
const [progress, setProgress] = useState<{ label: string; pct: number } | null>(null);
const [progress, setProgress] = useState<{
label: string;
pct: number;
kind: "upload" | "download";
indeterminate?: boolean;
} | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
@@ -111,9 +116,9 @@ export function Files() {
const upload = useMutation({
mutationFn: (file: File) => {
setProgress({ label: file.name, pct: 0 });
setProgress({ label: file.name, pct: 0, kind: "upload" });
return filesApi.upload(path, file, false, "", host, (pct) =>
setProgress({ label: file.name, pct })
setProgress({ label: file.name, pct, kind: "upload" })
);
},
onSuccess: (r) => {
@@ -149,6 +154,7 @@ export function Files() {
setProgress({
label: `${f.name} (${i + 1}/${total})`,
pct: ((i + filePct / 100) / total) * 100,
kind: "upload",
})
);
ok += 1;
@@ -200,10 +206,27 @@ export function Files() {
onError: (e) => toast.error(apiErrorMessage(e)),
});
const download = (e: HostPathEntry) =>
filesApi
.download(join(path, e.name), e.type === "dir" ? `${e.name}.zip` : e.name, host)
.catch((err) => toast.error(apiErrorMessage(err)));
const download = (e: HostPathEntry) => {
const isDir = e.type === "dir";
// Folders are zipped server-side first (no bytes yet) — start with an
// indeterminate "Preparing…" bar, then switch to % once data flows.
setProgress({ label: e.name, pct: 0, kind: "download", indeterminate: true });
return filesApi
.download(
join(path, e.name),
isDir ? `${e.name}.zip` : e.name,
host,
(loaded, total) =>
setProgress({
label: e.name,
pct: total ? (loaded / total) * 100 : 0,
kind: "download",
indeterminate: !total,
}),
)
.catch((err) => toast.error(apiErrorMessage(err)))
.finally(() => setProgress(null));
};
return (
<div className="space-y-4">
@@ -294,21 +317,37 @@ export function Files() {
</div>
</div>
{/* Upload progress */}
{/* Upload / download progress */}
{progress && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
<span className="flex min-w-0 items-center gap-1.5">
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Uploading {progress.label}</span>
{progress.kind === "download" ? (
<Download className="h-3.5 w-3.5 shrink-0" />
) : (
<Upload className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">
{progress.kind === "download"
? progress.indeterminate
? `Preparing ${progress.label}`
: `Downloading ${progress.label}`
: `Uploading ${progress.label}`}
</span>
</span>
<span className="tabular-nums">
{progress.indeterminate ? "" : `${Math.round(progress.pct)}%`}
</span>
<span className="tabular-nums">{Math.round(progress.pct)}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-full rounded-full bg-accent transition-all dark:bg-accent-dark"
style={{ width: `${progress.pct}%` }}
/>
{progress.indeterminate ? (
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-accent dark:bg-accent-dark" />
) : (
<div
className="h-full rounded-full bg-accent transition-all dark:bg-accent-dark"
style={{ width: `${progress.pct}%` }}
/>
)}
</div>
</div>
)}
+10
View File
@@ -37,6 +37,16 @@ export default {
pill: "var(--sp-r-pill)",
chip: "var(--sp-r-chip)",
},
keyframes: {
// Sliding bar for indeterminate progress (e.g. server zipping a folder).
indeterminate: {
"0%": { transform: "translateX(-100%)" },
"100%": { transform: "translateX(400%)" },
},
},
animation: {
indeterminate: "indeterminate 1.2s ease-in-out infinite",
},
},
},
plugins: [],