Skip to content

Commit c5bc68c

Browse files
committed
Terminate Windows exec process trees
1 parent 24c0dc4 commit c5bc68c

8 files changed

Lines changed: 367 additions & 122 deletions

lib/instances/windows_guest_agent_integration_linux_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,30 @@ func TestWindowsGuestAgentIntegration(t *testing.T) {
7575
require.NoError(t, err)
7676

7777
var stdout, stderr bytes.Buffer
78+
jobStart := time.Now()
7879
exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
80+
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `& ping.exe -n 60 127.0.0.1`},
81+
Stdout: &stdout,
82+
Stderr: &stderr,
83+
Timeout: 2,
84+
})
85+
require.NoError(t, err, stderr.String())
86+
assert.Less(t, time.Since(jobStart), 10*time.Second, "timed out process tree did not terminate promptly")
87+
88+
stdout.Reset()
89+
stderr.Reset()
90+
exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
91+
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `if (Get-Process ping -ErrorAction SilentlyContinue) { exit 42 }`},
92+
Stdout: &stdout,
93+
Stderr: &stderr,
94+
Timeout: 10,
95+
})
96+
require.NoError(t, err, stderr.String())
97+
require.Equal(t, 0, exit.Code, "job object left a child process running")
98+
99+
stdout.Reset()
100+
stderr.Reset()
101+
exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
79102
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write('HYPEMAN_SYSTEM_OK')"},
80103
Stdout: &stdout,
81104
Stderr: &stderr,

lib/system/guest_agent/exec.go

