33a246cd13
DefaultTimeoutSeconds now means "no timeout" when 0/empty, and that is the new default, rather than an invalid config forcing a positive value. runner.RunJob avoids context.WithTimeout with a zero duration (which would expire immediately) and instead runs on a plain cancelable context when no timeout is configured. Per-job TimeoutSeconds inherit semantics are unchanged.
146 lines
5.0 KiB
Go
146 lines
5.0 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
|
)
|
|
|
|
const commandWaitDelay = 2 * time.Second
|
|
|
|
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error) {
|
|
started := time.Now()
|
|
// Commands can hang forever if a script waits for input or a child process
|
|
// stalls. The effective timeout is resolved by the caller (per-job value or
|
|
// the global default), keeping the runner ignorant of the global config. A
|
|
// non-positive timeout means "no timeout": context.WithTimeout(ctx, 0) would
|
|
// expire immediately, so fall back to a plain cancelable context that only
|
|
// ever ends via ctx (e.g. app shutdown).
|
|
var runCtx context.Context
|
|
var cancel context.CancelFunc
|
|
if timeout > 0 {
|
|
runCtx, cancel = context.WithTimeout(ctx, timeout)
|
|
} else {
|
|
runCtx, cancel = context.WithCancel(ctx)
|
|
}
|
|
defer cancel()
|
|
|
|
var output string
|
|
var state string
|
|
var detail string
|
|
var durationMS int64
|
|
if job.StartOnly {
|
|
invocation := jobInvocation(ctx, *job)
|
|
// StartOnly jobs don't wait for process exit, so the duration measures
|
|
// launch latency (time to spawn the process) rather than run time.
|
|
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
|
|
} else {
|
|
var stdoutBuf strings.Builder
|
|
var stderrBuf strings.Builder
|
|
invocation := jobInvocation(runCtx, *job)
|
|
command := invocation.command
|
|
command.WaitDelay = commandWaitDelay
|
|
if invocation.hideWindow {
|
|
winproc.ConfigureHiddenWindow(command)
|
|
}
|
|
command.Stdout = &stdoutBuf
|
|
command.Stderr = &stderrBuf
|
|
|
|
err := command.Run()
|
|
duration := time.Since(started).Round(time.Millisecond)
|
|
durationMS = duration.Milliseconds()
|
|
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
|
state, detail = runStateDetail(err, runCtx.Err(), duration, timeout)
|
|
}
|
|
|
|
now := time.Now()
|
|
timestamp := now.Format("2006-01-02 15:04:05")
|
|
logFile, logErr := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now)
|
|
|
|
// The runner is now pure with respect to the job: it returns a RunRecord and
|
|
// lets the caller fold that record into the job's JobRuntime. Run state no
|
|
// longer lives on Job, so there is nothing on the job to mutate here.
|
|
return domain.RunRecord{
|
|
Time: timestamp,
|
|
JobID: job.ID,
|
|
JobName: job.Name,
|
|
Trigger: trigger,
|
|
State: state,
|
|
Detail: detail,
|
|
LogFile: logFile,
|
|
Output: output,
|
|
DurationMS: durationMS,
|
|
}, logErr
|
|
}
|
|
|
|
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string, int64) {
|
|
command := invocation.command
|
|
if invocation.hideWindow {
|
|
winproc.ConfigureHiddenWindow(command)
|
|
}
|
|
err := command.Start()
|
|
duration := time.Since(started).Round(time.Millisecond)
|
|
durationMS := duration.Milliseconds()
|
|
if err != nil {
|
|
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0), durationMS
|
|
}
|
|
pid := command.Process.Pid
|
|
if releaseErr := command.Process.Release(); releaseErr != nil {
|
|
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid), durationMS
|
|
}
|
|
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid), durationMS
|
|
}
|
|
|
|
func startOnlyOutput(job domain.Job, pid int) string {
|
|
var builder strings.Builder
|
|
builder.WriteString("status:\n")
|
|
if pid > 0 {
|
|
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
|
|
} else {
|
|
builder.WriteString("Process did not start.\n\n")
|
|
}
|
|
builder.WriteString("command:\n")
|
|
builder.WriteString(job.Command + "\n\n")
|
|
builder.WriteString("arguments:\n")
|
|
builder.WriteString(logArguments(job.Arguments))
|
|
builder.WriteString("\n\nstart_only:\ntrue")
|
|
return builder.String()
|
|
}
|
|
|
|
func runStateDetail(err error, runErr error, duration time.Duration, timeout time.Duration) (string, string) {
|
|
if err == nil {
|
|
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
|
}
|
|
if errors.Is(runErr, context.DeadlineExceeded) {
|
|
return "Failed", fmt.Sprintf("Timed out after %s", timeout)
|
|
}
|
|
if errors.Is(err, exec.ErrWaitDelay) {
|
|
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
|
}
|
|
var exitError *exec.ExitError
|
|
if errors.As(err, &exitError) {
|
|
return "Failed", fmt.Sprintf("Failed with exit code %d", exitError.ExitCode())
|
|
}
|
|
return "Failed", fmt.Sprintf("%T: %v", err, err)
|
|
}
|
|
|
|
func formatOutput(stdout string, stderr string) string {
|
|
stdout = strings.TrimSpace(stdout)
|
|
stderr = strings.TrimSpace(stderr)
|
|
if stdout == "" {
|
|
// Showing an explicit placeholder is clearer than an empty panel in the
|
|
// GUI: the user can tell that the command ran but produced no stream data.
|
|
stdout = "<empty>"
|
|
}
|
|
if stderr == "" {
|
|
stderr = "<empty>"
|
|
}
|
|
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
|
|
}
|