Add memoization to match and fix zero-timeout

This commit is contained in:
Chris Wanstrath 2026-03-10 15:06:23 -07:00
parent 6df26b90ac
commit 6b23930908
3 changed files with 57 additions and 14 deletions

View File

@ -20,10 +20,27 @@ func matchLine(pattern, actual string) bool {
}
func matchOutput(expected, actual []string) bool {
return doMatch(expected, 0, actual, 0)
memo := make(map[[2]int]int8) // 0=unknown, 1=true, -1=false
return doMatch(expected, 0, actual, 0, memo)
}
func doMatch(expected []string, ei int, actual []string, ai int) bool {
func doMatch(expected []string, ei int, actual []string, ai int, memo map[[2]int]int8) bool {
key := [2]int{ei, ai}
if v := memo[key]; v != 0 {
return v == 1
}
result := doMatchInner(expected, ei, actual, ai, memo)
if result {
memo[key] = 1
} else {
memo[key] = -1
}
return result
}
func doMatchInner(expected []string, ei int, actual []string, ai int, memo map[[2]int]int8) bool {
if ei == len(expected) && ai == len(actual) {
return true
}
@ -36,7 +53,7 @@ func doMatch(expected []string, ei int, actual []string, ai int) bool {
// Multi-line wildcard
if exp == "..." {
for skip := ai; skip <= len(actual); skip++ {
if doMatch(expected, ei+1, actual, skip) {
if doMatch(expected, ei+1, actual, skip, memo) {
return true
}
}
@ -48,7 +65,7 @@ func doMatch(expected []string, ei int, actual []string, ai int) bool {
}
if matchLine(exp, actual[ai]) {
return doMatch(expected, ei+1, actual, ai+1)
return doMatch(expected, ei+1, actual, ai+1, memo)
}
return false

View File

@ -99,6 +99,19 @@ func TestMatchOutputInlineAndMultiline(t *testing.T) {
}
}
func TestMatchOutputConsecutiveWildcards(t *testing.T) {
// This would hang before memoization (exponential backtracking)
expected := []string{"...", "...", "...", "...", "end"}
actual := make([]string, 200)
for i := range actual {
actual[i] = "line"
}
// "end" is not in actual, so this should return false — quickly.
if matchOutput(expected, actual) {
t.Error("should not match when final line is missing")
}
}
func TestDiffBasic(t *testing.T) {
d := diff([]string{"hello"}, []string{"world"})
if len(d) != 2 {

33
run.go
View File

@ -186,8 +186,29 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
stdin.Close()
var output []byte
select {
case r := <-ch:
if totalTimeout > 0 {
select {
case r := <-ch:
if r.err != nil {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
_ = cmd.Wait()
waited = true
return FileResult{File: file, TmpDir: tmpDir, Error: r.err.Error()}
}
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()
waited = true
return FileResult{File: file, TmpDir: tmpDir, Error: "Timeout reading output"}
}
} else {
r := <-ch
if r.err != nil {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
@ -197,14 +218,6 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
return FileResult{File: file, TmpDir: tmpDir, Error: r.err.Error()}
}
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()
waited = true
return FileResult{File: file, TmpDir: tmpDir, Error: "Timeout reading output"}
}
_ = cmd.Wait()