fix: harden stage execution failures
All checks were successful
build / build (push) Successful in 1m35s
build / trigger-build-image (push) Has been skipped

This commit is contained in:
张泊明518370910136 2026-08-02 00:39:44 -07:00
parent 0a5947f45e
commit edbdbd41a8
GPG Key ID: D47306D7062CDA9D
13 changed files with 237 additions and 68 deletions

View File

@ -104,6 +104,7 @@ func run(conf *joj3Conf.Conf, conventionalCommit *joj3Conf.ConventionalCommit) e
) )
if err != nil { if err != nil {
slog.Error("stage run", "error", err) slog.Error("stage run", "error", err)
return err
} }
if forceQuitStageName != "" { if forceQuitStageName != "" {
slog.Info("stage force quit", "name", forceQuitStageName) slog.Info("stage force quit", "name", forceQuitStageName)

View File

@ -1,7 +1,9 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@ -124,6 +126,7 @@ func newErrorStageResults(err error) ([]stage.StageResult, string) {
}, "Internal Error" }, "Internal Error"
} }
//nolint:unparam // named err lets deferred cleanup errors reach the caller
func runStages( func runStages(
conf *conf.Conf, conf *conf.Conf,
groups []string, groups []string,
@ -153,36 +156,42 @@ func runStages(
stageResults, forceQuitStageName = newErrorStageResults(err) stageResults, forceQuitStageName = newErrorStageResults(err)
return stageResults, forceQuitStageName, err return stageResults, forceQuitStageName, err
} }
defer stage.Cleanup() ctx := context.Background()
defer func() {
err = errors.Join(err, stage.Cleanup(ctx))
}()
// ignore force quit in preStages & postStages // ignore force quit in preStages & postStages
slog.Info("run preStages") slog.Info("run preStages")
_, _, err = stage.Run(preStages) _, _, preErr := stage.Run(ctx, preStages)
if err != nil { if preErr != nil {
slog.Error("run preStages", "error", err) slog.Error("run preStages", "error", preErr)
} }
slog.Info("run stages") slog.Info("run stages")
stageResults, forceQuitStageName, err = stage.Run(stages) stageResults, forceQuitStageName, mainErr := stage.Run(ctx, stages)
if err != nil { if mainErr != nil {
slog.Error("run stages", "error", err) slog.Error("run stages", "error", mainErr)
stageResults, forceQuitStageName = newErrorStageResults(err) stageResults, forceQuitStageName = newErrorStageResults(mainErr)
} }
onStagesComplete(stageResults, forceQuitStageName) onStagesComplete(stageResults, forceQuitStageName)
slog.Info("output result start", "path", conf.OutputPath) slog.Info("output result start", "path", conf.OutputPath)
slog.Debug("output result start", slog.Debug("output result start",
"path", conf.OutputPath, "results", stageResults) "path", conf.OutputPath, "results", stageResults)
content, err := json.Marshal(stageResults) content, marshalErr := json.Marshal(stageResults)
if err != nil { if marshalErr != nil {
slog.Error("marshal stageResults", "error", err) slog.Error("marshal stageResults", "error", marshalErr)
} }
err = os.WriteFile(conf.OutputPath, var outputErr error
append(content, []byte("\n")...), 0o600) if marshalErr == nil {
if err != nil { outputErr = os.WriteFile(conf.OutputPath,
slog.Error("write stageResults", "error", err) append(content, '\n'), 0o600)
if outputErr != nil {
slog.Error("write stageResults", "error", outputErr)
}
} }
slog.Info("run postStages") slog.Info("run postStages")
_, _, err = stage.Run(postStages) _, _, postErr := stage.Run(ctx, postStages)
if err != nil { if postErr != nil {
slog.Error("run postStages", "error", err) slog.Error("run postStages", "error", postErr)
} }
return stageResults, forceQuitStageName, err return stageResults, forceQuitStageName, errors.Join(preErr, mainErr, marshalErr, outputErr, postErr)
} }

