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 = "" } if stderr == "" { stderr = "" } return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr }