diff --git a/debian/changelog b/debian/changelog index 6480b1a..c765768 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,14 @@ +devdesk (0.7.2) bookworm; urgency=medium + + * Fix: Go to file failed in big non-git folders such as /root/code: the + file walk was cut off at its cap with SIGPIPE, which failed the command + and truncated its reply. Producers piped into a cap now tolerate it (also + for very large folders and git status). Outside git, Go to file skips + build/cache folders and stops at 10,000 files. + * Large side-channel replies no longer cost quadratic time in the browser. + + -- Jared Vititoe Thu, 08 Oct 2026 01:50:50 -0400 + devdesk (0.7.1) bookworm; urgency=medium * Files pane reworked like VS Code (#37): diff --git a/libexec/devdesk-files.sh b/libexec/devdesk-files.sh index 2dc64e0..0bec86e 100644 --- a/libexec/devdesk-files.sh +++ b/libexec/devdesk-files.sh @@ -7,6 +7,10 @@ : "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing : "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}" +# Producers piped into `head` get SIGPIPE once the cap is reached; under +# pipefail that would fail the command and cut its reply short, so they are +# wrapped as `{ producer || true; }`. + # Blob replies: a JSON header line, then base64 between markers (no body for # binary files the viewer can't show). The client waits for the END marker. DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN @@ -81,7 +85,7 @@ cmd_fs_list() { [ -d "$dir" ] || dd_die "not a folder: $rel" tmp=$(mktemp) # %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name. - find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null | + { find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null || true; } | head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" >"$tmp" if git --no-optional-locks -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true) @@ -98,16 +102,18 @@ cmd_fs_list() { # --fs-find SESSION: every file under the session folder, for "go to file". # git ls-files in a repository (tracked + untracked, .gitignore honoured), else -# find without node_modules and .git; capped at DEVDESK_FS_FIND_MAX. +# find without node_modules, .git and build/cache folders; capped at +# DEVDESK_FS_FIND_MAX (10000). cmd_fs_find() { [ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION" - local root max=${DEVDESK_FS_FIND_MAX:-20000} + local root max=${DEVDESK_FS_FIND_MAX:-10000} root=$(dd_session_root "$1") if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null + git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null || true else - (cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv \) -prune \ - -o -type f -printf '%P\0' 2>/dev/null) + (cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv -o -name venv \ + -o -name dist -o -name build -o -name target -o -name .cache -o -name .next -o -name coverage \) -prune \ + -o -type f -printf '%P\0' 2>/dev/null) || true fi | head -z -n "$((max + 1))" | jq -cMRs --argjson max "$max" --arg root "$root" \ '(split("\u0000") | map(select(length > 0))) as $f | {root: $root, truncated: ($f | length > $max), files: $f[:$max]}' @@ -142,9 +148,9 @@ cmd_git_status() { branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)") { printf 'S\n' - "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null | head -z -n 1000 | tr '\0' '\n' + { "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n' printf 'N\n' - "${g[@]}" diff --numstat HEAD -- . 2>/dev/null | head -n 1000 + { "${g[@]}" diff --numstat HEAD -- . 2>/dev/null || true; } | head -n 1000 } | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \ --rawfile e <(cat -- "$edits" 2>/dev/null || true) ' (split("\nN\n") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st diff --git a/tests/attach.bats b/tests/attach.bats index 8048483..1a560a7 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -611,6 +611,12 @@ blob_body() { rm -rf "$p/.git" run devdesk attach --fs-find alpha [ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ] + # Hitting the cap must not fail the command (SIGPIPE in the producer). + for i in $(seq 1 3000); do : >"$p/src/f$i.txt"; done run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha + [ "$status" -eq 0 ] [ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ] + run env DEVDESK_FS_LIST_MAX=5 "$REPO/bin/devdesk" attach --fs-list alpha src + [ "$status" -eq 0 ] + [ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ] } diff --git a/web/src/connection.js b/web/src/connection.js index 1ebb131..0ee66f5 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -152,13 +152,21 @@ async function runCommandNow(args, timeoutMs) { clearTimeout(timer); resolve(new TextDecoder().decode(concat(chunks))); }; + const decoder = new TextDecoder(); + let head = ''; + let tail = ''; socket.onmessage = (event) => { const { type, payload } = decodeServer(event.data); if (type !== Server.OUTPUT) return; chunks.push(payload.slice()); + const piece = decoder.decode(payload, { stream: true }); + if (head.length < 4096) head += piece; + tail = (tail + piece).slice(-64); // Hang up as soon as the answer is complete: devdesk waits for that // before exiting, so ttyd can't drop its last output (devdesk#35). - if (isComplete(new TextDecoder().decode(concat(chunks)))) { + // Replies are one JSON line (or a devdesk: error), so only a chunk that + // ends a line with "}" is worth a full parse; big replies stay linear. + if (/^devdesk: .*\n/m.test(head) || (/\}\s*\n\s*$/.test(tail) && isComplete(new TextDecoder().decode(concat(chunks))))) { finish(); socket.close(); } diff --git a/web/src/files.js b/web/src/files.js index b7afeb8..6ab8468 100644 --- a/web/src/files.js +++ b/web/src/files.js @@ -406,7 +406,7 @@ export class FilesPane { async loadFileList() { if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items; - const list = parseListOutput(await runCommand(['--fs-find', this.session], 20000)); + const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000)); const items = list.files.map((path) => { const name = base(path); const [icon] = iconFor(name);