Lines changed: 2 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ package main
33
import (
44
"context"
55
"fmt"
6-
"io"
76
"log"
87
"os"
9-
"os/exec"
108
"strings"
11-
"sync"
129
"time"
1310

1411
pb "github.com/kernel/hypeman/lib/guest"
@@ -36,8 +33,8 @@ func (s *guestServer) Exec(stream pb.GuestService_ExecServer) error {
3633
log.Printf("[guest-agent] exec: command=%v tty=%v cwd=%s timeout=%d",
3734
start.Command, start.Tty, start.Cwd, start.TimeoutSeconds)
3835

39-
// Create context with timeout if specified
40-
ctx := context.Background()
36+
// Windows ties process lifetime to the RPC stream; Unix keeps the existing behavior.
37+
ctx := execContext(stream.Context())
4138
if start.TimeoutSeconds > 0 {
4239
var cancel context.CancelFunc
4340
ctx, cancel = context.WithTimeout(ctx, time.Duration(start.TimeoutSeconds)*time.Second)
@@ -50,119 +47,6 @@ func (s *guestServer) Exec(stream pb.GuestService_ExecServer) error {
5047
return s.executeNoTTY(ctx, stream, start)
5148
}
5249

53-
// executeNoTTY executes command without TTY
54-
func (s *guestServer) executeNoTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error {
55-
// Run command directly - guest-agent is already running in container namespace
56-
if len(start.Command) == 0 {
57-
return fmt.Errorf("empty command")
58-
}
59-
60-
cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...)
61-
cleanup, err := configureExecCommand(cmd, start.Session)
62-
if err != nil {
63-
return err
64-
}
65-
defer cleanup()
66-
67-
// Set up environment (no TTY defaults for non-TTY mode)
68-
cmd.Env = s.buildEnv(start.Env, false)
69-
70-
// Set up working directory
71-
if start.Cwd != "" {
72-
cmd.Dir = start.Cwd
73-
}
74-
75-
stdin, _ := cmd.StdinPipe()
76-
stdout, _ := cmd.StdoutPipe()
77-
stderr, _ := cmd.StderrPipe()
78-
79-
if err := cmd.Start(); err != nil {
80-
return fmt.Errorf("start command: %w", err)
81-
}
82-
83-
// Mutex to protect concurrent stream.Send calls (gRPC streams are not thread-safe)
84-
var sendMu sync.Mutex
85-
86-
// Use WaitGroup to ensure all output is read before sending
87-
var wg sync.WaitGroup
88-
var stdoutData, stderrData []byte
89-
90-
// Handle stdin in background
91-
go func() {
92-
defer stdin.Close()
93-
for {
94-
req, err := stream.Recv()
95-
if err != nil {
96-
return
97-
}
98-
if data := req.GetStdin(); data != nil {
99-
stdin.Write(data)
100-
}
101-
}
102-
}()
103-
104-
// Read all stdout/stderr BEFORE calling Wait() - Wait() closes the pipes!
105-
wg.Add(1)
106-
go func() {
107-
defer wg.Done()
108-
data, _ := io.ReadAll(stdout)
109-
stdoutData = data
110-
}()
111-
112-
wg.Add(1)
113-
go func() {
114-
defer wg.Done()
115-
data, _ := io.ReadAll(stderr)
116-
stderrData = data
117-
}()
118-
119-
// Wait for all reads to complete FIRST (before Wait closes pipes)
120-
wg.Wait()
121-
122-
// Now safe to call Wait - pipes are fully drained
123-
waitErr := cmd.Wait()
124-
125-
// Now stream output in chunks (streaming compatible)
126-
const chunkSize = 32 * 1024
127-
for i := 0; i < len(stdoutData); i += chunkSize {
128-
end := i + chunkSize
129-
if end > len(stdoutData) {
130-
end = len(stdoutData)
131-
}
132-
sendMu.Lock()
133-
stream.Send(&pb.ExecResponse{
134-
Response: &pb.ExecResponse_Stdout{Stdout: stdoutData[i:end]},
135-
})
136-
sendMu.Unlock()
137-
}
138-
for i := 0; i < len(stderrData); i += chunkSize {
139-
end := i + chunkSize
140-
if end > len(stderrData) {
141-
end = len(stderrData)
142-
}
143-
sendMu.Lock()
144-
stream.Send(&pb.ExecResponse{
145-
Response: &pb.ExecResponse_Stderr{Stderr: stderrData[i:end]},
146-
})
147-
sendMu.Unlock()
148-
}
149-
150-
exitCode := int32(0)
151-
if cmd.ProcessState != nil {
152-
exitCode = int32(cmd.ProcessState.ExitCode())
153-
} else if waitErr != nil {
154-
// If killed by timeout, exit with 124 (GNU timeout convention)
155-
exitCode = 124
156-
}
157-
158-
log.Printf("[guest-agent] command finished with exit code: %d", exitCode)
159-
160-
// Send exit code
161-
return stream.Send(&pb.ExecResponse{
162-
Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode},
163-
})
164-
}
165-
16650
// buildEnv constructs environment variables by merging provided env with defaults.
16751
// When tty is true, adds sensible defaults for interactive terminal sessions.
16852
// User-provided env vars override both base environment and defaults.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//go:build !windows
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"io"
9+
"log"
10+
"os/exec"
11+
"sync"
12+
13+
pb "github.com/kernel/hypeman/lib/guest"
14+
)
15+
16+
func (s *guestServer) executeNoTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error {
17+
if len(start.Command) == 0 {
18+
return fmt.Errorf("empty command")
19+
}
20+
21+
cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...)
22+
cleanup, err := configureExecCommand(cmd, start.Session)
23+
if err != nil {
24+
return err
25+
}
26+
defer cleanup()
27+
cmd.Env = s.buildEnv(start.Env, false)
28+
if start.Cwd != "" {
29+
cmd.Dir = start.Cwd
30+
}
31+
32+
stdin, _ := cmd.StdinPipe()
33+
stdout, _ := cmd.StdoutPipe()
34+
stderr, _ := cmd.StderrPipe()
35+
if err := cmd.Start(); err != nil {
36+
return fmt.Errorf("start command: %w", err)
37+
}
38+
39+
var sendMu sync.Mutex
40+
var wg sync.WaitGroup
41+
var stdoutData, stderrData []byte
42+
go func() {
43+
defer stdin.Close()
44+
for {
45+
req, err := stream.Recv()
46+
if err != nil {
47+
return
48+
}
49+
if data := req.GetStdin(); data != nil {
50+
_, _ = stdin.Write(data)
51+
}
52+
}
53+
}()
54+
55+
wg.Add(1)
56+
go func() {
57+
defer wg.Done()
58+
stdoutData, _ = io.ReadAll(stdout)
59+
}()
60+
wg.Add(1)
61+
go func() {
62+
defer wg.Done()
63+
stderrData, _ = io.ReadAll(stderr)
64+
}()
65+
wg.Wait()
66+
waitErr := cmd.Wait()
67+
68+
const chunkSize = 32 * 1024
69+
for i := 0; i < len(stdoutData); i += chunkSize {
70+
end := min(i+chunkSize, len(stdoutData))
71+
sendMu.Lock()
72+
_ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stdout{Stdout: stdoutData[i:end]}})
73+
sendMu.Unlock()
74+
}
75+
for i := 0; i < len(stderrData); i += chunkSize {
76+
end := min(i+chunkSize, len(stderrData))
77+
sendMu.Lock()
78+
_ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stderr{Stderr: stderrData[i:end]}})
79+
sendMu.Unlock()
80+
}
81+
82+
exitCode := int32(0)
83+
if cmd.ProcessState != nil {
84+
exitCode = int32(cmd.ProcessState.ExitCode())
85+
} else if waitErr != nil {
86+
exitCode = 124
87+
}
88+
log.Printf("[guest-agent] command finished with exit code: %d", exitCode)
89+
return stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}})
90+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//go:build windows
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"io"
9+
"log"
10+
"os"
11+
"time"
12+
13+
pb "github.com/kernel/hypeman/lib/guest"
14+
)
15+
16+
func (s *guestServer) executeNoTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error {
17+
if len(start.Command) == 0 {
18+
return fmt.Errorf("empty command")
19+
}
20+
21+
cmd := execCommand(ctx, start.Command[0], start.Command[1:]...)
22+
cleanup, err := configureExecCommand(cmd, start.Session)
23+
if err != nil {
24+
return err
25+
}
26+
defer cleanup()
27+
cmd.Env = s.buildEnv(start.Env, false)
28+
if start.Cwd != "" {
29+
cmd.Dir = start.Cwd
30+
}
31+
32+
stdin, _ := cmd.StdinPipe()
33+
stdoutFile, err := os.CreateTemp("", "hypeman-exec-stdout-*")
34+
if err != nil {
35+
return fmt.Errorf("create stdout capture: %w", err)
36+
}
37+
defer func() {
38+
stdoutFile.Close()
39+
os.Remove(stdoutFile.Name())
40+
}()
41+
stderrFile, err := os.CreateTemp("", "hypeman-exec-stderr-*")
42+
if err != nil {
43+
return fmt.Errorf("create stderr capture: %w", err)
44+
}
45+
defer func() {
46+
stderrFile.Close()
47+
os.Remove(stderrFile.Name())
48+
}()
49+
cmd.Stdout = stdoutFile
50+
cmd.Stderr = stderrFile
51+
52+
if err := cmd.Start(); err != nil {
53+
return fmt.Errorf("start command: %w", err)
54+
}
55+
jobCleanup, err := attachProcessJob(ctx, cmd.Process, time.Duration(start.TimeoutSeconds)*time.Second)
56+
if err != nil {
57+
_ = cmd.Process.Kill()
58+
_ = cmd.Wait()
59+
return fmt.Errorf("attach process job: %w", err)
60+
}
61+
defer jobCleanup()
62+
63+
go func() {
64+
defer stdin.Close()
65+
for {
66+
req, err := stream.Recv()
67+
if err != nil {
68+
return
69+
}
70+
if data := req.GetStdin(); data != nil {
71+
_, _ = stdin.Write(data)
72+
}
73+
}
74+
}()
75+
76+
waitErr := cmd.Wait()
77+
if _, err := stdoutFile.Seek(0, io.SeekStart); err != nil {
78+
return fmt.Errorf("rewind stdout capture: %w", err)
79+
}
80+
stdout, err := io.ReadAll(stdoutFile)
81+
if err != nil {
82+
return fmt.Errorf("read stdout capture: %w", err)
83+
}
84+
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
85+
return fmt.Errorf("rewind stderr capture: %w", err)
86+
}
87+
stderr, err := io.ReadAll(stderrFile)
88+
if err != nil {
89+
return fmt.Errorf("read stderr capture: %w", err)
90+
}
91+
92+
const chunkSize = 32 * 1024
93+
for i := 0; i < len(stdout); i += chunkSize {
94+
end := min(i+chunkSize, len(stdout))
95+
_ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stdout{Stdout: stdout[i:end]}})
96+
}
97+
for i := 0; i < len(stderr); i += chunkSize {
98+
end := min(i+chunkSize, len(stderr))
99+
_ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stderr{Stderr: stderr[i:end]}})
100+
}
101+
102+
exitCode := int32(0)
103+
if cmd.ProcessState != nil {
104+
exitCode = int32(cmd.ProcessState.ExitCode())
105+
} else if waitErr != nil {
106+
exitCode = 124
107+
}
108+
log.Printf("[guest-agent] command finished with exit code: %d", exitCode)
109+
return stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}})
110+
}

lib/system/guest_agent/exec_session_unix.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package main
44

55
import (
6+
"context"
67
"fmt"
78
"os/exec"
89

@@ -11,6 +12,8 @@ import (
1112

1213
func defaultCommand() []string { return []string{"/bin/sh"} }
1314

15+
func execContext(context.Context) context.Context { return context.Background() }
16+
1417
func configureExecCommand(_ *exec.Cmd, session pb.ExecSession) (func(), error) {
1518
switch session {
1619
case pb.ExecSession_EXEC_SESSION_SYSTEM:

0 commit comments

Comments
 (0)