Skip to content

Fix hang in source <(lets completion -s zsh) under job control#383

Merged
kindermax merged 3 commits into
masterfrom
fix-completion-hang-background-pgrp
Jul 16, 2026
Merged

Fix hang in source <(lets completion -s zsh) under job control#383
kindermax merged 3 commits into
masterfrom
fix-completion-hang-background-pgrp

Conversation

@kindermax

@kindermax kindermax commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

source <(lets completion -s zsh) hangs indefinitely in an interactive shell (regression from the recent theming work).

Root cause

Every invocation eagerly queries the terminal background color (OSC 11) via lipgloss.HasDarkBackground(os.Stdin, stderr) when stderr is a TTY — once in logging.InitLogging and once in progress-bar setup. Inside process substitution under an interactive (job-control) shell, stdout is a pipe but stdin/stderr are still the terminal, and lets runs in a background process group. Reading/reconfiguring the controlling terminal from a background process group suspends the process with SIGTTIN/SIGTTOU, so the fifo never reaches EOF and source blocks forever.

Fix

Add util.IsForegroundProcess (compares TIOCGPGRP on the tty with our process group) and route both call sites through a new theme.IsDarkBackground, which skips the query and assumes a dark background (lipgloss's own fallback) when the process is not in the foreground. Foreground behavior is unchanged — the query still runs and themes resolve as before.

This also fixes the same hang for any lets command run inside <(...) or &, not just completion.

Testing

  • New bats regression test runs zsh -c 'set -m; source <(lets completion -s zsh)' under a PTY (script) with a timeout: fails with status 124 before the fix, passes after (verified both directions in Docker).
  • go test ./..., lets test-bats completion.bats, and lets lint all pass.

Second commit is an unrelated %v%w in readMixin that lets lint --fix (errorlint) applies automatically; committed separately to keep the fix focused.

Note: the lets-cli/fang fork has the same unguarded query in mustColorscheme (gated on stdout being a TTY, so reachable via e.g. lets --help &) — worth porting the same guard upstream.

Summary by Sourcery

Guard terminal background-color detection behind a foreground-process check to prevent hangs when lets runs in background contexts, and tidy up related configuration and documentation.

Bug Fixes:

  • Skip terminal background-color queries when the process is not in the terminal's foreground process group to avoid hangs in shells using process substitution or job control.
  • Correct mixin download error wrapping to use the proper %w verb for nested errors.

Enhancements:

  • Introduce a reusable foreground-process detection utility for Unix and Windows and use it to centralize dark-background detection in the theming layer.

Build:

  • Promote golang.org/x/sys to a direct dependency for terminal process-group inspection.

Documentation:

  • Document the fix for hanging zsh completion under job control in the changelog.

Tests:

  • Add a regression test ensuring source <(lets completion -s zsh) under zsh job control completes successfully rather than hanging.

Every invocation queried the terminal background color (OSC 11) via
lipgloss.HasDarkBackground(os.Stdin, stderr) when stderr is a TTY -- in
logging.InitLogging and in progress bar setup. Inside process
substitution under an interactive (job-control) shell, lets runs in a
background process group; reading/reconfiguring the controlling
terminal suspends it with SIGTTIN/SIGTTOU, the fifo never reaches EOF,
and `source` blocks forever.

Skip background-color detection when the process is not in the
terminal's foreground process group (TIOCGPGRP vs getpgrp), defaulting
to a dark background, lipgloss's own fallback. Windows has no terminal
process groups, so it always queries.

Regression test runs zsh with job control under a PTY (script) in bats;
it failed with a timeout before the fix.
Applied by golangci-lint --fix (errorlint); lets errors.Is/As unwrap
the read error alongside the download error.
@sourcery-ai

sourcery-ai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Introduces a guarded terminal-background detection helper used by both theming and logging so that color detection is skipped when lets is not in the terminal’s foreground process group, preventing hangs in shells using job control and process substitution, and adds a regression test plus minor supporting tweaks.

File-Level Changes

Change Details Files
Centralize and guard terminal background-color detection to avoid suspending background process groups.
  • Add theme.IsDarkBackground(out term.File) to wrap lipgloss.HasDarkBackground and short-circuit to a dark-background assumption when the process is not in the terminal foreground using util.IsForegroundProcess.
  • Refactor ProgressColorsByName to use theme.IsDarkBackground instead of calling lipgloss.HasDarkBackground directly.
  • Update logging formatter initialization to use theme.IsDarkBackground for color scheme selection instead of directly querying lipgloss.
internal/theme/theme.go
internal/logging/formatter.go
Introduce a cross-platform foreground-process detection utility used by theming.
  • Add Unix implementation util.IsForegroundProcess using unix.IoctlGetInt with TIOCGPGRP and unix.Getpgrp to test if the process group matches the terminal’s foreground process group.
  • Add Windows stub util.IsForegroundProcess that always returns true because Windows lacks terminal process groups.
  • Promote golang.org/x/sys from indirect to direct dependency to support the new unix ioctl usage.
internal/util/foreground_unix.go
internal/util/foreground_windows.go
go.mod
Add regression test and documentation for the zsh completion hang under job control.
  • Extend completion.bats with a new test that runs zsh with job control enabled inside a PTY and sources process substitution for lets completion, asserting it completes successfully within a timeout and prints a sentinel string.
  • Document the fix in the Unreleased changelog entry describing skipping background-color detection when lets is outside the foreground process group.
tests/completion.bats
docs/docs/changelog.md
Tighten error wrapping in mixin download failure path.
  • Switch from %v to %w when wrapping the cached-read error in readMixin so both the download and cache-read errors are properly wrapped and discoverable via errors.Is / errors.As.
internal/config/config/config.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The new Bats test relies on script -qec and timeout, which are GNU-specific flags/utilities and may behave differently or be missing on BSD/macOS systems; consider guarding the test, using more portable flags, or documenting the requirement so local runs don’t fail unexpectedly.
  • IsForegroundProcess returns false on any ioctl error, which silently forces a dark theme even for non-TTY or unexpected fds; if that fallback is intentional, it might be worth constraining where the function is used or adding a short comment about this implicit behavior so future callers don’t assume an error means “not foreground” in a stronger sense.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new Bats test relies on `script -qec` and `timeout`, which are GNU-specific flags/utilities and may behave differently or be missing on BSD/macOS systems; consider guarding the test, using more portable flags, or documenting the requirement so local runs don’t fail unexpectedly.
- IsForegroundProcess returns false on any ioctl error, which silently forces a dark theme even for non-TTY or unexpected fds; if that fallback is intentional, it might be worth constraining where the function is used or adding a short comment about this implicit behavior so future callers don’t assume an error means “not foreground” in a stronger sense.

## Individual Comments

### Comment 1
<location path="internal/util/foreground_unix.go" line_range="14-16" />
<code_context>
+// terminal from a background process group suspends the process with
+// SIGTTIN/SIGTTOU — e.g. inside process substitution:
+// `source <(lets completion -s zsh)`.
+func IsForegroundProcess(fd uintptr) bool {
+	pgrp, err := unix.IoctlGetInt(int(fd), unix.TIOCGPGRP)
+	if err != nil {
+		return false
+	}
</code_context>
<issue_to_address>
**issue (bug_risk):** Differentiate ioctl failures from true background status to avoid disabling detection on non-tty fds.

Because any `unix.IoctlGetInt` error is treated as "not foreground", non-tty fds (e.g., when stdout is a file/pipe or `EBADF`) are incorrectly handled as background, forcing callers to skip detection. Since `SIGTTIN/SIGTTOU` only apply to ttys, it would be better to treat `ENOTTY` (and possibly other ioctl failures) as "safe" and either return `true` or at least distinguish them from a real background process-group indication, so non-tty outputs retain normal detection behavior.
</issue_to_address>

### Comment 2
<location path="tests/completion.bats" line_range="59-62" />
<code_context>
+# group suspends the process with SIGTTOU, so `source <(lets completion -s zsh)`
+# in an interactive (job-control) shell hung forever. `script` allocates a PTY,
+# `set -m` enables job control like an interactive shell.
+@test "completion: source <(lets completion -s zsh) must not hang under job control" {
+    run timeout 10 script -qec "zsh -c 'set -m; source <(lets completion -s zsh); echo SOURCED_OK'" /dev/null
+    assert_success
+    assert_output --partial "SOURCED_OK"
+}
</code_context>
<issue_to_address>
**suggestion (testing):** Guard the regression test with skips when `zsh` or `script` binaries are unavailable

This test will fail on systems without `zsh` or `script` (or with a different `script` implementation), which is common on minimal CI images. Please guard it with availability checks (e.g., `command -v zsh >/dev/null || skip "zsh not available"` and similarly for `script`) so the suite stays portable while still exercising the regression where possible.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +14 to +16
func IsForegroundProcess(fd uintptr) bool {
pgrp, err := unix.IoctlGetInt(int(fd), unix.TIOCGPGRP)
if err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Differentiate ioctl failures from true background status to avoid disabling detection on non-tty fds.

Because any unix.IoctlGetInt error is treated as "not foreground", non-tty fds (e.g., when stdout is a file/pipe or EBADF) are incorrectly handled as background, forcing callers to skip detection. Since SIGTTIN/SIGTTOU only apply to ttys, it would be better to treat ENOTTY (and possibly other ioctl failures) as "safe" and either return true or at least distinguish them from a real background process-group indication, so non-tty outputs retain normal detection behavior.

Comment thread tests/completion.bats
Comment on lines +59 to +62
@test "completion: source <(lets completion -s zsh) must not hang under job control" {
run timeout 10 script -qec "zsh -c 'set -m; source <(lets completion -s zsh); echo SOURCED_OK'" /dev/null
assert_success
assert_output --partial "SOURCED_OK"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Guard the regression test with skips when zsh or script binaries are unavailable

This test will fail on systems without zsh or script (or with a different script implementation), which is common on minimal CI images. Please guard it with availability checks (e.g., command -v zsh >/dev/null || skip "zsh not available" and similarly for script) so the suite stays portable while still exercising the regression where possible.

@kindermax kindermax merged commit dff6781 into master Jul 16, 2026
5 checks passed
@kindermax kindermax deleted the fix-completion-hang-background-pgrp branch July 16, 2026 07:06
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