Skip to content

Commit 1349e74

Browse files
authored
fix: disable scale-to-zero when process is spawned (#102)
## Problem `ProcessSpawn` starts asynchronous processes that continue running after the HTTP request completes. The middleware only disables scale-to-zero during the HTTP request lifecycle, meaning the VM could scale down while a spawned process was still running. ## Solution Explicitly call `stz.Disable()` after starting the process, and `stz.Enable()` when the process exits. Uses `context.WithoutCancel()` for the Enable call since the goroutine runs after the HTTP request returns. The `DebouncedController` handles reference counting, so multiple spawned processes correctly keep scale-to-zero disabled until all have exited. ## Changes - Add `s.stz.Disable(ctx)` after `cmd.Start()` succeeds - Add `s.stz.Enable(stzCtx)` in the waiter goroutine when the process exits - Use `context.WithoutCancel(ctx)` to ensure Enable works after HTTP request completes This follows the same pattern used by `FFmpegRecorder.Start()` for handling long-running processes. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Disables scale-to-zero after spawning a process and re-enables it on exit using a non-cancelled context; tests updated to use a Noop scale-to-zero controller. > > - **Backend (Process management)** > - `ProcessSpawn`: call `s.stz.Disable(ctx)` after `cmd.Start()` and track success via `stzDisabled`. > - Waiter goroutine: use `context.WithoutCancel(ctx)` and re-enable via `s.stz.Enable(stzCtx)` only if previously disabled. > - Minor: pass `stzDisabled` into waiter; add log on enable/disable errors. > - **Tests** > - Update spawn/stream/stdin/kill tests to initialize `ApiService` with `scaletozero.NewNoopController()`. > - Add `scaletozero` import. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bdb981c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent bd49aff commit 1349e74

File tree

2 files changed

+27
-6
lines changed

2 files changed

+27
-6
lines changed

server/cmd/api/api/process.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,15 @@ func (s *ApiService) ProcessSpawn(ctx context.Context, request oapi.ProcessSpawn
240240
return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start process"}}, nil
241241
}
242242

243+
// Disable scale-to-zero while the process is running.
244+
// Track success so we only re-enable if disable succeeded.
245+
stzDisabled := false
246+
if err := s.stz.Disable(ctx); err != nil {
247+
log.Error("failed to disable scale-to-zero", "err", err)
248+
} else {
249+
stzDisabled = true
250+
}
251+
243252
id := openapi_types.UUID(uuid.New())
244253
h := &processHandle{
245254
id: id,
@@ -294,8 +303,10 @@ func (s *ApiService) ProcessSpawn(ctx context.Context, request oapi.ProcessSpawn
294303
}
295304
}()
296305

297-
// Waiter goroutine
298-
go func() {
306+
// Waiter goroutine - use context without cancel since HTTP request may complete
307+
// before the process exits
308+
stzCtx := context.WithoutCancel(ctx)
309+
go func(stzWasDisabled bool) {
299310
err := cmd.Wait()
300311
code := 0
301312
if err != nil {
@@ -310,6 +321,15 @@ func (s *ApiService) ProcessSpawn(ctx context.Context, request oapi.ProcessSpawn
310321
}
311322
}
312323
h.setExited(code)
324+
325+
// Re-enable scale-to-zero now that the process has exited,
326+
// but only if we successfully disabled it earlier
327+
if stzWasDisabled {
328+
if err := s.stz.Enable(stzCtx); err != nil {
329+
log.Error("failed to enable scale-to-zero", "err", err)
330+
}
331+
}
332+
313333
// Send exit event
314334
evt := oapi.ProcessStreamEventEvent("exit")
315335
h.outCh <- oapi.ProcessStreamEvent{Event: &evt, ExitCode: &code}
@@ -325,7 +345,7 @@ func (s *ApiService) ProcessSpawn(ctx context.Context, request oapi.ProcessSpawn
325345
delete(s.procs, procID)
326346
s.procMu.Unlock()
327347
}(id.String())
328-
}()
348+
}(stzDisabled)
329349

330350
startedAt := h.started
331351
pid := h.pid

server/cmd/api/api/process_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/google/uuid"
1616
openapi_types "github.com/oapi-codegen/runtime/types"
1717
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
18+
"github.com/onkernel/kernel-images/server/lib/scaletozero"
1819
"github.com/stretchr/testify/require"
1920
)
2021

@@ -43,7 +44,7 @@ func TestProcessExec(t *testing.T) {
4344
func TestProcessSpawnStatusAndStream(t *testing.T) {
4445
t.Parallel()
4546
ctx := context.Background()
46-
svc := &ApiService{procs: make(map[string]*processHandle)}
47+
svc := &ApiService{procs: make(map[string]*processHandle), stz: scaletozero.NewNoopController()}
4748

4849
// Spawn a short-lived process that emits stdout and stderr then exits
4950
cmd := "sh"
@@ -111,7 +112,7 @@ func TestProcessSpawnStatusAndStream(t *testing.T) {
111112
func TestProcessStdinAndExit(t *testing.T) {
112113
t.Parallel()
113114
ctx := context.Background()
114-
svc := &ApiService{procs: make(map[string]*processHandle)}
115+
svc := &ApiService{procs: make(map[string]*processHandle), stz: scaletozero.NewNoopController()}
115116

116117
// Spawn a process that reads exactly 3 bytes then exits
117118
cmd := "sh"
@@ -150,7 +151,7 @@ func TestProcessStdinAndExit(t *testing.T) {
150151
func TestProcessKill(t *testing.T) {
151152
t.Parallel()
152153
ctx := context.Background()
153-
svc := &ApiService{procs: make(map[string]*processHandle)}
154+
svc := &ApiService{procs: make(map[string]*processHandle), stz: scaletozero.NewNoopController()}
154155

155156
cmd := "sh"
156157
args := []string{"-c", "sleep 5"}

0 commit comments

Comments
 (0)