27
cmd/joj3/stage_test.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"strings"
"testing"
"github.com/joint-online-judge/JOJ3/cmd/joj3/conf"
"github.com/joint-online-judge/JOJ3/internal/stage"
)
func TestRunStagesPreservesMainAndOutputErrors(t *testing.T) {
brokenStage := conf.ConfStage{Name: "broken"}
brokenStage.Executor.Name = "missing-executor"
c := &conf.Conf{
SandboxExecServer: "localhost:5051",
OutputPath: t.TempDir(),
Stages: []conf.ConfStage{brokenStage},
}
_, _, err := runStages(c, nil, func([]stage.StageResult, string) {})
if err == nil {
t.Fatal("runStages() unexpectedly succeeded")
}
if !strings.Contains(err.Error(), "executor not found") ||
!strings.Contains(err.Error(), "is a directory") {
t.Fatalf("runStages() error = %v, want main and output errors", err)
}
}

View File

@ -1,8 +1,12 @@
package dummy package dummy
import "github.com/joint-online-judge/JOJ3/internal/stage" import (
"context"
func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { "github.com/joint-online-judge/JOJ3/internal/stage"
)
func (e *Dummy) Run(_ context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
res := make([]stage.ExecutorResult, 0, len(cmds)) res := make([]stage.ExecutorResult, 0, len(cmds))
for range cmds { for range cmds {
res = append(res, stage.ExecutorResult{ res = append(res, stage.ExecutorResult{
@ -19,6 +23,6 @@ func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
return res, nil return res, nil
} }
func (e *Dummy) Cleanup() error { func (e *Dummy) Cleanup(_ context.Context) error {
return nil return nil
} }

View File

@ -2,6 +2,7 @@ package local
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"math" "math"
@ -105,10 +106,16 @@ func (e *Local) generateResult(
return result return result
} }
func (e *Local) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { func (e *Local) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
var results []stage.ExecutorResult var results []stage.ExecutorResult
for _, cmd := range cmds { for _, cmd := range cmds {
if len(cmd.Args) == 0 {
return nil, fmt.Errorf("command args must not be empty")
}
if err := ctx.Err(); err != nil {
return nil, err
}
execCmd := exec.Command(cmd.Args[0], cmd.Args[1:]...) // #nosec G204 execCmd := exec.Command(cmd.Args[0], cmd.Args[1:]...) // #nosec G204
execCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} execCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if cmd.CPULimit > 0 && cmd.ClockLimit <= 0 { if cmd.CPULimit > 0 && cmd.ClockLimit <= 0 {
@ -167,6 +174,10 @@ func (e *Local) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
false, false,
) )
results = append(results, result) results = append(results, result)
case <-ctx.Done():
_ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL)
<-done
return nil, ctx.Err()
case <-time.After(duration): case <-time.After(duration):
_ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL) _ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL)
err := <-done err := <-done
@ -216,6 +227,6 @@ func handleCopyOut(result *stage.ExecutorResult, cmd stage.Cmd) error {
return nil return nil
} }
func (e *Local) Cleanup() error { func (e *Local) Cleanup(_ context.Context) error {
return nil return nil
} }

View File

@ -1,7 +1,7 @@
package sandbox package sandbox
import ( import (
"log/slog" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -12,14 +12,22 @@ import (
) )
// copied from https://github.com/criyle/go-judge/blob/master/cmd/go-judge-shell/grpc.go // copied from https://github.com/criyle/go-judge/blob/master/cmd/go-judge-shell/grpc.go
func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType { func convertPBCmd(cmd []stage.Cmd) ([]*pb.Request_CmdType, error) {
ret := make([]*pb.Request_CmdType, 0, len(cmd)) ret := make([]*pb.Request_CmdType, 0, len(cmd))
for _, c := range cmd { for index, c := range cmd {
files, err := convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr})
if err != nil {
return nil, fmt.Errorf("command %d standard file: %w", index, err)
}
copyIn, err := convertPBCopyIn(c.CopyIn, c.CopyInDir)
if err != nil {
return nil, fmt.Errorf("command %d copy-in: %w", index, err)
}
req := &pb.Request_CmdType{} req := &pb.Request_CmdType{}
req.SetArgs(c.Args) req.SetArgs(c.Args)
req.SetEnv(c.Env) req.SetEnv(c.Env)
req.SetTty(c.TTY) req.SetTty(c.TTY)
req.SetFiles(convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr})) req.SetFiles(files)
req.SetCpuTimeLimit(c.CPULimit) req.SetCpuTimeLimit(c.CPULimit)
req.SetClockTimeLimit(c.ClockLimit) req.SetClockTimeLimit(c.ClockLimit)
req.SetMemoryLimit(c.MemoryLimit) req.SetMemoryLimit(c.MemoryLimit)
@ -29,7 +37,7 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
req.SetCpuSetLimit(c.CPUSetLimit) req.SetCpuSetLimit(c.CPUSetLimit)
req.SetDataSegmentLimit(c.DataSegmentLimit) req.SetDataSegmentLimit(c.DataSegmentLimit)
req.SetAddressSpaceLimit(c.AddressSpaceLimit) req.SetAddressSpaceLimit(c.AddressSpaceLimit)
req.SetCopyIn(convertPBCopyIn(c.CopyIn, c.CopyInDir)) req.SetCopyIn(copyIn)
req.SetCopyOut(convertPBCopyOut(c.CopyOut)) req.SetCopyOut(convertPBCopyOut(c.CopyOut))
req.SetCopyOutCached(convertPBCopyOut(c.CopyOutCached)) req.SetCopyOutCached(convertPBCopyOut(c.CopyOutCached))
req.SetCopyOutMax(c.CopyOutMax) req.SetCopyOutMax(c.CopyOutMax)
@ -37,25 +45,25 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
req.SetSymlinks(convertSymlink(c.CopyIn)) req.SetSymlinks(convertSymlink(c.CopyIn))
ret = append(ret, req) ret = append(ret, req)
} }
return ret return ret, nil
} }
func convertPBCopyIn( func convertPBCopyIn(
copyIn map[string]stage.CmdFile, copyInDir string, copyIn map[string]stage.CmdFile, copyInDir string,
) map[string]*pb.Request_File { ) (map[string]*pb.Request_File, error) {
if copyInDir != "" { if copyInDir != "" {
_ = filepath.Walk(copyInDir, err := filepath.Walk(copyInDir,
func(path string, info os.FileInfo, err error) error { func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return nil return err
} }
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return nil return err
} }
relPath, err := filepath.Rel(copyInDir, path) relPath, err := filepath.Rel(copyInDir, path)
if err != nil { if err != nil {
return nil return err
} }
_, exists := copyIn[relPath] _, exists := copyIn[relPath]
if !info.IsDir() && !exists { if !info.IsDir() && !exists {
@ -63,15 +71,22 @@ func convertPBCopyIn(
} }
return nil return nil
}) })
if err != nil {
return nil, fmt.Errorf("walk %q: %w", copyInDir, err)
}
} }
rt := make(map[string]*pb.Request_File, len(copyIn)) rt := make(map[string]*pb.Request_File, len(copyIn))
for k, i := range copyIn { for k, i := range copyIn {
if i.Symlink != nil { if i.Symlink != nil {
continue continue
} }
rt[k] = convertPBFile(i) file, err := convertPBFile(i)
if err != nil {
return nil, fmt.Errorf("file %q: %w", k, err)
}
rt[k] = file
} }
return rt return rt, nil
} }
func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile { func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile {
@ -101,65 +116,67 @@ func convertSymlink(copyIn map[string]stage.CmdFile) map[string]string {
return ret return ret
} }
func convertPBFiles(files []*stage.CmdFile) []*pb.Request_File { func convertPBFiles(files []*stage.CmdFile) ([]*pb.Request_File, error) {
var ret []*pb.Request_File var ret []*pb.Request_File
for _, f := range files { for _, f := range files {
if f == nil { if f == nil {
ret = append(ret, nil) ret = append(ret, nil)
} else { } else {
ret = append(ret, convertPBFile(*f)) file, err := convertPBFile(*f)
if err != nil {
return nil, err
}
ret = append(ret, file)
} }
} }
return ret return ret, nil
} }
func convertPBFile(i stage.CmdFile) *pb.Request_File { func convertPBFile(i stage.CmdFile) (*pb.Request_File, error) {
req := &pb.Request_File{} req := &pb.Request_File{}
switch { switch {
case i.Src != nil: case i.Src != nil:
if !filepath.IsAbs(*i.Src) { if !filepath.IsAbs(*i.Src) {
absPath, err := filepath.Abs(*i.Src) absPath, err := filepath.Abs(*i.Src)
if err != nil { if err != nil {
slog.Error("convert pb file get abs path", "path", *i.Src, "error", err) return nil, fmt.Errorf("resolve source path %q: %w", *i.Src, err)
absPath = "/"
} }
i.Src = &absPath i.Src = &absPath
} }
s, err := os.ReadFile(*i.Src) s, err := os.ReadFile(*i.Src)
if err != nil { if err != nil {
s = []byte{} return nil, fmt.Errorf("read source file %q: %w", *i.Src, err)
slog.Error("convert pb file read file", "path", *i.Src, "error", err)
} }
m := &pb.Request_MemoryFile{} m := &pb.Request_MemoryFile{}
m.SetContent(s) m.SetContent(s)
req.SetMemory(m) req.SetMemory(m)
return req return req, nil
case i.Content != nil: case i.Content != nil:
s := strToBytes(*i.Content) s := strToBytes(*i.Content)
m := &pb.Request_MemoryFile{} m := &pb.Request_MemoryFile{}
m.SetContent(s) m.SetContent(s)
req.SetMemory(m) req.SetMemory(m)
return req return req, nil
case i.FileID != nil: case i.FileID != nil:
c := &pb.Request_CachedFile{} c := &pb.Request_CachedFile{}
c.SetFileID(*i.FileID) c.SetFileID(*i.FileID)
req.SetCached(c) req.SetCached(c)
return req return req, nil
case i.Name != nil && i.Max != nil: case i.Name != nil && i.Max != nil:
p := &pb.Request_PipeCollector{} p := &pb.Request_PipeCollector{}
p.SetName(*i.Name) p.SetName(*i.Name)
p.SetMax(*i.Max) p.SetMax(*i.Max)
p.SetPipe(i.Pipe) p.SetPipe(i.Pipe)
req.SetPipe(p) req.SetPipe(p)
return req return req, nil
case i.StreamIn: case i.StreamIn:
req.SetStreamIn(&emptypb.Empty{}) req.SetStreamIn(&emptypb.Empty{})
return req return req, nil
case i.StreamOut: case i.StreamOut:
req.SetStreamOut(&emptypb.Empty{}) req.SetStreamOut(&emptypb.Empty{})
return req return req, nil
} }
return nil return nil, nil
} }
func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult { func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult {

View File

@ -0,0 +1,21 @@
package sandbox
import (
"strings"
"testing"
"github.com/joint-online-judge/JOJ3/internal/stage"
)
func TestConvertPBCmdReturnsSourceReadError(t *testing.T) {
missing := t.TempDir() + "/missing"
_, err := convertPBCmd([]stage.Cmd{{
Args: []string{"true"},
CopyIn: map[string]stage.CmdFile{
"input": {Src: &missing},
},
}})
if err == nil || !strings.Contains(err.Error(), "read source file") {
t.Fatalf("convertPBCmd() error = %v, want source read error", err)
}
}

View File

@ -2,6 +2,7 @@ package sandbox
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps" "maps"
@ -11,11 +12,11 @@ import (
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { func (e *Sandbox) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
var err error var err error
if e.execClient == nil { if e.execClient == nil {
slog.Debug("create exec client", "server", e.execServer) slog.Debug("create exec client", "server", e.execServer)
e.execClient, err = createExecClient(e.execServer, e.token) e.execClient, e.conn, err = createExecClient(e.execServer, e.token)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -32,14 +33,19 @@ func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
} }
} }
} }
pbCmds := convertPBCmd(cmds) pbCmds, err := convertPBCmd(cmds)
if err != nil {
return nil, err
}
for i, pbCmd := range pbCmds { for i, pbCmd := range pbCmds {
slog.Debug("sandbox execute", "i", i, "pbCmd size", proto.Size(pbCmd)) slog.Debug("sandbox execute", "i", i, "pbCmd size", proto.Size(pbCmd))
} }
pbReq := &pb.Request{} pbReq := &pb.Request{}
pbReq.SetCmd(pbCmds) pbReq.SetCmd(pbCmds)
slog.Debug("sandbox execute", "pbReq size", proto.Size(pbReq)) slog.Debug("sandbox execute", "pbReq size", proto.Size(pbReq))
pbRet, err := e.execClient.Exec(context.TODO(), pbReq) callCtx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
pbRet, err := e.execClient.Exec(callCtx, pbReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -53,15 +59,24 @@ func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
return results, nil return results, nil
} }
func (e *Sandbox) Cleanup() error { func (e *Sandbox) Cleanup(ctx context.Context) error {
var cleanupErr error
for k, fileID := range e.cachedMap { for k, fileID := range e.cachedMap {
req := &pb.FileID{} req := &pb.FileID{}
req.SetFileID(fileID) req.SetFileID(fileID)
_, err := e.execClient.FileDelete(context.TODO(), req) callCtx, cancel := context.WithTimeout(ctx, e.timeout)
_, err := e.execClient.FileDelete(callCtx, req)
cancel()
if err != nil { if err != nil {
slog.Error("sandbox cleanup", "error", err) slog.Error("sandbox cleanup", "error", err)
cleanupErr = errors.Join(cleanupErr, err)
} }
delete(e.cachedMap, k) delete(e.cachedMap, k)
} }
return nil if e.conn != nil {
cleanupErr = errors.Join(cleanupErr, e.conn.Close())
e.conn = nil
e.execClient = nil
}
return cleanupErr
} }

