Skip to content

fix deadlock in shellcheck integration on darwin by using cmd.Stdin#651

Merged
rhysd merged 1 commit intorhysd:mainfrom
attehuhtakangas:fix-shellcheck-deadlock-macos
Apr 19, 2026
Merged

fix deadlock in shellcheck integration on darwin by using cmd.Stdin#651
rhysd merged 1 commit intorhysd:mainfrom
attehuhtakangas:fix-shellcheck-deadlock-macos

Conversation

@attehuhtakangas
Copy link
Copy Markdown
Contributor

Fixes #650.

cmdExecution.run used to create a stdin pipe with cmd.StdinPipe(), write the full script to it, close the writer, and only then call cmd.Output() which internally calls cmd.Start(). That relies on the kernel pipe buffer being large enough to absorb the whole payload before a reader is attached. On darwin, under concurrent load, the buffer fills up and every worker goroutine ends up blocked on internal/poll.(*FD).Write -> waitWrite inside io.WriteString, so no child ever gets started and actionlint spins until killed.

This replaces the manual pipe management with cmd.Stdin = strings.NewReader(e.stdin). cmd.Output() / cmd.CombinedOutput() then spawn a copy goroutine after Start(), which is the canonical os/exec pattern.

The bug reproduces reliably on darwin_arm64 (tested on 1.7.10, 1.7.11, and 1.7.12 official release binaries) with any workflow that has ≥2 run: steps whose scripts combined cross the pipe-buffer threshold — the smallest case I could trim it to was a 2 KB workflow with 50 echo hello lines per step.

Changes

  • process.go — replace the pre-Start() pipe write with cmd.Stdin = strings.NewReader(...). No behavior change on Linux, fixes the deadlock on darwin.
  • process_test.go — add TestProcessConcurrentStdinDoesNotDeadlock, which runs 5 concurrent cat invocations with a 64 KiB stdin payload guarded by a 10 s watchdog. Confirmed:
    • FAILS on main (watchdog fires at 10 s)
    • PASSES on this branch

Test evidence

Before the fix, running on my local darwin/arm64 M-series against the issue's 50-echo repro:

$ actionlint deadlock.yaml   # spins at ~450% CPU, never exits

After the fix (same binary built from this branch):

$ ./actionlint deadlock.yaml
$ echo $?
0

Full test suite (go test ./...) still passes.

Writing the script to cmd.StdinPipe() before cmd.Start() relies on the
kernel pipe buffer absorbing the whole payload. On darwin with several
concurrent workers this deadlocks because the buffer fills up and no
reader is attached until Start() runs, which never happens.

Set cmd.Stdin to a strings.Reader instead so the standard library pipes
the payload to the child after Start() on a copy goroutine. This is the
canonical os/exec pattern and removes the manual pipe management.

Closes rhysd#650
Copy link
Copy Markdown
Owner

@rhysd rhysd left a comment

Choose a reason for hiding this comment

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

I could not reproduce the test failure by just copying&pasting the test case on my old Intel Mac. However this change looks more clean than previous and the CI passed. Merging.

@rhysd rhysd merged commit 011a6d1 into rhysd:main Apr 19, 2026
18 checks passed
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.

Deadlock in shellcheck integration on macOS: process.go:32 blocks writing to unstarted child stdin

2 participants