//go:build !windows package infra import ( "bytes" "fmt" "os" "os/exec" "strings" "syscall" "time" ) // ProcessSpawn launches a subprocess using the given shell. // If shell is empty, "sh" is used. If command contains newlines it is treated // as a multi-line script: the content is written to a temp file and executed // with `shell `. Otherwise it is executed with `shell -c `. // dir sets the working directory (empty = inherit). env sets the environment // (nil = inherit parent env). The process group is created with Setpgid so // that ProcessKill can target the whole group. func ProcessSpawn(command string, dir string, env []string, shell string) (*ProcessHandle, error) { if shell == "" { shell = "sh" } var cmd *exec.Cmd if strings.Contains(command, "\n") { // Multi-line script: write to a temp file and execute it. tmp, err := os.CreateTemp("", "fn-proc-*.sh") if err != nil { return nil, fmt.Errorf("process_spawn: create temp file: %w", err) } if _, err := tmp.WriteString(command); err != nil { _ = os.Remove(tmp.Name()) return nil, fmt.Errorf("process_spawn: write temp file: %w", err) } if err := tmp.Close(); err != nil { _ = os.Remove(tmp.Name()) return nil, fmt.Errorf("process_spawn: close temp file: %w", err) } cmd = exec.Command(shell, tmp.Name()) } else { cmd = exec.Command(shell, "-c", command) } if dir != "" { cmd.Dir = dir } if len(env) > 0 { cmd.Env = env } // New process group so we can kill all children as a group. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Use buffers instead of pipes to avoid race between Wait() and ReadAll(). var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf start := time.Now() if err := cmd.Start(); err != nil { return nil, fmt.Errorf("process_spawn: start: %w", err) } return &ProcessHandle{ Cmd: cmd, Pid: cmd.Process.Pid, StartTime: start, Dir: dir, stdout: &stdoutBuf, stderr: &stderrBuf, }, nil }