View File

@ -12,13 +12,13 @@ import (
) )
// copied from https://github.com/criyle/go-judger-demo/blob/master/apigateway/main.go // copied from https://github.com/criyle/go-judger-demo/blob/master/apigateway/main.go
func createExecClient(execServer, token string) (pb.ExecutorClient, error) { func createExecClient(execServer, token string) (pb.ExecutorClient, *grpc.ClientConn, error) {
conn, err := createGRPCConnection(execServer, token) conn, err := createGRPCConnection(execServer, token)
if err != nil { if err != nil {
slog.Error("gRPC connection", "error", err) slog.Error("gRPC connection", "error", err)
return nil, err return nil, nil, err
} }
return pb.NewExecutorClient(conn), nil return pb.NewExecutorClient(conn), conn, nil
} }
func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) { func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) {

View File

@ -5,8 +5,11 @@
package sandbox package sandbox
import ( import (
"time"
"github.com/criyle/go-judge/pb" "github.com/criyle/go-judge/pb"
"github.com/joint-online-judge/JOJ3/internal/stage" "github.com/joint-online-judge/JOJ3/internal/stage"
"google.golang.org/grpc"
) )
var name = "sandbox" var name = "sandbox"
@ -15,6 +18,8 @@ type Sandbox struct {
execServer, token string execServer, token string
cachedMap map[string]string cachedMap map[string]string
execClient pb.ExecutorClient execClient pb.ExecutorClient
conn *grpc.ClientConn
timeout time.Duration
} }
func init() { func init() {
@ -22,6 +27,7 @@ func init() {
execServer: "localhost:5051", execServer: "localhost:5051",
token: "", token: "",
cachedMap: make(map[string]string), cachedMap: make(map[string]string),
timeout: 30 * time.Second,
}) })
} }
@ -31,5 +37,6 @@ func InitWithConf(execServer, token string) {
execServer: execServer, execServer: execServer,
token: token, token: token,
cachedMap: make(map[string]string), cachedMap: make(map[string]string),
timeout: 30 * time.Second,
}) })
} }

View File

@ -1,6 +1,7 @@
package stage package stage
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
@ -9,8 +10,8 @@ import (
var executorMap = map[string]Executor{} var executorMap = map[string]Executor{}
type Executor interface { type Executor interface {
Run([]Cmd) ([]ExecutorResult, error) Run(context.Context, []Cmd) ([]ExecutorResult, error)
Cleanup() error Cleanup(context.Context) error
} }
func RegisterExecutor(name string, executor Executor) { func RegisterExecutor(name string, executor Executor) {

View File

@ -4,11 +4,13 @@
package stage package stage
import ( import (
"context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
) )
func Run(stages []Stage) ( func Run(ctx context.Context, stages []Stage) (
stageResults []StageResult, forceQuitStageName string, err error, stageResults []StageResult, forceQuitStageName string, err error,
) { ) {
var executorResults []ExecutorResult var executorResults []ExecutorResult
@ -59,9 +61,10 @@ func Run(stages []Stage) (
"name", stage.Executor.Name, "name", stage.Executor.Name,
) )
err = fmt.Errorf("executor not found: %s", stage.Executor.Name) err = fmt.Errorf("executor not found: %s", stage.Executor.Name)
forceQuitStageName = stage.Name
return return
} }
executorResults, err = executor.Run(stage.Executor.Cmds) executorResults, err = executor.Run(ctx, stage.Executor.Cmds)
if err != nil { if err != nil {
slog.Error( slog.Error(
"executor run error", "executor run error",
@ -69,6 +72,7 @@ func Run(stages []Stage) (
"name", stage.Executor.Name, "name", stage.Executor.Name,
"error", err, "error", err,
) )
forceQuitStageName = stage.Name
return return
} }
for i, executorResult := range executorResults { for i, executorResult := range executorResults {
@ -115,6 +119,7 @@ func Run(stages []Stage) (
"name", stageParser.Name, "name", stageParser.Name,
) )
err = fmt.Errorf("parser not found: %s", stageParser.Name) err = fmt.Errorf("parser not found: %s", stageParser.Name)
forceQuitStageName = stage.Name
return return
} }
var parserForceQuit bool var parserForceQuit bool
@ -130,6 +135,14 @@ func Run(stages []Stage) (
forceQuitStageName = stage.Name forceQuitStageName = stage.Name
break break
} }
if len(tmpParserResults) != len(executorResults) {
err = fmt.Errorf(
"parser %q returned %d results for %d executor results",
stageParser.Name, len(tmpParserResults), len(executorResults),
)
forceQuitStageName = stage.Name
break
}
for i, parserResult := range tmpParserResults { for i, parserResult := range tmpParserResults {
parserScoresMap[stageParser.Name][i] += parserResult.Score parserScoresMap[stageParser.Name][i] += parserResult.Score
} }
@ -185,12 +198,15 @@ func Run(stages []Stage) (
return stageResults, forceQuitStageName, err return stageResults, forceQuitStageName, err
} }
func Cleanup() { func Cleanup(ctx context.Context) error {
slog.Info("stage cleanup start") slog.Info("stage cleanup start")
var cleanupErr error
for name, executor := range executorMap { for name, executor := range executorMap {
err := executor.Cleanup() err := executor.Cleanup(ctx)
if err != nil { if err != nil {
slog.Error("executor cleanup error", "name", name, "error", err) slog.Error("executor cleanup error", "name", name, "error", err)
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("executor %q cleanup: %w", name, err))
} }
} }
return cleanupErr
} }

View File

@ -0,0 +1,40 @@
package stage
import (
"context"
"strings"
"testing"
)
type contractTestExecutor struct{}
func (contractTestExecutor) Run(context.Context, []Cmd) ([]ExecutorResult, error) {
return []ExecutorResult{{Status: StatusAccepted}}, nil
}
func (contractTestExecutor) Cleanup(context.Context) error { return nil }
type contractTestParser struct{}
func (contractTestParser) Run([]ExecutorResult, any) ([]ParserResult, bool, error) {
return nil, false, nil
}
func TestRunRejectsParserResultCountMismatch(t *testing.T) {
const executorName = "contract-test-executor"
const parserName = "contract-test-parser"
RegisterExecutor(executorName, contractTestExecutor{})
RegisterParser(parserName, contractTestParser{})
_, forceQuit, err := Run(context.Background(), []Stage{{
Name: "test",
Executor: StageExecutor{Name: executorName, Cmds: []Cmd{{}}},
Parsers: []StageParser{{Name: parserName}},
}})
if err == nil || !strings.Contains(err.Error(), "returned 0 results for 1") {
t.Fatalf("Run() error = %v, want result count error", err)
}
if forceQuit != "test" {
t.Fatalf("Run() force quit = %q, want test", forceQuit)
}
}