Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

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)
}