internal/platform/utils/cmd.go (208 lines of code) (raw):

/* * Copyright 2021-2024 JetBrains s.r.o. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package utils import ( bt "bytes" "errors" "fmt" "io" "math" "os" "os/exec" "os/signal" "runtime" "strings" "syscall" "time" log "github.com/sirupsen/logrus" ) const ( // QodanaSuccessExitCode is Qodana exit code when the analysis is successfully completed. QodanaSuccessExitCode = 0 // QodanaFailThresholdExitCode same as QodanaSuccessExitCode, but the threshold is set and exceeded. QodanaFailThresholdExitCode = 255 // QodanaOutOfMemoryExitCode reports an interrupted process, sometimes because of an OOM. QodanaOutOfMemoryExitCode = 137 // QodanaEapLicenseExpiredExitCode reports an expired license. QodanaEapLicenseExpiredExitCode = 7 // QodanaTimeoutExitCodePlaceholder is not a real exit code (it is not obtained from IDE process! and not returned from CLI) // Placeholder used to identify the case when the analysis reached timeout QodanaTimeoutExitCodePlaceholder = 1000 // QodanaEmptyChangesetExitCodePlaceholder is not a real exit code (it is not obtained from IDE process! and not returned from CLI) // Placeholder used to identify the case when the changeset for scoped analysis is empty QodanaEmptyChangesetExitCodePlaceholder = 2000 ) // Bootstrap takes the given command (from CLI or qodana.yaml) and runs it. func Bootstrap(command string, project string) { if command == "" { return } var executor string var flag string switch runtime.GOOS { case "windows": executor = "cmd" flag = "/c" default: executor = "sh" flag = "-c" } if res, err := RunCmd(project, executor, flag, "\""+command+"\""); res > 0 || err != nil { log.Printf("Provided bootstrap command finished with error: %d. Exiting...", res) os.Exit(res) } } // RunCmd executes subprocess with forwarding of signals, and returns its exit code. func RunCmd(cwd string, args ...string) (int, error) { return RunCmdWithTimeout(cwd, os.Stdout, os.Stderr, time.Duration(math.MaxInt64), 1, args...) } // RunCmdWithTimeout executes subprocess with forwarding of signals, and returns its exit code. func RunCmdWithTimeout( cwd string, stdout *os.File, stderr *os.File, timeout time.Duration, timeoutExitCode int, args ...string, ) (int, error) { log.Debugf("Running command: %v", args) cmd := exec.Command("bash", "-c", strings.Join(args, " ")) // TODO : Viktor told about set -e var stdoutPipe, stderrPipe io.ReadCloser var err error if //goland:noinspection GoBoolExpressions runtime.GOOS == "windows" { cmd = prepareWinCmd(args...) stdoutPipe, err = cmd.StdoutPipe() if err != nil { return 1, fmt.Errorf("failed to get stdout pipe: %w", err) } stderrPipe, err = cmd.StderrPipe() if err != nil { return 1, fmt.Errorf("failed to get stderr pipe: %w", err) } } else { cmd.Stdout = stdout cmd.Stderr = stderr } if cmd.Dir, err = getCwdPath(cwd); err != nil { return 1, err } cmd.Stdin = bt.NewBuffer([]byte{}) if err := cmd.Start(); err != nil { return 1, fmt.Errorf("failed to start command: %w", err) } waitCh := make(chan error, 1) go func() { waitCh <- cmd.Wait() close(waitCh) }() if //goland:noinspection GoBoolExpressions runtime.GOOS == "windows" { go readAndWrite(stdoutPipe, stdout) go readAndWrite(stderrPipe, stderr) } return handleSignals(cmd, waitCh, timeout, timeoutExitCode) } // closePipe closes the pipe func closePipe(file *os.File) { err := file.Close() if err != nil { log.Error(err) } } // RunCmdRedirectOutput executes subprocess with forwarding of signals, returns stdout, stderr and exit code. func RunCmdRedirectOutput(cwd string, args ...string) (string, string, int, error) { outReader, outWriter, err := os.Pipe() if err != nil { return "", "", -1, fmt.Errorf("failed to create stdout pipe: %w", err) } defer closePipe(outReader) errReader, errWriter, err := os.Pipe() if err != nil { return "", "", -1, fmt.Errorf("failed to create stderr pipe: %w", err) } defer closePipe(errReader) outChannel := make(chan string) errChannel := make(chan string) go copyToChannel(outReader, outChannel) go copyToChannel(errReader, errChannel) res, err := RunCmdWithTimeout(cwd, outWriter, errWriter, time.Duration(math.MaxInt64), 1, args...) closePipes(outWriter, errWriter) stdout := <-outChannel stderr := <-errChannel return stdout, stderr, res, err } // closePipes closes the pairs of pipes func closePipes(outWriter *os.File, errWriter *os.File) { err := outWriter.Close() if err != nil { log.Error("Error while closing stdout: ", err) } err = errWriter.Close() if err != nil { log.Error("Error while closing stderr: ", err) } } // copyToChannel copies the content of a Reader to a channel func copyToChannel(reader io.Reader, ch chan<- string) { var buf bt.Buffer _, err := io.Copy(&buf, reader) if err != nil { log.Error(err) } ch <- buf.String() close(ch) } // getCwdPath gets the current working directory path func getCwdPath(cwd string) (string, error) { if cwd != "" { return cwd, nil } wd, err := os.Getwd() if err != nil { return "", fmt.Errorf("failed to get current working directory: %w", err) } return wd, nil } // handleSignals handles the signals from the subprocess func handleSignals(cmd *exec.Cmd, waitCh <-chan error, timeout time.Duration, timeoutExitCode int) (int, error) { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) defer func() { signal.Stop(sigChan) // Use Stop to prevent panics close(sigChan) }() var timeoutCh = time.After(timeout) for { select { case <-sigChan: if err := RequestTermination(cmd.Process); err != nil && !errors.Is( err, os.ErrProcessDone, ) { // Use errors.Is for semantic comparison log.Error("Error terminating process: ", err) } case <-timeoutCh: if err := RequestTermination(cmd.Process); err != nil { log.Fatal("failed to kill process on timeout: ", err) } _, _ = cmd.Process.Wait() return timeoutExitCode, nil case ret := <-waitCh: var exitError *exec.ExitError if errors.As(ret, &exitError) { log.Println(ret) waitStatus := exitError.Sys().(syscall.WaitStatus) if waitStatus.Exited() { return waitStatus.ExitStatus(), nil } log.Println("Process killed (OOM?)") return QodanaOutOfMemoryExitCode, nil } if ret != nil { log.Println(ret) } return cmd.ProcessState.ExitCode(), ret } } } func readAndWrite(pipe io.ReadCloser, output *os.File) { buf := make([]byte, 1024) for { n, err := pipe.Read(buf) if n > 0 { _, writeErr := output.Write(buf[:n]) if writeErr != nil { log.Printf("failed to write to output: %v", writeErr) break } } if err != nil { if err != io.EOF { log.Printf("error reading from pipe: %v", err) } break } } }