Summary
actionlint can deadlock forever (~0 CPU, no child process) while running its shellcheck integration, burning the caller's full timeout. The cause is in process.go cmdExecution.run(): the rule's stdin is written to cmd.StdinPipe() before the process is started (cmd.Output() is what calls Start):
p, err := cmd.StdinPipe()
...
if _, err := io.WriteString(p, e.stdin); err != nil { // pre-start write
...
stdout, err = cmd.Output() // Start happens here
A pre-start pipe has no reader, so the write can only complete if it fits the kernel pipe buffer. On Linux the default capacity is 64KiB, but once a user's total pipe allocation crosses fs.pipe-user-pages-soft (16384 pages by default, shared across all of the user's pipes), new pipes are allocated with as little as one page. On busy hosts (CI runners with many concurrent jobs) that regime is routine. Any run: script larger than the current capacity then parks the writer goroutine forever in poll.waitWrite; shellcheck is never spawned, and RuleShellcheck.VisitWorkflowPost waits forever in errgroup.Wait (rule_shellcheck.go:103).
We hit this in production CI as recurring silent 30-minute job-wall burns (76 in one 48h window): a workflow whose biggest run: block was 8419 bytes (incl. the injected set -eo pipefail\n prefix) deadlocked while freshly created pipes measured 8192 bytes capacity (F_GETPIPE_SZ) under pipe-buffer pressure.
Environment
- actionlint v1.7.12 (release-page binary,
built with go1.26.1), linux/amd64
- shellcheck v0.10.0 (static), invoked via
-shellcheck=<path>
- Linux 7.1.x,
fs.pipe-user-pages-soft = 16384 (default)
Evidence — SIGQUIT goroutine dump during a hang
Main goroutine:
goroutine 1 gp=0x8b87b1c41e0 m=nil [sync.WaitGroup.Wait]:
runtime.gopark(0x0?, 0x8b87b36f860?, 0x0?, 0xa0?, 0x0?)
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/proc.go:462 +0xce fp=0x8b87b389450 sp=0x8b87b389430 pc=0x48660e
runtime.goparkunlock(...)
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/proc.go:468
runtime.semacquire1(0x8b87b2d6818, 0x0, 0x1, 0x0, 0x19)
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/sema.go:192 +0x232 fp=0x8b87b3894b8 sp=0x8b87b389450 pc=0x466932
sync.runtime_SemacquireWaitGroup(0x1?, 0x0?)
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/sema.go:114 +0x2e fp=0x8b87b3894f0 sp=0x8b87b3894b8 pc=0x487d0e
sync.(*WaitGroup).Wait(0x8b87b2d6810)
/opt/hostedtoolcache/go/1.26.1/x64/src/sync/waitgroup.go:206 +0x85 fp=0x8b87b389518 sp=0x8b87b3894f0 pc=0x4968e5
golang.org/x/sync/errgroup.(*Group).Wait(0x8b87b2d6808)
/home/runner/go/pkg/mod/golang.org/x/sync@v0.20.0/errgroup/errgroup.go:56 +0x1e fp=0x8b87b389538 sp=0x8b87b389518 pc=0x5c681e
github.com/rhysd/actionlint.(*externalCommand).wait(...)
/home/runner/work/actionlint/actionlint/process.go:172
github.com/rhysd/actionlint.(*RuleShellcheck).VisitWorkflowPost(0x8b87b1d4540?, 0x8b87b2d4240?)
/home/runner/work/actionlint/actionlint/rule_shellcheck.go:103 +0x45 fp=0x8b87b389550 sp=0x8b87b389538 pc=0x6962e5
github.com/rhysd/actionlint.(*Visitor).Visit(0x8b87b3898f0, 0x8b87b2d8320)
/home/runner/work/actionlint/actionlint/pass.go:79 +0x28e fp=0x8b87b389628 sp=0x8b87b389550 pc=0x67ddee
github.com/rhysd/actionlint.(*Linter).check(0x8b87b2ec2c0, {0x7ffeb4efa3a8, 0x29}, {0x8b87b3a2000, 0x6a03, 0x6a04}, 0x0, 0x8b87b36d470, 0x8b87b2a4c40, 0x8b87b2d82d0)
/home/runner/work/actionlint/actionlint/linter.go:614 +0x135d fp=0x8b87b389948 sp=0x8b87b389628 pc=0x65f7bd
github.com/rhysd/actionlint.(*Linter).LintFile(0x8b87b2ec2c0, {0x7ffeb4efa3a8, 0x29}, 0x0?)
/home/runner/work/actionlint/actionlint/linter.go:457 +0x388 fp=0x8b87b389a80 sp=0x8b87b389948 pc=0x65db28
github.com/rhysd/actionlint.(*Linter).LintFiles(0x7a5c98?, {0x8b87b1d4230?, 0x8b87b2ec160?, 0x6f3280?}, 0x8b87b298701?)
/home/runner/work/actionlint/actionlint/linter.go:322 +0x2ba fp=0x8b87b389d88 sp=0x8b87b389a80 pc=0x65c55a
github.com/rhysd/actionlint.(*Command).runLinter(0x8b87b36d080, {0x8b87b1d4230, 0x1, 0x1}, 0xe?, 0x0)
/home/runner/work/actionlint/actionlint/command.go:110 +0xa5 fp=0x8b87b389dc0 sp=0x8b87b389d88 pc=0x649a85
github.com/rhysd/actionlint.(*Command).Main(0x8b87b36d080, {0x8b87b1d41e0, 0x6, 0x6})
/home/runner/work/actionlint/actionlint/command.go:184 +0x70e fp=0x8b87b389ee0 sp=0x8b87b389dc0 pc=0x64a34e
main.main()
/home/runner/work/actionlint/actionlint/cmd/actionlint/main.go:17 +0xcf fp=0x8b87b389f48 sp=0x8b87b389ee0 pc=0x6a1e6f
runtime.main()
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/proc.go:290 +0x2d5 fp=0x8b87b389fe0 sp=0x8b87b389f48 pc=0x452875
runtime.goexit({})
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/asm_amd64.s:1771 +0x1 fp=0x8b87b389fe8 sp=0x8b87b389fe0 pc=0x48d0a1
The parked writer (note io.WriteString from process.go mid-write of 0x20e3 = 8419 bytes):
goroutine 10 gp=0x8b87b40a1e0 m=nil [IO wait]:
runtime.gopark(0x8b87b26a008?, 0x8b87b40a1e0?, 0x0?, 0x0?, 0xb?)
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/proc.go:462 +0xce fp=0x8b87b2a8b58 sp=0x8b87b2a8b38 pc=0x48660e
runtime.netpollblock(0x4a4a7b?, 0x41a2e6?, 0x0?)
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/netpoll.go:575 +0xf7 fp=0x8b87b2a8b90 sp=0x8b87b2a8b58 pc=0x44b777
internal/poll.runtime_pollWait(0x7efee2260e00, 0x77)
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/netpoll.go:351 +0x85 fp=0x8b87b2a8bb0 sp=0x8b87b2a8b90 pc=0x485865
internal/poll.(*pollDesc).wait(0x8b87b50a720?, 0x8b87b3eaf00?, 0x1)
/opt/hostedtoolcache/go/1.26.1/x64/src/internal/poll/fd_poll_runtime.go:84 +0x27 fp=0x8b87b2a8bd8 sp=0x8b87b2a8bb0 pc=0x4c0ee7
internal/poll.(*pollDesc).waitWrite(...)
/opt/hostedtoolcache/go/1.26.1/x64/src/internal/poll/fd_poll_runtime.go:93
internal/poll.(*FD).Write(0x8b87b50a720, {0x8b87b3e8f00, 0x20e3, 0x20e3})
/opt/hostedtoolcache/go/1.26.1/x64/src/internal/poll/fd_unix.go:390 +0x334 fp=0x8b87b2a8cc0 sp=0x8b87b2a8bd8 pc=0x4c4054
os.(*File).write(...)
/opt/hostedtoolcache/go/1.26.1/x64/src/os/file_posix.go:46
os.(*File).Write(0x8b87b5080c8, {0x8b87b3e8f00?, 0x20e3, 0x4217ff?})
/opt/hostedtoolcache/go/1.26.1/x64/src/os/file.go:215 +0x4e fp=0x8b87b2a8d20 sp=0x8b87b2a8cc0 pc=0x4cda0e
os.(*File).WriteString(0x9c0d10?, {0x8b87b3e8f00?, 0x8b87b5080c8?})
/opt/hostedtoolcache/go/1.26.1/x64/src/os/file.go:321 +0x26 fp=0x8b87b2a8d50 sp=0x8b87b2a8d20 pc=0x4ce046
io.WriteString({0x7a5c98, 0x8b87b5080c8}, {0x8b87b3e8f00?, 0x20e3?})
/opt/hostedtoolcache/go/1.26.1/x64/src/io/io.go:316 +0x54 fp=0x8b87b2a8d80 sp=0x8b87b2a8d50 pc=0x4bd174
github.com/rhysd/actionlint.(*cmdExecution).run(0x8b87b2a5280)
/home/runner/work/actionlint/actionlint/process.go:32 +0x18c fp=0x8b87b2a8ea0 sp=0x8b87b2a8d80 pc=0x67e4ec
github.com/rhysd/actionlint.(*concurrentProcess).run.func1()
/home/runner/work/actionlint/actionlint/process.go:99 +0x19a fp=0x8b87b2a8f78 sp=0x8b87b2a8ea0 pc=0x67eb5a
golang.org/x/sync/errgroup.(*Group).Go.func1()
/home/runner/go/pkg/mod/golang.org/x/sync@v0.20.0/errgroup/errgroup.go:93 +0x50 fp=0x8b87b2a8fe0 sp=0x8b87b2a8f78 pc=0x5c6970
runtime.goexit({})
/opt/hostedtoolcache/go/1.26.1/x64/src/runtime/asm_amd64.s:1771 +0x1 fp=0x8b87b2a8fe8 sp=0x8b87b2a8fe0 pc=0x48d0a1
created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 1
/home/runner/go/pkg/mod/golang.org/x/sync@v0.20.0/errgroup/errgroup.go:78 +0x95
rax 0xca
rbx 0x0
rcx 0x48ebe3
rdx 0x0
rdi 0x9cce98
rsi 0x80
rbp 0x7ffeb4ef7ed0
rsp 0x7ffeb4ef7e88
r8 0x0
r9 0x0
r10 0x0
r11 0x286
r12 0x7ffeb4ef7f08
r13 0x8b87b624001
r14 0x9cbf80
r15 0x5
rip 0x48ebe1
rflags 0x286
cs 0x33
fs 0x0
gs 0x0
No shellcheck process exists at this point (it was never spawned), and CPU is ~0.
Reproduction
Drive the invoking user over fs.pipe-user-pages-soft so fresh pipes shrink below the script size — e.g. hold many F_SETPIPE_SZ-enlarged pipes open in another process of the same user, confirming shrinkage via F_GETPIPE_SZ on a probe pipe — then run actionlint with -shellcheck on a workflow whose largest run: block exceeds the shrunk capacity. actionlint hangs at ~0 CPU until killed. In our measurements the unpatched binary hung on 2/3-5/6 of attempts per pressure window while a patched binary (below) never hung across the same interleaved windows, with byte-identical lint output otherwise.
Suggested fix
Let os/exec pump stdin concurrently after Start instead of hand-writing to the pipe pre-start — correct at any script size and pipe capacity:
---
process.go | 23 +++++++++++++----------
1 file changed, 13 insertions(+), 10 deletions(-)
diff --git a/process.go b/process.go
index cc978e5..0f24bbd 100644
--- a/process.go
+++ b/process.go
@@ -3,8 +3,8 @@ package actionlint
import (
"context"
"fmt"
- "io"
"os/exec"
+ "strings"
"sync"
"github.com/mattn/go-shellwords"
@@ -25,17 +25,20 @@ func (e *cmdExecution) run() ([]byte, error) {
cmd := exec.Command(e.cmd, e.args...)
cmd.Stderr = nil
- p, err := cmd.StdinPipe()
- if err != nil {
- return nil, fmt.Errorf("could not make stdin pipe for %s process: %w", e.cmd, err)
- }
- if _, err := io.WriteString(p, e.stdin); err != nil {
- p.Close()
- return nil, fmt.Errorf("could not write to stdin of %s process: %w", e.cmd, err)
- }
- p.Close()
+ // Feed stdin through a Reader so os/exec pumps it in a goroutine AFTER the
+ // child process starts. The previous code wrote the whole script into
+ // StdinPipe() BEFORE Start (Output() below is what starts the process): a
+ // pre-start pipe has no reader, so a write larger than the pipe's current
+ // capacity can park the writer goroutine forever (observed in production:
+ // io.WriteString -> poll.waitWrite parked with an 8419-byte script while
+ // fresh pipes measured 8192 bytes via F_GETPIPE_SZ -- Linux allocates new
+ // pipes below 64KiB once the user crosses fs.pipe-user-pages-soft, which
+ // is routine on busy CI hosts). shellcheck is then never spawned and
+ // errgroup.Wait in rule_shellcheck.go blocks forever at ~0 CPU.
+ cmd.Stdin = strings.NewReader(e.stdin)
var stdout []byte
+ var err error
if e.combineOutput {
stdout, err = cmd.CombinedOutput()
} else {
--
2.55.0
(The same pre-start-write pattern would also affect the pyflakes integration path, since both share cmdExecution.run.)
Happy to open a PR with this change if useful. Thanks for actionlint!
Summary
actionlintcan deadlock forever (~0 CPU, no child process) while running its shellcheck integration, burning the caller's full timeout. The cause is inprocess.gocmdExecution.run(): the rule's stdin is written tocmd.StdinPipe()before the process is started (cmd.Output()is what callsStart):A pre-start pipe has no reader, so the write can only complete if it fits the kernel pipe buffer. On Linux the default capacity is 64KiB, but once a user's total pipe allocation crosses
fs.pipe-user-pages-soft(16384 pages by default, shared across all of the user's pipes), new pipes are allocated with as little as one page. On busy hosts (CI runners with many concurrent jobs) that regime is routine. Anyrun:script larger than the current capacity then parks the writer goroutine forever inpoll.waitWrite; shellcheck is never spawned, andRuleShellcheck.VisitWorkflowPostwaits forever inerrgroup.Wait(rule_shellcheck.go:103).We hit this in production CI as recurring silent 30-minute job-wall burns (76 in one 48h window): a workflow whose biggest
run:block was 8419 bytes (incl. the injectedset -eo pipefail\nprefix) deadlocked while freshly created pipes measured 8192 bytes capacity (F_GETPIPE_SZ) under pipe-buffer pressure.Environment
built with go1.26.1), linux/amd64-shellcheck=<path>fs.pipe-user-pages-soft= 16384 (default)Evidence — SIGQUIT goroutine dump during a hang
Main goroutine:
The parked writer (note
io.WriteStringfromprocess.gomid-write of 0x20e3 = 8419 bytes):No shellcheck process exists at this point (it was never spawned), and CPU is ~0.
Reproduction
Drive the invoking user over
fs.pipe-user-pages-softso fresh pipes shrink below the script size — e.g. hold manyF_SETPIPE_SZ-enlarged pipes open in another process of the same user, confirming shrinkage viaF_GETPIPE_SZon a probe pipe — then run actionlint with-shellcheckon a workflow whose largestrun:block exceeds the shrunk capacity. actionlint hangs at ~0 CPU until killed. In our measurements the unpatched binary hung on 2/3-5/6 of attempts per pressure window while a patched binary (below) never hung across the same interleaved windows, with byte-identical lint output otherwise.Suggested fix
Let
os/execpump stdin concurrently afterStartinstead of hand-writing to the pipe pre-start — correct at any script size and pipe capacity:(The same pre-start-write pattern would also affect the pyflakes integration path, since both share
cmdExecution.run.)Happy to open a PR with this change if useful. Thanks for actionlint!