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++ {
ch := line[i]
switch {
case ch == '\\' && !inSingle:
i++ // skip escaped character (backslash escapes in double-quoted and unquoted contexts)
case ch == '\'' && !inDouble:
inSingle = !inSingle
case ch == '"' && !inSingle:

29
run.go
View File

@ -1,6 +1,8 @@
package main
import (
"crypto/rand"
"fmt"
"io"
"os"
"os/exec"
@ -11,7 +13,11 @@ import (
"time"
)
const sentinelPrefix = "__SHOUT_SENTINEL_"
func newSentinelPrefix() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return fmt.Sprintf("__SHOUT_%x_", b)
}
type RunOptions struct {
CleanEnv bool
@ -22,7 +28,7 @@ type RunOptions struct {
OnCommand func(Command)
}
func buildScript(commands []Command) string {
func buildScript(commands []Command, sentinelPrefix string) string {
var b strings.Builder
b.WriteString("exec 2>&1\n")
for i, cmd := range commands {
@ -33,7 +39,7 @@ func buildScript(commands []Command) 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+)__`)
remaining := raw
@ -91,7 +97,8 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
return FileResult{File: file, TmpDir: tmpDir}
}
script := buildScript(file.Commands)
sentinel := newSentinelPrefix()
script := buildScript(file.Commands, sentinel)
// Build environment
var envMap map[string]string
@ -115,7 +122,12 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
if len(opts.PathDirs) > 0 {
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))
@ -172,12 +184,17 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
}
output = r.data
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"}
}
_ = 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))
for i, c := range file.Commands {

BIN
shout

Binary file not shown.

View File

@ -1,6 +1,7 @@
package main
import (
"fmt"
"regexp"
"strings"
)
@ -20,6 +21,13 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string
output = append(output, line)
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++
continue
}
@ -62,6 +70,10 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string
output = append(output, result.Actual...)
if 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++ {
output = append(output, "")