From b66403fc65c6e2f52f98ce01686def2c992a5ac9 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 8 May 2026 22:48:16 +0000 Subject: [PATCH 1/9] Add /system/standby API for out-of-band scale-to-zero pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new endpoints to the kernel-images server: - POST /system/standby/disable — pins scale-to-zero off until released - POST /system/standby/enable — releases the pin The pin lives alongside the existing request-driven middleware refcount in DebouncedController: scale-to-zero stays disabled while either holders are inflight requests OR the pin is held. Request-driven Enable calls do not release the pin, so a pinned VM survives idle periods. Releasing the pin honors any configured re-enable cooldown. This is the in-VM surface for future control-plane integrations (e.g. a hot-pool controller reserving a VM until it is claimed). Control-plane wiring will follow in metro-api and the API server. Co-Authored-By: Claude Opus 4.7 --- server/cmd/api/api/api.go | 4 +- server/cmd/api/api/standby.go | 24 + server/lib/oapi/oapi.go | 719 ++++++++++++++++----- server/lib/scaletozero/scaletozero.go | 94 ++- server/lib/scaletozero/scaletozero_test.go | 110 ++++ server/openapi.yaml | 27 + 6 files changed, 785 insertions(+), 193 deletions(-) create mode 100644 server/cmd/api/api/standby.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 84164b26..d28fedd9 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -49,7 +49,7 @@ type ApiService struct { // DevTools upstream manager (Chromium supervisord log tailer) upstreamMgr *devtoolsproxy.UpstreamManager - stz scaletozero.Controller + stz scaletozero.PinnedController // inputMu serializes input-related operations (mouse, keyboard, screenshot) inputMu sync.Mutex @@ -96,7 +96,7 @@ func New( recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory, upstreamMgr *devtoolsproxy.UpstreamManager, - stz scaletozero.Controller, + stz scaletozero.PinnedController, nekoAuthClient *nekoclient.AuthClient, captureSession *capturesession.CaptureSession, eventStream *events.EventStream, diff --git a/server/cmd/api/api/standby.go b/server/cmd/api/api/standby.go new file mode 100644 index 00000000..456a3a58 --- /dev/null +++ b/server/cmd/api/api/standby.go @@ -0,0 +1,24 @@ +package api + +import ( + "context" + + "github.com/kernel/kernel-images/server/lib/logger" + oapi "github.com/kernel/kernel-images/server/lib/oapi" +) + +func (s *ApiService) DisableStandby(ctx context.Context, _ oapi.DisableStandbyRequestObject) (oapi.DisableStandbyResponseObject, error) { + if err := s.stz.DisablePin(ctx); err != nil { + logger.FromContext(ctx).Error("failed to pin scale-to-zero disabled", "err", err) + return oapi.DisableStandby500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable standby"}}, nil + } + return oapi.DisableStandby204Response{}, nil +} + +func (s *ApiService) EnableStandby(ctx context.Context, _ oapi.EnableStandbyRequestObject) (oapi.EnableStandbyResponseObject, error) { + if err := s.stz.EnablePin(ctx); err != nil { + logger.FromContext(ctx).Error("failed to release scale-to-zero pin", "err", err) + return oapi.EnableStandby500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable standby"}}, nil + } + return oapi.EnableStandby204Response{}, nil +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 28ae96f3..bc00bb71 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -1645,6 +1645,12 @@ type ClientInterface interface { StopRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DisableStandby request + DisableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // EnableStandby request + EnableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) PatchChromiumFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -2655,6 +2661,30 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques return c.Client.Do(req) } +func (c *Client) DisableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDisableStandbyRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) EnableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnableStandbyRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewPatchChromiumFlagsRequest calls the generic PatchChromiumFlags builder with application/json body func NewPatchChromiumFlagsRequest(server string, body PatchChromiumFlagsJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -4791,6 +4821,60 @@ func NewStopRecordingRequestWithBody(server string, contentType string, body io. return req, nil } +// NewDisableStandbyRequest generates requests for DisableStandby +func NewDisableStandbyRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/system/standby/disable") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewEnableStandbyRequest generates requests for EnableStandby +func NewEnableStandbyRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/system/standby/enable") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -5054,6 +5138,12 @@ type ClientWithResponsesInterface interface { StopRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) + + // DisableStandbyWithResponse request + DisableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableStandbyResponse, error) + + // EnableStandbyWithResponse request + EnableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableStandbyResponse, error) } type PatchChromiumFlagsResponse struct { @@ -6315,6 +6405,50 @@ func (r StopRecordingResponse) StatusCode() int { return 0 } +type DisableStandbyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DisableStandbyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DisableStandbyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type EnableStandbyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r EnableStandbyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r EnableStandbyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + // PatchChromiumFlagsWithBodyWithResponse request with arbitrary body returning *PatchChromiumFlagsResponse func (c *ClientWithResponses) PatchChromiumFlagsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchChromiumFlagsResponse, error) { rsp, err := c.PatchChromiumFlagsWithBody(ctx, contentType, body, reqEditors...) @@ -7040,6 +7174,24 @@ func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, bod return ParseStopRecordingResponse(rsp) } +// DisableStandbyWithResponse request returning *DisableStandbyResponse +func (c *ClientWithResponses) DisableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableStandbyResponse, error) { + rsp, err := c.DisableStandby(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseDisableStandbyResponse(rsp) +} + +// EnableStandbyWithResponse request returning *EnableStandbyResponse +func (c *ClientWithResponses) EnableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableStandbyResponse, error) { + rsp, err := c.EnableStandby(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseEnableStandbyResponse(rsp) +} + // ParsePatchChromiumFlagsResponse parses an HTTP response from a PatchChromiumFlagsWithResponse call func ParsePatchChromiumFlagsResponse(rsp *http.Response) (*PatchChromiumFlagsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -9045,6 +9197,58 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err return response, nil } +// ParseDisableStandbyResponse parses an HTTP response from a DisableStandbyWithResponse call +func ParseDisableStandbyResponse(rsp *http.Response) (*DisableStandbyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DisableStandbyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseEnableStandbyResponse parses an HTTP response from a EnableStandbyWithResponse call +func ParseEnableStandbyResponse(rsp *http.Response) (*EnableStandbyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &EnableStandbyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Update Chromium launch flags and restart @@ -9206,6 +9410,12 @@ type ServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) + // Pin scale-to-zero off until /system/standby/enable is called + // (POST /system/standby/disable) + DisableStandby(w http.ResponseWriter, r *http.Request) + // Release the standby pin set by /system/standby/disable + // (POST /system/standby/enable) + EnableStandby(w http.ResponseWriter, r *http.Request) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -9530,6 +9740,18 @@ func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Pin scale-to-zero off until /system/standby/enable is called +// (POST /system/standby/disable) +func (_ Unimplemented) DisableStandby(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Release the standby pin set by /system/standby/disable +// (POST /system/standby/enable) +func (_ Unimplemented) EnableStandby(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // ServerInterfaceWrapper converts contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface @@ -10577,6 +10799,34 @@ func (siw *ServerInterfaceWrapper) StopRecording(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// DisableStandby operation middleware +func (siw *ServerInterfaceWrapper) DisableStandby(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DisableStandby(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EnableStandby operation middleware +func (siw *ServerInterfaceWrapper) EnableStandby(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EnableStandby(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -10849,6 +11099,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/recording/stop", wrapper.StopRecording) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/system/standby/disable", wrapper.DisableStandby) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/system/standby/enable", wrapper.EnableStandby) + }) return r } @@ -13079,6 +13335,54 @@ func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.R return json.NewEncoder(w).Encode(response) } +type DisableStandbyRequestObject struct { +} + +type DisableStandbyResponseObject interface { + VisitDisableStandbyResponse(w http.ResponseWriter) error +} + +type DisableStandby204Response struct { +} + +func (response DisableStandby204Response) VisitDisableStandbyResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type DisableStandby500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DisableStandby500JSONResponse) VisitDisableStandbyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type EnableStandbyRequestObject struct { +} + +type EnableStandbyResponseObject interface { + VisitEnableStandbyResponse(w http.ResponseWriter) error +} + +type EnableStandby204Response struct { +} + +func (response EnableStandby204Response) VisitEnableStandbyResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type EnableStandby500JSONResponse struct{ InternalErrorJSONResponse } + +func (response EnableStandby500JSONResponse) VisitEnableStandbyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Update Chromium launch flags and restart @@ -13240,6 +13544,12 @@ type StrictServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) + // Pin scale-to-zero off until /system/standby/enable is called + // (POST /system/standby/disable) + DisableStandby(ctx context.Context, request DisableStandbyRequestObject) (DisableStandbyResponseObject, error) + // Release the standby pin set by /system/standby/disable + // (POST /system/standby/enable) + EnableStandby(ctx context.Context, request EnableStandbyRequestObject) (EnableStandbyResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -14837,187 +15147,240 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { } } +// DisableStandby operation middleware +func (sh *strictHandler) DisableStandby(w http.ResponseWriter, r *http.Request) { + var request DisableStandbyRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DisableStandby(ctx, request.(DisableStandbyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DisableStandby") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DisableStandbyResponseObject); ok { + if err := validResponse.VisitDisableStandbyResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// EnableStandby operation middleware +func (sh *strictHandler) EnableStandby(w http.ResponseWriter, r *http.Request) { + var request EnableStandbyRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.EnableStandby(ctx, request.(EnableStandbyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "EnableStandby") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(EnableStandbyResponseObject); ok { + if err := validResponse.VisitEnableStandbyResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 5bc62af8..028005c7 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -20,6 +20,24 @@ type Controller interface { Enable(ctx context.Context) error } +// PinnedController extends Controller with an out-of-band "pin" that holds +// scale-to-zero disabled independently of the request-driven refcount used by +// the HTTP middleware. While the pin is held, request-driven Enable calls do +// not re-enable scale-to-zero; only DisablePin/EnablePin can release it. +// +// This is intended for explicit lifecycle control (e.g. a control-plane API +// reserving a VM in a hot pool) where the holder is not tied to an inflight +// HTTP request. +type PinnedController interface { + Controller + // DisablePin pins scale-to-zero disabled until EnablePin is called. The + // pin is a boolean, not a counter: repeated calls are idempotent. + DisablePin(ctx context.Context) error + // EnablePin releases the pin. If no request-driven holders remain, + // scale-to-zero is re-enabled (honoring any configured cooldown). + EnablePin(ctx context.Context) error +} + type unikraftCloudController struct { path string } @@ -64,8 +82,10 @@ type NoopController struct{} func NewNoopController() *NoopController { return &NoopController{} } -func (NoopController) Disable(context.Context) error { return nil } -func (NoopController) Enable(context.Context) error { return nil } +func (NoopController) Disable(context.Context) error { return nil } +func (NoopController) Enable(context.Context) error { return nil } +func (NoopController) DisablePin(context.Context) error { return nil } +func (NoopController) EnablePin(context.Context) error { return nil } // Oncer wraps a Controller and ensures that Disable and Enable are called at most once. type Oncer struct { @@ -89,16 +109,17 @@ func (o *Oncer) Enable(ctx context.Context) error { } type DebouncedController struct { - ctrl Controller - cooldown time.Duration - mu sync.Mutex - disabled bool - activeCount int - reenableTimer *time.Timer + ctrl Controller + cooldown time.Duration + mu sync.Mutex + disabled bool + activeCount int + pinned bool + reenableTimer *time.Timer } // NewDebouncedController creates a DebouncedController with no re-enable cooldown. -func NewDebouncedController(ctrl Controller) Controller { +func NewDebouncedController(ctrl Controller) *DebouncedController { return &DebouncedController{ctrl: ctrl} } @@ -106,7 +127,7 @@ func NewDebouncedController(ctrl Controller) Controller { // re-enabling scale-to-zero by the given cooldown after the last active holder // releases. A new Disable call during the cooldown cancels the pending // re-enable, avoiding rapid toggling from sequential requests. -func NewDebouncedControllerWithCooldown(ctrl Controller, cooldown time.Duration) Controller { +func NewDebouncedControllerWithCooldown(ctrl Controller, cooldown time.Duration) *DebouncedController { return &DebouncedController{ctrl: ctrl, cooldown: cooldown} } @@ -141,8 +162,55 @@ func (c *DebouncedController) Enable(ctx context.Context) error { c.activeCount-- } - // nothing to do - if c.activeCount > 0 || !c.disabled { + return c.maybeReenableLocked(ctx) +} + +// DisablePin sets the out-of-band pin and ensures scale-to-zero is disabled. +// Idempotent: re-pinning while already pinned is a no-op. Cancels any pending +// cooldown timer. +func (c *DebouncedController) DisablePin(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.reenableTimer != nil { + c.reenableTimer.Stop() + c.reenableTimer = nil + } + + if c.pinned { + return nil + } + + if !c.disabled { + if err := c.ctrl.Disable(ctx); err != nil { + return err + } + c.disabled = true + } + + c.pinned = true + return nil +} + +// EnablePin releases the pin. If no request-driven holders remain, scale-to-zero +// is re-enabled (honoring any configured cooldown). Idempotent: calling when no +// pin is held is a no-op. +func (c *DebouncedController) EnablePin(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.pinned { + return nil + } + c.pinned = false + + return c.maybeReenableLocked(ctx) +} + +// maybeReenableLocked re-enables scale-to-zero if no holders (request-driven or +// pin) remain. Caller must hold c.mu. +func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { + if c.activeCount > 0 || c.pinned || !c.disabled { return nil } @@ -161,7 +229,7 @@ func (c *DebouncedController) Enable(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.activeCount > 0 || !c.disabled { + if c.activeCount > 0 || c.pinned || !c.disabled { return } diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 20c3560a..43df1928 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -260,3 +260,113 @@ func TestDebouncedControllerCooldownZeroBehavesLikeOriginal(t *testing.T) { assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 1, mock.enableCalls) } + +func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + // Pin first. + require.NoError(t, c.DisablePin(t.Context())) + assert.Equal(t, 1, mock.disableCalls) + + // Simulate a middleware-wrapped request: Disable then Enable. + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) + + // Pin still held, so no Enable should have hit the underlying ctrl. + assert.Equal(t, 1, mock.disableCalls) + assert.Equal(t, 0, mock.enableCalls) + + // Release the pin: Enable fires. + require.NoError(t, c.EnablePin(t.Context())) + assert.Equal(t, 1, mock.enableCalls) +} + +func TestDebouncedControllerPinIdempotent(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.DisablePin(t.Context())) + assert.Equal(t, 1, mock.disableCalls) + + require.NoError(t, c.EnablePin(t.Context())) + require.NoError(t, c.EnablePin(t.Context())) + assert.Equal(t, 1, mock.enableCalls) +} + +func TestDebouncedControllerEnablePinWithoutPinNoWrite(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.EnablePin(t.Context())) + assert.Equal(t, 0, mock.disableCalls) + assert.Equal(t, 0, mock.enableCalls) +} + +func TestDebouncedControllerEnablePinDefersToActiveRequests(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.Disable(t.Context())) // simulate inflight request + + // Releasing the pin while a request is inflight must not re-enable. + require.NoError(t, c.EnablePin(t.Context())) + assert.Equal(t, 0, mock.enableCalls) + + // Request completes -> Enable fires. + require.NoError(t, c.Enable(t.Context())) + assert.Equal(t, 1, mock.enableCalls) +} + +func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) + + // Drive a request through, putting us into the cooldown window. + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) + + // Pin during the cooldown: should cancel the pending re-enable. + require.NoError(t, c.DisablePin(t.Context())) + + time.Sleep(100 * time.Millisecond) + + mock.mu.Lock() + assert.Equal(t, 1, mock.disableCalls) + assert.Equal(t, 0, mock.enableCalls) + mock.mu.Unlock() + + require.NoError(t, c.EnablePin(t.Context())) + time.Sleep(100 * time.Millisecond) + mock.mu.Lock() + assert.Equal(t, 1, mock.enableCalls) + mock.mu.Unlock() +} + +func TestDebouncedControllerEnablePinHonorsCooldown(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) + + require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.EnablePin(t.Context())) + + // Cooldown should defer the underlying Enable. + mock.mu.Lock() + assert.Equal(t, 0, mock.enableCalls) + mock.mu.Unlock() + + time.Sleep(100 * time.Millisecond) + + mock.mu.Lock() + assert.Equal(t, 1, mock.enableCalls) + mock.mu.Unlock() +} diff --git a/server/openapi.yaml b/server/openapi.yaml index 112334b9..6cd9ec3a 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1334,6 +1334,33 @@ paths: text/event-stream: schema: $ref: "#/components/schemas/PublishedEnvelope" + /system/standby/disable: + post: + summary: Pin scale-to-zero off until /system/standby/enable is called + description: > + Disables scale-to-zero out-of-band of the request-driven middleware, + holding it disabled across idle periods. The pin is independent of the + inflight-request refcount: request-driven Enable calls will not release + it. Idempotent — repeated calls have no additional effect. + operationId: disableStandby + responses: + "204": + description: Standby pinned disabled + "500": + $ref: "#/components/responses/InternalError" + /system/standby/enable: + post: + summary: Release the standby pin set by /system/standby/disable + description: > + Releases the out-of-band scale-to-zero pin. If no request-driven + holders remain, scale-to-zero re-enables (honoring any configured + cooldown). Idempotent — calling without a held pin has no effect. + operationId: enableStandby + responses: + "204": + description: Standby pin released + "500": + $ref: "#/components/responses/InternalError" components: schemas: Event: From 6d93680cb491780bd9725edcb6d74cd75f348c5b Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 8 May 2026 23:06:31 +0000 Subject: [PATCH 2/9] Add e2e test for /system/standby endpoints Spins up the headless image via testcontainers and exercises: - Idempotent disable (two consecutive 204s) - A normal request flows while pinned (middleware coexistence) - Idempotent enable (two consecutive 204s) The unikraft control file does not exist inside the docker test container, so the underlying scale-to-zero write is a no-op. The test validates HTTP wiring and handler/middleware coexistence; the deep pin semantics are covered by unit tests against DebouncedController. Co-Authored-By: Claude Opus 4.7 --- server/e2e/e2e_standby_test.go | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 server/e2e/e2e_standby_test.go diff --git a/server/e2e/e2e_standby_test.go b/server/e2e/e2e_standby_test.go new file mode 100644 index 00000000..649e2f83 --- /dev/null +++ b/server/e2e/e2e_standby_test.go @@ -0,0 +1,60 @@ +package e2e + +import ( + "context" + "net/http" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestStandbyDisableEnable exercises POST /system/standby/{disable,enable} +// against the real built image. The unikraft control file does not exist +// inside the docker test container, so the underlying scale-to-zero write +// is a no-op — this test validates HTTP wiring, idempotency, and that the +// scale-to-zero middleware coexists with the pin handlers. +func TestStandbyDisableEnable(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + + client, err := c.APIClient() + require.NoError(t, err, "failed to create API client") + + // Idempotent disable. + r1, err := client.DisableStandbyWithResponse(ctx) + require.NoError(t, err, "DisableStandby request failed") + require.Equal(t, http.StatusNoContent, r1.StatusCode(), "unexpected status: %s body=%s", r1.Status(), string(r1.Body)) + + r2, err := client.DisableStandbyWithResponse(ctx) + require.NoError(t, err, "second DisableStandby request failed") + require.Equal(t, http.StatusNoContent, r2.StatusCode(), "unexpected status: %s body=%s", r2.Status(), string(r2.Body)) + + // Normal request must still flow while pinned (scaletozero middleware + // runs on every request — the pin must not deadlock or break it). + readResp, err := client.ReadClipboardWithResponse(ctx) + require.NoError(t, err, "ReadClipboard request failed while pinned") + require.Equal(t, http.StatusOK, readResp.StatusCode(), "unexpected read status while pinned: %s body=%s", readResp.Status(), string(readResp.Body)) + + // Idempotent enable. + r3, err := client.EnableStandbyWithResponse(ctx) + require.NoError(t, err, "EnableStandby request failed") + require.Equal(t, http.StatusNoContent, r3.StatusCode(), "unexpected status: %s body=%s", r3.Status(), string(r3.Body)) + + r4, err := client.EnableStandbyWithResponse(ctx) + require.NoError(t, err, "second EnableStandby request failed") + require.Equal(t, http.StatusNoContent, r4.StatusCode(), "unexpected status: %s body=%s", r4.Status(), string(r4.Body)) +} From a41fed34b7b0a58e20a400880ac0c65e5c39ed15 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 9 May 2026 00:30:30 +0000 Subject: [PATCH 3/9] Rename to /scaletozero/{pin,unpin} and Pin/Unpin per review Address review feedback: - Path /system/standby/* implied VM-state mutation; rename to /scaletozero/{pin,unpin} so the operation is specific to the scale-to-zero gate. - Interface methods DisablePin/EnablePin read as inverted; rename to Pin/Unpin for clarity. - Rewrite openapi summary/description to be caller-focused (what it does, when to call, what pairs with). --- server/cmd/api/api/scaletozero.go | 24 + server/cmd/api/api/standby.go | 24 - ...tandby_test.go => e2e_scaletozero_test.go} | 32 +- server/lib/oapi/oapi.go | 550 +++++++++--------- server/lib/scaletozero/scaletozero.go | 28 +- server/lib/scaletozero/scaletozero_test.go | 34 +- server/openapi.yaml | 30 +- 7 files changed, 361 insertions(+), 361 deletions(-) create mode 100644 server/cmd/api/api/scaletozero.go delete mode 100644 server/cmd/api/api/standby.go rename server/e2e/{e2e_standby_test.go => e2e_scaletozero_test.go} (62%) diff --git a/server/cmd/api/api/scaletozero.go b/server/cmd/api/api/scaletozero.go new file mode 100644 index 00000000..3c35fbd2 --- /dev/null +++ b/server/cmd/api/api/scaletozero.go @@ -0,0 +1,24 @@ +package api + +import ( + "context" + + "github.com/kernel/kernel-images/server/lib/logger" + oapi "github.com/kernel/kernel-images/server/lib/oapi" +) + +func (s *ApiService) PinScaleToZero(ctx context.Context, _ oapi.PinScaleToZeroRequestObject) (oapi.PinScaleToZeroResponseObject, error) { + if err := s.stz.Pin(ctx); err != nil { + logger.FromContext(ctx).Error("failed to pin scale-to-zero", "err", err) + return oapi.PinScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to pin scale-to-zero"}}, nil + } + return oapi.PinScaleToZero204Response{}, nil +} + +func (s *ApiService) UnpinScaleToZero(ctx context.Context, _ oapi.UnpinScaleToZeroRequestObject) (oapi.UnpinScaleToZeroResponseObject, error) { + if err := s.stz.Unpin(ctx); err != nil { + logger.FromContext(ctx).Error("failed to unpin scale-to-zero", "err", err) + return oapi.UnpinScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to unpin scale-to-zero"}}, nil + } + return oapi.UnpinScaleToZero204Response{}, nil +} diff --git a/server/cmd/api/api/standby.go b/server/cmd/api/api/standby.go deleted file mode 100644 index 456a3a58..00000000 --- a/server/cmd/api/api/standby.go +++ /dev/null @@ -1,24 +0,0 @@ -package api - -import ( - "context" - - "github.com/kernel/kernel-images/server/lib/logger" - oapi "github.com/kernel/kernel-images/server/lib/oapi" -) - -func (s *ApiService) DisableStandby(ctx context.Context, _ oapi.DisableStandbyRequestObject) (oapi.DisableStandbyResponseObject, error) { - if err := s.stz.DisablePin(ctx); err != nil { - logger.FromContext(ctx).Error("failed to pin scale-to-zero disabled", "err", err) - return oapi.DisableStandby500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable standby"}}, nil - } - return oapi.DisableStandby204Response{}, nil -} - -func (s *ApiService) EnableStandby(ctx context.Context, _ oapi.EnableStandbyRequestObject) (oapi.EnableStandbyResponseObject, error) { - if err := s.stz.EnablePin(ctx); err != nil { - logger.FromContext(ctx).Error("failed to release scale-to-zero pin", "err", err) - return oapi.EnableStandby500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable standby"}}, nil - } - return oapi.EnableStandby204Response{}, nil -} diff --git a/server/e2e/e2e_standby_test.go b/server/e2e/e2e_scaletozero_test.go similarity index 62% rename from server/e2e/e2e_standby_test.go rename to server/e2e/e2e_scaletozero_test.go index 649e2f83..6e380f9e 100644 --- a/server/e2e/e2e_standby_test.go +++ b/server/e2e/e2e_scaletozero_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/require" ) -// TestStandbyDisableEnable exercises POST /system/standby/{disable,enable} -// against the real built image. The unikraft control file does not exist -// inside the docker test container, so the underlying scale-to-zero write -// is a no-op — this test validates HTTP wiring, idempotency, and that the -// scale-to-zero middleware coexists with the pin handlers. -func TestStandbyDisableEnable(t *testing.T) { +// TestScaleToZeroPinUnpin exercises POST /scaletozero/{pin,unpin} against the +// real built image. The unikraft control file does not exist inside the docker +// test container, so the underlying scale-to-zero write is a no-op — this test +// validates HTTP wiring, idempotency, and that the scale-to-zero middleware +// coexists with the pin handlers. +func TestScaleToZeroPinUnpin(t *testing.T) { t.Parallel() if _, err := exec.LookPath("docker"); err != nil { @@ -34,13 +34,13 @@ func TestStandbyDisableEnable(t *testing.T) { client, err := c.APIClient() require.NoError(t, err, "failed to create API client") - // Idempotent disable. - r1, err := client.DisableStandbyWithResponse(ctx) - require.NoError(t, err, "DisableStandby request failed") + // Idempotent pin. + r1, err := client.PinScaleToZeroWithResponse(ctx) + require.NoError(t, err, "PinScaleToZero request failed") require.Equal(t, http.StatusNoContent, r1.StatusCode(), "unexpected status: %s body=%s", r1.Status(), string(r1.Body)) - r2, err := client.DisableStandbyWithResponse(ctx) - require.NoError(t, err, "second DisableStandby request failed") + r2, err := client.PinScaleToZeroWithResponse(ctx) + require.NoError(t, err, "second PinScaleToZero request failed") require.Equal(t, http.StatusNoContent, r2.StatusCode(), "unexpected status: %s body=%s", r2.Status(), string(r2.Body)) // Normal request must still flow while pinned (scaletozero middleware @@ -49,12 +49,12 @@ func TestStandbyDisableEnable(t *testing.T) { require.NoError(t, err, "ReadClipboard request failed while pinned") require.Equal(t, http.StatusOK, readResp.StatusCode(), "unexpected read status while pinned: %s body=%s", readResp.Status(), string(readResp.Body)) - // Idempotent enable. - r3, err := client.EnableStandbyWithResponse(ctx) - require.NoError(t, err, "EnableStandby request failed") + // Idempotent unpin. + r3, err := client.UnpinScaleToZeroWithResponse(ctx) + require.NoError(t, err, "UnpinScaleToZero request failed") require.Equal(t, http.StatusNoContent, r3.StatusCode(), "unexpected status: %s body=%s", r3.Status(), string(r3.Body)) - r4, err := client.EnableStandbyWithResponse(ctx) - require.NoError(t, err, "second EnableStandby request failed") + r4, err := client.UnpinScaleToZeroWithResponse(ctx) + require.NoError(t, err, "second UnpinScaleToZero request failed") require.Equal(t, http.StatusNoContent, r4.StatusCode(), "unexpected status: %s body=%s", r4.Status(), string(r4.Body)) } diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index bc00bb71..2719fc51 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -1646,11 +1646,11 @@ type ClientInterface interface { StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // DisableStandby request - DisableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // PinScaleToZero request + PinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) - // EnableStandby request - EnableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // UnpinScaleToZero request + UnpinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) PatchChromiumFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -2661,8 +2661,8 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques return c.Client.Do(req) } -func (c *Client) DisableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewDisableStandbyRequest(c.Server) +func (c *Client) PinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPinScaleToZeroRequest(c.Server) if err != nil { return nil, err } @@ -2673,8 +2673,8 @@ func (c *Client) DisableStandby(ctx context.Context, reqEditors ...RequestEditor return c.Client.Do(req) } -func (c *Client) EnableStandby(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewEnableStandbyRequest(c.Server) +func (c *Client) UnpinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUnpinScaleToZeroRequest(c.Server) if err != nil { return nil, err } @@ -4821,8 +4821,8 @@ func NewStopRecordingRequestWithBody(server string, contentType string, body io. return req, nil } -// NewDisableStandbyRequest generates requests for DisableStandby -func NewDisableStandbyRequest(server string) (*http.Request, error) { +// NewPinScaleToZeroRequest generates requests for PinScaleToZero +func NewPinScaleToZeroRequest(server string) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -4830,7 +4830,7 @@ func NewDisableStandbyRequest(server string) (*http.Request, error) { return nil, err } - operationPath := fmt.Sprintf("/system/standby/disable") + operationPath := fmt.Sprintf("/scaletozero/pin") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -4848,8 +4848,8 @@ func NewDisableStandbyRequest(server string) (*http.Request, error) { return req, nil } -// NewEnableStandbyRequest generates requests for EnableStandby -func NewEnableStandbyRequest(server string) (*http.Request, error) { +// NewUnpinScaleToZeroRequest generates requests for UnpinScaleToZero +func NewUnpinScaleToZeroRequest(server string) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -4857,7 +4857,7 @@ func NewEnableStandbyRequest(server string) (*http.Request, error) { return nil, err } - operationPath := fmt.Sprintf("/system/standby/enable") + operationPath := fmt.Sprintf("/scaletozero/unpin") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -5139,11 +5139,11 @@ type ClientWithResponsesInterface interface { StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) - // DisableStandbyWithResponse request - DisableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableStandbyResponse, error) + // PinScaleToZeroWithResponse request + PinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PinScaleToZeroResponse, error) - // EnableStandbyWithResponse request - EnableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableStandbyResponse, error) + // UnpinScaleToZeroWithResponse request + UnpinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*UnpinScaleToZeroResponse, error) } type PatchChromiumFlagsResponse struct { @@ -6405,14 +6405,14 @@ func (r StopRecordingResponse) StatusCode() int { return 0 } -type DisableStandbyResponse struct { +type PinScaleToZeroResponse struct { Body []byte HTTPResponse *http.Response JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r DisableStandbyResponse) Status() string { +func (r PinScaleToZeroResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -6420,21 +6420,21 @@ func (r DisableStandbyResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r DisableStandbyResponse) StatusCode() int { +func (r PinScaleToZeroResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type EnableStandbyResponse struct { +type UnpinScaleToZeroResponse struct { Body []byte HTTPResponse *http.Response JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r EnableStandbyResponse) Status() string { +func (r UnpinScaleToZeroResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -6442,7 +6442,7 @@ func (r EnableStandbyResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r EnableStandbyResponse) StatusCode() int { +func (r UnpinScaleToZeroResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -7174,22 +7174,22 @@ func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, bod return ParseStopRecordingResponse(rsp) } -// DisableStandbyWithResponse request returning *DisableStandbyResponse -func (c *ClientWithResponses) DisableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableStandbyResponse, error) { - rsp, err := c.DisableStandby(ctx, reqEditors...) +// PinScaleToZeroWithResponse request returning *PinScaleToZeroResponse +func (c *ClientWithResponses) PinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PinScaleToZeroResponse, error) { + rsp, err := c.PinScaleToZero(ctx, reqEditors...) if err != nil { return nil, err } - return ParseDisableStandbyResponse(rsp) + return ParsePinScaleToZeroResponse(rsp) } -// EnableStandbyWithResponse request returning *EnableStandbyResponse -func (c *ClientWithResponses) EnableStandbyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableStandbyResponse, error) { - rsp, err := c.EnableStandby(ctx, reqEditors...) +// UnpinScaleToZeroWithResponse request returning *UnpinScaleToZeroResponse +func (c *ClientWithResponses) UnpinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*UnpinScaleToZeroResponse, error) { + rsp, err := c.UnpinScaleToZero(ctx, reqEditors...) if err != nil { return nil, err } - return ParseEnableStandbyResponse(rsp) + return ParseUnpinScaleToZeroResponse(rsp) } // ParsePatchChromiumFlagsResponse parses an HTTP response from a PatchChromiumFlagsWithResponse call @@ -9197,15 +9197,15 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err return response, nil } -// ParseDisableStandbyResponse parses an HTTP response from a DisableStandbyWithResponse call -func ParseDisableStandbyResponse(rsp *http.Response) (*DisableStandbyResponse, error) { +// ParsePinScaleToZeroResponse parses an HTTP response from a PinScaleToZeroWithResponse call +func ParsePinScaleToZeroResponse(rsp *http.Response) (*PinScaleToZeroResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &DisableStandbyResponse{ + response := &PinScaleToZeroResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -9223,15 +9223,15 @@ func ParseDisableStandbyResponse(rsp *http.Response) (*DisableStandbyResponse, e return response, nil } -// ParseEnableStandbyResponse parses an HTTP response from a EnableStandbyWithResponse call -func ParseEnableStandbyResponse(rsp *http.Response) (*EnableStandbyResponse, error) { +// ParseUnpinScaleToZeroResponse parses an HTTP response from a UnpinScaleToZeroWithResponse call +func ParseUnpinScaleToZeroResponse(rsp *http.Response) (*UnpinScaleToZeroResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &EnableStandbyResponse{ + response := &UnpinScaleToZeroResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -9410,12 +9410,12 @@ type ServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) - // Pin scale-to-zero off until /system/standby/enable is called - // (POST /system/standby/disable) - DisableStandby(w http.ResponseWriter, r *http.Request) - // Release the standby pin set by /system/standby/disable - // (POST /system/standby/enable) - EnableStandby(w http.ResponseWriter, r *http.Request) + // Hold this VM awake until /scaletozero/unpin + // (POST /scaletozero/pin) + PinScaleToZero(w http.ResponseWriter, r *http.Request) + // Release the awake-hold set by /scaletozero/pin + // (POST /scaletozero/unpin) + UnpinScaleToZero(w http.ResponseWriter, r *http.Request) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -9740,15 +9740,15 @@ func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Pin scale-to-zero off until /system/standby/enable is called -// (POST /system/standby/disable) -func (_ Unimplemented) DisableStandby(w http.ResponseWriter, r *http.Request) { +// Hold this VM awake until /scaletozero/unpin +// (POST /scaletozero/pin) +func (_ Unimplemented) PinScaleToZero(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Release the standby pin set by /system/standby/disable -// (POST /system/standby/enable) -func (_ Unimplemented) EnableStandby(w http.ResponseWriter, r *http.Request) { +// Release the awake-hold set by /scaletozero/pin +// (POST /scaletozero/unpin) +func (_ Unimplemented) UnpinScaleToZero(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -10799,11 +10799,11 @@ func (siw *ServerInterfaceWrapper) StopRecording(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// DisableStandby operation middleware -func (siw *ServerInterfaceWrapper) DisableStandby(w http.ResponseWriter, r *http.Request) { +// PinScaleToZero operation middleware +func (siw *ServerInterfaceWrapper) PinScaleToZero(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DisableStandby(w, r) + siw.Handler.PinScaleToZero(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -10813,11 +10813,11 @@ func (siw *ServerInterfaceWrapper) DisableStandby(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// EnableStandby operation middleware -func (siw *ServerInterfaceWrapper) EnableStandby(w http.ResponseWriter, r *http.Request) { +// UnpinScaleToZero operation middleware +func (siw *ServerInterfaceWrapper) UnpinScaleToZero(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.EnableStandby(w, r) + siw.Handler.UnpinScaleToZero(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -11100,10 +11100,10 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Post(options.BaseURL+"/recording/stop", wrapper.StopRecording) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/system/standby/disable", wrapper.DisableStandby) + r.Post(options.BaseURL+"/scaletozero/pin", wrapper.PinScaleToZero) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/system/standby/enable", wrapper.EnableStandby) + r.Post(options.BaseURL+"/scaletozero/unpin", wrapper.UnpinScaleToZero) }) return r @@ -13335,48 +13335,48 @@ func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.R return json.NewEncoder(w).Encode(response) } -type DisableStandbyRequestObject struct { +type PinScaleToZeroRequestObject struct { } -type DisableStandbyResponseObject interface { - VisitDisableStandbyResponse(w http.ResponseWriter) error +type PinScaleToZeroResponseObject interface { + VisitPinScaleToZeroResponse(w http.ResponseWriter) error } -type DisableStandby204Response struct { +type PinScaleToZero204Response struct { } -func (response DisableStandby204Response) VisitDisableStandbyResponse(w http.ResponseWriter) error { +func (response PinScaleToZero204Response) VisitPinScaleToZeroResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type DisableStandby500JSONResponse struct{ InternalErrorJSONResponse } +type PinScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } -func (response DisableStandby500JSONResponse) VisitDisableStandbyResponse(w http.ResponseWriter) error { +func (response PinScaleToZero500JSONResponse) VisitPinScaleToZeroResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type EnableStandbyRequestObject struct { +type UnpinScaleToZeroRequestObject struct { } -type EnableStandbyResponseObject interface { - VisitEnableStandbyResponse(w http.ResponseWriter) error +type UnpinScaleToZeroResponseObject interface { + VisitUnpinScaleToZeroResponse(w http.ResponseWriter) error } -type EnableStandby204Response struct { +type UnpinScaleToZero204Response struct { } -func (response EnableStandby204Response) VisitEnableStandbyResponse(w http.ResponseWriter) error { +func (response UnpinScaleToZero204Response) VisitUnpinScaleToZeroResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type EnableStandby500JSONResponse struct{ InternalErrorJSONResponse } +type UnpinScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } -func (response EnableStandby500JSONResponse) VisitEnableStandbyResponse(w http.ResponseWriter) error { +func (response UnpinScaleToZero500JSONResponse) VisitUnpinScaleToZeroResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) @@ -13544,12 +13544,12 @@ type StrictServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) - // Pin scale-to-zero off until /system/standby/enable is called - // (POST /system/standby/disable) - DisableStandby(ctx context.Context, request DisableStandbyRequestObject) (DisableStandbyResponseObject, error) - // Release the standby pin set by /system/standby/disable - // (POST /system/standby/enable) - EnableStandby(ctx context.Context, request EnableStandbyRequestObject) (EnableStandbyResponseObject, error) + // Hold this VM awake until /scaletozero/unpin + // (POST /scaletozero/pin) + PinScaleToZero(ctx context.Context, request PinScaleToZeroRequestObject) (PinScaleToZeroResponseObject, error) + // Release the awake-hold set by /scaletozero/pin + // (POST /scaletozero/unpin) + UnpinScaleToZero(ctx context.Context, request UnpinScaleToZeroRequestObject) (UnpinScaleToZeroResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -15147,23 +15147,23 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { } } -// DisableStandby operation middleware -func (sh *strictHandler) DisableStandby(w http.ResponseWriter, r *http.Request) { - var request DisableStandbyRequestObject +// PinScaleToZero operation middleware +func (sh *strictHandler) PinScaleToZero(w http.ResponseWriter, r *http.Request) { + var request PinScaleToZeroRequestObject handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.DisableStandby(ctx, request.(DisableStandbyRequestObject)) + return sh.ssi.PinScaleToZero(ctx, request.(PinScaleToZeroRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "DisableStandby") + handler = middleware(handler, "PinScaleToZero") } response, err := handler(r.Context(), w, r, request) if err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(DisableStandbyResponseObject); ok { - if err := validResponse.VisitDisableStandbyResponse(w); err != nil { + } else if validResponse, ok := response.(PinScaleToZeroResponseObject); ok { + if err := validResponse.VisitPinScaleToZeroResponse(w); err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) } } else if response != nil { @@ -15171,23 +15171,23 @@ func (sh *strictHandler) DisableStandby(w http.ResponseWriter, r *http.Request) } } -// EnableStandby operation middleware -func (sh *strictHandler) EnableStandby(w http.ResponseWriter, r *http.Request) { - var request EnableStandbyRequestObject +// UnpinScaleToZero operation middleware +func (sh *strictHandler) UnpinScaleToZero(w http.ResponseWriter, r *http.Request) { + var request UnpinScaleToZeroRequestObject handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.EnableStandby(ctx, request.(EnableStandbyRequestObject)) + return sh.ssi.UnpinScaleToZero(ctx, request.(UnpinScaleToZeroRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "EnableStandby") + handler = middleware(handler, "UnpinScaleToZero") } response, err := handler(r.Context(), w, r, request) if err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(EnableStandbyResponseObject); ok { - if err := validResponse.VisitEnableStandbyResponse(w); err != nil { + } else if validResponse, ok := response.(UnpinScaleToZeroResponseObject); ok { + if err := validResponse.VisitUnpinScaleToZeroResponse(w); err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) } } else if response != nil { @@ -15198,189 +15198,189 @@ func (sh *strictHandler) EnableStandby(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 028005c7..21c76f43 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -23,19 +23,19 @@ type Controller interface { // PinnedController extends Controller with an out-of-band "pin" that holds // scale-to-zero disabled independently of the request-driven refcount used by // the HTTP middleware. While the pin is held, request-driven Enable calls do -// not re-enable scale-to-zero; only DisablePin/EnablePin can release it. +// not re-enable scale-to-zero; only Unpin can release it. // // This is intended for explicit lifecycle control (e.g. a control-plane API // reserving a VM in a hot pool) where the holder is not tied to an inflight // HTTP request. type PinnedController interface { Controller - // DisablePin pins scale-to-zero disabled until EnablePin is called. The - // pin is a boolean, not a counter: repeated calls are idempotent. - DisablePin(ctx context.Context) error - // EnablePin releases the pin. If no request-driven holders remain, + // Pin holds scale-to-zero disabled until Unpin is called. The pin is a + // boolean, not a counter: repeated calls are idempotent. + Pin(ctx context.Context) error + // Unpin releases the pin. If no request-driven holders remain, // scale-to-zero is re-enabled (honoring any configured cooldown). - EnablePin(ctx context.Context) error + Unpin(ctx context.Context) error } type unikraftCloudController struct { @@ -82,10 +82,10 @@ type NoopController struct{} func NewNoopController() *NoopController { return &NoopController{} } -func (NoopController) Disable(context.Context) error { return nil } -func (NoopController) Enable(context.Context) error { return nil } -func (NoopController) DisablePin(context.Context) error { return nil } -func (NoopController) EnablePin(context.Context) error { return nil } +func (NoopController) Disable(context.Context) error { return nil } +func (NoopController) Enable(context.Context) error { return nil } +func (NoopController) Pin(context.Context) error { return nil } +func (NoopController) Unpin(context.Context) error { return nil } // Oncer wraps a Controller and ensures that Disable and Enable are called at most once. type Oncer struct { @@ -165,10 +165,10 @@ func (c *DebouncedController) Enable(ctx context.Context) error { return c.maybeReenableLocked(ctx) } -// DisablePin sets the out-of-band pin and ensures scale-to-zero is disabled. +// Pin sets the out-of-band pin and ensures scale-to-zero is disabled. // Idempotent: re-pinning while already pinned is a no-op. Cancels any pending // cooldown timer. -func (c *DebouncedController) DisablePin(ctx context.Context) error { +func (c *DebouncedController) Pin(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -192,10 +192,10 @@ func (c *DebouncedController) DisablePin(ctx context.Context) error { return nil } -// EnablePin releases the pin. If no request-driven holders remain, scale-to-zero +// Unpin releases the pin. If no request-driven holders remain, scale-to-zero // is re-enabled (honoring any configured cooldown). Idempotent: calling when no // pin is held is a no-op. -func (c *DebouncedController) EnablePin(ctx context.Context) error { +func (c *DebouncedController) Unpin(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 43df1928..3221fb39 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -267,7 +267,7 @@ func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { c := NewDebouncedController(mock) // Pin first. - require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.Pin(t.Context())) assert.Equal(t, 1, mock.disableCalls) // Simulate a middleware-wrapped request: Disable then Enable. @@ -279,7 +279,7 @@ func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { assert.Equal(t, 0, mock.enableCalls) // Release the pin: Enable fires. - require.NoError(t, c.EnablePin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 1, mock.enableCalls) } @@ -288,36 +288,36 @@ func TestDebouncedControllerPinIdempotent(t *testing.T) { mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.DisablePin(t.Context())) - require.NoError(t, c.DisablePin(t.Context())) - require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) assert.Equal(t, 1, mock.disableCalls) - require.NoError(t, c.EnablePin(t.Context())) - require.NoError(t, c.EnablePin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerEnablePinWithoutPinNoWrite(t *testing.T) { +func TestDebouncedControllerUnpinWithoutPinNoWrite(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.EnablePin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 0, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) } -func TestDebouncedControllerEnablePinDefersToActiveRequests(t *testing.T) { +func TestDebouncedControllerUnpinDefersToActiveRequests(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.Pin(t.Context())) require.NoError(t, c.Disable(t.Context())) // simulate inflight request // Releasing the pin while a request is inflight must not re-enable. - require.NoError(t, c.EnablePin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 0, mock.enableCalls) // Request completes -> Enable fires. @@ -335,7 +335,7 @@ func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { require.NoError(t, c.Enable(t.Context())) // Pin during the cooldown: should cancel the pending re-enable. - require.NoError(t, c.DisablePin(t.Context())) + require.NoError(t, c.Pin(t.Context())) time.Sleep(100 * time.Millisecond) @@ -344,20 +344,20 @@ func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { assert.Equal(t, 0, mock.enableCalls) mock.mu.Unlock() - require.NoError(t, c.EnablePin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) time.Sleep(100 * time.Millisecond) mock.mu.Lock() assert.Equal(t, 1, mock.enableCalls) mock.mu.Unlock() } -func TestDebouncedControllerEnablePinHonorsCooldown(t *testing.T) { +func TestDebouncedControllerUnpinHonorsCooldown(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.DisablePin(t.Context())) - require.NoError(t, c.EnablePin(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) // Cooldown should defer the underlying Enable. mock.mu.Lock() diff --git a/server/openapi.yaml b/server/openapi.yaml index 6cd9ec3a..aa14e86f 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1334,31 +1334,31 @@ paths: text/event-stream: schema: $ref: "#/components/schemas/PublishedEnvelope" - /system/standby/disable: + /scaletozero/pin: post: - summary: Pin scale-to-zero off until /system/standby/enable is called + summary: Hold this VM awake until /scaletozero/unpin description: > - Disables scale-to-zero out-of-band of the request-driven middleware, - holding it disabled across idle periods. The pin is independent of the - inflight-request refcount: request-driven Enable calls will not release - it. Idempotent — repeated calls have no additional effect. - operationId: disableStandby + Prevents the VM from scaling to zero, regardless of HTTP traffic on it. + Call this when the VM needs to stay awake across idle periods (e.g. + when adding it to a warm pool). Stays in effect until /scaletozero/unpin + is called on the same VM. Idempotent. + operationId: pinScaleToZero responses: "204": - description: Standby pinned disabled + description: Scale-to-zero pinned "500": $ref: "#/components/responses/InternalError" - /system/standby/enable: + /scaletozero/unpin: post: - summary: Release the standby pin set by /system/standby/disable + summary: Release the awake-hold set by /scaletozero/pin description: > - Releases the out-of-band scale-to-zero pin. If no request-driven - holders remain, scale-to-zero re-enables (honoring any configured - cooldown). Idempotent — calling without a held pin has no effect. - operationId: enableStandby + Restores normal scale-to-zero behavior on this VM, allowing it to + scale to zero again when idle. Call this when the VM no longer needs + to be held awake (e.g. when claimed from a warm pool). Idempotent. + operationId: unpinScaleToZero responses: "204": - description: Standby pin released + description: Scale-to-zero unpinned "500": $ref: "#/components/responses/InternalError" components: From 101e27c736c20675591038129911fd43333445dd Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 9 May 2026 00:38:58 +0000 Subject: [PATCH 4/9] Rename API paths to /scaletozero/{disable,enable} Match user-facing terminology to the action ("disable scale to zero") rather than the internal pin mechanism. Internal PinnedController.Pin/Unpin methods retain pin/unpin naming since they're distinct from the refcounted Controller.Disable/Enable. --- server/cmd/api/api/scaletozero.go | 16 +-- server/e2e/e2e_scaletozero_test.go | 38 +++--- server/lib/oapi/oapi.go | 195 ++++++++++++++--------------- server/openapi.yaml | 23 ++-- 4 files changed, 134 insertions(+), 138 deletions(-) diff --git a/server/cmd/api/api/scaletozero.go b/server/cmd/api/api/scaletozero.go index 3c35fbd2..9e840914 100644 --- a/server/cmd/api/api/scaletozero.go +++ b/server/cmd/api/api/scaletozero.go @@ -7,18 +7,18 @@ import ( oapi "github.com/kernel/kernel-images/server/lib/oapi" ) -func (s *ApiService) PinScaleToZero(ctx context.Context, _ oapi.PinScaleToZeroRequestObject) (oapi.PinScaleToZeroResponseObject, error) { +func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScaleToZeroRequestObject) (oapi.DisableScaleToZeroResponseObject, error) { if err := s.stz.Pin(ctx); err != nil { - logger.FromContext(ctx).Error("failed to pin scale-to-zero", "err", err) - return oapi.PinScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to pin scale-to-zero"}}, nil + logger.FromContext(ctx).Error("failed to disable scale-to-zero", "err", err) + return oapi.DisableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable scale-to-zero"}}, nil } - return oapi.PinScaleToZero204Response{}, nil + return oapi.DisableScaleToZero204Response{}, nil } -func (s *ApiService) UnpinScaleToZero(ctx context.Context, _ oapi.UnpinScaleToZeroRequestObject) (oapi.UnpinScaleToZeroResponseObject, error) { +func (s *ApiService) EnableScaleToZero(ctx context.Context, _ oapi.EnableScaleToZeroRequestObject) (oapi.EnableScaleToZeroResponseObject, error) { if err := s.stz.Unpin(ctx); err != nil { - logger.FromContext(ctx).Error("failed to unpin scale-to-zero", "err", err) - return oapi.UnpinScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to unpin scale-to-zero"}}, nil + logger.FromContext(ctx).Error("failed to enable scale-to-zero", "err", err) + return oapi.EnableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable scale-to-zero"}}, nil } - return oapi.UnpinScaleToZero204Response{}, nil + return oapi.EnableScaleToZero204Response{}, nil } diff --git a/server/e2e/e2e_scaletozero_test.go b/server/e2e/e2e_scaletozero_test.go index 6e380f9e..2de99568 100644 --- a/server/e2e/e2e_scaletozero_test.go +++ b/server/e2e/e2e_scaletozero_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/require" ) -// TestScaleToZeroPinUnpin exercises POST /scaletozero/{pin,unpin} against the -// real built image. The unikraft control file does not exist inside the docker -// test container, so the underlying scale-to-zero write is a no-op — this test -// validates HTTP wiring, idempotency, and that the scale-to-zero middleware -// coexists with the pin handlers. -func TestScaleToZeroPinUnpin(t *testing.T) { +// TestScaleToZeroDisableEnable exercises POST /scaletozero/{disable,enable} +// against the real built image. The unikraft control file does not exist +// inside the docker test container, so the underlying scale-to-zero write is +// a no-op — this test validates HTTP wiring, idempotency, and that the +// scale-to-zero middleware coexists with the disable/enable handlers. +func TestScaleToZeroDisableEnable(t *testing.T) { t.Parallel() if _, err := exec.LookPath("docker"); err != nil { @@ -34,27 +34,27 @@ func TestScaleToZeroPinUnpin(t *testing.T) { client, err := c.APIClient() require.NoError(t, err, "failed to create API client") - // Idempotent pin. - r1, err := client.PinScaleToZeroWithResponse(ctx) - require.NoError(t, err, "PinScaleToZero request failed") + // Idempotent disable. + r1, err := client.DisableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "DisableScaleToZero request failed") require.Equal(t, http.StatusNoContent, r1.StatusCode(), "unexpected status: %s body=%s", r1.Status(), string(r1.Body)) - r2, err := client.PinScaleToZeroWithResponse(ctx) - require.NoError(t, err, "second PinScaleToZero request failed") + r2, err := client.DisableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "second DisableScaleToZero request failed") require.Equal(t, http.StatusNoContent, r2.StatusCode(), "unexpected status: %s body=%s", r2.Status(), string(r2.Body)) - // Normal request must still flow while pinned (scaletozero middleware + // Normal request must still flow while disabled (scaletozero middleware // runs on every request — the pin must not deadlock or break it). readResp, err := client.ReadClipboardWithResponse(ctx) - require.NoError(t, err, "ReadClipboard request failed while pinned") - require.Equal(t, http.StatusOK, readResp.StatusCode(), "unexpected read status while pinned: %s body=%s", readResp.Status(), string(readResp.Body)) + require.NoError(t, err, "ReadClipboard request failed while disabled") + require.Equal(t, http.StatusOK, readResp.StatusCode(), "unexpected read status while disabled: %s body=%s", readResp.Status(), string(readResp.Body)) - // Idempotent unpin. - r3, err := client.UnpinScaleToZeroWithResponse(ctx) - require.NoError(t, err, "UnpinScaleToZero request failed") + // Idempotent enable. + r3, err := client.EnableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "EnableScaleToZero request failed") require.Equal(t, http.StatusNoContent, r3.StatusCode(), "unexpected status: %s body=%s", r3.Status(), string(r3.Body)) - r4, err := client.UnpinScaleToZeroWithResponse(ctx) - require.NoError(t, err, "second UnpinScaleToZero request failed") + r4, err := client.EnableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "second EnableScaleToZero request failed") require.Equal(t, http.StatusNoContent, r4.StatusCode(), "unexpected status: %s body=%s", r4.Status(), string(r4.Body)) } diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 2719fc51..525b2c47 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -1646,11 +1646,11 @@ type ClientInterface interface { StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // PinScaleToZero request - PinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // DisableScaleToZero request + DisableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) - // UnpinScaleToZero request - UnpinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // EnableScaleToZero request + EnableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) PatchChromiumFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -2661,8 +2661,8 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques return c.Client.Do(req) } -func (c *Client) PinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPinScaleToZeroRequest(c.Server) +func (c *Client) DisableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDisableScaleToZeroRequest(c.Server) if err != nil { return nil, err } @@ -2673,8 +2673,8 @@ func (c *Client) PinScaleToZero(ctx context.Context, reqEditors ...RequestEditor return c.Client.Do(req) } -func (c *Client) UnpinScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewUnpinScaleToZeroRequest(c.Server) +func (c *Client) EnableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnableScaleToZeroRequest(c.Server) if err != nil { return nil, err } @@ -4821,8 +4821,8 @@ func NewStopRecordingRequestWithBody(server string, contentType string, body io. return req, nil } -// NewPinScaleToZeroRequest generates requests for PinScaleToZero -func NewPinScaleToZeroRequest(server string) (*http.Request, error) { +// NewDisableScaleToZeroRequest generates requests for DisableScaleToZero +func NewDisableScaleToZeroRequest(server string) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -4830,7 +4830,7 @@ func NewPinScaleToZeroRequest(server string) (*http.Request, error) { return nil, err } - operationPath := fmt.Sprintf("/scaletozero/pin") + operationPath := fmt.Sprintf("/scaletozero/disable") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -4848,8 +4848,8 @@ func NewPinScaleToZeroRequest(server string) (*http.Request, error) { return req, nil } -// NewUnpinScaleToZeroRequest generates requests for UnpinScaleToZero -func NewUnpinScaleToZeroRequest(server string) (*http.Request, error) { +// NewEnableScaleToZeroRequest generates requests for EnableScaleToZero +func NewEnableScaleToZeroRequest(server string) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -4857,7 +4857,7 @@ func NewUnpinScaleToZeroRequest(server string) (*http.Request, error) { return nil, err } - operationPath := fmt.Sprintf("/scaletozero/unpin") + operationPath := fmt.Sprintf("/scaletozero/enable") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -5139,11 +5139,11 @@ type ClientWithResponsesInterface interface { StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) - // PinScaleToZeroWithResponse request - PinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PinScaleToZeroResponse, error) + // DisableScaleToZeroWithResponse request + DisableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableScaleToZeroResponse, error) - // UnpinScaleToZeroWithResponse request - UnpinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*UnpinScaleToZeroResponse, error) + // EnableScaleToZeroWithResponse request + EnableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableScaleToZeroResponse, error) } type PatchChromiumFlagsResponse struct { @@ -6405,14 +6405,14 @@ func (r StopRecordingResponse) StatusCode() int { return 0 } -type PinScaleToZeroResponse struct { +type DisableScaleToZeroResponse struct { Body []byte HTTPResponse *http.Response JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r PinScaleToZeroResponse) Status() string { +func (r DisableScaleToZeroResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -6420,21 +6420,21 @@ func (r PinScaleToZeroResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r PinScaleToZeroResponse) StatusCode() int { +func (r DisableScaleToZeroResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type UnpinScaleToZeroResponse struct { +type EnableScaleToZeroResponse struct { Body []byte HTTPResponse *http.Response JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r UnpinScaleToZeroResponse) Status() string { +func (r EnableScaleToZeroResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -6442,7 +6442,7 @@ func (r UnpinScaleToZeroResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r UnpinScaleToZeroResponse) StatusCode() int { +func (r EnableScaleToZeroResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -7174,22 +7174,22 @@ func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, bod return ParseStopRecordingResponse(rsp) } -// PinScaleToZeroWithResponse request returning *PinScaleToZeroResponse -func (c *ClientWithResponses) PinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PinScaleToZeroResponse, error) { - rsp, err := c.PinScaleToZero(ctx, reqEditors...) +// DisableScaleToZeroWithResponse request returning *DisableScaleToZeroResponse +func (c *ClientWithResponses) DisableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableScaleToZeroResponse, error) { + rsp, err := c.DisableScaleToZero(ctx, reqEditors...) if err != nil { return nil, err } - return ParsePinScaleToZeroResponse(rsp) + return ParseDisableScaleToZeroResponse(rsp) } -// UnpinScaleToZeroWithResponse request returning *UnpinScaleToZeroResponse -func (c *ClientWithResponses) UnpinScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*UnpinScaleToZeroResponse, error) { - rsp, err := c.UnpinScaleToZero(ctx, reqEditors...) +// EnableScaleToZeroWithResponse request returning *EnableScaleToZeroResponse +func (c *ClientWithResponses) EnableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableScaleToZeroResponse, error) { + rsp, err := c.EnableScaleToZero(ctx, reqEditors...) if err != nil { return nil, err } - return ParseUnpinScaleToZeroResponse(rsp) + return ParseEnableScaleToZeroResponse(rsp) } // ParsePatchChromiumFlagsResponse parses an HTTP response from a PatchChromiumFlagsWithResponse call @@ -9197,15 +9197,15 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err return response, nil } -// ParsePinScaleToZeroResponse parses an HTTP response from a PinScaleToZeroWithResponse call -func ParsePinScaleToZeroResponse(rsp *http.Response) (*PinScaleToZeroResponse, error) { +// ParseDisableScaleToZeroResponse parses an HTTP response from a DisableScaleToZeroWithResponse call +func ParseDisableScaleToZeroResponse(rsp *http.Response) (*DisableScaleToZeroResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &PinScaleToZeroResponse{ + response := &DisableScaleToZeroResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -9223,15 +9223,15 @@ func ParsePinScaleToZeroResponse(rsp *http.Response) (*PinScaleToZeroResponse, e return response, nil } -// ParseUnpinScaleToZeroResponse parses an HTTP response from a UnpinScaleToZeroWithResponse call -func ParseUnpinScaleToZeroResponse(rsp *http.Response) (*UnpinScaleToZeroResponse, error) { +// ParseEnableScaleToZeroResponse parses an HTTP response from a EnableScaleToZeroWithResponse call +func ParseEnableScaleToZeroResponse(rsp *http.Response) (*EnableScaleToZeroResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &UnpinScaleToZeroResponse{ + response := &EnableScaleToZeroResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -9410,12 +9410,12 @@ type ServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) - // Hold this VM awake until /scaletozero/unpin - // (POST /scaletozero/pin) - PinScaleToZero(w http.ResponseWriter, r *http.Request) - // Release the awake-hold set by /scaletozero/pin - // (POST /scaletozero/unpin) - UnpinScaleToZero(w http.ResponseWriter, r *http.Request) + // Idempotently disable scale to zero on this VM. + // (POST /scaletozero/disable) + DisableScaleToZero(w http.ResponseWriter, r *http.Request) + // Idempotently enable scale to zero on this VM. + // (POST /scaletozero/enable) + EnableScaleToZero(w http.ResponseWriter, r *http.Request) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -9740,15 +9740,15 @@ func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Hold this VM awake until /scaletozero/unpin -// (POST /scaletozero/pin) -func (_ Unimplemented) PinScaleToZero(w http.ResponseWriter, r *http.Request) { +// Idempotently disable scale to zero on this VM. +// (POST /scaletozero/disable) +func (_ Unimplemented) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Release the awake-hold set by /scaletozero/pin -// (POST /scaletozero/unpin) -func (_ Unimplemented) UnpinScaleToZero(w http.ResponseWriter, r *http.Request) { +// Idempotently enable scale to zero on this VM. +// (POST /scaletozero/enable) +func (_ Unimplemented) EnableScaleToZero(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -10799,11 +10799,11 @@ func (siw *ServerInterfaceWrapper) StopRecording(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// PinScaleToZero operation middleware -func (siw *ServerInterfaceWrapper) PinScaleToZero(w http.ResponseWriter, r *http.Request) { +// DisableScaleToZero operation middleware +func (siw *ServerInterfaceWrapper) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.PinScaleToZero(w, r) + siw.Handler.DisableScaleToZero(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -10813,11 +10813,11 @@ func (siw *ServerInterfaceWrapper) PinScaleToZero(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// UnpinScaleToZero operation middleware -func (siw *ServerInterfaceWrapper) UnpinScaleToZero(w http.ResponseWriter, r *http.Request) { +// EnableScaleToZero operation middleware +func (siw *ServerInterfaceWrapper) EnableScaleToZero(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UnpinScaleToZero(w, r) + siw.Handler.EnableScaleToZero(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -11100,10 +11100,10 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Post(options.BaseURL+"/recording/stop", wrapper.StopRecording) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/scaletozero/pin", wrapper.PinScaleToZero) + r.Post(options.BaseURL+"/scaletozero/disable", wrapper.DisableScaleToZero) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/scaletozero/unpin", wrapper.UnpinScaleToZero) + r.Post(options.BaseURL+"/scaletozero/enable", wrapper.EnableScaleToZero) }) return r @@ -13335,48 +13335,48 @@ func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.R return json.NewEncoder(w).Encode(response) } -type PinScaleToZeroRequestObject struct { +type DisableScaleToZeroRequestObject struct { } -type PinScaleToZeroResponseObject interface { - VisitPinScaleToZeroResponse(w http.ResponseWriter) error +type DisableScaleToZeroResponseObject interface { + VisitDisableScaleToZeroResponse(w http.ResponseWriter) error } -type PinScaleToZero204Response struct { +type DisableScaleToZero204Response struct { } -func (response PinScaleToZero204Response) VisitPinScaleToZeroResponse(w http.ResponseWriter) error { +func (response DisableScaleToZero204Response) VisitDisableScaleToZeroResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type PinScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } +type DisableScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } -func (response PinScaleToZero500JSONResponse) VisitPinScaleToZeroResponse(w http.ResponseWriter) error { +func (response DisableScaleToZero500JSONResponse) VisitDisableScaleToZeroResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type UnpinScaleToZeroRequestObject struct { +type EnableScaleToZeroRequestObject struct { } -type UnpinScaleToZeroResponseObject interface { - VisitUnpinScaleToZeroResponse(w http.ResponseWriter) error +type EnableScaleToZeroResponseObject interface { + VisitEnableScaleToZeroResponse(w http.ResponseWriter) error } -type UnpinScaleToZero204Response struct { +type EnableScaleToZero204Response struct { } -func (response UnpinScaleToZero204Response) VisitUnpinScaleToZeroResponse(w http.ResponseWriter) error { +func (response EnableScaleToZero204Response) VisitEnableScaleToZeroResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type UnpinScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } +type EnableScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } -func (response UnpinScaleToZero500JSONResponse) VisitUnpinScaleToZeroResponse(w http.ResponseWriter) error { +func (response EnableScaleToZero500JSONResponse) VisitEnableScaleToZeroResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) @@ -13544,12 +13544,12 @@ type StrictServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) - // Hold this VM awake until /scaletozero/unpin - // (POST /scaletozero/pin) - PinScaleToZero(ctx context.Context, request PinScaleToZeroRequestObject) (PinScaleToZeroResponseObject, error) - // Release the awake-hold set by /scaletozero/pin - // (POST /scaletozero/unpin) - UnpinScaleToZero(ctx context.Context, request UnpinScaleToZeroRequestObject) (UnpinScaleToZeroResponseObject, error) + // Idempotently disable scale to zero on this VM. + // (POST /scaletozero/disable) + DisableScaleToZero(ctx context.Context, request DisableScaleToZeroRequestObject) (DisableScaleToZeroResponseObject, error) + // Idempotently enable scale to zero on this VM. + // (POST /scaletozero/enable) + EnableScaleToZero(ctx context.Context, request EnableScaleToZeroRequestObject) (EnableScaleToZeroResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -15147,23 +15147,23 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { } } -// PinScaleToZero operation middleware -func (sh *strictHandler) PinScaleToZero(w http.ResponseWriter, r *http.Request) { - var request PinScaleToZeroRequestObject +// DisableScaleToZero operation middleware +func (sh *strictHandler) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { + var request DisableScaleToZeroRequestObject handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.PinScaleToZero(ctx, request.(PinScaleToZeroRequestObject)) + return sh.ssi.DisableScaleToZero(ctx, request.(DisableScaleToZeroRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PinScaleToZero") + handler = middleware(handler, "DisableScaleToZero") } response, err := handler(r.Context(), w, r, request) if err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(PinScaleToZeroResponseObject); ok { - if err := validResponse.VisitPinScaleToZeroResponse(w); err != nil { + } else if validResponse, ok := response.(DisableScaleToZeroResponseObject); ok { + if err := validResponse.VisitDisableScaleToZeroResponse(w); err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) } } else if response != nil { @@ -15171,23 +15171,23 @@ func (sh *strictHandler) PinScaleToZero(w http.ResponseWriter, r *http.Request) } } -// UnpinScaleToZero operation middleware -func (sh *strictHandler) UnpinScaleToZero(w http.ResponseWriter, r *http.Request) { - var request UnpinScaleToZeroRequestObject +// EnableScaleToZero operation middleware +func (sh *strictHandler) EnableScaleToZero(w http.ResponseWriter, r *http.Request) { + var request EnableScaleToZeroRequestObject handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.UnpinScaleToZero(ctx, request.(UnpinScaleToZeroRequestObject)) + return sh.ssi.EnableScaleToZero(ctx, request.(EnableScaleToZeroRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "UnpinScaleToZero") + handler = middleware(handler, "EnableScaleToZero") } response, err := handler(r.Context(), w, r, request) if err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(UnpinScaleToZeroResponseObject); ok { - if err := validResponse.VisitUnpinScaleToZeroResponse(w); err != nil { + } else if validResponse, ok := response.(EnableScaleToZeroResponseObject); ok { + if err := validResponse.VisitEnableScaleToZeroResponse(w); err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) } } else if response != nil { @@ -15375,12 +15375,11 @@ var swaggerSpec = []string{ "aUoYN6JtIUGpAcmxywvRcm1tn9jUTjbB/Qa0XA9P5uZBt2hgsVjYXFFsNoN9URkntpuAqvUklWvLBNUm", "yli2B4FYtk/fcMKpLVCtkBcBQzR3kCgps6dHb/7gG8fY6qbFTct8gE0Hip/NZnp2guwD5Y5sRwtZrvLW", "EuxomtaHbYKt0xc4EHp314dvc5Lt9R/7WPRbL9ToextUcm1EXvN0jQkGlazLQZKz59gYFCv+L5jS2Lu0", - "LCM66mJZ5JuQLPK7x3FtjsPVq0Y95i9VRt/XcS7ha8GtYpqCFh9AiuO8aTDoNM6x9wQzyD9e2QJu5mss", - "+iGIGWFgEEtlkuLVZU5+evv2nGhJ53MWE8EJ0yPyjGI1f6aqE/EfrwgHSFyQNF0TekUvgdBYCqUIS1Js", - "U8tEolxvf/ySJvaIs+U2yRWVGcmFSI9G5ELTNWbnwnwOsSa2x3ZjrwXPGbqAsRZt4mvXK5qZ9YzIWQJZ", - "LgwJBct1Mn5hBnsrfgMpol2iIvH9oRZDMz3JGXdelJv3mBNpYgH6j1cOcn377aLc/tyL9DegtJCgCDfK", - "XIr4rjZRNvZA4OECBkZwi6sKMfiFpw+nbiD6DFp7qUG4GiQVXcyALCFN3AZrZBCnlGW+1mOTDDbj8J3Z", - "+s2wiNC7LTy+AaeWLcHucoid/rBQ55p0GNWIof8bAAD//zXpfFyh4gAA", + "LCM66mJZ5JuQLPK7x3FtjsPVq0Y95i9VRt/XcS7ha8GtYpqCFh9AiuOEKTpLNzchs3cFM9A/XtkibmYE", + "LPwhiBllYJBLZZLi9WVOfnr79pxoSedzFhPBCdMj8oymqa8VcnJ+ZgvHM2WGvDKn1RW9BMI0mUFMCwXk", + "HWeXks61fer78ceu3dkluNY9a1/EwOec/ONVsNSH3eaF2flb8RtIEe0S1ojvD7UYml0SB6vkVpBzlkCW", + "C22PDTcywhU8VGsgGnURB3wz3t6A0kKCItzoZKkdutxK2Z+jmmNg5K+4QhUCodlcjNUaUKNhSQoWofbb", + "Us35xyvChSslQjhAopxus4Q0IdSgLehl5zfHDfA7Qo0deCNmPn36vwEAAP//FmsxyDbiAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index aa14e86f..c29fd98b 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1334,31 +1334,28 @@ paths: text/event-stream: schema: $ref: "#/components/schemas/PublishedEnvelope" - /scaletozero/pin: + /scaletozero/disable: post: - summary: Hold this VM awake until /scaletozero/unpin + summary: Idempotently disable scale to zero on this VM. description: > Prevents the VM from scaling to zero, regardless of HTTP traffic on it. - Call this when the VM needs to stay awake across idle periods (e.g. - when adding it to a warm pool). Stays in effect until /scaletozero/unpin - is called on the same VM. Idempotent. - operationId: pinScaleToZero + Calling the API on this VM will wake it because Unikraft will automatically wake for any request to the VM. + operationId: disableScaleToZero responses: "204": - description: Scale-to-zero pinned + description: Scale-to-zero disabled "500": $ref: "#/components/responses/InternalError" - /scaletozero/unpin: + /scaletozero/enable: post: - summary: Release the awake-hold set by /scaletozero/pin + summary: Idempotently enable scale to zero on this VM. description: > Restores normal scale-to-zero behavior on this VM, allowing it to - scale to zero again when idle. Call this when the VM no longer needs - to be held awake (e.g. when claimed from a warm pool). Idempotent. - operationId: unpinScaleToZero + scale to zero again when idle. Call this when the VM no longer needs to be held awake. + operationId: enableScaleToZero responses: "204": - description: Scale-to-zero unpinned + description: Scale-to-zero enabled "500": $ref: "#/components/responses/InternalError" components: From 7d26ce6c70b1431990c1118bd928c2f5b2e9f524 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 9 May 2026 00:52:33 +0000 Subject: [PATCH 5/9] Align scale-to-zero internal terminology with OpenAPI spec Rename refcounted hold methods to Acquire/Release so that Disable/Enable can carry the idempotent persistent-toggle semantics defined by the /scaletozero/{disable,enable} API. Split the low-level direct toggle out into a separate Toggler interface (unikraftCloudToggler) wrapped by DebouncedController. --- server/cmd/api/api/api.go | 4 +- server/cmd/api/api/scaletozero.go | 4 +- server/cmd/api/main.go | 2 +- server/lib/recorder/ffmpeg.go | 14 +- server/lib/scaletozero/middleware.go | 16 +- server/lib/scaletozero/middleware_test.go | 49 +++-- server/lib/scaletozero/scaletozero.go | 180 ++++++++++-------- server/lib/scaletozero/scaletozero_test.go | 207 +++++++++++---------- 8 files changed, 260 insertions(+), 216 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index d28fedd9..84164b26 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -49,7 +49,7 @@ type ApiService struct { // DevTools upstream manager (Chromium supervisord log tailer) upstreamMgr *devtoolsproxy.UpstreamManager - stz scaletozero.PinnedController + stz scaletozero.Controller // inputMu serializes input-related operations (mouse, keyboard, screenshot) inputMu sync.Mutex @@ -96,7 +96,7 @@ func New( recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory, upstreamMgr *devtoolsproxy.UpstreamManager, - stz scaletozero.PinnedController, + stz scaletozero.Controller, nekoAuthClient *nekoclient.AuthClient, captureSession *capturesession.CaptureSession, eventStream *events.EventStream, diff --git a/server/cmd/api/api/scaletozero.go b/server/cmd/api/api/scaletozero.go index 9e840914..7d14b7c0 100644 --- a/server/cmd/api/api/scaletozero.go +++ b/server/cmd/api/api/scaletozero.go @@ -8,7 +8,7 @@ import ( ) func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScaleToZeroRequestObject) (oapi.DisableScaleToZeroResponseObject, error) { - if err := s.stz.Pin(ctx); err != nil { + if err := s.stz.Disable(ctx); err != nil { logger.FromContext(ctx).Error("failed to disable scale-to-zero", "err", err) return oapi.DisableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable scale-to-zero"}}, nil } @@ -16,7 +16,7 @@ func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScale } func (s *ApiService) EnableScaleToZero(ctx context.Context, _ oapi.EnableScaleToZeroRequestObject) (oapi.EnableScaleToZeroResponseObject, error) { - if err := s.stz.Unpin(ctx); err != nil { + if err := s.stz.Enable(ctx); err != nil { logger.FromContext(ctx).Error("failed to enable scale-to-zero", "err", err) return oapi.EnableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable scale-to-zero"}}, nil } diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 48d17351..9584e05c 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -51,7 +51,7 @@ func main() { // ensure ffmpeg is available mustFFmpeg() - stz := scaletozero.NewDebouncedControllerWithCooldown(scaletozero.NewUnikraftCloudController(), config.ScaleToZeroCooldown) + stz := scaletozero.NewDebouncedControllerWithCooldown(scaletozero.NewUnikraftCloudToggler(), config.ScaleToZeroCooldown) r := chi.NewRouter() r.Use( chiMiddleware.Logger, diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 48a82178..7ef98eb2 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -183,9 +183,9 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { return fmt.Errorf("recording already in progress") } - if err := fr.stz.Disable(ctx); err != nil { + if err := fr.stz.Acquire(ctx); err != nil { fr.mu.Unlock() - return fmt.Errorf("failed to disable scale-to-zero: %w", err) + return fmt.Errorf("failed to acquire scale-to-zero hold: %w", err) } // ensure internal state @@ -196,7 +196,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { args, err := ffmpegArgs(fr.params, fr.outputPath) if err != nil { - _ = fr.stz.Enable(context.WithoutCancel(ctx)) + _ = fr.stz.Release(context.WithoutCancel(ctx)) fr.cmd = nil close(fr.exited) fr.mu.Unlock() @@ -214,7 +214,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { fr.mu.Unlock() if err := cmd.Start(); err != nil { - _ = fr.stz.Enable(context.WithoutCancel(ctx)) + _ = fr.stz.Release(context.WithoutCancel(ctx)) fr.mu.Lock() fr.ffmpegErr = err fr.cmd = nil // reset cmd on failure to start so IsRecording() remains correct @@ -238,7 +238,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { // Stop gracefully stops the recording using a multi-phase shutdown process. func (fr *FFmpegRecorder) Stop(ctx context.Context) error { - defer fr.stz.Enable(context.WithoutCancel(ctx)) + defer fr.stz.Release(context.WithoutCancel(ctx)) // Use singleflight to prevent concurrent Stop() calls from sending multiple SIGINTs // to ffmpeg, which causes immediate abort without proper file closure. @@ -281,7 +281,7 @@ func (fr *FFmpegRecorder) WaitForFinalization(ctx context.Context) error { func (fr *FFmpegRecorder) ForceStop(ctx context.Context) error { log := logger.FromContext(ctx) - defer fr.stz.Enable(context.WithoutCancel(ctx)) + defer fr.stz.Release(context.WithoutCancel(ctx)) shutdownErr := fr.shutdownInPhases(ctx, []shutdownPhase{ {"kill", []syscall.Signal{syscall.SIGKILL}, 100 * time.Millisecond, "immediate kill"}, }) @@ -530,7 +530,7 @@ func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, erro // update the internal state accordingly. It also triggers finalization to add proper duration // metadata for recordings that exit naturally (max duration, max file size, etc.). func (fr *FFmpegRecorder) waitForCommand(ctx context.Context) { - defer fr.stz.Enable(context.WithoutCancel(ctx)) + defer fr.stz.Release(context.WithoutCancel(ctx)) log := logger.FromContext(ctx) diff --git a/server/lib/scaletozero/middleware.go b/server/lib/scaletozero/middleware.go index b5452c06..503d7b58 100644 --- a/server/lib/scaletozero/middleware.go +++ b/server/lib/scaletozero/middleware.go @@ -8,10 +8,10 @@ import ( "github.com/kernel/kernel-images/server/lib/logger" ) -// Middleware returns a standard net/http middleware that disables scale-to-zero -// at the start of each request and re-enables it after the handler completes. -// Connections from loopback addresses are ignored and do not affect the -// scale-to-zero state. +// Middleware returns a standard net/http middleware that acquires a +// scale-to-zero hold at the start of each request and releases it after the +// handler completes. Connections from loopback addresses are ignored and do +// not affect the scale-to-zero state. func Middleware(ctrl Controller) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -20,12 +20,12 @@ func Middleware(ctrl Controller) func(http.Handler) http.Handler { return } - if err := ctrl.Disable(r.Context()); err != nil { - logger.FromContext(r.Context()).Error("failed to disable scale-to-zero", "error", err) - http.Error(w, "failed to disable scale-to-zero", http.StatusInternalServerError) + if err := ctrl.Acquire(r.Context()); err != nil { + logger.FromContext(r.Context()).Error("failed to acquire scale-to-zero hold", "error", err) + http.Error(w, "failed to acquire scale-to-zero hold", http.StatusInternalServerError) return } - defer ctrl.Enable(context.WithoutCancel(r.Context())) + defer ctrl.Release(context.WithoutCancel(r.Context())) next.ServeHTTP(w, r) }) diff --git a/server/lib/scaletozero/middleware_test.go b/server/lib/scaletozero/middleware_test.go index c48b6122..ed89d94c 100644 --- a/server/lib/scaletozero/middleware_test.go +++ b/server/lib/scaletozero/middleware_test.go @@ -1,17 +1,44 @@ package scaletozero import ( + "context" "net/http" "net/http/httptest" + "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestMiddlewareDisablesAndEnablesForExternalAddr(t *testing.T) { +type mockController struct { + mu sync.Mutex + acquireCalls int + releaseCalls int + acquireErr error + releaseErr error +} + +func (m *mockController) Acquire(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + m.acquireCalls++ + return m.acquireErr +} + +func (m *mockController) Release(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + m.releaseCalls++ + return m.releaseErr +} + +func (m *mockController) Disable(ctx context.Context) error { return nil } +func (m *mockController) Enable(ctx context.Context) error { return nil } + +func TestMiddlewareAcquiresAndReleasesForExternalAddr(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockController{} handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -23,8 +50,8 @@ func TestMiddlewareDisablesAndEnablesForExternalAddr(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, 1, mock.disableCalls) - assert.Equal(t, 1, mock.enableCalls) + assert.Equal(t, 1, mock.acquireCalls) + assert.Equal(t, 1, mock.releaseCalls) } func TestMiddlewareSkipsLoopbackAddrs(t *testing.T) { @@ -41,7 +68,7 @@ func TestMiddlewareSkipsLoopbackAddrs(t *testing.T) { for _, tc := range loopbackAddrs { t.Run(tc.name, func(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockController{} var called bool handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true @@ -56,15 +83,15 @@ func TestMiddlewareSkipsLoopbackAddrs(t *testing.T) { assert.True(t, called, "handler should still be called") assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, 0, mock.disableCalls, "should not disable for loopback addr") - assert.Equal(t, 0, mock.enableCalls, "should not enable for loopback addr") + assert.Equal(t, 0, mock.acquireCalls, "should not acquire for loopback addr") + assert.Equal(t, 0, mock.releaseCalls, "should not release for loopback addr") }) } } -func TestMiddlewareDisableError(t *testing.T) { +func TestMiddlewareAcquireError(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{disableErr: assert.AnError} + mock := &mockController{acquireErr: assert.AnError} var called bool handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true @@ -76,9 +103,9 @@ func TestMiddlewareDisableError(t *testing.T) { handler.ServeHTTP(rec, req) - assert.False(t, called, "handler should not be called on disable error") + assert.False(t, called, "handler should not be called on acquire error") assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Equal(t, 0, mock.enableCalls) + assert.Equal(t, 0, mock.releaseCalls) } func TestIsLoopbackAddr(t *testing.T) { diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 21c76f43..0a00537d 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -13,48 +13,62 @@ import ( // https://unikraft.cloud/docs/api/v1/instances/#scaletozero_app const unikraftScaleToZeroFile = "/uk/libukp/scale_to_zero_disable" -type Controller interface { +// Toggler is the low-level scale-to-zero control. Implementations directly +// flip the underlying state (e.g. write to the unikraft control file). +type Toggler interface { // Disable turns scale-to-zero off. Disable(ctx context.Context) error - // Enable re-enables scale-to-zero after it has previously been disabled. + // Enable turns scale-to-zero on. Enable(ctx context.Context) error } -// PinnedController extends Controller with an out-of-band "pin" that holds -// scale-to-zero disabled independently of the request-driven refcount used by -// the HTTP middleware. While the pin is held, request-driven Enable calls do -// not re-enable scale-to-zero; only Unpin can release it. +// Controller is the high-level scale-to-zero control used by the rest of +// the server. It supports two independent holder mechanisms: +// +// 1. Refcounted holds via Acquire/Release. Multiple callers (HTTP +// middleware, ffmpeg recorder, ...) may hold scale-to-zero off +// concurrently; scale-to-zero re-enables only when the last hold is +// released. Use the pair as Acquire(ctx); defer Release(ctx). // -// This is intended for explicit lifecycle control (e.g. a control-plane API -// reserving a VM in a hot pool) where the holder is not tied to an inflight -// HTTP request. -type PinnedController interface { - Controller - // Pin holds scale-to-zero disabled until Unpin is called. The pin is a - // boolean, not a counter: repeated calls are idempotent. - Pin(ctx context.Context) error - // Unpin releases the pin. If no request-driven holders remain, - // scale-to-zero is re-enabled (honoring any configured cooldown). - Unpin(ctx context.Context) error -} - -type unikraftCloudController struct { +// 2. An idempotent persistent override via Disable/Enable. Disable puts +// scale-to-zero in the off state and keeps it there until Enable is +// called, independent of any refcounted holds. Both calls are +// idempotent. +type Controller interface { + // Acquire holds scale-to-zero disabled (refcounted). Pair with Release. + Acquire(ctx context.Context) error + // Release releases one refcounted hold. If no holds and no idempotent + // disable remain, scale-to-zero re-enables (honoring any cooldown). + Release(ctx context.Context) error + // Disable idempotently puts scale-to-zero in the off state. The state + // persists until Enable is called, even if all refcounted holds are + // released. Repeated calls are no-ops. + Disable(ctx context.Context) error + // Enable releases the idempotent disable. If no refcounted holds remain, + // scale-to-zero re-enables (honoring any cooldown). Calling without a + // prior Disable is a no-op. + Enable(ctx context.Context) error +} + +type unikraftCloudToggler struct { path string } -func NewUnikraftCloudController() Controller { - return &unikraftCloudController{path: unikraftScaleToZeroFile} +// NewUnikraftCloudToggler returns a Toggler that flips scale-to-zero by +// writing to the unikraft control file. +func NewUnikraftCloudToggler() Toggler { + return &unikraftCloudToggler{path: unikraftScaleToZeroFile} } -func (c *unikraftCloudController) Disable(ctx context.Context) error { +func (c *unikraftCloudToggler) Disable(ctx context.Context) error { return c.write(ctx, "+") } -func (c *unikraftCloudController) Enable(ctx context.Context) error { +func (c *unikraftCloudToggler) Enable(ctx context.Context) error { return c.write(ctx, "-") } -func (c *unikraftCloudController) write(ctx context.Context, char string) error { +func (c *unikraftCloudToggler) write(ctx context.Context, char string) error { if _, err := os.Stat(c.path); err != nil { if os.IsNotExist(err) { logger.FromContext(ctx).Info("scale-to-zero control file not found, skipping write", "path", c.path, "value", char) @@ -82,56 +96,59 @@ type NoopController struct{} func NewNoopController() *NoopController { return &NoopController{} } +func (NoopController) Acquire(context.Context) error { return nil } +func (NoopController) Release(context.Context) error { return nil } func (NoopController) Disable(context.Context) error { return nil } func (NoopController) Enable(context.Context) error { return nil } -func (NoopController) Pin(context.Context) error { return nil } -func (NoopController) Unpin(context.Context) error { return nil } -// Oncer wraps a Controller and ensures that Disable and Enable are called at most once. +// Oncer wraps a Controller and ensures Acquire and Release fire at most once. type Oncer struct { ctrl Controller - disableOnce sync.Once - enableOnce sync.Once - disableErr error - enableErr error + acquireOnce sync.Once + releaseOnce sync.Once + acquireErr error + releaseErr error } func NewOncer(c Controller) *Oncer { return &Oncer{ctrl: c} } -func (o *Oncer) Disable(ctx context.Context) error { - o.disableOnce.Do(func() { o.disableErr = o.ctrl.Disable(ctx) }) - return o.disableErr +func (o *Oncer) Acquire(ctx context.Context) error { + o.acquireOnce.Do(func() { o.acquireErr = o.ctrl.Acquire(ctx) }) + return o.acquireErr } -func (o *Oncer) Enable(ctx context.Context) error { - o.enableOnce.Do(func() { o.enableErr = o.ctrl.Enable(ctx) }) - return o.enableErr +func (o *Oncer) Release(ctx context.Context) error { + o.releaseOnce.Do(func() { o.releaseErr = o.ctrl.Release(ctx) }) + return o.releaseErr } +// DebouncedController implements Controller by tracking both a refcount of +// active Acquire holders and a boolean idempotent-disable flag, debounced by +// an optional cooldown before scale-to-zero is re-enabled. type DebouncedController struct { - ctrl Controller + toggler Toggler cooldown time.Duration mu sync.Mutex + off bool + holdCount int disabled bool - activeCount int - pinned bool reenableTimer *time.Timer } // NewDebouncedController creates a DebouncedController with no re-enable cooldown. -func NewDebouncedController(ctrl Controller) *DebouncedController { - return &DebouncedController{ctrl: ctrl} +func NewDebouncedController(t Toggler) *DebouncedController { + return &DebouncedController{toggler: t} } // NewDebouncedControllerWithCooldown creates a DebouncedController that delays -// re-enabling scale-to-zero by the given cooldown after the last active holder -// releases. A new Disable call during the cooldown cancels the pending +// re-enabling scale-to-zero by the given cooldown after the last holder +// releases. A new Acquire call during the cooldown cancels the pending // re-enable, avoiding rapid toggling from sequential requests. -func NewDebouncedControllerWithCooldown(ctrl Controller, cooldown time.Duration) *DebouncedController { - return &DebouncedController{ctrl: ctrl, cooldown: cooldown} +func NewDebouncedControllerWithCooldown(t Toggler, cooldown time.Duration) *DebouncedController { + return &DebouncedController{toggler: t, cooldown: cooldown} } -func (c *DebouncedController) Disable(ctx context.Context) error { +func (c *DebouncedController) Acquire(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -140,35 +157,34 @@ func (c *DebouncedController) Disable(ctx context.Context) error { c.reenableTimer = nil } - c.activeCount++ - if c.disabled { + c.holdCount++ + if c.off { return nil } - if err := c.ctrl.Disable(ctx); err != nil { - c.activeCount-- + if err := c.toggler.Disable(ctx); err != nil { + c.holdCount-- return err } - c.disabled = true + c.off = true return nil } -func (c *DebouncedController) Enable(ctx context.Context) error { +func (c *DebouncedController) Release(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.activeCount > 0 { - c.activeCount-- + if c.holdCount > 0 { + c.holdCount-- } return c.maybeReenableLocked(ctx) } -// Pin sets the out-of-band pin and ensures scale-to-zero is disabled. -// Idempotent: re-pinning while already pinned is a no-op. Cancels any pending -// cooldown timer. -func (c *DebouncedController) Pin(ctx context.Context) error { +// Disable idempotently puts scale-to-zero in the off state. Cancels any +// pending cooldown timer. Repeated calls while already disabled are no-ops. +func (c *DebouncedController) Disable(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -177,64 +193,64 @@ func (c *DebouncedController) Pin(ctx context.Context) error { c.reenableTimer = nil } - if c.pinned { + if c.disabled { return nil } - if !c.disabled { - if err := c.ctrl.Disable(ctx); err != nil { + if !c.off { + if err := c.toggler.Disable(ctx); err != nil { return err } - c.disabled = true + c.off = true } - c.pinned = true + c.disabled = true return nil } -// Unpin releases the pin. If no request-driven holders remain, scale-to-zero -// is re-enabled (honoring any configured cooldown). Idempotent: calling when no -// pin is held is a no-op. -func (c *DebouncedController) Unpin(ctx context.Context) error { +// Enable releases the idempotent disable. If no refcounted holds remain, +// scale-to-zero is re-enabled (honoring any configured cooldown). Calling +// without a prior Disable is a no-op. +func (c *DebouncedController) Enable(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if !c.pinned { + if !c.disabled { return nil } - c.pinned = false + c.disabled = false return c.maybeReenableLocked(ctx) } -// maybeReenableLocked re-enables scale-to-zero if no holders (request-driven or -// pin) remain. Caller must hold c.mu. +// maybeReenableLocked re-enables scale-to-zero if no holders (refcount or +// idempotent disable) remain. Caller must hold c.mu. func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { - if c.activeCount > 0 || c.pinned || !c.disabled { + if c.holdCount > 0 || c.disabled || !c.off { return nil } - // No cooldown: re-enable immediately (original behavior). + // No cooldown: re-enable immediately. if c.cooldown <= 0 { - if err := c.ctrl.Enable(ctx); err != nil { + if err := c.toggler.Enable(ctx); err != nil { return err } - c.disabled = false + c.off = false return nil } - // Schedule re-enable after cooldown. If a new Disable arrives before the - // timer fires, it will be cancelled. + // Schedule re-enable after cooldown. If a new Acquire or Disable arrives + // before the timer fires, it will be cancelled. c.reenableTimer = time.AfterFunc(c.cooldown, func() { c.mu.Lock() defer c.mu.Unlock() - if c.activeCount > 0 || c.pinned || !c.disabled { + if c.holdCount > 0 || c.disabled || !c.off { return } - if c.ctrl.Enable(context.Background()) == nil { - c.disabled = false + if c.toggler.Enable(context.Background()) == nil { + c.off = false } }) diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 3221fb39..1c0e42a7 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -12,102 +12,102 @@ import ( "github.com/stretchr/testify/require" ) -func TestDebouncedControllerSingleDisableEnable(t *testing.T) { +func TestDebouncedControllerSingleAcquireRelease(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerMultipleDisablesDebounced(t *testing.T) { +func TestDebouncedControllerMultipleAcquiresDebounced(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Acquire(t.Context())) assert.Equal(t, 1, mock.disableCalls) } -func TestDebouncedControllerEnableOnlyOnLastHolder(t *testing.T) { +func TestDebouncedControllerReleaseOnlyOnLastHolder(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 0, mock.enableCalls) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerDisableFailureRollsBack(t *testing.T) { +func TestDebouncedControllerAcquireFailureRollsBack(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{disableErr: assert.AnError} + mock := &mockToggler{disableErr: assert.AnError} c := NewDebouncedController(mock) - err := c.Disable(t.Context()) + err := c.Acquire(t.Context()) require.Error(t, err) assert.Equal(t, 1, mock.disableCalls) - // Clear error; next Disable should write again + // Clear error; next Acquire should write again mock.disableErr = nil - require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) assert.Equal(t, 2, mock.disableCalls) - // Enable should write once - require.NoError(t, c.Enable(t.Context())) + // Release should write once + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerEnableFailureRetry(t *testing.T) { +func TestDebouncedControllerReleaseFailureRetry(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) mock.enableErr = assert.AnError - err := c.Enable(t.Context()) + err := c.Release(t.Context()) require.Error(t, err) assert.Equal(t, 1, mock.enableCalls) // Clear error; retry should succeed mock.enableErr = nil - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 2, mock.enableCalls) } -func TestDebouncedControllerEnableWithoutDisableNoWrite(t *testing.T) { +func TestDebouncedControllerReleaseWithoutAcquireNoWrite(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 0, mock.enableCalls) } func TestDebouncedControllerInterleavedSequence(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 2, mock.disableCalls) assert.Equal(t, 2, mock.enableCalls) } -type mockScaleToZeroer struct { +type mockToggler struct { mu sync.Mutex disableCalls int enableCalls int @@ -115,23 +115,24 @@ type mockScaleToZeroer struct { enableErr error } -func (m *mockScaleToZeroer) Disable(ctx context.Context) error { +func (m *mockToggler) Disable(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() m.disableCalls++ return m.disableErr } -func (m *mockScaleToZeroer) Enable(ctx context.Context) error { +func (m *mockToggler) Enable(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() m.enableCalls++ return m.enableErr } -func TestUnikraftCloudControllerNoFileNoError(t *testing.T) { + +func TestUnikraftCloudTogglerNoFileNoError(t *testing.T) { t.Parallel() p := filepath.Join(t.TempDir(), "scale_to_zero_disable") - c := &unikraftCloudController{path: p} + c := &unikraftCloudToggler{path: p} require.NoError(t, c.Disable(t.Context())) require.NoError(t, c.Enable(t.Context())) @@ -140,12 +141,12 @@ func TestUnikraftCloudControllerNoFileNoError(t *testing.T) { assert.True(t, os.IsNotExist(err), "should not create the file on no-op") } -func TestUnikraftCloudControllerWritesPlusAndMinus(t *testing.T) { +func TestUnikraftCloudTogglerWritesPlusAndMinus(t *testing.T) { t.Parallel() dir := t.TempDir() p := filepath.Join(dir, "scale_to_zero_disable") require.NoError(t, os.WriteFile(p, []byte{}, 0o600)) - c := &unikraftCloudController{path: p} + c := &unikraftCloudToggler{path: p} require.NoError(t, c.Disable(t.Context())) b, err := os.ReadFile(p) @@ -158,12 +159,12 @@ func TestUnikraftCloudControllerWritesPlusAndMinus(t *testing.T) { assert.Equal(t, []byte("-"), b) } -func TestUnikraftCloudControllerTruncatesExistingContent(t *testing.T) { +func TestUnikraftCloudTogglerTruncatesExistingContent(t *testing.T) { t.Parallel() dir := t.TempDir() p := filepath.Join(dir, "scale_to_zero_disable") require.NoError(t, os.WriteFile(p, []byte("abc123"), 0o600)) - c := &unikraftCloudController{path: p} + c := &unikraftCloudToggler{path: p} require.NoError(t, c.Disable(t.Context())) b, err := os.ReadFile(p) @@ -171,15 +172,15 @@ func TestUnikraftCloudControllerTruncatesExistingContent(t *testing.T) { assert.Equal(t, []byte("+"), b) } -func TestDebouncedControllerCooldownDelaysEnable(t *testing.T) { +func TestDebouncedControllerCooldownDelaysRelease(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) - // Enable should not have been called yet — still in cooldown + // Underlying Enable should not have been called yet — still in cooldown mock.mu.Lock() assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) @@ -193,29 +194,29 @@ func TestDebouncedControllerCooldownDelaysEnable(t *testing.T) { mock.mu.Unlock() } -func TestDebouncedControllerCooldownCancelledByNewDisable(t *testing.T) { +func TestDebouncedControllerCooldownCancelledByNewAcquire(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) // New request arrives before cooldown fires - require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) // Wait past what would have been the cooldown time.Sleep(100 * time.Millisecond) mock.mu.Lock() - // Enable should NOT have been called — the new Disable cancelled the timer + // Underlying Enable should NOT have been called — the new Acquire cancelled the timer assert.Equal(t, 0, mock.enableCalls) - // Only one actual Disable write (second Disable was already disabled) + // Only one actual underlying Disable (second Acquire saw already-off state) assert.Equal(t, 1, mock.disableCalls) mock.mu.Unlock() // Release the second request - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Release(t.Context())) time.Sleep(100 * time.Millisecond) mock.mu.Lock() @@ -225,16 +226,16 @@ func TestDebouncedControllerCooldownCancelledByNewDisable(t *testing.T) { func TestDebouncedControllerCooldownCollapsesRapidSequential(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) // Simulate 10 rapid sequential requests for i := 0; i < 10; i++ { - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) } - // Only 1 Disable write; Enable not yet called (still in cooldown) + // Only 1 underlying Disable; underlying Enable not yet called (still in cooldown) mock.mu.Lock() assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) @@ -251,91 +252,91 @@ func TestDebouncedControllerCooldownCollapsesRapidSequential(t *testing.T) { func TestDebouncedControllerCooldownZeroBehavesLikeOriginal(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedControllerWithCooldown(mock, 0) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { +func TestDebouncedControllerDisableHoldsAcrossRelease(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - // Pin first. - require.NoError(t, c.Pin(t.Context())) + // Idempotent Disable first. + require.NoError(t, c.Disable(t.Context())) assert.Equal(t, 1, mock.disableCalls) - // Simulate a middleware-wrapped request: Disable then Enable. - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + // Simulate a middleware-wrapped request: Acquire then Release. + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) - // Pin still held, so no Enable should have hit the underlying ctrl. + // Idempotent disable still held, so no underlying Enable should have fired. assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) - // Release the pin: Enable fires. - require.NoError(t, c.Unpin(t.Context())) + // Release the idempotent disable: Enable fires. + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerPinIdempotent(t *testing.T) { +func TestDebouncedControllerDisableIdempotent(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Pin(t.Context())) - require.NoError(t, c.Pin(t.Context())) - require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Disable(t.Context())) assert.Equal(t, 1, mock.disableCalls) - require.NoError(t, c.Unpin(t.Context())) - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerUnpinWithoutPinNoWrite(t *testing.T) { +func TestDebouncedControllerEnableWithoutDisableNoWrite(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 0, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) } -func TestDebouncedControllerUnpinDefersToActiveRequests(t *testing.T) { +func TestDebouncedControllerEnableDefersToActiveHolds(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedController(mock) - require.NoError(t, c.Pin(t.Context())) - require.NoError(t, c.Disable(t.Context())) // simulate inflight request + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) // simulate inflight request - // Releasing the pin while a request is inflight must not re-enable. - require.NoError(t, c.Unpin(t.Context())) + // Releasing the idempotent disable while a hold is active must not re-enable. + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 0, mock.enableCalls) - // Request completes -> Enable fires. - require.NoError(t, c.Enable(t.Context())) + // Hold released -> underlying Enable fires. + require.NoError(t, c.Release(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { +func TestDebouncedControllerDisableCancelsCooldownTimer(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) // Drive a request through, putting us into the cooldown window. - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Release(t.Context())) - // Pin during the cooldown: should cancel the pending re-enable. - require.NoError(t, c.Pin(t.Context())) + // Idempotent Disable during the cooldown: should cancel the pending re-enable. + require.NoError(t, c.Disable(t.Context())) time.Sleep(100 * time.Millisecond) @@ -344,20 +345,20 @@ func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { assert.Equal(t, 0, mock.enableCalls) mock.mu.Unlock() - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Enable(t.Context())) time.Sleep(100 * time.Millisecond) mock.mu.Lock() assert.Equal(t, 1, mock.enableCalls) mock.mu.Unlock() } -func TestDebouncedControllerUnpinHonorsCooldown(t *testing.T) { +func TestDebouncedControllerEnableHonorsCooldown(t *testing.T) { t.Parallel() - mock := &mockScaleToZeroer{} + mock := &mockToggler{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.Pin(t.Context())) - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) // Cooldown should defer the underlying Enable. mock.mu.Lock() From 7183576a57bbbc55d1faacd3a596105a7c412e12 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 9 May 2026 00:57:58 +0000 Subject: [PATCH 6/9] Revert "Align scale-to-zero internal terminology with OpenAPI spec" This reverts commit 7d26ce6c70b1431990c1118bd928c2f5b2e9f524. --- server/cmd/api/api/api.go | 4 +- server/cmd/api/api/scaletozero.go | 4 +- server/cmd/api/main.go | 2 +- server/lib/recorder/ffmpeg.go | 14 +- server/lib/scaletozero/middleware.go | 16 +- server/lib/scaletozero/middleware_test.go | 49 ++--- server/lib/scaletozero/scaletozero.go | 180 ++++++++---------- server/lib/scaletozero/scaletozero_test.go | 207 ++++++++++----------- 8 files changed, 216 insertions(+), 260 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 84164b26..d28fedd9 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -49,7 +49,7 @@ type ApiService struct { // DevTools upstream manager (Chromium supervisord log tailer) upstreamMgr *devtoolsproxy.UpstreamManager - stz scaletozero.Controller + stz scaletozero.PinnedController // inputMu serializes input-related operations (mouse, keyboard, screenshot) inputMu sync.Mutex @@ -96,7 +96,7 @@ func New( recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory, upstreamMgr *devtoolsproxy.UpstreamManager, - stz scaletozero.Controller, + stz scaletozero.PinnedController, nekoAuthClient *nekoclient.AuthClient, captureSession *capturesession.CaptureSession, eventStream *events.EventStream, diff --git a/server/cmd/api/api/scaletozero.go b/server/cmd/api/api/scaletozero.go index 7d14b7c0..9e840914 100644 --- a/server/cmd/api/api/scaletozero.go +++ b/server/cmd/api/api/scaletozero.go @@ -8,7 +8,7 @@ import ( ) func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScaleToZeroRequestObject) (oapi.DisableScaleToZeroResponseObject, error) { - if err := s.stz.Disable(ctx); err != nil { + if err := s.stz.Pin(ctx); err != nil { logger.FromContext(ctx).Error("failed to disable scale-to-zero", "err", err) return oapi.DisableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable scale-to-zero"}}, nil } @@ -16,7 +16,7 @@ func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScale } func (s *ApiService) EnableScaleToZero(ctx context.Context, _ oapi.EnableScaleToZeroRequestObject) (oapi.EnableScaleToZeroResponseObject, error) { - if err := s.stz.Enable(ctx); err != nil { + if err := s.stz.Unpin(ctx); err != nil { logger.FromContext(ctx).Error("failed to enable scale-to-zero", "err", err) return oapi.EnableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable scale-to-zero"}}, nil } diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 9584e05c..48d17351 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -51,7 +51,7 @@ func main() { // ensure ffmpeg is available mustFFmpeg() - stz := scaletozero.NewDebouncedControllerWithCooldown(scaletozero.NewUnikraftCloudToggler(), config.ScaleToZeroCooldown) + stz := scaletozero.NewDebouncedControllerWithCooldown(scaletozero.NewUnikraftCloudController(), config.ScaleToZeroCooldown) r := chi.NewRouter() r.Use( chiMiddleware.Logger, diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 7ef98eb2..48a82178 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -183,9 +183,9 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { return fmt.Errorf("recording already in progress") } - if err := fr.stz.Acquire(ctx); err != nil { + if err := fr.stz.Disable(ctx); err != nil { fr.mu.Unlock() - return fmt.Errorf("failed to acquire scale-to-zero hold: %w", err) + return fmt.Errorf("failed to disable scale-to-zero: %w", err) } // ensure internal state @@ -196,7 +196,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { args, err := ffmpegArgs(fr.params, fr.outputPath) if err != nil { - _ = fr.stz.Release(context.WithoutCancel(ctx)) + _ = fr.stz.Enable(context.WithoutCancel(ctx)) fr.cmd = nil close(fr.exited) fr.mu.Unlock() @@ -214,7 +214,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { fr.mu.Unlock() if err := cmd.Start(); err != nil { - _ = fr.stz.Release(context.WithoutCancel(ctx)) + _ = fr.stz.Enable(context.WithoutCancel(ctx)) fr.mu.Lock() fr.ffmpegErr = err fr.cmd = nil // reset cmd on failure to start so IsRecording() remains correct @@ -238,7 +238,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { // Stop gracefully stops the recording using a multi-phase shutdown process. func (fr *FFmpegRecorder) Stop(ctx context.Context) error { - defer fr.stz.Release(context.WithoutCancel(ctx)) + defer fr.stz.Enable(context.WithoutCancel(ctx)) // Use singleflight to prevent concurrent Stop() calls from sending multiple SIGINTs // to ffmpeg, which causes immediate abort without proper file closure. @@ -281,7 +281,7 @@ func (fr *FFmpegRecorder) WaitForFinalization(ctx context.Context) error { func (fr *FFmpegRecorder) ForceStop(ctx context.Context) error { log := logger.FromContext(ctx) - defer fr.stz.Release(context.WithoutCancel(ctx)) + defer fr.stz.Enable(context.WithoutCancel(ctx)) shutdownErr := fr.shutdownInPhases(ctx, []shutdownPhase{ {"kill", []syscall.Signal{syscall.SIGKILL}, 100 * time.Millisecond, "immediate kill"}, }) @@ -530,7 +530,7 @@ func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, erro // update the internal state accordingly. It also triggers finalization to add proper duration // metadata for recordings that exit naturally (max duration, max file size, etc.). func (fr *FFmpegRecorder) waitForCommand(ctx context.Context) { - defer fr.stz.Release(context.WithoutCancel(ctx)) + defer fr.stz.Enable(context.WithoutCancel(ctx)) log := logger.FromContext(ctx) diff --git a/server/lib/scaletozero/middleware.go b/server/lib/scaletozero/middleware.go index 503d7b58..b5452c06 100644 --- a/server/lib/scaletozero/middleware.go +++ b/server/lib/scaletozero/middleware.go @@ -8,10 +8,10 @@ import ( "github.com/kernel/kernel-images/server/lib/logger" ) -// Middleware returns a standard net/http middleware that acquires a -// scale-to-zero hold at the start of each request and releases it after the -// handler completes. Connections from loopback addresses are ignored and do -// not affect the scale-to-zero state. +// Middleware returns a standard net/http middleware that disables scale-to-zero +// at the start of each request and re-enables it after the handler completes. +// Connections from loopback addresses are ignored and do not affect the +// scale-to-zero state. func Middleware(ctrl Controller) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -20,12 +20,12 @@ func Middleware(ctrl Controller) func(http.Handler) http.Handler { return } - if err := ctrl.Acquire(r.Context()); err != nil { - logger.FromContext(r.Context()).Error("failed to acquire scale-to-zero hold", "error", err) - http.Error(w, "failed to acquire scale-to-zero hold", http.StatusInternalServerError) + if err := ctrl.Disable(r.Context()); err != nil { + logger.FromContext(r.Context()).Error("failed to disable scale-to-zero", "error", err) + http.Error(w, "failed to disable scale-to-zero", http.StatusInternalServerError) return } - defer ctrl.Release(context.WithoutCancel(r.Context())) + defer ctrl.Enable(context.WithoutCancel(r.Context())) next.ServeHTTP(w, r) }) diff --git a/server/lib/scaletozero/middleware_test.go b/server/lib/scaletozero/middleware_test.go index ed89d94c..c48b6122 100644 --- a/server/lib/scaletozero/middleware_test.go +++ b/server/lib/scaletozero/middleware_test.go @@ -1,44 +1,17 @@ package scaletozero import ( - "context" "net/http" "net/http/httptest" - "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type mockController struct { - mu sync.Mutex - acquireCalls int - releaseCalls int - acquireErr error - releaseErr error -} - -func (m *mockController) Acquire(ctx context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - m.acquireCalls++ - return m.acquireErr -} - -func (m *mockController) Release(ctx context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - m.releaseCalls++ - return m.releaseErr -} - -func (m *mockController) Disable(ctx context.Context) error { return nil } -func (m *mockController) Enable(ctx context.Context) error { return nil } - -func TestMiddlewareAcquiresAndReleasesForExternalAddr(t *testing.T) { +func TestMiddlewareDisablesAndEnablesForExternalAddr(t *testing.T) { t.Parallel() - mock := &mockController{} + mock := &mockScaleToZeroer{} handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -50,8 +23,8 @@ func TestMiddlewareAcquiresAndReleasesForExternalAddr(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, 1, mock.acquireCalls) - assert.Equal(t, 1, mock.releaseCalls) + assert.Equal(t, 1, mock.disableCalls) + assert.Equal(t, 1, mock.enableCalls) } func TestMiddlewareSkipsLoopbackAddrs(t *testing.T) { @@ -68,7 +41,7 @@ func TestMiddlewareSkipsLoopbackAddrs(t *testing.T) { for _, tc := range loopbackAddrs { t.Run(tc.name, func(t *testing.T) { t.Parallel() - mock := &mockController{} + mock := &mockScaleToZeroer{} var called bool handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true @@ -83,15 +56,15 @@ func TestMiddlewareSkipsLoopbackAddrs(t *testing.T) { assert.True(t, called, "handler should still be called") assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, 0, mock.acquireCalls, "should not acquire for loopback addr") - assert.Equal(t, 0, mock.releaseCalls, "should not release for loopback addr") + assert.Equal(t, 0, mock.disableCalls, "should not disable for loopback addr") + assert.Equal(t, 0, mock.enableCalls, "should not enable for loopback addr") }) } } -func TestMiddlewareAcquireError(t *testing.T) { +func TestMiddlewareDisableError(t *testing.T) { t.Parallel() - mock := &mockController{acquireErr: assert.AnError} + mock := &mockScaleToZeroer{disableErr: assert.AnError} var called bool handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true @@ -103,9 +76,9 @@ func TestMiddlewareAcquireError(t *testing.T) { handler.ServeHTTP(rec, req) - assert.False(t, called, "handler should not be called on acquire error") + assert.False(t, called, "handler should not be called on disable error") assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Equal(t, 0, mock.releaseCalls) + assert.Equal(t, 0, mock.enableCalls) } func TestIsLoopbackAddr(t *testing.T) { diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 0a00537d..21c76f43 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -13,62 +13,48 @@ import ( // https://unikraft.cloud/docs/api/v1/instances/#scaletozero_app const unikraftScaleToZeroFile = "/uk/libukp/scale_to_zero_disable" -// Toggler is the low-level scale-to-zero control. Implementations directly -// flip the underlying state (e.g. write to the unikraft control file). -type Toggler interface { +type Controller interface { // Disable turns scale-to-zero off. Disable(ctx context.Context) error - // Enable turns scale-to-zero on. + // Enable re-enables scale-to-zero after it has previously been disabled. Enable(ctx context.Context) error } -// Controller is the high-level scale-to-zero control used by the rest of -// the server. It supports two independent holder mechanisms: -// -// 1. Refcounted holds via Acquire/Release. Multiple callers (HTTP -// middleware, ffmpeg recorder, ...) may hold scale-to-zero off -// concurrently; scale-to-zero re-enables only when the last hold is -// released. Use the pair as Acquire(ctx); defer Release(ctx). +// PinnedController extends Controller with an out-of-band "pin" that holds +// scale-to-zero disabled independently of the request-driven refcount used by +// the HTTP middleware. While the pin is held, request-driven Enable calls do +// not re-enable scale-to-zero; only Unpin can release it. // -// 2. An idempotent persistent override via Disable/Enable. Disable puts -// scale-to-zero in the off state and keeps it there until Enable is -// called, independent of any refcounted holds. Both calls are -// idempotent. -type Controller interface { - // Acquire holds scale-to-zero disabled (refcounted). Pair with Release. - Acquire(ctx context.Context) error - // Release releases one refcounted hold. If no holds and no idempotent - // disable remain, scale-to-zero re-enables (honoring any cooldown). - Release(ctx context.Context) error - // Disable idempotently puts scale-to-zero in the off state. The state - // persists until Enable is called, even if all refcounted holds are - // released. Repeated calls are no-ops. - Disable(ctx context.Context) error - // Enable releases the idempotent disable. If no refcounted holds remain, - // scale-to-zero re-enables (honoring any cooldown). Calling without a - // prior Disable is a no-op. - Enable(ctx context.Context) error -} - -type unikraftCloudToggler struct { +// This is intended for explicit lifecycle control (e.g. a control-plane API +// reserving a VM in a hot pool) where the holder is not tied to an inflight +// HTTP request. +type PinnedController interface { + Controller + // Pin holds scale-to-zero disabled until Unpin is called. The pin is a + // boolean, not a counter: repeated calls are idempotent. + Pin(ctx context.Context) error + // Unpin releases the pin. If no request-driven holders remain, + // scale-to-zero is re-enabled (honoring any configured cooldown). + Unpin(ctx context.Context) error +} + +type unikraftCloudController struct { path string } -// NewUnikraftCloudToggler returns a Toggler that flips scale-to-zero by -// writing to the unikraft control file. -func NewUnikraftCloudToggler() Toggler { - return &unikraftCloudToggler{path: unikraftScaleToZeroFile} +func NewUnikraftCloudController() Controller { + return &unikraftCloudController{path: unikraftScaleToZeroFile} } -func (c *unikraftCloudToggler) Disable(ctx context.Context) error { +func (c *unikraftCloudController) Disable(ctx context.Context) error { return c.write(ctx, "+") } -func (c *unikraftCloudToggler) Enable(ctx context.Context) error { +func (c *unikraftCloudController) Enable(ctx context.Context) error { return c.write(ctx, "-") } -func (c *unikraftCloudToggler) write(ctx context.Context, char string) error { +func (c *unikraftCloudController) write(ctx context.Context, char string) error { if _, err := os.Stat(c.path); err != nil { if os.IsNotExist(err) { logger.FromContext(ctx).Info("scale-to-zero control file not found, skipping write", "path", c.path, "value", char) @@ -96,59 +82,56 @@ type NoopController struct{} func NewNoopController() *NoopController { return &NoopController{} } -func (NoopController) Acquire(context.Context) error { return nil } -func (NoopController) Release(context.Context) error { return nil } func (NoopController) Disable(context.Context) error { return nil } func (NoopController) Enable(context.Context) error { return nil } +func (NoopController) Pin(context.Context) error { return nil } +func (NoopController) Unpin(context.Context) error { return nil } -// Oncer wraps a Controller and ensures Acquire and Release fire at most once. +// Oncer wraps a Controller and ensures that Disable and Enable are called at most once. type Oncer struct { ctrl Controller - acquireOnce sync.Once - releaseOnce sync.Once - acquireErr error - releaseErr error + disableOnce sync.Once + enableOnce sync.Once + disableErr error + enableErr error } func NewOncer(c Controller) *Oncer { return &Oncer{ctrl: c} } -func (o *Oncer) Acquire(ctx context.Context) error { - o.acquireOnce.Do(func() { o.acquireErr = o.ctrl.Acquire(ctx) }) - return o.acquireErr +func (o *Oncer) Disable(ctx context.Context) error { + o.disableOnce.Do(func() { o.disableErr = o.ctrl.Disable(ctx) }) + return o.disableErr } -func (o *Oncer) Release(ctx context.Context) error { - o.releaseOnce.Do(func() { o.releaseErr = o.ctrl.Release(ctx) }) - return o.releaseErr +func (o *Oncer) Enable(ctx context.Context) error { + o.enableOnce.Do(func() { o.enableErr = o.ctrl.Enable(ctx) }) + return o.enableErr } -// DebouncedController implements Controller by tracking both a refcount of -// active Acquire holders and a boolean idempotent-disable flag, debounced by -// an optional cooldown before scale-to-zero is re-enabled. type DebouncedController struct { - toggler Toggler + ctrl Controller cooldown time.Duration mu sync.Mutex - off bool - holdCount int disabled bool + activeCount int + pinned bool reenableTimer *time.Timer } // NewDebouncedController creates a DebouncedController with no re-enable cooldown. -func NewDebouncedController(t Toggler) *DebouncedController { - return &DebouncedController{toggler: t} +func NewDebouncedController(ctrl Controller) *DebouncedController { + return &DebouncedController{ctrl: ctrl} } // NewDebouncedControllerWithCooldown creates a DebouncedController that delays -// re-enabling scale-to-zero by the given cooldown after the last holder -// releases. A new Acquire call during the cooldown cancels the pending +// re-enabling scale-to-zero by the given cooldown after the last active holder +// releases. A new Disable call during the cooldown cancels the pending // re-enable, avoiding rapid toggling from sequential requests. -func NewDebouncedControllerWithCooldown(t Toggler, cooldown time.Duration) *DebouncedController { - return &DebouncedController{toggler: t, cooldown: cooldown} +func NewDebouncedControllerWithCooldown(ctrl Controller, cooldown time.Duration) *DebouncedController { + return &DebouncedController{ctrl: ctrl, cooldown: cooldown} } -func (c *DebouncedController) Acquire(ctx context.Context) error { +func (c *DebouncedController) Disable(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -157,34 +140,35 @@ func (c *DebouncedController) Acquire(ctx context.Context) error { c.reenableTimer = nil } - c.holdCount++ - if c.off { + c.activeCount++ + if c.disabled { return nil } - if err := c.toggler.Disable(ctx); err != nil { - c.holdCount-- + if err := c.ctrl.Disable(ctx); err != nil { + c.activeCount-- return err } - c.off = true + c.disabled = true return nil } -func (c *DebouncedController) Release(ctx context.Context) error { +func (c *DebouncedController) Enable(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.holdCount > 0 { - c.holdCount-- + if c.activeCount > 0 { + c.activeCount-- } return c.maybeReenableLocked(ctx) } -// Disable idempotently puts scale-to-zero in the off state. Cancels any -// pending cooldown timer. Repeated calls while already disabled are no-ops. -func (c *DebouncedController) Disable(ctx context.Context) error { +// Pin sets the out-of-band pin and ensures scale-to-zero is disabled. +// Idempotent: re-pinning while already pinned is a no-op. Cancels any pending +// cooldown timer. +func (c *DebouncedController) Pin(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -193,64 +177,64 @@ func (c *DebouncedController) Disable(ctx context.Context) error { c.reenableTimer = nil } - if c.disabled { + if c.pinned { return nil } - if !c.off { - if err := c.toggler.Disable(ctx); err != nil { + if !c.disabled { + if err := c.ctrl.Disable(ctx); err != nil { return err } - c.off = true + c.disabled = true } - c.disabled = true + c.pinned = true return nil } -// Enable releases the idempotent disable. If no refcounted holds remain, -// scale-to-zero is re-enabled (honoring any configured cooldown). Calling -// without a prior Disable is a no-op. -func (c *DebouncedController) Enable(ctx context.Context) error { +// Unpin releases the pin. If no request-driven holders remain, scale-to-zero +// is re-enabled (honoring any configured cooldown). Idempotent: calling when no +// pin is held is a no-op. +func (c *DebouncedController) Unpin(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if !c.disabled { + if !c.pinned { return nil } - c.disabled = false + c.pinned = false return c.maybeReenableLocked(ctx) } -// maybeReenableLocked re-enables scale-to-zero if no holders (refcount or -// idempotent disable) remain. Caller must hold c.mu. +// maybeReenableLocked re-enables scale-to-zero if no holders (request-driven or +// pin) remain. Caller must hold c.mu. func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { - if c.holdCount > 0 || c.disabled || !c.off { + if c.activeCount > 0 || c.pinned || !c.disabled { return nil } - // No cooldown: re-enable immediately. + // No cooldown: re-enable immediately (original behavior). if c.cooldown <= 0 { - if err := c.toggler.Enable(ctx); err != nil { + if err := c.ctrl.Enable(ctx); err != nil { return err } - c.off = false + c.disabled = false return nil } - // Schedule re-enable after cooldown. If a new Acquire or Disable arrives - // before the timer fires, it will be cancelled. + // Schedule re-enable after cooldown. If a new Disable arrives before the + // timer fires, it will be cancelled. c.reenableTimer = time.AfterFunc(c.cooldown, func() { c.mu.Lock() defer c.mu.Unlock() - if c.holdCount > 0 || c.disabled || !c.off { + if c.activeCount > 0 || c.pinned || !c.disabled { return } - if c.toggler.Enable(context.Background()) == nil { - c.off = false + if c.ctrl.Enable(context.Background()) == nil { + c.disabled = false } }) diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 1c0e42a7..3221fb39 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -12,102 +12,102 @@ import ( "github.com/stretchr/testify/require" ) -func TestDebouncedControllerSingleAcquireRelease(t *testing.T) { +func TestDebouncedControllerSingleDisableEnable(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerMultipleAcquiresDebounced(t *testing.T) { +func TestDebouncedControllerMultipleDisablesDebounced(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Disable(t.Context())) assert.Equal(t, 1, mock.disableCalls) } -func TestDebouncedControllerReleaseOnlyOnLastHolder(t *testing.T) { +func TestDebouncedControllerEnableOnlyOnLastHolder(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 0, mock.enableCalls) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerAcquireFailureRollsBack(t *testing.T) { +func TestDebouncedControllerDisableFailureRollsBack(t *testing.T) { t.Parallel() - mock := &mockToggler{disableErr: assert.AnError} + mock := &mockScaleToZeroer{disableErr: assert.AnError} c := NewDebouncedController(mock) - err := c.Acquire(t.Context()) + err := c.Disable(t.Context()) require.Error(t, err) assert.Equal(t, 1, mock.disableCalls) - // Clear error; next Acquire should write again + // Clear error; next Disable should write again mock.disableErr = nil - require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Disable(t.Context())) assert.Equal(t, 2, mock.disableCalls) - // Release should write once - require.NoError(t, c.Release(t.Context())) + // Enable should write once + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerReleaseFailureRetry(t *testing.T) { +func TestDebouncedControllerEnableFailureRetry(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Disable(t.Context())) mock.enableErr = assert.AnError - err := c.Release(t.Context()) + err := c.Enable(t.Context()) require.Error(t, err) assert.Equal(t, 1, mock.enableCalls) // Clear error; retry should succeed mock.enableErr = nil - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 2, mock.enableCalls) } -func TestDebouncedControllerReleaseWithoutAcquireNoWrite(t *testing.T) { +func TestDebouncedControllerEnableWithoutDisableNoWrite(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 0, mock.enableCalls) } func TestDebouncedControllerInterleavedSequence(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 2, mock.disableCalls) assert.Equal(t, 2, mock.enableCalls) } -type mockToggler struct { +type mockScaleToZeroer struct { mu sync.Mutex disableCalls int enableCalls int @@ -115,24 +115,23 @@ type mockToggler struct { enableErr error } -func (m *mockToggler) Disable(ctx context.Context) error { +func (m *mockScaleToZeroer) Disable(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() m.disableCalls++ return m.disableErr } -func (m *mockToggler) Enable(ctx context.Context) error { +func (m *mockScaleToZeroer) Enable(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() m.enableCalls++ return m.enableErr } - -func TestUnikraftCloudTogglerNoFileNoError(t *testing.T) { +func TestUnikraftCloudControllerNoFileNoError(t *testing.T) { t.Parallel() p := filepath.Join(t.TempDir(), "scale_to_zero_disable") - c := &unikraftCloudToggler{path: p} + c := &unikraftCloudController{path: p} require.NoError(t, c.Disable(t.Context())) require.NoError(t, c.Enable(t.Context())) @@ -141,12 +140,12 @@ func TestUnikraftCloudTogglerNoFileNoError(t *testing.T) { assert.True(t, os.IsNotExist(err), "should not create the file on no-op") } -func TestUnikraftCloudTogglerWritesPlusAndMinus(t *testing.T) { +func TestUnikraftCloudControllerWritesPlusAndMinus(t *testing.T) { t.Parallel() dir := t.TempDir() p := filepath.Join(dir, "scale_to_zero_disable") require.NoError(t, os.WriteFile(p, []byte{}, 0o600)) - c := &unikraftCloudToggler{path: p} + c := &unikraftCloudController{path: p} require.NoError(t, c.Disable(t.Context())) b, err := os.ReadFile(p) @@ -159,12 +158,12 @@ func TestUnikraftCloudTogglerWritesPlusAndMinus(t *testing.T) { assert.Equal(t, []byte("-"), b) } -func TestUnikraftCloudTogglerTruncatesExistingContent(t *testing.T) { +func TestUnikraftCloudControllerTruncatesExistingContent(t *testing.T) { t.Parallel() dir := t.TempDir() p := filepath.Join(dir, "scale_to_zero_disable") require.NoError(t, os.WriteFile(p, []byte("abc123"), 0o600)) - c := &unikraftCloudToggler{path: p} + c := &unikraftCloudController{path: p} require.NoError(t, c.Disable(t.Context())) b, err := os.ReadFile(p) @@ -172,15 +171,15 @@ func TestUnikraftCloudTogglerTruncatesExistingContent(t *testing.T) { assert.Equal(t, []byte("+"), b) } -func TestDebouncedControllerCooldownDelaysRelease(t *testing.T) { +func TestDebouncedControllerCooldownDelaysEnable(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) - // Underlying Enable should not have been called yet — still in cooldown + // Enable should not have been called yet — still in cooldown mock.mu.Lock() assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) @@ -194,29 +193,29 @@ func TestDebouncedControllerCooldownDelaysRelease(t *testing.T) { mock.mu.Unlock() } -func TestDebouncedControllerCooldownCancelledByNewAcquire(t *testing.T) { +func TestDebouncedControllerCooldownCancelledByNewDisable(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) // New request arrives before cooldown fires - require.NoError(t, c.Acquire(t.Context())) + require.NoError(t, c.Disable(t.Context())) // Wait past what would have been the cooldown time.Sleep(100 * time.Millisecond) mock.mu.Lock() - // Underlying Enable should NOT have been called — the new Acquire cancelled the timer + // Enable should NOT have been called — the new Disable cancelled the timer assert.Equal(t, 0, mock.enableCalls) - // Only one actual underlying Disable (second Acquire saw already-off state) + // Only one actual Disable write (second Disable was already disabled) assert.Equal(t, 1, mock.disableCalls) mock.mu.Unlock() // Release the second request - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Enable(t.Context())) time.Sleep(100 * time.Millisecond) mock.mu.Lock() @@ -226,16 +225,16 @@ func TestDebouncedControllerCooldownCancelledByNewAcquire(t *testing.T) { func TestDebouncedControllerCooldownCollapsesRapidSequential(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) // Simulate 10 rapid sequential requests for i := 0; i < 10; i++ { - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) } - // Only 1 underlying Disable; underlying Enable not yet called (still in cooldown) + // Only 1 Disable write; Enable not yet called (still in cooldown) mock.mu.Lock() assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) @@ -252,91 +251,91 @@ func TestDebouncedControllerCooldownCollapsesRapidSequential(t *testing.T) { func TestDebouncedControllerCooldownZeroBehavesLikeOriginal(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 0) - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerDisableHoldsAcrossRelease(t *testing.T) { +func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - // Idempotent Disable first. - require.NoError(t, c.Disable(t.Context())) + // Pin first. + require.NoError(t, c.Pin(t.Context())) assert.Equal(t, 1, mock.disableCalls) - // Simulate a middleware-wrapped request: Acquire then Release. - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) + // Simulate a middleware-wrapped request: Disable then Enable. + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) - // Idempotent disable still held, so no underlying Enable should have fired. + // Pin still held, so no Enable should have hit the underlying ctrl. assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) - // Release the idempotent disable: Enable fires. - require.NoError(t, c.Enable(t.Context())) + // Release the pin: Enable fires. + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerDisableIdempotent(t *testing.T) { +func TestDebouncedControllerPinIdempotent(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) assert.Equal(t, 1, mock.disableCalls) - require.NoError(t, c.Enable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerEnableWithoutDisableNoWrite(t *testing.T) { +func TestDebouncedControllerUnpinWithoutPinNoWrite(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 0, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) } -func TestDebouncedControllerEnableDefersToActiveHolds(t *testing.T) { +func TestDebouncedControllerUnpinDefersToActiveRequests(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Acquire(t.Context())) // simulate inflight request + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Disable(t.Context())) // simulate inflight request - // Releasing the idempotent disable while a hold is active must not re-enable. - require.NoError(t, c.Enable(t.Context())) + // Releasing the pin while a request is inflight must not re-enable. + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 0, mock.enableCalls) - // Hold released -> underlying Enable fires. - require.NoError(t, c.Release(t.Context())) + // Request completes -> Enable fires. + require.NoError(t, c.Enable(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerDisableCancelsCooldownTimer(t *testing.T) { +func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) // Drive a request through, putting us into the cooldown window. - require.NoError(t, c.Acquire(t.Context())) - require.NoError(t, c.Release(t.Context())) - - // Idempotent Disable during the cooldown: should cancel the pending re-enable. require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) + + // Pin during the cooldown: should cancel the pending re-enable. + require.NoError(t, c.Pin(t.Context())) time.Sleep(100 * time.Millisecond) @@ -345,20 +344,20 @@ func TestDebouncedControllerDisableCancelsCooldownTimer(t *testing.T) { assert.Equal(t, 0, mock.enableCalls) mock.mu.Unlock() - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Unpin(t.Context())) time.Sleep(100 * time.Millisecond) mock.mu.Lock() assert.Equal(t, 1, mock.enableCalls) mock.mu.Unlock() } -func TestDebouncedControllerEnableHonorsCooldown(t *testing.T) { +func TestDebouncedControllerUnpinHonorsCooldown(t *testing.T) { t.Parallel() - mock := &mockToggler{} + mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.Disable(t.Context())) - require.NoError(t, c.Enable(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) // Cooldown should defer the underlying Enable. mock.mu.Lock() From d6e82ddb35991bf36a96e3e5bc5ee65270875f2e Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 9 May 2026 01:01:14 +0000 Subject: [PATCH 7/9] Rename Pin/Unpin to DisableStz/EnableStz PinnedController's Pin/Unpin are the in-scope additions for the /scaletozero/{disable,enable} endpoints. Rename them to DisableStz/EnableStz so the verb matches the API. The pre-existing refcounted Controller.Disable /Enable is left untouched, since DisableStz/EnableStz avoids a method-name collision on the concrete DebouncedController. --- server/cmd/api/api/scaletozero.go | 4 +- server/lib/scaletozero/scaletozero.go | 52 +++++++++++----------- server/lib/scaletozero/scaletozero_test.go | 50 ++++++++++----------- 3 files changed, 53 insertions(+), 53 deletions(-) diff --git a/server/cmd/api/api/scaletozero.go b/server/cmd/api/api/scaletozero.go index 9e840914..3306eaa2 100644 --- a/server/cmd/api/api/scaletozero.go +++ b/server/cmd/api/api/scaletozero.go @@ -8,7 +8,7 @@ import ( ) func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScaleToZeroRequestObject) (oapi.DisableScaleToZeroResponseObject, error) { - if err := s.stz.Pin(ctx); err != nil { + if err := s.stz.DisableStz(ctx); err != nil { logger.FromContext(ctx).Error("failed to disable scale-to-zero", "err", err) return oapi.DisableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable scale-to-zero"}}, nil } @@ -16,7 +16,7 @@ func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScale } func (s *ApiService) EnableScaleToZero(ctx context.Context, _ oapi.EnableScaleToZeroRequestObject) (oapi.EnableScaleToZeroResponseObject, error) { - if err := s.stz.Unpin(ctx); err != nil { + if err := s.stz.EnableStz(ctx); err != nil { logger.FromContext(ctx).Error("failed to enable scale-to-zero", "err", err) return oapi.EnableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable scale-to-zero"}}, nil } diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 21c76f43..58dbbc04 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -20,22 +20,22 @@ type Controller interface { Enable(ctx context.Context) error } -// PinnedController extends Controller with an out-of-band "pin" that holds -// scale-to-zero disabled independently of the request-driven refcount used by -// the HTTP middleware. While the pin is held, request-driven Enable calls do -// not re-enable scale-to-zero; only Unpin can release it. +// PinnedController extends Controller with an out-of-band override that holds +// scale-to-zero off independently of the request-driven refcount used by the +// HTTP middleware. While the override is held, request-driven Enable calls do +// not re-enable scale-to-zero; only EnableStz can release it. // // This is intended for explicit lifecycle control (e.g. a control-plane API // reserving a VM in a hot pool) where the holder is not tied to an inflight // HTTP request. type PinnedController interface { Controller - // Pin holds scale-to-zero disabled until Unpin is called. The pin is a - // boolean, not a counter: repeated calls are idempotent. - Pin(ctx context.Context) error - // Unpin releases the pin. If no request-driven holders remain, + // DisableStz holds scale-to-zero off until EnableStz is called. Boolean, + // not a counter: repeated calls are idempotent. + DisableStz(ctx context.Context) error + // EnableStz releases the override. If no request-driven holders remain, // scale-to-zero is re-enabled (honoring any configured cooldown). - Unpin(ctx context.Context) error + EnableStz(ctx context.Context) error } type unikraftCloudController struct { @@ -84,8 +84,8 @@ func NewNoopController() *NoopController { return &NoopController{} } func (NoopController) Disable(context.Context) error { return nil } func (NoopController) Enable(context.Context) error { return nil } -func (NoopController) Pin(context.Context) error { return nil } -func (NoopController) Unpin(context.Context) error { return nil } +func (NoopController) DisableStz(context.Context) error { return nil } +func (NoopController) EnableStz(context.Context) error { return nil } // Oncer wraps a Controller and ensures that Disable and Enable are called at most once. type Oncer struct { @@ -114,7 +114,7 @@ type DebouncedController struct { mu sync.Mutex disabled bool activeCount int - pinned bool + stzDisabled bool reenableTimer *time.Timer } @@ -165,10 +165,10 @@ func (c *DebouncedController) Enable(ctx context.Context) error { return c.maybeReenableLocked(ctx) } -// Pin sets the out-of-band pin and ensures scale-to-zero is disabled. -// Idempotent: re-pinning while already pinned is a no-op. Cancels any pending +// DisableStz sets the out-of-band override and ensures scale-to-zero is off. +// Idempotent: calling while already disabled is a no-op. Cancels any pending // cooldown timer. -func (c *DebouncedController) Pin(ctx context.Context) error { +func (c *DebouncedController) DisableStz(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -177,7 +177,7 @@ func (c *DebouncedController) Pin(ctx context.Context) error { c.reenableTimer = nil } - if c.pinned { + if c.stzDisabled { return nil } @@ -188,29 +188,29 @@ func (c *DebouncedController) Pin(ctx context.Context) error { c.disabled = true } - c.pinned = true + c.stzDisabled = true return nil } -// Unpin releases the pin. If no request-driven holders remain, scale-to-zero -// is re-enabled (honoring any configured cooldown). Idempotent: calling when no -// pin is held is a no-op. -func (c *DebouncedController) Unpin(ctx context.Context) error { +// EnableStz releases the override. If no request-driven holders remain, +// scale-to-zero is re-enabled (honoring any configured cooldown). Idempotent: +// calling when no override is held is a no-op. +func (c *DebouncedController) EnableStz(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if !c.pinned { + if !c.stzDisabled { return nil } - c.pinned = false + c.stzDisabled = false return c.maybeReenableLocked(ctx) } // maybeReenableLocked re-enables scale-to-zero if no holders (request-driven or -// pin) remain. Caller must hold c.mu. +// out-of-band override) remain. Caller must hold c.mu. func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { - if c.activeCount > 0 || c.pinned || !c.disabled { + if c.activeCount > 0 || c.stzDisabled || !c.disabled { return nil } @@ -229,7 +229,7 @@ func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.activeCount > 0 || c.pinned || !c.disabled { + if c.activeCount > 0 || c.stzDisabled || !c.disabled { return } diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 3221fb39..0b9ad701 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -261,63 +261,63 @@ func TestDebouncedControllerCooldownZeroBehavesLikeOriginal(t *testing.T) { assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { +func TestDebouncedControllerDisableStzHoldsAcrossMiddlewareEnable(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - // Pin first. - require.NoError(t, c.Pin(t.Context())) + // Out-of-band disable first. + require.NoError(t, c.DisableStz(t.Context())) assert.Equal(t, 1, mock.disableCalls) // Simulate a middleware-wrapped request: Disable then Enable. require.NoError(t, c.Disable(t.Context())) require.NoError(t, c.Enable(t.Context())) - // Pin still held, so no Enable should have hit the underlying ctrl. + // Override still held, so no Enable should have hit the underlying ctrl. assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) - // Release the pin: Enable fires. - require.NoError(t, c.Unpin(t.Context())) + // Release the override: Enable fires. + require.NoError(t, c.EnableStz(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerPinIdempotent(t *testing.T) { +func TestDebouncedControllerDisableStzIdempotent(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Pin(t.Context())) - require.NoError(t, c.Pin(t.Context())) - require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.DisableStz(t.Context())) + require.NoError(t, c.DisableStz(t.Context())) + require.NoError(t, c.DisableStz(t.Context())) assert.Equal(t, 1, mock.disableCalls) - require.NoError(t, c.Unpin(t.Context())) - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.EnableStz(t.Context())) + require.NoError(t, c.EnableStz(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerUnpinWithoutPinNoWrite(t *testing.T) { +func TestDebouncedControllerEnableStzWithoutDisableNoWrite(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.EnableStz(t.Context())) assert.Equal(t, 0, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) } -func TestDebouncedControllerUnpinDefersToActiveRequests(t *testing.T) { +func TestDebouncedControllerEnableStzDefersToActiveRequests(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.DisableStz(t.Context())) require.NoError(t, c.Disable(t.Context())) // simulate inflight request - // Releasing the pin while a request is inflight must not re-enable. - require.NoError(t, c.Unpin(t.Context())) + // Releasing the override while a request is inflight must not re-enable. + require.NoError(t, c.EnableStz(t.Context())) assert.Equal(t, 0, mock.enableCalls) // Request completes -> Enable fires. @@ -325,7 +325,7 @@ func TestDebouncedControllerUnpinDefersToActiveRequests(t *testing.T) { assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { +func TestDebouncedControllerDisableStzCancelsCooldownTimer(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) @@ -334,8 +334,8 @@ func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { require.NoError(t, c.Disable(t.Context())) require.NoError(t, c.Enable(t.Context())) - // Pin during the cooldown: should cancel the pending re-enable. - require.NoError(t, c.Pin(t.Context())) + // DisableStz during the cooldown: should cancel the pending re-enable. + require.NoError(t, c.DisableStz(t.Context())) time.Sleep(100 * time.Millisecond) @@ -344,20 +344,20 @@ func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { assert.Equal(t, 0, mock.enableCalls) mock.mu.Unlock() - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.EnableStz(t.Context())) time.Sleep(100 * time.Millisecond) mock.mu.Lock() assert.Equal(t, 1, mock.enableCalls) mock.mu.Unlock() } -func TestDebouncedControllerUnpinHonorsCooldown(t *testing.T) { +func TestDebouncedControllerEnableStzHonorsCooldown(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.Pin(t.Context())) - require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.DisableStz(t.Context())) + require.NoError(t, c.EnableStz(t.Context())) // Cooldown should defer the underlying Enable. mock.mu.Lock() From 8361537b1f8e492ae448d082bc0e6ef41960dd29 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 9 May 2026 01:04:33 +0000 Subject: [PATCH 8/9] Revert "Rename Pin/Unpin to DisableStz/EnableStz" This reverts commit d6e82ddb35991bf36a96e3e5bc5ee65270875f2e. --- server/cmd/api/api/scaletozero.go | 4 +- server/lib/scaletozero/scaletozero.go | 52 +++++++++++----------- server/lib/scaletozero/scaletozero_test.go | 50 ++++++++++----------- 3 files changed, 53 insertions(+), 53 deletions(-) diff --git a/server/cmd/api/api/scaletozero.go b/server/cmd/api/api/scaletozero.go index 3306eaa2..9e840914 100644 --- a/server/cmd/api/api/scaletozero.go +++ b/server/cmd/api/api/scaletozero.go @@ -8,7 +8,7 @@ import ( ) func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScaleToZeroRequestObject) (oapi.DisableScaleToZeroResponseObject, error) { - if err := s.stz.DisableStz(ctx); err != nil { + if err := s.stz.Pin(ctx); err != nil { logger.FromContext(ctx).Error("failed to disable scale-to-zero", "err", err) return oapi.DisableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable scale-to-zero"}}, nil } @@ -16,7 +16,7 @@ func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScale } func (s *ApiService) EnableScaleToZero(ctx context.Context, _ oapi.EnableScaleToZeroRequestObject) (oapi.EnableScaleToZeroResponseObject, error) { - if err := s.stz.EnableStz(ctx); err != nil { + if err := s.stz.Unpin(ctx); err != nil { logger.FromContext(ctx).Error("failed to enable scale-to-zero", "err", err) return oapi.EnableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable scale-to-zero"}}, nil } diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 58dbbc04..21c76f43 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -20,22 +20,22 @@ type Controller interface { Enable(ctx context.Context) error } -// PinnedController extends Controller with an out-of-band override that holds -// scale-to-zero off independently of the request-driven refcount used by the -// HTTP middleware. While the override is held, request-driven Enable calls do -// not re-enable scale-to-zero; only EnableStz can release it. +// PinnedController extends Controller with an out-of-band "pin" that holds +// scale-to-zero disabled independently of the request-driven refcount used by +// the HTTP middleware. While the pin is held, request-driven Enable calls do +// not re-enable scale-to-zero; only Unpin can release it. // // This is intended for explicit lifecycle control (e.g. a control-plane API // reserving a VM in a hot pool) where the holder is not tied to an inflight // HTTP request. type PinnedController interface { Controller - // DisableStz holds scale-to-zero off until EnableStz is called. Boolean, - // not a counter: repeated calls are idempotent. - DisableStz(ctx context.Context) error - // EnableStz releases the override. If no request-driven holders remain, + // Pin holds scale-to-zero disabled until Unpin is called. The pin is a + // boolean, not a counter: repeated calls are idempotent. + Pin(ctx context.Context) error + // Unpin releases the pin. If no request-driven holders remain, // scale-to-zero is re-enabled (honoring any configured cooldown). - EnableStz(ctx context.Context) error + Unpin(ctx context.Context) error } type unikraftCloudController struct { @@ -84,8 +84,8 @@ func NewNoopController() *NoopController { return &NoopController{} } func (NoopController) Disable(context.Context) error { return nil } func (NoopController) Enable(context.Context) error { return nil } -func (NoopController) DisableStz(context.Context) error { return nil } -func (NoopController) EnableStz(context.Context) error { return nil } +func (NoopController) Pin(context.Context) error { return nil } +func (NoopController) Unpin(context.Context) error { return nil } // Oncer wraps a Controller and ensures that Disable and Enable are called at most once. type Oncer struct { @@ -114,7 +114,7 @@ type DebouncedController struct { mu sync.Mutex disabled bool activeCount int - stzDisabled bool + pinned bool reenableTimer *time.Timer } @@ -165,10 +165,10 @@ func (c *DebouncedController) Enable(ctx context.Context) error { return c.maybeReenableLocked(ctx) } -// DisableStz sets the out-of-band override and ensures scale-to-zero is off. -// Idempotent: calling while already disabled is a no-op. Cancels any pending +// Pin sets the out-of-band pin and ensures scale-to-zero is disabled. +// Idempotent: re-pinning while already pinned is a no-op. Cancels any pending // cooldown timer. -func (c *DebouncedController) DisableStz(ctx context.Context) error { +func (c *DebouncedController) Pin(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -177,7 +177,7 @@ func (c *DebouncedController) DisableStz(ctx context.Context) error { c.reenableTimer = nil } - if c.stzDisabled { + if c.pinned { return nil } @@ -188,29 +188,29 @@ func (c *DebouncedController) DisableStz(ctx context.Context) error { c.disabled = true } - c.stzDisabled = true + c.pinned = true return nil } -// EnableStz releases the override. If no request-driven holders remain, -// scale-to-zero is re-enabled (honoring any configured cooldown). Idempotent: -// calling when no override is held is a no-op. -func (c *DebouncedController) EnableStz(ctx context.Context) error { +// Unpin releases the pin. If no request-driven holders remain, scale-to-zero +// is re-enabled (honoring any configured cooldown). Idempotent: calling when no +// pin is held is a no-op. +func (c *DebouncedController) Unpin(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if !c.stzDisabled { + if !c.pinned { return nil } - c.stzDisabled = false + c.pinned = false return c.maybeReenableLocked(ctx) } // maybeReenableLocked re-enables scale-to-zero if no holders (request-driven or -// out-of-band override) remain. Caller must hold c.mu. +// pin) remain. Caller must hold c.mu. func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { - if c.activeCount > 0 || c.stzDisabled || !c.disabled { + if c.activeCount > 0 || c.pinned || !c.disabled { return nil } @@ -229,7 +229,7 @@ func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.activeCount > 0 || c.stzDisabled || !c.disabled { + if c.activeCount > 0 || c.pinned || !c.disabled { return } diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 0b9ad701..3221fb39 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -261,63 +261,63 @@ func TestDebouncedControllerCooldownZeroBehavesLikeOriginal(t *testing.T) { assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerDisableStzHoldsAcrossMiddlewareEnable(t *testing.T) { +func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - // Out-of-band disable first. - require.NoError(t, c.DisableStz(t.Context())) + // Pin first. + require.NoError(t, c.Pin(t.Context())) assert.Equal(t, 1, mock.disableCalls) // Simulate a middleware-wrapped request: Disable then Enable. require.NoError(t, c.Disable(t.Context())) require.NoError(t, c.Enable(t.Context())) - // Override still held, so no Enable should have hit the underlying ctrl. + // Pin still held, so no Enable should have hit the underlying ctrl. assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) - // Release the override: Enable fires. - require.NoError(t, c.EnableStz(t.Context())) + // Release the pin: Enable fires. + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerDisableStzIdempotent(t *testing.T) { +func TestDebouncedControllerPinIdempotent(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.DisableStz(t.Context())) - require.NoError(t, c.DisableStz(t.Context())) - require.NoError(t, c.DisableStz(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) assert.Equal(t, 1, mock.disableCalls) - require.NoError(t, c.EnableStz(t.Context())) - require.NoError(t, c.EnableStz(t.Context())) + require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerEnableStzWithoutDisableNoWrite(t *testing.T) { +func TestDebouncedControllerUnpinWithoutPinNoWrite(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.EnableStz(t.Context())) + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 0, mock.disableCalls) assert.Equal(t, 0, mock.enableCalls) } -func TestDebouncedControllerEnableStzDefersToActiveRequests(t *testing.T) { +func TestDebouncedControllerUnpinDefersToActiveRequests(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedController(mock) - require.NoError(t, c.DisableStz(t.Context())) + require.NoError(t, c.Pin(t.Context())) require.NoError(t, c.Disable(t.Context())) // simulate inflight request - // Releasing the override while a request is inflight must not re-enable. - require.NoError(t, c.EnableStz(t.Context())) + // Releasing the pin while a request is inflight must not re-enable. + require.NoError(t, c.Unpin(t.Context())) assert.Equal(t, 0, mock.enableCalls) // Request completes -> Enable fires. @@ -325,7 +325,7 @@ func TestDebouncedControllerEnableStzDefersToActiveRequests(t *testing.T) { assert.Equal(t, 1, mock.enableCalls) } -func TestDebouncedControllerDisableStzCancelsCooldownTimer(t *testing.T) { +func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) @@ -334,8 +334,8 @@ func TestDebouncedControllerDisableStzCancelsCooldownTimer(t *testing.T) { require.NoError(t, c.Disable(t.Context())) require.NoError(t, c.Enable(t.Context())) - // DisableStz during the cooldown: should cancel the pending re-enable. - require.NoError(t, c.DisableStz(t.Context())) + // Pin during the cooldown: should cancel the pending re-enable. + require.NoError(t, c.Pin(t.Context())) time.Sleep(100 * time.Millisecond) @@ -344,20 +344,20 @@ func TestDebouncedControllerDisableStzCancelsCooldownTimer(t *testing.T) { assert.Equal(t, 0, mock.enableCalls) mock.mu.Unlock() - require.NoError(t, c.EnableStz(t.Context())) + require.NoError(t, c.Unpin(t.Context())) time.Sleep(100 * time.Millisecond) mock.mu.Lock() assert.Equal(t, 1, mock.enableCalls) mock.mu.Unlock() } -func TestDebouncedControllerEnableStzHonorsCooldown(t *testing.T) { +func TestDebouncedControllerUnpinHonorsCooldown(t *testing.T) { t.Parallel() mock := &mockScaleToZeroer{} c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) - require.NoError(t, c.DisableStz(t.Context())) - require.NoError(t, c.EnableStz(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) // Cooldown should defer the underlying Enable. mock.mu.Lock() From 1f9779c47bd03297f285ab255a7cff55ce2fcde9 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Sat, 9 May 2026 01:12:37 +0000 Subject: [PATCH 9/9] Make Unpin retryable when the underlying Enable fails Previously, Unpin set c.pinned = false before calling maybeReenableLocked. If the underlying ctrl.Enable returned an error (no-cooldown path), the pin was already released but c.disabled remained true, so a retry of Unpin hit the !c.pinned early-return and became a no-op. The controller was stuck disabled with no API-driven recovery path. Restore c.pinned on error so the caller can retry, mirroring Pin's "flip the state flag only after the side effect succeeded" pattern. --- server/lib/scaletozero/scaletozero.go | 8 +++++++- server/lib/scaletozero/scaletozero_test.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 21c76f43..96cc1610 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -204,7 +204,13 @@ func (c *DebouncedController) Unpin(ctx context.Context) error { } c.pinned = false - return c.maybeReenableLocked(ctx) + if err := c.maybeReenableLocked(ctx); err != nil { + // Restore the pin so a retry can re-attempt the underlying Enable; + // otherwise the caller has no API-driven recovery path. + c.pinned = true + return err + } + return nil } // maybeReenableLocked re-enables scale-to-zero if no holders (request-driven or diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 3221fb39..575265dd 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -370,3 +370,22 @@ func TestDebouncedControllerUnpinHonorsCooldown(t *testing.T) { assert.Equal(t, 1, mock.enableCalls) mock.mu.Unlock() } + +func TestDebouncedControllerUnpinRetryableAfterEnableFailure(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.Pin(t.Context())) + + // First Unpin: underlying Enable fails. Pin must remain held so the caller + // can retry; otherwise the controller is stuck in disabled=true forever. + mock.enableErr = assert.AnError + require.Error(t, c.Unpin(t.Context())) + assert.Equal(t, 1, mock.enableCalls) + + // Retry succeeds. + mock.enableErr = nil + require.NoError(t, c.Unpin(t.Context())) + assert.Equal(t, 2, mock.enableCalls) +}