feat(desktop): raise the install ceiling and make installs observable - #3368
Conversation
8e04e9b to
31416cf
Compare
wesbillman
left a comment
There was a problem hiding this comment.
The bounded capture/logging and timeout-triggered process-tree cleanup are useful improvements, but the inline issues need addressing before merge. Please also resolve the current conflict with main and rerun CI on the resulting head.
16b6bfa to
dc6421a
Compare
…tput A hard 300s wall killed installs that were working, just slowly: the Goose step downloads a ~79MB release asset and Windows Defender scans every file npm extracts, which routinely pushes a healthy install past five minutes (#2401). The ceiling is now 900s, and it stays a pure wall-clock ceiling — nothing observable distinguishes a hung installer from one silently transferring a large artifact, so silence alone never kills an install. A ceiling kill used to return empty stdout and a bare timeout string, discarding the one piece of evidence that says where the install stalled. Output now drains into a bounded head/tail sink shared with the reader rather than a String the reader returns, so the partial capture is readable at the ceiling and an installer printing megabytes costs a fixed amount of memory. The install shell is a session leader, so signalling only its PID left descendants alive holding the output pipes — the drain joins could then block past the ceiling, holding the concurrency guard that rejects the user's next install attempt. The ceiling now terminates the whole process group through the same SIGTERM/grace/SIGKILL path used for managed agent runtimes, and waits a bounded grace for the drains instead of joining unconditionally. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A failing install surfaced only its last step's stdout/stderr, truncated to 1.5KB for a toast. Everything before it — earlier retries of the same step, the prerequisite step that actually broke, the managed-Node bootstrap — was discarded, so the user got a symptom with no history behind it. The install now writes a log file: one self-contained record per attempt of per step, appended under 0o600, and the failure message ends with `Full log: <path>`. Each record is bounded independently at 128KiB of head and tail per stream, so an early attempt that printed megabytes cannot push out the later record that explains the failure, and the run's total is bounded by steps x attempts. A record cut at the cap says so inline, so the file never implies completeness it lacks. Both bounds hang off one drain seam: each stream's drain feeds a capture holding two views of the same bytes, the UI's 512/1024 and the log's 128KiB/128KiB. Install output can echo a registry token from the environment it ran in and the file is written unattended, so records are redacted and the owner-only mode is set by the create rather than a later chmod. A runtime id that cannot be a filename yields no log rather than a sanitized one, which could collide with another runtime's. The same seam feeds the live output line the install cards now show. A 15-minute ceiling with nothing but a spinner behind it is indistinguishable from a hang; the newest line the installer printed makes the wait observable. Lines are throttled to four events a second and tagged with their attempt, so a line emitted just before a retry starts cannot sit under the spinner describing work that has already been superseded. The ceiling's own kill now escalates on the group's liveness rather than the leader's: a descendant that ignores SIGTERM outlives the install shell, and escalation keyed to the leader would leave it running with the output pipes open. Reaping the killed child and finishing the drains share one bounded grace, since a termination that failed outright must not extend the ceiling that fired or the per-runtime guard behind it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… seq The ceiling only bounded the child's exit: the drain joins ran after it, so a descendant that outlived a normally-exited install shell held the output pipes — and the per-runtime install guard behind them — open with no bound at all. Exit and drains now fold into one resumable settle under a single deadline, and the deadline path terminates the process group on the normal-exit branch too, so the guard cannot stick either way. Live output was keyed on the per-step attempt number, which restarts at 1 for every step, so one step succeeding on attempt 2 froze the display for the rest of the install. The key is now a sequence monotonic across the whole install, paired with an unthrottled clear signal at each attempt start; the throttle retains the newest pending line and flushes it rather than dropping the first line of a new attempt. Log redaction matched only known secret prefixes, so a value with no recognisable shape survived; it now redacts by env variable name, snapshotted for the run. Install result types move to `installTypes.ts` so `tauri.ts` and `types.ts`, both already over the size cap, do not have to grow to carry them. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…arts The live-output subscription was created only once React had committed the pending install state, but the install command is invoked from the click handler: the backend emits the attempt-start clear and can emit its first line inside that window, and nothing replays them. A fast command's entire output could therefore never appear. The listener is now mounted for the runtime's whole lifetime; the run boundary resets the ordering key when the install settles, and the line is rendered only while installing, so a straggler from a finished drain cannot show up under a fresh Install button. The E2E seam was hiding this: it delayed every event, with the first gap existing specifically to let the clicked row mount its listener. The clear and the first line now emit synchronously with the invocation, and the spec asserts the first line is observed, so it pins production ordering instead of accommodating it. The bridge's sequence also restarts per install, matching the backend's per-run counter, and a second install asserts the display accepts a restarted sequence. The install log header now names the app version and OS: a Windows install failure and a macOS one on the same runtime are different bugs, and a stale version explains a failure that no longer reproduces. The version is read from the app's own package info rather than the frontend plugin, so it cannot be mocked out from under the log. The bridge gains the `plugin:app|version` stub it was missing, which also removes an unhandled page error on every Settings render. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A drain thread holds an owned clone of `Live` and can call `offer`
after `InstallReporter` is dropped (the run boundary). An atomic flag
check before emit is insufficient: a thread admitted while active can
pause between the check and the emit, and `Drop` can complete in that
window — letting the late publication land in the permanent listener's
React state with run 1's high `seq`, causing run 2's restarted
`seq=0` events to be rejected by the `event.seq <= current.seq` guard
and displaying run 1's stale output.
Fix: replace `Arc<AtomicBool>` with `Arc<RwLock<bool>>` in `Live`.
Drain threads hold a **shared read guard** from the admission check
through the `(self.emit)(...)` call, making the check-then-emit pair
atomic with respect to shutdown. `InstallReporter::drop` takes the
**exclusive write guard** and stores `false`; this blocks until every
in-flight publication has released its read guard, then prevents any
new admission. Deactivation is bounded: the write lock holds only for
the flag store, so it can block at most for the duration of one emit
call (microseconds to low milliseconds for the Tauri IPC broadcast).
No deadlock is possible: `(self.emit)` in production is
`app.emit("acp-install-output", event)` — fire-and-forget Tauri IPC
that does not call back into `Live`. In tests it is a Vec push.
The declaration order in `install_acp_runtime_blocking` is load-
bearing: `reporter` is declared after `_guard` (line 311 vs line 306),
so Rust drops it first (reverse-declaration order), ensuring the
exclusive write completes before the per-runtime concurrency guard
releases and a new install for the same runtime can begin.
Three pin tests:
- `test_a_detached_observer_cannot_emit_after_the_reporter_is_dropped`:
sequential drain-after-drop is a no-op. Fails when the lifecycle
guard is removed from offer.
- `test_deactivation_blocks_until_in_flight_publication_completes`:
white-box concurrency pin — acquires a read guard, asserts
`try_write()` fails while the guard is held, releases the guard, then
completes deactivation. Fails to compile on the old atomic shape
(`dc6421ac4`) because the `lifecycle` field does not exist.
- `test_run2_output_replaces_stale_run1_state_through_shared_consumer`:
one shared event sink for both runs; consumer resets to null at
run-1 settlement (modeling the frontend `isInstalling` reset); verifies
run 2's output wins through the shared reducer. Fails when the
lifecycle guard is removed from offer (late drain emits with high seq,
poisons the null-reset consumer before run 2 can emit seq=0).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
dc6421a to
79593af
Compare
|
Disclosure: Carl is commenting on Wes Billman's behalf. The concurrency/timeout/log-rotation work is clean at
Those complete values also match neither fallback prefix ( There is a third frontend-visible path to cover in the same fix: Required coverage: proxy and PAT credentials are removed from (1) the log record, (2) the live event, and (3) returned step stdout/stderr. |
An install inherits Buzz's environment and installers echo it back, but
the secret filter missed two credentials that are routinely present:
proxy URLs carrying `user:password` userinfo, and personal access tokens
under `GITHUB_PAT`/`GH_PAT`. Neither matched a name marker nor a value
prefix, so an installer that printed one wrote it verbatim to the install
log and the live output line.
For proxies only the userinfo is treated as secret, not the whole value.
An install that fails behind a proxy is diagnosable only if the record
still says which proxy it went through, and the host and port are not the
credential — redacting the entire value would destroy that while
protecting nothing more. A bare username with no password is likewise not
a credential, and scrubbing it would erase every occurrence of a common
word from the log.
PAT variables match `_PAT` as a suffix rather than a substring:
`contains("_PAT")` would match every `*_PATH` variable and scrub
directory names out of the whole file.
The name-based and shape-based paths do not overlap. A token in our own
environment under a PAT-ish name is caught by name whatever its shape;
the GitHub value prefixes added to `redact_secrets_with` catch a token
that reaches output from outside the environment — embedded in a git
remote URL an installer echoes — which no name-based rule can see. That
list lives in the shared scrubber so the next caller cannot silently miss
GitHub tokens.
The third surface was the step returned to the frontend:
`InstallStepResult.stdout/stderr` came straight from the capture with no
scrubbing, and `getInstallErrorMessage` renders it on failure. Scrubbing
happens in the two reporter funnels every step already passes through,
which covers the ordinary exit, the status-check failure and the timeout
path together rather than at each construction site.
Secret classification is split into a pure function so the proxy and PAT
rules are assertable without exporting a live `HTTPS_PROXY` into the test
process, where every HTTP client the suite builds would read it.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…g-pipeline * origin/main: (25 commits) Refine agent sharing dialog (#3699) desktop: enable getUserMedia in the Linux WebKitGTK webview (#3607) fix: align responsive agent views (#3688) Add macOS agent menu-bar menu (#3565) Fix pending message feedback (#3543) fix(desktop): remove remaining Projects panel fills (#3742) feat(mobile): desktop-parity emoji and thread experience (#3485) desktop: restore direct community member adds (#3634) fix(desktop): explain open agent access (#2561) fix(cli): resolve agents from owner records (#3178) fix(desktop): remove Projects overview card fills (#3416) feat(replica): portable heartbeat-token fence with snapshot-local reader routing (#3268) fix(git): channel binding tooling + author remediation for unbound repos (#3626) feat: configure S3 URL addressing style (#3400) feat: add first-class OpenRouter provider support (#1975) feat(agent,acp): wire provider total_tokens through NIP-AM publish chain (#3593) chore(release): release Buzz Desktop version 0.5.2 (#3624) docs: add Linux rendering troubleshooting guide (#3573) fix(desktop): discover bun-installed agent CLIs in ~/.bun/bin (#3343) feat(tracing): correlate trace IDs in relay logs (#3608) ... Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…g-pipeline * origin/main: feat(catalog): resolve publisher display name in catalog detail pane (#3640) feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) (#3741) docs(nips): specify kind:30621 multi-repo projects (NIP-MP) (#3163) Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
npm resolves NPM_CONFIG_PROXY and NPM_CONFIG_HTTPS_PROXY ahead of the conventional proxy variables and echoes the resolved value back, but neither name carries a marker the name-based classifier recognises, so a credential set only under an alias never entered the scrub list and could reach the log, the live output line and the returned UI step. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
npm accepts every setting as an npm_config_* environment variable and echoes its resolved config on an auth failure, so a client key, a basic-auth blob, a one-time password or a credentialed registry URL could reach the install log, the live output line and the returned UI step. None of those names carries a marker the name-based classifier recognises. The credentials are listed by exact name rather than matched on KEY or AUTH substrings: both occur throughout an ordinary environment on values that are paths and people's names, and scrubbing on them would delete unrelated text from every record. The registry follows the proxy policy instead — which registry an install talked to is what a 401 has to be read against — so only its userinfo is secret, which is why the URL list is no longer proxy-specific. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The redaction cases grew the single test file past the desktop 1000-line ratchet, which is a hard cap for a file that does not exist on main. They are a self-contained concern, so they move to their own module alongside the shared harness, following the split migration.rs already uses. No test changes: 37 before, 37 after. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Removes the Require-main gate so this workflow can be dispatched from a throwaway branch for pre-merge testing of #3368. The gate is in the workflow file itself, not a repo rule, so running on a branch that omits it leaves main's copy untouched. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: (29 commits) feat(desktop): raise the install ceiling and make installs observable (#3368) fix(db): isolate usage metrics advisory-lock test on scratch DB (#3670) Add Devin as a preset ACP harness (#3225) feat(desktop): improve agent activity header ui (#3321) perf(presence): reduce heartbeat frequency (#3783) Tighten continuation message rows (#3724) Fix video reviews in thread replies (#3719) feat(release): make desktop releases immutable (#3568) Make relay reconnect backoff authoritative (#3774) feat(desktop): add password-protected backups in settings (#3701) fix(desktop): reuse profiles when joining communities (#2155) Render mobile agent mention chips (#3702) fix(catalog): update Amp description (#3758) fix(acp): preserve truncated thread context (#3340) feat(catalog): resolve publisher display name in catalog detail pane (#3640) feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) (#3741) docs(nips): specify kind:30621 multi-repo projects (NIP-MP) (#3163) Refine agent sharing dialog (#3699) desktop: enable getUserMedia in the Linux WebKitGTK webview (#3607) fix: align responsive agent views (#3688) ... Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
* origin/main: fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) fix(db): isolate usage metrics advisory-lock test on scratch DB (#3670) Add Devin as a preset ACP harness (#3225) feat(desktop): improve agent activity header ui (#3321) perf(presence): reduce heartbeat frequency (#3783) Tighten continuation message rows (#3724) Fix video reviews in thread replies (#3719) feat(release): make desktop releases immutable (#3568) Make relay reconnect backoff authoritative (#3774) feat(desktop): add password-protected backups in settings (#3701) fix(desktop): reuse profiles when joining communities (#2155) Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Main's d40a332 (install ceiling PR #3368) grew types.rs by +5 lines after the 8c87f57 paydown, pushing the merge-commit count to 1005 (limit 1000). Reclaim 7 readability-neutral lines from doc comment blocks in ManagedAgentRecord and ManagedAgentSummary — three field comments reflowed to eliminate wrapped-word lines (turn_timeout_seconds, model, provider, persona_source_version) and two pairs condensed in the Summary section (agent_command_override, mcp_command/turn_timeout_seconds). No semantic change. Simulated merge count: 998 (verified via git merge-tree --write-tree origin/main HEAD). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: (70 commits) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) fix(db): isolate usage metrics advisory-lock test on scratch DB (#3670) Add Devin as a preset ACP harness (#3225) feat(desktop): improve agent activity header ui (#3321) perf(presence): reduce heartbeat frequency (#3783) Tighten continuation message rows (#3724) Fix video reviews in thread replies (#3719) feat(release): make desktop releases immutable (#3568) Make relay reconnect backoff authoritative (#3774) feat(desktop): add password-protected backups in settings (#3701) fix(desktop): reuse profiles when joining communities (#2155) ... Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
Main's d40a332 (install ceiling PR #3368) grew types.rs by +5 lines after the 8c87f57 paydown, pushing the merge-commit count to 1005 (limit 1000). Reclaim 7 readability-neutral lines from doc comment blocks in ManagedAgentRecord and ManagedAgentSummary — three field comments reflowed to eliminate wrapped-word lines (turn_timeout_seconds, model, provider, persona_source_version) and two pairs condensed in the Summary section (agent_command_override, mcp_command/turn_timeout_seconds). No semantic change. Simulated merge count: 998 (verified via git merge-tree --write-tree origin/main HEAD). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…chive * origin/main: (25 commits) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src/testing/e2eBridge.ts # desktop/tests/helpers/bridge.ts
…chive * origin/main: (25 commits) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src/testing/e2eBridge.ts # desktop/tests/helpers/bridge.ts
…-style * origin/main: (22 commits) feat(desktop): import local Pocket voices (block#3259) fix(desktop): open profiles from avatars (block#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands block#2467 + block#3208) (block#3910) docs: add VISION_REMOTE_AGENTS.md (block#3924) feat(desktop): auto-enable huddle transcription for agents (block#3180) feat(agent): optional reply guard reminds a silent turn to publish (block#3763) feat(desktop): upgrade Pocket TTS model (block#3266) feat(desktop): delete a message by clearing its edit to empty (block#3813) feat(relay): raise hosted community limit to five (block#3829) feat(desktop): locally stored NIP-49 encrypted key backup (block#2937) fix(catalog): update Amp tagline (block#3806) fix(desktop): channel topic and membership metadata cleanup (block#3642) fix(desktop): align data deletion labels (block#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (block#3635) fix(desktop): allow linux-only media items as dead code off-linux (block#3811) fix(desktop): report authenticated relay recovery (block#3812) fix(desktop): don't gate hover affordances on the hover media query (block#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (block#3358) test(desktop): click visible thread collapse guide (block#3800) feat(desktop): raise the install ceiling and make installs observable (block#3368) ... Amp-Thread-ID: https://ampcode.com/threads/T-019fb8e1-6ece-72a7-8808-9b12e0f7e833 Co-authored-by: Amp <amp@ampcode.com> Signed-off-by: Joah Gerstenberg <joah@squareup.com> # Conflicts: # desktop/src-tauri/src/linux_media.rs # desktop/src/app/AppShell.tsx
Main's d40a332 (install ceiling PR #3368) grew types.rs by +5 lines after the 8c87f57 paydown, pushing the merge-commit count to 1005 (limit 1000). Reclaim 7 readability-neutral lines from doc comment blocks in ManagedAgentRecord and ManagedAgentSummary — three field comments reflowed to eliminate wrapped-word lines (turn_timeout_seconds, model, provider, persona_source_version) and two pairs condensed in the Summary section (agent_command_override, mcp_command/turn_timeout_seconds). No semantic change. Simulated merge count: 998 (verified via git merge-tree --write-tree origin/main HEAD). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Windows installs of Goose and other harnesses failed at exactly five minutes with an empty error (#2401). The 300s ceiling was killing installs that were working, just slowly — the Goose step pulls a ~79MB release asset, and Windows Defender scans every file npm extracts. When the ceiling fired it discarded the output it had already read, so the user got a bare timeout string and no way to tell a hang from a large download.
The ceiling
INSTALL_TIMEOUTis 900s, and the error names the limit:install command exceeded the 15m ceiling and was terminated. It stays a pure wall-clock ceiling with no inactivity kill — nothing observable distinguishes a hung installer from one silently transferring a large artifact, so silence alone never kills an install. A ceiling kill remains non-retryable; re-running a command that already burned 15 minutes costs the user more time with no plausible path to success.The child's exit and both stream drains fold into one resumable settle governed by a single deadline. Waiting on the drains outside that deadline would let a descendant that outlived the install shell hold the output pipes — and the per-runtime install guard behind them — open with no bound, which is the failure the ceiling exists to prevent. So the deadline path terminates the process group on the normal-exit branch too: a leader that exited with a real status still gets its stragglers killed, and the guard cannot stick either way. Whether the leader had already exited only decides the verdict — its real status outranks a timeout.
The install shell is a session leader and its descendants inherit the output pipes, so signalling only the leader left them running and the drains blocked on a pipe nobody would close. Escalation keys off the group's liveness rather than the leader's, since a descendant that ignores SIGTERM outlives the leader and would otherwise never receive the group SIGKILL. Reaping the killed child and finishing the drains share one bounded grace, so a termination that failed outright cannot extend the ceiling that just fired.
Output capture
Each stream drains into a bounded capture that is shared with the reader rather than returned by it, so whatever arrived before a stall is readable at the ceiling — exactly when the output matters most. Output of any size costs a fixed amount of memory.
One capture holds two independently bounded views of the same bytes:
InstallStepResult)... (N bytes omitted) ...... [N bytes omitted at cap] ...The UI budget is screen space; the log's is disk. Both markers are inline, so neither ever implies completeness it does not have. Both ends are cut at arbitrary byte offsets, so a partial character is trimmed and the partial token each cut left behind is dropped — the marker's byte count includes both trims.
Install log
stepscarries only the last attempt of each step, truncated for display. Everything else — earlier retries, the prerequisite step that actually broke, the managed-Node bootstrap — used to be discarded.InstallReporternow appends one self-contained record per attempt of per step toinstall-<runtime-id>.logbeside the agent logs, andInstallRuntimeResult.log_pathcarries the file to the UI, where a failure message ends withFull log: <path>.Each record is bounded independently by the log-scale capture that produced it, so a first attempt that printed megabytes cannot push out the later record explaining the failure; the run's total is bounded by steps × attempts × per-record cap. Every early return builds its result through one
InstallReporter::failedhelper, so no failure path can omit the log pointer, and synthesized steps go throughrecord_step— a step that reaches the UI without passing it would be invisible in the file.Install output can echo a registry token or proxy credential from the environment it ran in, and the file is written unattended. Redaction keys off the names of the environment variables the install inherited, snapshotted once per run, rather than a list of known secret value prefixes: a credential with no recognisable shape is exactly the one a prefix match misses. Three name rules apply, because the variables need different treatment:
HTTP_PROXY,HTTPS_PROXY,ALL_PROXY,NPM_CONFIG_PROXY,NPM_CONFIG_HTTPS_PROXY,NPM_CONFIG_REGISTRYuser:passwordonlyNPM_CONFIG_KEY,NPM_CONFIG__AUTH,NPM_CONFIG_OTP*TOKEN*,*SECRET*,*PASSWORD*,*_PAT, …A proxy or registry keeps its host and port, because an install that fails behind one is diagnosable only if the record still says which one it went through, and a bare
user@with no password is not treated as a credential. npm's own settings are listed by exact name rather than matched onKEYorAUTHsubstrings — both occur throughout an ordinary environment on values that are paths and people's names — and they bypass the 8-byte floor, since a six-digit one-time password is a credential at that length. Matching is case-insensitive, which is what npm's lowercasenpm_config_*spelling needs.0o600is set by the create rather than a laterchmod, which would leave a window where the umask decides. A runtime id that cannot safely be a filename yields no log rather than a sanitized one — a rewritten id could collide with another runtime's log.The file holds exactly one run. A run opens its own session after the runtime id has been canonically resolved — the previous file rotates to
.1and any older.1is removed before the rename, since a rename that will not replace its destination would otherwise wedge rotation permanently on Windows. The session writes a header naming the runtime, the app version (app.package_info().versionon the Rust side — cannot be mocked or fail), the OS (std::env::consts::OS), and the start time: a Windows failure and a macOS one on the same runtime are different bugs, and a stale app version explains a failure that no longer reproduces. Each record carries its attempt's elapsed time.Live output line
A 15-minute ceiling with nothing behind it but a spinner is indistinguishable from a hang. The same drain seam feeds an
acp-install-outputevent carrying the newest complete line, and the three install entry points — Doctor harness rows, the harness catalog dialog, and onboarding runtime cards — render it under the spinner witharia-live="polite".Ordering is keyed on a
seqmonotonic across the whole install, not on the attempt number, which restarts at 1 for every step: keyed on attempt, one step succeeding on attempt 2 would make the next step's attempt-1 output look stale and freeze the display for the rest of the install. Each executed attempt begins with an unthrottledline: nullclear signal, so a stale failure line cannot sit under the spinner while the retry runs. Events are otherwise throttled to four per second, and the throttle retains the newest pending line and flushes it when the window reopens rather than dropping it — at an attempt boundary a drop would silently eat the new attempt's first line.The subscription is mounted for the runtime's whole lifetime rather than started when the install begins. The install command is invoked from the click handler, so the clear and a fast command's first lines can be emitted before React has committed the pending state, and nothing replays them — a subscription that waited for that state would lose the entire output of a short install. The run boundary resets the ordering key when the install settles, since
seqrestarts for the next run, and the line renders only while installing, so a straggler from a finishing drain cannot appear under a fresh Install button.The 15-minute ceiling deliberately stops waiting on stuck drain threads — a hung installer must not freeze the app. That means a drain thread can outlive its
InstallReporter. Without a generation guard, a drain that callsofferafter the run settles would publish an event with the run's highseq, poison the permanent listener's React state, and cause the next install's restartedseq=0events to be rejected.Livenow carries alifecycle: Arc<RwLock<bool>>; drain threads hold a shared read guard from the admission check through the(self.emit)(...)call, making the check-then-emit pair atomic with respect to shutdown.InstallReporter::droptakes the exclusive write guard and storesfalse— this blocks until every in-flight drain publication releases its read guard, then prevents any new admission. Deactivation is bounded: the write lock holds only for the flag store, so it can block at most for the duration of one emit call (microseconds to low milliseconds). Rust drops locals in reverse-declaration order, soreporterdrops before_guard, ensuring the exclusive write completes before the per-runtime concurrency guard releases and a new install can start.Also
Install result types move to
desktop/src/shared/api/installTypes.ts, following the existingsearchTypes.ts/workflowTypes.tsconvention, and are re-exported fromtauri.tsandtypes.ts— both already over the file-size cap, so neither can grow to carry them.Two comments described
AdapterOutdatedas applying only to the deprecated package; it also covers a version below the supported floor.Report: #2401