fix(host-service): bound the diff patch, and stop depending on the launch directory
Two host-service worker failures, both first seen 2026-09-07, unrelated except that both end in a worker. One root cause each, in its own section.
Is this worth fixing?
1. User-visible harm. 5C: in a workspace whose changeset produces more than ~512 MB of patch text — one unignored large file is enough, since an untracked file's diff is the whole file — every file in the Changes pane renders as "Unable to load diff" with a Retry that can never succeed, for as long as that file is on disk. It is deterministic for that working tree, not a flake. 5D: on a host whose launch directory has been deleted (start a host from a shell inside a workspace, later delete that workspace — ordinary), no worker thread can start; after three attempts HostWorkerPool opens its crash circuit and every git status/diff runs on the host-service event loop for the rest of that process's life, which is exactly what the pool exists to prevent.
2. Does the change reach the reported failure? Yes for both. 5C came from 1.27.0 with the host-service bundled in the desktop app (dist/main/host-worker.js in the stack); 5D from a standalone 1.27.0 host on Linux (no run_mode/sandbox tags, so a CLI/systemd launch through serve.ts). Both files ship in the shared desktop/host-service/CLI release, so the fix reaches both surfaces in the next one; nothing here is behind a flag.
3. Why code, and not something else? Neither is a telemetry-only event. For 5C the throw happens in the host before any response exists, so no renderer-side handling can help, and the patch API is the Changes pane's primary data source — it cannot be deleted. For 5D the alternative is making each launcher pass an explicit cwd, which leaves systemd, docker, manual launches and the next launcher uncovered; one call inside the process covers all of them. An inbound Sentry filter is repo law (#6164) not to write, and would leave both failures in place. No in-flight PR restructures either path (#7210 touches WorkerTaskRunner.ts, which this does not).
No typed cause is added: nothing reads one on either path, so failures keep throwing what they threw before. No new Sentry capture.
5C — RangeError: Invalid string length in git.getDiffPatch
Problem. buildDiffPatch built one string for a whole category: git diff for the tracked side, plus a --no-index diff per untracked file, each buffered whole by simple-git and then concatenated. Its size is whatever the repository state makes it, so Array.join threw RangeError: Invalid string length at V8's ~512 MB string limit and the request 500'd. The same unbounded read sat on the failure path too: simple-git builds its error message as stdout + stderr, and stdout here is the patch.
Root cause. The patch was materialised without a bound, so a single working tree — one unignored log, dump or vendored tree — could exceed what the runtime can represent. Same class as #7279, different place.
Fix. git runs under a shared byte budget for the request. runDiff spawns git directly, keeps stdout only while the budget allows, kills git as soon as it is spent, and cuts the output back to its last complete diff --git section. So:
- The oversized string is never built, rather than caught afterwards.
- No file is ever shown a diff missing its hunks — a section is whole or absent.
- What the Changes pane shows: every file whose section fit renders normally; files past the cut get no section and fall into the placeholder the pane already shows for a file the patch has no section for. Today's failure fails all of them; after this, the giant file is the one that loses its diff. A dedicated "too large to display" placeholder would read better than the current "Unable to load diff", but that is renderer copy in 17 locales and not what makes the request fail — deliberately left out.
- The bound is 32 MB, the same one #7279 put on transcript lines: far past any patch a person reads, and an order of magnitude clear of the limit after JSON encoding.
Behaviour otherwise preserved: output is byte-identical to the simple-git path (checked below, including non-ASCII paths, which git quotes the same either way), simple-git's failure rule (non-zero exit with stderr) is kept so the same commands still reject, and an untracked file that fails on its own still yields no section instead of failing the request. The error message is now git's stderr alone, bounded at 64 KB — dropping the stdout prefix is the same bug in the same function.
5D — ENOENT ... uv_cwd from process.cwd(worker_thread)
Problem. Not a task failure: the reported frames are Node's own worker bootstrap (node:internal/main/worker_thread), reached before the task script loads, and the event arrives through the parent's worker.on("error"). Three events, which is exactly CRASH_BUDGET; after those the pool runs every git task inline on the event loop for the life of the process.
Root cause. host-service inherits its working directory from whoever launched it — for superset start, a shell that is very often inside a workspace — and then outlives that directory, because deleting a workspace deletes the worktree. Nothing in the host needs it (every path it touches arrives absolute), but Node reads the process working directory while bootstrapping a worker thread, so once it is unlinked no worker can start at all.
Fix. detachFromLaunchDirectory() moves the process to the home directory (temp directory if home is unusable) at startup, called from both entrypoints — serve.ts and the desktop's own bootstrap, which has its own main() and has been the half that kept crashing before (HOST-SERVICE-5). No try/catch around the failure, no retry: the dependency is removed.
The one worker task that read the process working directory implicitly — readGitIdentity, via a simple-git built with no base directory — is now given the home directory explicitly, so which repository the host happened to be launched from no longer decides whose name a global setting previews.
Adjacent, deliberately left: resolve-repo.ts builds simple-git with no base directory twice more (clone targets are absolute, so its cwd is irrelevant; the launch-directory fix covers them), and loadFileDiffContent in git-helpers.ts reads whole file contents with git show for the per-file diff path — the same unbounded-string class as 5C, in a different procedure, with no reported events.
Verification
bunx biome check on all ten changed files:
Checked 10 files in 19ms. No fixes applied.
cd packages/host-service && bun run typecheck, and the same in apps/desktop (both silent, no diagnostics):
$ tsc --noEmit --emitDeclarationOnly false
$ tsc --noEmit
Full host-service package suite, bun test (first pass; re-run below):
1659 pass
8 todo
0 fail
3857 expect() calls
Ran 1667 tests across 161 files. [228.02s]
apps/desktop/src/no-main-process-blocking.test.ts (desktop entrypoint touched; allowlisted counts unchanged):
3 pass
0 fail
6 expect() calls
New diff-patch.test.ts pins the bound in both directions. It fails before the cut exists (patch runs past its last complete section) and fails again if the cut is applied unconditionally rather than only to a truncated read — the over-match case, checked by temporarily applying it to every diff:
(fail) buildDiffPatch > cuts the patch on a file boundary instead of mid-section
(fail) buildDiffPatch > an untracked file past the bound leaves the tracked patch intact
(fail) buildDiffPatch > a bound nothing reaches keeps every section, last one included
Output equivalence with the replaced simple-git path, on a temp repository with non-ASCII tracked and untracked paths:
identical: true
length before/after: 566 566
5D end to end, outside the test suite: a Node process started in a directory that is then deleted, spawning a worker before and after the fix (home path elided):
launch dir deleted, before the fix: FAILED: ENOENT: process.cwd failed with error no such file or directory, the current working directory was likely removed without changing the working directory, uv_cwd
cwd after detachFromLaunchDirectory(): <home>
after the fix: worker started (worker booted ok)
The reproduced stack matches the reported one frame for frame (process.cwd (node:internal/main/worker_thread:130:19) ← MessagePort.<anonymous> (node:internal/main/worker_thread:147:50)).
Merge-gate fixes (22c558b)
Both findings were real; both are fixed in a second commit.
1. A cut landing exactly on a section boundary discarded a complete section. When the budget expired precisely where the next file's section began, nothing incomplete had been read, but the cut still looked for an earlier header — and dropped the last section, or returned "" when the boundary was the first one, putting a file whose diff was read whole behind the missing-section placeholder. runDiff now keeps the first bytes the budget refused (SECTION_HEADER.length of them, accumulated across chunks since git's already-written output stays readable after the kill), and keepCompleteSections returns the read unchanged when that overflow begins the next section. A new case constructs a budget that expires exactly between two sections and asserts both survive byte for byte:
(fail) buildDiffPatch > a bound expiring exactly between two sections keeps both
— that is the new test run against the pre-fix behaviour (the overflow check forced off); the other three cases still pass there, so it isolates this bug and nothing else.
2. identity.test.ts could not have caught a regression. It built expectedAuthor with the same home-anchored call the implementation makes, so a read that regressed to the process's own directory would have satisfied it. It now writes user.name = home-identity into a temporary home and user.name = repo-identity into a temporary repository, runs with the process standing in the repository, and asserts the home value literally. Against the pre-fix implementation (createUserSimpleGit() with no base directory):
error: expect(received).toEqual(expected)
- "authorName": "home-identity",
+ "authorName": "repo-identity",
(fail) readGitIdentity > reads the author from home, not from the directory it runs in
Re-run after both fixes. bunx biome check on the three changed files:
Checked 3 files in 6ms. No fixes applied.
cd packages/host-service && bun run typecheck (silent):
$ tsc --noEmit --emitDeclarationOnly false
Full host-service package suite, bun test (one more test than the first pass):
1660 pass
8 todo
0 fail
3860 expect() calls
Ran 1668 tests across 161 files. [247.19s]
Refs HOST-SERVICE-5C
Refs HOST-SERVICE-5D
https://claude.ai/code/session_01AmxfJtNVEMuTLj5FsmdBvF
Summary by cubic
Fixes two host-service worker failures: unbounded diff patch size caused RangeError: Invalid string length on large repositories, and the process working directory could be deleted out from under the running host, preventing worker threads from starting.
Bounded diff patch generation
buildDiffPatchnow spawns git with a 32 MB byte budget shared across the tracked diff and all untracked file sections, killing git when the budget is spent.- Output is cut to the last complete
diff --gitsection; a budget that expires exactly between sections keeps everything already read. No file renders a truncated diff, and files past the bound show the existing placeholder — one giant file no longer takes down the whole Changes pane. - Error messages are now bounded stderr (64 KB) instead of simple-git's stdout-as-patch prefix, which was the same unbounded string.
- Output is byte-identical to the previous simple-git path on non-ASCII paths, and simple-git's failure semantics (non-zero exit with stderr) are preserved.
Stopped depending on the launch directory
- Both entrypoints (
serve.tsand the desktop bootstrap) now calldetachFromLaunchDirectory()at startup, which moves the process to the home directory. - Previously, launching the host from a workspace and then deleting that workspace left the process in a nonexistent directory, which made Node's worker bootstrap throw
ENOENT ... uv_cwd; three such failures opened the crash circuit and ran all git tasks on the event loop. readGitIdentityno longer implicitly reads the launch directory — it now anchors its git config read at the home directory, so a global setting preview no longer depends on which repository the host happened to start in.
Refs HOST-SERVICE-5C, HOST-SERVICE-5D
Summary by CodeRabbit
-
Bug Fixes
- Host processes now continue running if the directory used to launch the app is deleted.
- Git identity detection consistently uses the user’s home configuration.
- Git diff generation now limits output size and preserves complete file sections.
- Oversized untracked files are excluded from diffs when necessary to stay within size limits.
-
Tests
- Added coverage for launch-directory handling, Git identity detection, and Git diff size limits.