Skip to content

Add hang and stall guards to installs and downloads - #215

Open
jm2 wants to merge 5 commits into
google:masterfrom
jm2:installer-hang-guards
Open

jm2 wants to merge 5 commits into
google:masterfrom
jm2:installer-hang-guards

Conversation

@jm2

@jm2 jm2 commented Oct 1, 2026

Copy link
Copy Markdown
Member

Problem

A single hung installer or stalled download blocks a googet run indefinitely:

  • An installer waiting on a modal dialog, a stuck child process, or a deadlock never exits, so googet install/update never returns and every later package in the batch is skipped.
  • If an outer agent kills googet on its own timeout, the installer process tree is orphaned and keeps running.
  • A download whose connection goes silent (no bytes, no RST) blocks forever, and a dropped connection restarts the whole file.
  • A failed install can leave files overwritten and the package half-installed.

Change

Installer supervisor (supervisor/)

Every installer command (.msi, .exe, .msu, scripts) now runs under a supervisor:

  • Inactivity watchdog (default 5m): the installer is terminated only when it makes no forward progress for the whole window. Progress is any of: stdout/stderr output, growth of known installer log files (auto-discovered from /l*v, /log, /LOG= arguments; CBS.log for .msu), CPU time, or I/O in the process tree. Slow-but-working installers are never killed.
  • Interactive UI detection (unattended mode, default 30s): a visible modal dialog owned by the installer tree that persists without progress is treated as a hang.
  • Hard cap (default 4h): an absolute ceiling for installers that keep producing activity but never finish. Packages that legitimately need longer set a per-command override.
  • Process tree containment: on Windows the tree runs in a Job Object with KILL_ON_JOB_CLOSE, so killing googet also kills the installer; on Unix a process group is used. Termination targets the whole tree, including MSI service-spawned children.

A terminated installer fails only that package: its changes are rolled back, the run continues with the next package, and googet exits non-zero. The package is never recorded as installed.

Download stall detection and resume (client/, download/)

  • StallReader cancels a read that receives zero bytes for DownloadStallTimeout (default 120s). Any byte of progress resets the timer, so slow links are never aborted.
  • Stalled or reset transfers resume with HTTP Range (validated against Content-Range) or a GCS range reader; servers that ignore ranges fall back to a full restart. Partial files left on disk by an earlier run are resumed.
  • Retries use exponential backoff with a progress-aware budget: attempts that move the high-water mark do not consume the budget; repeated zero-progress attempts fail. 429/5xx are retried; other 4xx and checksum mismatches are not (a mismatch after a resume restarts from scratch once).
  • HTTP requests get a response-header timeout.

Transactional installs (install/txn.go)

Files overwritten during an install are backed up and restored if the install fails or is terminated; installer logs are preserved and their location is logged. Batch updates continue past packages that cannot be resolved.

Configuration

googet.conf:

Key Default Notes
SupervisorMode enforce enforce, monitor (log what would be killed, kill nothing), or off
InactivityTimeout 5m 0 disables
InstallTimeout 4h 0 disables
UIGracePeriod 30s
UIDetection true
DownloadStallTimeout 120s

Per package, ExecFile gains timeout and inactivityTimeout (Go durations; "0" disables), validated at goospec parse time.

SupervisorMode: monitor is intended for staged rollouts: it logs every decision without terminating anything.

Progress UI integration

Supervisor child output goes through progress.Stdout()/Stderr(), and watchdog/rollback errors are written via a new progress.Interrupt so the spinner cannot overwrite them.

Behavior changes

  • Installers running longer than 4h are now terminated unless the package sets timeout or the host sets InstallTimeout.
  • .msu (wusa) installs have no hard cap unless the package sets one.
  • Killing googet now also terminates the in-flight installer tree.

Testing

  • go vet ./... and GOOS=windows go vet ./... are clean; GOOS=windows go build ./... and Windows test compilation succeed.
  • go test -race ./... passes on Linux for all packages. New tests cover the supervisor state machine, process-tree termination (Unix), stall detection, range resume against misbehaving servers, retry budgeting, transactional rollback, multi-package continuation after a killed install, and config/goospec parsing.
  • Windows-specific paths (Job Objects, UI detection, MSI service trees) are covered by unit tests with fakes but have not yet been exercised on a live Windows host in this PR; validation with SupervisorMode: monitor before enforcing is recommended.

jm2 added 4 commits September 28, 2026 15:06
Bad installers can hang googet indefinitely, most often by becoming
unexpectedly interactive in an unattended context, and downloads can hang
on a stalled connection. This change kills only true hangs and stalls,
never work that is still progressing, and makes a killed install fail
cleanly.

Installer supervision (new supervisor package, goolib, system):
- Installers run in a Windows Job Object (process group on Unix) with
  stdin set to the null device. The child is started suspended, assigned
  to the job and then resumed, so there is no startup race.
