fix sentinel collision, backslash escape, and timeout cleanup

This commit is contained in:
Chris Wanstrath 2026-03-10 12:20:11 -07:00
parent 57fba7acb0
commit c7730bcaa6
4 changed files with 37 additions and 6 deletions

View File

@ -12,6 +12,8 @@ func stripComment(line string) string {
for i := 0; i < len(line); i++ { for i := 0; i < len(line); i++ {
ch := line[i] ch := line[i]
switch { switch {
case ch == '\\' && !inSingle:
i++ // skip escaped character (backslash escapes in double-quoted and unquoted contexts)
case ch == '\'' && !inDouble: case ch == '\'' && !inDouble:
inSingle = !inSingle inSingle = !inSingle
case ch == '"' && !inSingle: case ch == '"' && !inSingle:

29
run.go
View File

@ -1,6 +1,8 @@
package main package main
import ( import (
"crypto/rand"
"fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@ -11,7 +13,11 @@ import (
"time" "time"
) )
const sentinelPrefix = "__SHOUT_SENTINEL_" func newSentinelPrefix() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return fmt.Sprintf("__SHOUT_%x_", b)
}
type RunOptions struct { type RunOptions struct {
CleanEnv bool CleanEnv bool
@ -22,7 +28,7 @@ type RunOptions struct {
OnCommand func(Command) OnCommand func(Command)
} }
func buildScript(commands []Command) string { func buildScript(commands []Command, sentinelPrefix string) string {
var b strings.Builder var b strings.Builder
b.WriteString("exec 2>&1\n") b.WriteString("exec 2>&1\n")
for i, cmd := range commands { for i, cmd := range commands {
@ -33,7 +39,7 @@ func buildScript(commands []Command) string {
return b.String() return b.String()
} }
func parseSentinelOutput(raw string, commandCount int) (outputs [][]string, exitCodes []int) { func parseSentinelOutput(raw string, commandCount int, sentinelPrefix string) (outputs [][]string, exitCodes []int) {
re := regexp.MustCompile(regexp.QuoteMeta(sentinelPrefix) + `(\d+)_(\d+)__`) re := regexp.MustCompile(regexp.QuoteMeta(sentinelPrefix) + `(\d+)_(\d+)__`)
remaining := raw remaining := raw
@ -91,7 +97,8 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
return FileResult{File: file, TmpDir: tmpDir} return FileResult{File: file, TmpDir: tmpDir}
} }
script := buildScript(file.Commands) sentinel := newSentinelPrefix()
script := buildScript(file.Commands, sentinel)
// Build environment // Build environment
var envMap map[string]string var envMap map[string]string
@ -115,7 +122,12 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
if len(opts.PathDirs) > 0 { if len(opts.PathDirs) > 0 {
existing := envMap["PATH"] existing := envMap["PATH"]
envMap["PATH"] = strings.Join(opts.PathDirs, ":") + ":" + existing prepend := strings.Join(opts.PathDirs, ":")
if existing != "" {
envMap["PATH"] = prepend + ":" + existing
} else {
envMap["PATH"] = prepend
}
} }
envSlice := make([]string, 0, len(envMap)) envSlice := make([]string, 0, len(envMap))
@ -172,12 +184,17 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
} }
output = r.data output = r.data
case <-time.After(totalTimeout): case <-time.After(totalTimeout):
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
<-ch // drain the reader goroutine so pipe FDs are released
_ = cmd.Wait()
return FileResult{File: file, TmpDir: tmpDir, Error: "Timeout reading output"} return FileResult{File: file, TmpDir: tmpDir, Error: "Timeout reading output"}
} }
_ = cmd.Wait() _ = cmd.Wait()
outputs, exitCodesList := parseSentinelOutput(string(output), len(file.Commands)) outputs, exitCodesList := parseSentinelOutput(string(output), len(file.Commands), sentinel)
results := make([]CommandResult, len(file.Commands)) results := make([]CommandResult, len(file.Commands))
for i, c := range file.Commands { for i, c := range file.Commands {

BIN
shout

Binary file not shown.

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"regexp" "regexp"
"strings" "strings"
) )
@ -20,6 +21,13 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string
output = append(output, line) output = append(output, line)
if cmdIdx >= len(file.Commands) || cmdIdx >= len(results) { if cmdIdx >= len(file.Commands) || cmdIdx >= len(results) {
// Out of bounds — skip past expected output, preserving original lines
j := i + 1
for j < len(lines) && !strings.HasPrefix(lines[j], "$ ") {
j++
}
output = append(output, lines[i+1:j]...)
i = j - 1
cmdIdx++ cmdIdx++
continue continue
} }
@ -62,6 +70,10 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string
output = append(output, result.Actual...) output = append(output, result.Actual...)
if oldExitMarker != "" { if oldExitMarker != "" {
output = append(output, oldExitMarker) output = append(output, oldExitMarker)
} else if len(result.Actual) > 0 && exitCodeMarkerRe.MatchString(result.Actual[len(result.Actual)-1]) {
// Actual output's last line looks like an exit code marker.
// Add an explicit marker to prevent parser from consuming it as one.
output = append(output, fmt.Sprintf("[%d]", result.ExitCode))
} }
for k := 0; k < trailingBlanks; k++ { for k := 0; k < trailingBlanks; k++ {
output = append(output, "") output = append(output, "")