-
Notifications
You must be signed in to change notification settings - Fork 460
refactor(orchestrator): Fix leaking resources on shutdown and add framework for better lifecycle tracking #2770
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1b9501c
49d0b3a
e6e9091
bb8eddf
114563f
3c8efb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| //go:build linux | ||
|
|
||
| package sandbox | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestFactoryStartDrainingRejectsNewStarts(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| factory := testFactory() | ||
| factory.StartDraining(t.Context()) | ||
|
|
||
| _, err := factory.enterSandboxStart() | ||
| require.ErrorIs(t, err, ErrFactoryDraining) | ||
| } | ||
|
|
||
| func TestFactoryWaitSandboxStartsWaitsUntilStartLeaves(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| factory := testFactory() | ||
| release, err := factory.enterSandboxStart() | ||
| require.NoError(t, err) | ||
|
|
||
| waitCtx, cancel := context.WithTimeout(t.Context(), time.Second) | ||
| defer cancel() | ||
|
|
||
| done := make(chan error, 1) | ||
| go func() { | ||
| done <- factory.WaitSandboxStarts(waitCtx) | ||
| }() | ||
|
|
||
| select { | ||
| case err := <-done: | ||
| require.Failf(t, "WaitSandboxStarts returned before start left", "err: %v", err) | ||
| case <-time.After(25 * time.Millisecond): | ||
| } | ||
|
|
||
| release() | ||
| require.NoError(t, <-done) | ||
| } | ||
|
|
||
| func TestFactoryWaitSandboxStartsReturnsContextError(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| factory := testFactory() | ||
| release, err := factory.enterSandboxStart() | ||
| require.NoError(t, err) | ||
| defer release() | ||
|
|
||
| waitCtx, cancel := context.WithCancel(t.Context()) | ||
| cancel() | ||
|
|
||
| require.ErrorIs(t, factory.WaitSandboxStarts(waitCtx), context.Canceled) | ||
| } | ||
|
|
||
| func testFactory() *Factory { | ||
| return &Factory{ | ||
| Sandboxes: NewSandboxesMap(), | ||
| drainDone: make(chan struct{}), | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -661,21 +661,25 @@ func (p *Process) Stop(ctx context.Context) error { | |
| logger.L().Warn(ctx, "failed to remove fc metrics FIFO", zap.Error(removeErr), logger.WithSandboxID(p.files.SandboxID)) | ||
| } | ||
|
|
||
| // Check if process has already exited. | ||
| pid := p.cmd.Process.Pid | ||
|
|
||
| // Check if process has already exited. The parent exiting is not enough for | ||
| // cleanup: descendants in the same process group can still hold resources. | ||
| select { | ||
| case <-p.Exit.Done(): | ||
| logger.L().Info(ctx, "fc process already exited", logger.WithSandboxID(p.files.SandboxID)) | ||
|
|
||
| return nil | ||
| if !processGroupExists(pid) { | ||
| return nil | ||
| } | ||
| default: | ||
| } | ||
|
|
||
| // this function should never fail b/c a previous context was canceled. | ||
| ctx = context.WithoutCancel(ctx) | ||
|
|
||
| err := p.cmd.Process.Signal(syscall.SIGTERM) | ||
| err := signalProcessGroup(pid, syscall.SIGTERM) | ||
| if err != nil { | ||
| if errors.Is(err, os.ErrProcessDone) { | ||
| if errors.Is(err, os.ErrProcessDone) && !processGroupExists(pid) { | ||
| logger.L().Info(ctx, "fc process already exited", logger.WithSandboxID(p.files.SandboxID)) | ||
|
|
||
| return nil | ||
|
|
@@ -684,40 +688,79 @@ func (p *Process) Stop(ctx context.Context) error { | |
| logger.L().Warn(ctx, "failed to send SIGTERM to fc process", zap.Error(err), logger.WithSandboxID(p.files.SandboxID)) | ||
| } | ||
|
|
||
| go func() { | ||
| termDeadline := time.NewTimer(10 * time.Second) | ||
| defer termDeadline.Stop() | ||
| poll := time.NewTicker(50 * time.Millisecond) | ||
| defer poll.Stop() | ||
|
|
||
| for processGroupExists(pid) { | ||
| select { | ||
| // Wait 10 sec for the FC process to exit, if it doesn't, send SIGKILL. | ||
| case <-time.After(10 * time.Second): | ||
| // Check process status right before Kill — the pre-SIGTERM status | ||
| // captured above is 10s stale and no longer useful here. | ||
| status, stateErr := getProcessStatus(p.cmd.Process.Pid) | ||
| case <-termDeadline.C: | ||
| status, stateErr := getProcessStatus(pid) | ||
| if errors.Is(stateErr, process.ErrorProcessNotRunning) { | ||
| // Process already exited, no need to send SIGKILL. | ||
| return | ||
| logger.L().Info(ctx, "fc parent process exited before SIGKILL; checking process group", logger.WithSandboxID(p.files.SandboxID)) | ||
| } else if stateErr != nil { | ||
| logger.L().Warn(ctx, "failed to get fc process status before SIGKILL", zap.Error(stateErr), logger.WithSandboxID(p.files.SandboxID)) | ||
| } | ||
|
|
||
| err := p.cmd.Process.Kill() | ||
| if err == nil { | ||
| logger.L().Info(ctx, "sent SIGKILL to fc process because it was not responding to SIGTERM for 10 seconds", | ||
| killErr := signalProcessGroup(pid, syscall.SIGKILL) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Agentic Security Review Impact: tenant-triggerable sandbox churn can cause cross-sandbox availability impact by killing the wrong process group. Reviewed by Cursor Security Reviewer for commit 3c8efb1. Configure here. |
||
| if killErr == nil { | ||
| logger.L().Info(ctx, "sent SIGKILL to fc process group because it was not responding to SIGTERM for 10 seconds", | ||
| zap.Strings("status", status), | ||
| logger.WithSandboxID(p.files.SandboxID), | ||
| ) | ||
| } | ||
| if err != nil && !errors.Is(err, os.ErrProcessDone) { | ||
| logger.L().Warn(ctx, "failed to send SIGKILL to fc process", zap.Error(err), logger.WithSandboxID(p.files.SandboxID)) | ||
| if killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { | ||
| logger.L().Warn(ctx, "failed to send SIGKILL to fc process", zap.Error(killErr), logger.WithSandboxID(p.files.SandboxID)) | ||
| } | ||
|
|
||
| // If the FC process exited, we can return. | ||
| case <-p.Exit.Done(): | ||
| return | ||
| killDeadline := time.NewTimer(time.Second) | ||
| for processGroupExists(pid) { | ||
| select { | ||
| case <-killDeadline.C: | ||
| return fmt.Errorf("fc process group %d still exists after SIGKILL", pid) | ||
| case <-poll.C: | ||
| } | ||
| } | ||
| killDeadline.Stop() | ||
|
|
||
| return nil | ||
| case <-poll.C: | ||
| } | ||
| }() | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func signalProcessGroup(pid int, signal syscall.Signal) error { | ||
| if pid <= 0 { | ||
| return os.ErrProcessDone | ||
| } | ||
|
|
||
| // Firecracker is launched with Setsid, so the process PID is also the process | ||
| // group ID. Signal the group so unshare/bash/ip descendants cannot keep the VM | ||
| // mount namespace or Firecracker process alive after shutdown. | ||
| if err := syscall.Kill(-pid, signal); err != nil { | ||
| if errors.Is(err, syscall.ESRCH) { | ||
| return os.ErrProcessDone | ||
| } | ||
|
|
||
| return err | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func processGroupExists(pid int) bool { | ||
| if pid <= 0 { | ||
| return false | ||
| } | ||
|
|
||
| err := syscall.Kill(-pid, 0) | ||
|
|
||
| return err == nil || errors.Is(err, syscall.EPERM) | ||
| } | ||
|
|
||
| func (p *Process) Pause(ctx context.Context) error { | ||
| ctx, childSpan := tracer.Start(ctx, "pause-fc") | ||
| defer childSpan.End() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| //go:build linux | ||
|
|
||
| package fc | ||
|
|
||
| import ( | ||
| "context" | ||
| "os/exec" | ||
| "syscall" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestProcessGroupExistsForSetsidChild(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ctx, cancel := context.WithTimeout(t.Context(), time.Minute) | ||
| defer cancel() | ||
|
|
||
| cmd := exec.CommandContext(ctx, "sleep", "60") | ||
| cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | ||
| require.NoError(t, cmd.Start()) | ||
|
|
||
| pid := cmd.Process.Pid | ||
| t.Cleanup(func() { | ||
| _ = syscall.Kill(-pid, syscall.SIGKILL) | ||
| _ = cmd.Wait() | ||
| }) | ||
|
|
||
| require.True(t, processGroupExists(pid)) | ||
| require.NoError(t, signalProcessGroup(pid, syscall.SIGKILL)) | ||
| require.Error(t, cmd.Wait()) | ||
| require.Eventually(t, func() bool { | ||
| return !processGroupExists(pid) | ||
| }, time.Second, 10*time.Millisecond) | ||
| } |


Uh oh!
There was an error while loading. Please reload this page.