- Forward progress is a rolling-window check across the whole tree: CPU
  >= 250ms, read+write I/O >= 64KiB, or installer/MSI log growth within
  30s.
- Watchdogs: inactivity (no progress for 5m); hard cap (60m, lifted for
  wusa/dism unless the admin or package set one); modal UI in unattended
  mode (a #32770 or owned modal-frame window persisting 30s with no
  progress since it appeared).
- Work outside the job counts as progress: the Windows Installer service
  tree while _MSIExecute is held, the TrustedInstaller/TiWorker tree for
  wusa/dism, and CBS.log growth. After an msiexec abort, only
  service-side descendants created after start and observed idle for the
  inactivity timeout are terminated; the services themselves never are.
- No abort decisions are made after the root installer exits. On success
  the job's kill-on-close limit is cleared so tray apps and updaters
  survive. Waits are bounded (WaitDelay 30s, post-kill wait 60s).
- If the job cannot be created, only the hard cap is enforced on the root
  process, so a running child is never orphaned.

Configuration:
- googet.conf: SupervisorMode (enforce|monitor|off, default enforce),
  InactivityTimeout, InstallTimeout, UIGracePeriod, UIDetection,
  DownloadStallTimeout. "0" disables the inactivity and install timeouts.
- goospec install/uninstall/verify accept timeout and inactivity_timeout
  overrides on every OS.
- monitor mode logs one WOULD_KILL line per reason and never kills, for
  staged rollout. goopack build commands run with supervision off.

Failure semantics (install, cli/install, cli/update):
- Installs are transactional: overwritten files are backed up beside
  themselves (copy fallback when a rename is impossible), new files and
  directories are tracked, removed empty directories are recorded, and
  everything is rolled back on failure. The package is never recorded in
  the database on failure, and installer logs are preserved.
- Multi-package install/update continues past a failed package and exits
  1.

Downloads (client, download):
- ResponseHeaderTimeout is 30s; there is no overall timeout, so slow links
  are never capped. Downloader now uses its own http.Client instead of
  mutating http.DefaultClient.
- An idle-read StallReader (default 120s, Downloader.StallTimeout) guards
  package bodies (HTTP and GCS) and repo index bodies.
- Retryable failures (stall, reset, EOF, timeouts, http2, 5xx, 429) resume
  with Range and back off with jitter. Attempts that advance the file are
  unlimited; the download fails after 4 consecutive attempts with no
  progress or 20 attempts that each advanced it by less than 1 MiB.
- Content-Range is validated. A 416, a mismatched range, or a checksum
  mismatch after a resume restarts from byte 0 and resets the budget;
  repeated restarts that never pass the previous best offset fail.
- Fixed a GCS index error-shadowing bug that hid non-404 errors.

Notes:
- Defaults are enforce with a 60m cap; installers that legitimately run
  longer must set timeout in their goospec. Fleets can stage with
  SupervisorMode: monitor.
- Killing googet now also kills the in-flight installer tree.
- A bootstrapper that exits while a child holding its stdout keeps running
  is considered finished after 30s instead of being waited on forever.
- The Windows-specific paths have been cross-compiled and vetted but not
  yet executed; supervisor_windows_test.go runs in the Windows CI job.
Long-running installers (SQL Server, Visual Studio, Office) routinely
exceed 60m while making steady progress. The inactivity (5m) and UI (30s)
watchdogs still catch true hangs quickly; the hard cap is only a backstop
for spin loops or log spam that masquerade as progress.
- Run watchdog termination messages and install rollback/commit logging
  under the new progress.Interrupt so the active spinner cannot redraw
  over error lines that logger writes to stderr.
- StallReader now returns ErrDownloadStalled only after cancel has
  completed, fixing a race that made
  TestStallReader_ZeroByteReadsDoNotResetTimer flaky under -race.
- enrichOptions now discovers InnoSetup /LOG=path and fully quoted
  "/log:path" arguments, and no longer treats a following switch such as
  /quiet as the log path. The log-only corner-case test is replaced with
  assertions.
- Port upstream's TestPackageHTTP table, which the merge dropped, as
  TestPackageHTTP_ResumeFromDisk.
@jm2
jm2 requested a review from nguyen-phillip October 1, 2026 14:42
Windows CI ran these code paths for the first time and found:

- TestWindows_MessageBoxAborts hung until the job timeout. On a
  non-interactive window station, such as session 0 where googet runs as
  SYSTEM, a blocking #32770 dialog reports IsWindowVisible false, so the
  visibility filter hid every prompt and UI detection never fired. The
  filter now applies only on an interactive window station, detected via
  GetProcessWindowStation and the WSF_VISIBLE flag. The test also gets a
  hard timeout so a detection regression fails instead of hanging.
- Three install tests asserted Unix permission bits (0640, 0604, 0750)
  that Windows does not model. They now compare against the mode the OS
  applied after Chmod.
@jm2
jm2 requested review from r0mflip and sbrito85 October 1, 2026 20:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant