Skip to content

Commit 8dcff9f

Browse files
authored
🐛 QD-11516: Ensure subprocess termination on Windows (#589)
* 🐛 QD-11516: Fix Qodana CLI not killing children on exit on Windows * 🎨 QD-11516: Remove unused imports * 🐛 QD-11516: Do not try sending os.Interrupt on Windows
1 parent 11ae2ee commit 8dcff9f

File tree

9 files changed

+111
-54
lines changed

9 files changed

+111
-54
lines changed

cdnet/main.go

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,9 @@
1717
package main
1818

1919
import (
20-
"fmt"
21-
log "github.com/sirupsen/logrus"
22-
"io"
2320
"os"
24-
"os/signal"
25-
"syscall"
21+
22+
"github.com/JetBrains/qodana-cli/v2025/platform/process"
2623
)
2724

2825
const (
@@ -38,14 +35,6 @@ var buildDateStr = "2023-12-05T10:52:23Z"
3835

3936
// noinspection GoUnusedFunction
4037
func main() {
41-
InterruptChannel = make(chan os.Signal, 1)
42-
signal.Notify(InterruptChannel, os.Interrupt)
43-
signal.Notify(InterruptChannel, syscall.SIGINT, syscall.SIGTERM)
44-
go func() {
45-
<-InterruptChannel
46-
fmt.Println("Interrupting Qodana...")
47-
log.SetOutput(io.Discard)
48-
os.Exit(0)
49-
}()
38+
process.Init()
5039
Execute(productCode, linterName, version, buildDateStr, true)
5140
}

clang/main.go

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
package main
22

33
import (
4-
"fmt"
5-
log "github.com/sirupsen/logrus"
6-
"io"
74
"os"
8-
"os/signal"
9-
"syscall"
5+
6+
"github.com/JetBrains/qodana-cli/v2025/platform/process"
107
)
118

129
const (
@@ -20,14 +17,6 @@ var buildDateStr = "2024-04-05T10:52:23Z"
2017

2118
// noinspection GoUnusedFunction
2219
func main() {
23-
InterruptChannel = make(chan os.Signal, 1)
24-
signal.Notify(InterruptChannel, os.Interrupt)
25-
signal.Notify(InterruptChannel, syscall.SIGINT, syscall.SIGTERM)
26-
go func() {
27-
<-InterruptChannel
28-
fmt.Println("Interrupting Qodana...")
29-
log.SetOutput(io.Discard)
30-
os.Exit(0)
31-
}()
20+
process.Init()
3221
Execute(productCode, linterName, version, buildDateStr, true)
3322
}

cli/main.go

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,11 @@ package main
1818

1919
import (
2020
"github.com/JetBrains/qodana-cli/v2025/cmd"
21-
"github.com/JetBrains/qodana-cli/v2025/core"
22-
"github.com/JetBrains/qodana-cli/v2025/platform/commoncontext"
23-
"github.com/JetBrains/qodana-cli/v2025/platform/msg"
24-
"github.com/JetBrains/qodana-cli/v2025/platform/version"
25-
log "github.com/sirupsen/logrus"
26-
"io"
27-
"os"
28-
"os/signal"
29-
"syscall"
21+
"github.com/JetBrains/qodana-cli/v2025/platform/process"
3022
)
3123

3224
func main() {
33-
commoncontext.InterruptChannel = make(chan os.Signal, 1)
34-
signal.Notify(commoncontext.InterruptChannel, os.Interrupt)
35-
signal.Notify(commoncontext.InterruptChannel, syscall.SIGINT, syscall.SIGTERM)
36-
go func() {
37-
<-commoncontext.InterruptChannel
38-
msg.WarningMessage("Interrupting Qodana CLI...")
39-
log.SetOutput(io.Discard)
40-
core.CheckForUpdates(version.Version)
41-
core.ContainerCleanup()
42-
_ = msg.QodanaSpinner.Stop()
43-
os.Exit(0)
44-
}()
25+
process.Init()
4526
cmd.InitCli()
4627
cmd.Execute()
4728
}

platform/process/init.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package process
2+
3+
import (
4+
"github.com/JetBrains/qodana-cli/v2025/core"
5+
"github.com/JetBrains/qodana-cli/v2025/platform/commoncontext"
6+
"github.com/JetBrains/qodana-cli/v2025/platform/msg"
7+
"github.com/JetBrains/qodana-cli/v2025/platform/version"
8+
"io"
9+
"log"
10+
"os"
11+
"os/signal"
12+
"syscall"
13+
"time"
14+
)
15+
16+
// Init runs miscellaneous process-wide utility code.
17+
func Init() {
18+
KillProcessTreeOnClose()
19+
20+
commoncontext.InterruptChannel = make(chan os.Signal, 1)
21+
signal.Notify(commoncontext.InterruptChannel, os.Interrupt)
22+
signal.Notify(commoncontext.InterruptChannel, syscall.SIGINT, syscall.SIGTERM)
23+
go func() {
24+
<-commoncontext.InterruptChannel
25+
msg.WarningMessage("Interrupting Qodana...")
26+
log.SetOutput(io.Discard)
27+
core.CheckForUpdates(version.Version)
28+
core.ContainerCleanup()
29+
_ = msg.QodanaSpinner.Stop()
30+
// Sleep for a second to allow other functions monitoring signals elsewhere to do their thing.
31+
// A future rewrite of the subprocess API should incorporate a more structured signal handling.
32+
time.Sleep(1 * time.Second)
33+
os.Exit(0)
34+
}()
35+
}

platform/process/init_posix.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//go:build unix
2+
3+
package process
4+
5+
func KillProcessTreeOnClose() {
6+
// Not implemented
7+
}

platform/process/init_windows.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package process
2+
3+
import (
4+
"golang.org/x/sys/windows"
5+
"log"
6+
"unsafe"
7+
)
8+
9+
func KillProcessTreeOnClose() {
10+
job, err := windows.CreateJobObject(nil, nil)
11+
if err != nil {
12+
log.Fatal(err)
13+
}
14+
15+
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
16+
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
17+
windows.SetInformationJobObject(
18+
job,
19+
windows.JobObjectExtendedLimitInformation,
20+
uintptr(unsafe.Pointer(&info)),
21+
uint32(unsafe.Sizeof(info)),
22+
)
23+
24+
windows.AssignProcessToJobObject(job, windows.CurrentProcess())
25+
}

platform/utils/cmd.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ func getCwdPath(cwd string) (string, error) {
198198
// handleSignals handles the signals from the subprocess
199199
func handleSignals(cmd *exec.Cmd, waitCh <-chan error, timeout time.Duration, timeoutExitCode int) (int, error) {
200200
sigChan := make(chan os.Signal, 1)
201-
signal.Notify(sigChan)
201+
signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
202202
defer func() {
203203
signal.Stop(sigChan) // Use Stop to prevent panics
204204
close(sigChan)
@@ -208,15 +208,15 @@ func handleSignals(cmd *exec.Cmd, waitCh <-chan error, timeout time.Duration, ti
208208

209209
for {
210210
select {
211-
case sig := <-sigChan:
212-
if err := cmd.Process.Signal(sig); err != nil && !errors.Is(
211+
case <-sigChan:
212+
if err := RequestTermination(cmd.Process); err != nil && !errors.Is(
213213
err,
214214
os.ErrProcessDone,
215215
) { // Use errors.Is for semantic comparison
216-
log.Error("Error sending signal: ", sig, err)
216+
log.Error("Error terminating process: ", err)
217217
}
218218
case <-timeoutCh:
219-
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
219+
if err := RequestTermination(cmd.Process); err != nil {
220220
log.Fatal("failed to kill process on timeout: ", err)
221221
}
222222
_, _ = cmd.Process.Wait()

platform/utils/cmd_others.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,17 @@ package utils
2020

2121
import (
2222
"log"
23+
"os"
2324
"os/exec"
25+
"syscall"
2426
)
2527

2628
//goland:noinspection GoUnusedParameter
2729
func prepareWinCmd(args ...string) *exec.Cmd {
2830
log.Fatal("Function should not be called on non-windows platforms")
2931
return nil
3032
}
33+
34+
func RequestTermination(proc *os.Process) error {
35+
return proc.Signal(syscall.SIGTERM)
36+
}

platform/utils/cmd_windows.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
package utils
2121

2222
import (
23+
log "github.com/sirupsen/logrus"
2324
"os"
2425
"os/exec"
26+
"strconv"
2527
"strings"
2628
"syscall"
2729
)
@@ -38,3 +40,26 @@ func prepareWinCmd(args ...string) *exec.Cmd {
3840
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: "/C \"" + commandLine + "\""}
3941
return cmd
4042
}
43+
44+
func RequestTermination(proc *os.Process) error {
45+
command := exec.Command("taskkill.exe", "/pid", strconv.Itoa(proc.Pid))
46+
output, err := command.CombinedOutput()
47+
log.Debugf("%s: %s", command.String(), output)
48+
49+
if exitErr, ok := err.(*exec.ExitError); ok {
50+
if exitErr.ExitCode() == 1 {
51+
// Exit code 1 could mean at least:
52+
// - access denied
53+
// - the process can only be terminated forcefully
54+
// if the process can only be terminated forcefully, this is considered a "we tried" scenario
55+
// and no error should be returned. other errors with this exit code are logged and ignored.
56+
return nil
57+
}
58+
if exitErr.ExitCode() == 128 {
59+
// process not found (supposedly already exited)
60+
return os.ErrProcessDone
61+
}
62+
}
63+
64+
return err
65+
}

0 commit comments

Comments
 (0)