e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
247 lines
7.6 KiB
Go
247 lines
7.6 KiB
Go
// Copyright 2025 Alibaba Group Holding Ltd.
|
|
//
|
|
// 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
|
|
//
|
|
// http://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.
|
|
|
|
//go:build !windows
|
|
// +build !windows
|
|
|
|
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestRunCommand_CancelKillsChildren verifies that cancelling the context
|
|
// terminates not only the bash group leader but also its descendant
|
|
// processes. Regression test for
|
|
// https://github.com/alibaba/OpenSandbox/issues/922.
|
|
func TestRunCommand_CancelKillsChildren(t *testing.T) {
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
|
|
|
c := NewController("", "")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
started := make(chan struct{})
|
|
var once sync.Once
|
|
|
|
req := &ExecuteCodeRequest{
|
|
// Spawn a sleep child, record its pid, then wait so the bash
|
|
// leader stays alive until the context is cancelled.
|
|
Code: `sleep 30 & echo $! > "` + pidFile + `"; echo READY; wait`,
|
|
Cwd: t.TempDir(),
|
|
Timeout: 30 * time.Second,
|
|
Hooks: ExecuteResultHook{
|
|
OnExecuteInit: func(_ string) {},
|
|
OnExecuteStdout: func(s string) {
|
|
if strings.TrimSpace(s) == "READY" {
|
|
once.Do(func() { close(started) })
|
|
}
|
|
},
|
|
OnExecuteStderr: func(_ string) {},
|
|
OnExecuteError: func(_ *execute.ErrorOutput) {},
|
|
OnExecuteComplete: func(_ time.Duration) {},
|
|
},
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = c.runCommand(ctx, req)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-started:
|
|
case <-time.After(10 * time.Second):
|
|
cancel()
|
|
<-done
|
|
t.Fatal("command did not emit READY in time")
|
|
}
|
|
|
|
pidBytes, err := os.ReadFile(pidFile)
|
|
require.NoError(t, err, "expected child pid file")
|
|
childPid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
|
|
require.NoError(t, err)
|
|
require.Positive(t, childPid)
|
|
|
|
require.NoError(t, syscall.Kill(childPid, 0), "child should be alive before cancel")
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("runCommand did not return after cancel")
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if err := syscall.Kill(childPid, 0); err != nil {
|
|
require.True(t, errors.Is(err, syscall.ESRCH),
|
|
"unexpected liveness probe error: %v", err)
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Fatalf("child pid %d still alive 2s after cancel — process leak", childPid)
|
|
}
|
|
|
|
// TestInterrupt_AfterFinished_ReturnsError verifies that an Interrupt
|
|
// arriving after the command has completed does not signal a recycled PID.
|
|
// Without this guard, group-wide kill would amplify the stale-PID hazard
|
|
// to every process in an unrelated process group.
|
|
func TestInterrupt_AfterFinished_ReturnsError(t *testing.T) {
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
c := NewController("", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var session string
|
|
completeCh := make(chan struct{}, 1)
|
|
req := &ExecuteCodeRequest{
|
|
Code: `echo done`,
|
|
Cwd: t.TempDir(),
|
|
Timeout: 5 * time.Second,
|
|
Hooks: ExecuteResultHook{
|
|
OnExecuteInit: func(s string) { session = s },
|
|
OnExecuteStdout: func(_ string) {},
|
|
OnExecuteStderr: func(_ string) {},
|
|
OnExecuteError: func(_ *execute.ErrorOutput) {},
|
|
OnExecuteComplete: func(_ time.Duration) { completeCh <- struct{}{} },
|
|
},
|
|
}
|
|
require.NoError(t, c.runCommand(ctx, req))
|
|
|
|
select {
|
|
case <-completeCh:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("command did not complete in time")
|
|
}
|
|
require.NotEmpty(t, session)
|
|
|
|
err := c.Interrupt(session)
|
|
require.Error(t, err, "Interrupt on finished session must error")
|
|
require.Contains(t, err.Error(), "not running")
|
|
|
|
snap := c.commandSnapshot(session)
|
|
require.NotNil(t, snap)
|
|
require.False(t, snap.running, "running flag should be cleared")
|
|
require.Equal(t, 0, snap.pid, "pid should be cleared to avoid stale-PID kill")
|
|
}
|
|
|
|
// TestKillPid_ZombieLeaderDoesNotFail verifies that killPid does not
|
|
// return an error when a group leader becomes a zombie before its parent
|
|
// has reaped it. kill(-pid, 0) keeps reporting the group as observable
|
|
// while the zombie lingers, but SIGKILL has already been delivered and
|
|
// the kernel will tear the group down once Wait() runs. Treating that
|
|
// state as a failure caused Interrupt to surface a 500 even though the
|
|
// kill succeeded.
|
|
func TestKillPid_ZombieLeaderDoesNotFail(t *testing.T) {
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
cmd := exec.Command("bash", "-c", `sleep 30 & wait`)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
require.NoError(t, cmd.Start())
|
|
// Deliberately omit a reaper goroutine so the leader stays as a
|
|
// zombie after kill — that is the condition we want to exercise.
|
|
t.Cleanup(func() {
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
_, _ = cmd.Process.Wait()
|
|
})
|
|
|
|
// Give bash a moment to spawn the sleep child so the group has more
|
|
// than just the leader.
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
c := &Controller{}
|
|
require.NoError(t, c.killPid(cmd.Process.Pid),
|
|
"slow post-SIGKILL teardown must not be reported as a hard failure")
|
|
}
|
|
|
|
// TestKillPid_TerminatesEntireProcessGroup verifies that killPid signals
|
|
// the whole process group, not just the leader. Regression test for
|
|
// https://github.com/alibaba/OpenSandbox/issues/922.
|
|
func TestKillPid_TerminatesEntireProcessGroup(t *testing.T) {
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
|
cmd := exec.Command("bash", "-c",
|
|
`sleep 30 & echo $! > "`+pidFile+`"; wait`)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
require.NoError(t, cmd.Start())
|
|
// Reap the leader concurrently so it doesn't linger as a zombie that
|
|
// keeps the process group "alive" from killPid's liveness probe
|
|
// perspective. Mirrors how runCommand's cmd.Wait() reaps in production.
|
|
waitDone := make(chan struct{})
|
|
go func() {
|
|
_, _ = cmd.Process.Wait()
|
|
close(waitDone)
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
<-waitDone
|
|
})
|
|
|
|
var childPid int
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if data, err := os.ReadFile(pidFile); err == nil {
|
|
if pid, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && pid > 0 {
|
|
childPid = pid
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
require.Positive(t, childPid, "failed to capture child pid")
|
|
require.NoError(t, syscall.Kill(childPid, 0), "child should be alive before kill")
|
|
|
|
c := &Controller{}
|
|
require.NoError(t, c.killPid(cmd.Process.Pid))
|
|
|
|
deadline = time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if err := syscall.Kill(childPid, 0); err != nil {
|
|
require.True(t, errors.Is(err, syscall.ESRCH),
|
|
"unexpected liveness probe error: %v", err)
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Fatalf("child pid %d still alive 2s after killPid — process leak", childPid)
|
|
}
|