From 3033b16aa0bf43ddd3ac7438cc964e237e96eb54 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 25 Sep 2026 22:55:21 +0100 Subject: [PATCH] fix(serverless): bound async waits and wait on resume/activate Resume and activate returned as soon as the API accepted the request, and first-image --wait could poll until Ctrl-C. --timeout (default 0) now caps those waits via context. --- docs/runware_serverless_apps_invoke.md | 6 +- docs/runware_serverless_apps_resume.md | 13 +- ...nware_serverless_apps_versions_activate.md | 15 ++- docs/runware_serverless_deploy.md | 6 +- internal/cmd/serverless/apps_invoke.go | 18 ++- internal/cmd/serverless/apps_lifecycle.go | 56 +++++++-- internal/cmd/serverless/apps_versions.go | 40 +++++- internal/cmd/serverless/deploy.go | 19 ++- internal/cmd/serverless/deploy_test.go | 3 + internal/cmd/serverless/wait.go | 42 +++++++ internal/cmd/serverless/wait_test.go | 114 ++++++++++++++++++ 11 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 internal/cmd/serverless/wait.go create mode 100644 internal/cmd/serverless/wait_test.go diff --git a/docs/runware_serverless_apps_invoke.md b/docs/runware_serverless_apps_invoke.md index 3f5e081..4f3165a 100644 --- a/docs/runware_serverless_apps_invoke.md +++ b/docs/runware_serverless_apps_invoke.md @@ -10,7 +10,8 @@ endpointPath is a bare lowercase segment as returned by apps endpoints (e.g. infer). A leading slash is rejected. The default is async: the command prints the accepted task id. Pass --wait -to poll until the task is completed or failed. +to poll until the task is completed or failed, and --timeout to bound that +wait. --sync uses the sync invocation endpoint. If the platform wait window expires, the command polls the returned task id; it never treats expiry as @@ -35,7 +36,7 @@ runware serverless apps invoke [flags] runware serverless apps invoke my-app infer --sync -f payload.json # async invoke and poll - runware serverless apps invoke my-app infer --wait -f payload.json + runware serverless apps invoke my-app infer --wait --timeout 2m -f payload.json # retry a lost response without starting a second task runware serverless apps invoke my-app infer --task-id 7c9e6679-7425-40de-944b-e07fc1f90ae7 -f payload.json @@ -49,6 +50,7 @@ runware serverless apps invoke [flags] --poll-interval duration Polling interval when waiting for a task (default 2s) --sync Use sync invocation and wait for a terminal task --task-id string Client task id (UUID); generated if omitted + --timeout duration Maximum time to wait (0 = no limit) --wait Poll until the task is completed or failed ``` diff --git a/docs/runware_serverless_apps_resume.md b/docs/runware_serverless_apps_resume.md index 517bd64..f21c358 100644 --- a/docs/runware_serverless_apps_resume.md +++ b/docs/runware_serverless_apps_resume.md @@ -7,8 +7,9 @@ Resume a stopped serverless application Resume a stopped serverless application. The server accepts the resume and returns immediately with status initializing. -Worker start is asynchronous; this command does not wait until the application -is active. The application must be stopped. +Worker start is asynchronous. Pass --wait to poll until the application is +active or failed, and --timeout to bound that wait. The application must be +stopped. ``` runware serverless apps resume [flags] @@ -19,12 +20,18 @@ runware serverless apps resume [flags] ``` # resume a stopped application runware serverless apps resume my-app + + # wait until the application is active or failed + runware serverless apps resume my-app --wait --timeout 5m ``` ### Options ``` - -h, --help help for resume + -h, --help help for resume + --poll-interval duration Polling interval when waiting for the application (default 2s) + --timeout duration Maximum time to wait (0 = no limit) + --wait Poll until the application is active or failed ``` ### Options inherited from parent commands diff --git a/docs/runware_serverless_apps_versions_activate.md b/docs/runware_serverless_apps_versions_activate.md index 35e6038..31c2824 100644 --- a/docs/runware_serverless_apps_versions_activate.md +++ b/docs/runware_serverless_apps_versions_activate.md @@ -7,9 +7,10 @@ Activate a ready application version Activate a ready version by number, including rollback to an older version. The server accepts the deploy and returns immediately with the updated app. -Worker rollout is asynchronous; this command does not wait until workers are -healthy. Re-activating the currently active version is permitted and re-applies -it. On a stopped app the version is recorded and applied on resume. +Worker rollout is asynchronous. Pass --wait to poll until the application is +active or failed, and --timeout to bound that wait. Re-activating the currently +active version is permitted and re-applies it. On a stopped app the version is +recorded and applied on resume. A missing app is 404. A missing version, a version that is not ready, or an app that is deleting is 409. @@ -27,12 +28,18 @@ runware serverless apps versions activate [flags] # roll back to an older ready version runware serverless apps versions activate my-app 1 + + # wait until the rollout is active or failed + runware serverless apps versions activate my-app 2 --wait --timeout 5m ``` ### Options ``` - -h, --help help for activate + -h, --help help for activate + --poll-interval duration Polling interval when waiting for the application (default 2s) + --timeout duration Maximum time to wait (0 = no limit) + --wait Poll until the application is active or failed ``` ### Options inherited from parent commands diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index eac3cb1..44b19de 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -29,7 +29,8 @@ builds a hosted wrapper image from that archive; the version records a buildId, not a customer image reference. Invalid container.yaml is rejected on create (400 if it cannot be parsed, 422 if it breaks a rule). The app stays initializing until that first build rolls out. Pass --wait to poll until the -application is active or failed. A successful wait is not a live worker: +application is active or failed, and --timeout to bound that wait. A successful +wait is not a live worker: minWorkers=0 stays scaled to zero until the first invoke. --container cannot be combined with an entry file, --src-dir, --base-image, or @@ -112,7 +113,7 @@ runware serverless deploy [file] [flags] runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper # wait until the first rollout is active or failed - runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait + runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait --timeout 10m ``` ### Options @@ -138,6 +139,7 @@ runware serverless deploy [file] [flags] --scaling-delay int32 Scaling delay in seconds (default 10) --secret stringArray Organisation secret to attach at create, as NAME or NAME=ENV_VAR (repeatable) --src-dir string Directory to package as the application source (default: the working directory; code deploys only) + --timeout duration Maximum time to wait (0 = no limit) --volume stringArray Absolute path inside the app backed by persistent node-local storage; immutable after create (repeatable) --wait Poll until the application is active or failed ``` diff --git a/internal/cmd/serverless/apps_invoke.go b/internal/cmd/serverless/apps_invoke.go index c0d3664..8c9cbc8 100644 --- a/internal/cmd/serverless/apps_invoke.go +++ b/internal/cmd/serverless/apps_invoke.go @@ -19,6 +19,7 @@ func newAppsInvokeCmd(logger *log.Logger) *cobra.Command { var ( sync bool wait bool + timeout time.Duration bodyFile string taskID string pollInterval time.Duration @@ -33,7 +34,8 @@ endpointPath is a bare lowercase segment as returned by apps endpoints (e.g. infer). A leading slash is rejected. The default is async: the command prints the accepted task id. Pass --wait -to poll until the task is completed or failed. +to poll until the task is completed or failed, and --timeout to bound that +wait. --sync uses the sync invocation endpoint. If the platform wait window expires, the command polls the returned task id; it never treats expiry as @@ -50,7 +52,7 @@ instead of starting a second run.`, runware serverless apps invoke my-app infer --sync -f payload.json # async invoke and poll - runware serverless apps invoke my-app infer --wait -f payload.json + runware serverless apps invoke my-app infer --wait --timeout 2m -f payload.json # retry a lost response without starting a second task runware serverless apps invoke my-app infer --task-id 7c9e6679-7425-40de-944b-e07fc1f90ae7 -f payload.json`, @@ -79,12 +81,15 @@ instead of starting a second run.`, shouldWait := sync || wait if shouldWait && task.Status == serverlessapi.TaskStatusPending { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Task %s accepted; waiting...\n", task.Id) - spin.SetMessage(fmt.Sprintf("Waiting for task %s...", task.Id)) - task, err = client.WaitTask(cmd.Context(), appID, task.Id, pollInterval) + waitedID := task.Id + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Task %s accepted; waiting...\n", waitedID) + spin.SetMessage(fmt.Sprintf("Waiting for task %s...", waitedID)) + waitCtx, cancel := waitContext(cmd.Context(), timeout) + task, err = client.WaitTask(waitCtx, appID, waitedID, pollInterval) + cancel() if err != nil { spin.Stop() - return err + return waitTimeoutErr(err, "task "+waitedID, timeout) } } spin.Stop() @@ -98,6 +103,7 @@ instead of starting a second run.`, cmd.Flags().BoolVar(&sync, "sync", false, "Use sync invocation and wait for a terminal task") cmd.Flags().BoolVar(&wait, "wait", false, "Poll until the task is completed or failed") + cmd.Flags().DurationVar(&timeout, "timeout", 0, "Maximum time to wait (0 = no limit)") cmd.Flags().StringVarP(&bodyFile, "body", "f", "", "JSON payload file, or - for stdin (default {})") cmd.Flags().StringVar(&taskID, "task-id", "", "Client task id (UUID); generated if omitted") cmd.Flags().DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for a task") diff --git a/internal/cmd/serverless/apps_lifecycle.go b/internal/cmd/serverless/apps_lifecycle.go index 1ee75b3..c29aeb3 100644 --- a/internal/cmd/serverless/apps_lifecycle.go +++ b/internal/cmd/serverless/apps_lifecycle.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "strings" + "time" "github.com/charmbracelet/log" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" @@ -38,27 +39,43 @@ is stopped. The application must be active.`, runware serverless apps stop my-app`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runLifecycle(cmd, logger, args[0], "Stopping", (*serverlessapi.Client).StopApp) + return runLifecycle(cmd, logger, args[0], "Stopping", (*serverlessapi.Client).StopApp, waitOptions{}) }, } } func newAppsResumeCmd(logger *log.Logger) *cobra.Command { - return &cobra.Command{ + var ( + wait bool + timeout time.Duration + pollInterval time.Duration + ) + + cmd := &cobra.Command{ Use: "resume ", Short: "Resume a stopped serverless application", Long: `Resume a stopped serverless application. The server accepts the resume and returns immediately with status initializing. -Worker start is asynchronous; this command does not wait until the application -is active. The application must be stopped.`, +Worker start is asynchronous. Pass --wait to poll until the application is +active or failed, and --timeout to bound that wait. The application must be +stopped.`, Example: ` # resume a stopped application - runware serverless apps resume my-app`, + runware serverless apps resume my-app + + # wait until the application is active or failed + runware serverless apps resume my-app --wait --timeout 5m`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runLifecycle(cmd, logger, args[0], "Resuming", (*serverlessapi.Client).ResumeApp) + return runLifecycle(cmd, logger, args[0], "Resuming", (*serverlessapi.Client).ResumeApp, waitOptions{ + Wait: wait, + Timeout: timeout, + Interval: pollInterval, + }) }, } + addAppWaitFlags(cmd, &wait, &timeout, &pollInterval) + return cmd } func newAppsDeleteCmd(logger *log.Logger) *cobra.Command { @@ -88,7 +105,7 @@ Confirmation is required unless --yes or --force is passed.`, if err := confirmDelete("application "+id, yes || force, cmd.InOrStdin(), cmd.ErrOrStderr(), stdinIsTerminal(cmd.InOrStdin()), config.GetAPIKey()); err != nil { return err } - return runLifecycle(cmd, logger, id, "Deleting", (*serverlessapi.Client).DeleteApp) + return runLifecycle(cmd, logger, id, "Deleting", (*serverlessapi.Client).DeleteApp, waitOptions{}) }, } @@ -98,7 +115,13 @@ Confirmation is required unless --yes or --force is passed.`, type lifecycleAction func(*serverlessapi.Client, context.Context, string) (*serverlessapi.App, error) -func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, action lifecycleAction) error { +type waitOptions struct { + Wait bool + Timeout time.Duration + Interval time.Duration +} + +func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, action lifecycleAction, wait waitOptions) error { spin := cmdutil.NewSpinner(fmt.Sprintf("%s application %s...", verb, id)) spin.Start() @@ -108,9 +131,24 @@ func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, actio spin.Stop() return err } + if wait.Wait && !serverlessapi.AppDeployTerminal(app.Status) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Application %s is %s; waiting...\n", app.AppId, app.Status) + spin.SetMessage(fmt.Sprintf("Waiting for application %s...", app.AppId)) + app, err = waitForApp(cmd.Context(), client, app.AppId, wait.Interval, wait.Timeout) + if err != nil { + spin.Stop() + return err + } + } spin.Stop() - return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + if err := output.Print(cmdutil.FormatFor(cmd), appResult(*app)); err != nil { + return err + } + if !wait.Wait { + return nil + } + return appFailedErr(cmd.Context(), client, app) } func addDeleteConfirmFlags(cmd *cobra.Command, yes, force *bool) { diff --git a/internal/cmd/serverless/apps_versions.go b/internal/cmd/serverless/apps_versions.go index 6d0b179..e49bd25 100644 --- a/internal/cmd/serverless/apps_versions.go +++ b/internal/cmd/serverless/apps_versions.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "strconv" + "time" "github.com/charmbracelet/log" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" @@ -107,15 +108,22 @@ func newAppsVersionsShowCmd(logger *log.Logger) *cobra.Command { } func newAppsVersionsActivateCmd(logger *log.Logger) *cobra.Command { - return &cobra.Command{ + var ( + wait bool + timeout time.Duration + pollInterval time.Duration + ) + + cmd := &cobra.Command{ Use: "activate ", Short: "Activate a ready application version", Long: `Activate a ready version by number, including rollback to an older version. The server accepts the deploy and returns immediately with the updated app. -Worker rollout is asynchronous; this command does not wait until workers are -healthy. Re-activating the currently active version is permitted and re-applies -it. On a stopped app the version is recorded and applied on resume. +Worker rollout is asynchronous. Pass --wait to poll until the application is +active or failed, and --timeout to bound that wait. Re-activating the currently +active version is permitted and re-applies it. On a stopped app the version is +recorded and applied on resume. A missing app is 404. A missing version, a version that is not ready, or an app that is deleting is 409.`, @@ -124,7 +132,10 @@ app that is deleting is 409.`, runware serverless apps versions activate my-app 2 # roll back to an older ready version - runware serverless apps versions activate my-app 1`, + runware serverless apps versions activate my-app 1 + + # wait until the rollout is active or failed + runware serverless apps versions activate my-app 2 --wait --timeout 5m`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { appID := args[0] @@ -142,11 +153,28 @@ app that is deleting is 409.`, spin.Stop() return err } + if wait && !serverlessapi.AppDeployTerminal(app.Status) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Application %s is %s; waiting...\n", app.AppId, app.Status) + spin.SetMessage(fmt.Sprintf("Waiting for application %s...", app.AppId)) + app, err = waitForApp(cmd.Context(), client, app.AppId, pollInterval, timeout) + if err != nil { + spin.Stop() + return err + } + } spin.Stop() - return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + if err := output.Print(cmdutil.FormatFor(cmd), appResult(*app)); err != nil { + return err + } + if !wait { + return nil + } + return appFailedErr(cmd.Context(), client, app) }, } + addAppWaitFlags(cmd, &wait, &timeout, &pollInterval) + return cmd } func newAppsVersionsDeleteCmd(logger *log.Logger) *cobra.Command { diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 3876639..bf367a3 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -2,6 +2,7 @@ package serverless import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -137,6 +138,7 @@ func newDeployCmd(logger *log.Logger) *cobra.Command { envVars []string envFiles []string wait bool + timeout time.Duration pollInterval time.Duration ) @@ -168,7 +170,8 @@ builds a hosted wrapper image from that archive; the version records a buildId, not a customer image reference. Invalid container.yaml is rejected on create (400 if it cannot be parsed, 422 if it breaks a rule). The app stays initializing until that first build rolls out. Pass --wait to poll until the -application is active or failed. A successful wait is not a live worker: +application is active or failed, and --timeout to bound that wait. A successful +wait is not a live worker: minWorkers=0 stays scaled to zero until the first invoke. --container cannot be combined with an entry file, --src-dir, --base-image, or @@ -243,7 +246,7 @@ paths and an invoke example once the application is active.`, runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper # wait until the first rollout is active or failed - runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait`, + runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait --timeout 10m`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := validateDeployArgs(cmd, args, containerDir); err != nil { @@ -366,9 +369,11 @@ paths and an invoke example once the application is active.`, spin.Stop() return err } + waitCtx, cancelWait := waitContext(cmd.Context(), timeout) + defer cancelWait() if wait && !serverlessapi.AppDeployTerminal(app.Status) { var lastStatus string - app, err = waitForAppDeploy(cmd.Context(), client, app.AppId, pollInterval, func(appStatus, buildStatus string) { + app, err = waitForAppDeploy(waitCtx, client, app.AppId, pollInterval, func(appStatus, buildStatus string) { msg := deployWaitMessage(app.AppId, appStatus, buildStatus) if msg == lastStatus { return @@ -379,7 +384,7 @@ paths and an invoke example once the application is active.`, }) if err != nil { spin.Stop() - return err + return waitTimeoutErr(err, "application "+app.AppId, timeout) } } spin.Stop() @@ -390,7 +395,10 @@ paths and an invoke example once the application is active.`, // says nothing about whether this deploy landed. The pin is what moves // when it does. if canCompare { - settled, err := waitForSubmittedVersion(cmd.Context(), client, app.AppId, versionBefore, pollInterval) + settled, err := waitForSubmittedVersion(waitCtx, client, app.AppId, versionBefore, pollInterval) + if errors.Is(err, context.DeadlineExceeded) { + return waitTimeoutErr(err, "application "+app.AppId, timeout) + } if err == nil && activationMoved(versionBefore, settled.ActiveVersionId) { if paths, err := deployEndpointPaths(cmd.Context(), client, app.AppId); err == nil { reportEndpointSetChange(cmd.ErrOrStderr(), compareEndpointSets(endpointsBefore, paths)) @@ -432,6 +440,7 @@ paths and an invoke example once the application is active.`, cmd.Flags().Int32Var(&minAvailableWorkers, "min-available-workers", 0, "Minimum idle workers kept as a buffer") cmd.Flags().Int32Var(&availableWorkersPct, "available-workers-pct", 0, "Idle-worker buffer as a percentage of load (0-100)") cmd.Flags().BoolVar(&wait, "wait", false, "Poll until the application is active or failed") + cmd.Flags().DurationVar(&timeout, "timeout", 0, "Maximum time to wait (0 = no limit)") cmd.Flags().DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for the application") if err := cmd.MarkFlagRequired("id"); err != nil { diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index c6ad94f..61f879b 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -203,6 +203,9 @@ func TestNewDeployCmd_RegistersContainerFlag(t *testing.T) { if cmd.Flags().Lookup("poll-interval") == nil { t.Fatal("deploy is missing --poll-interval") } + if cmd.Flags().Lookup("timeout") == nil { + t.Fatal("deploy is missing --timeout") + } if cmd.Use != "deploy [file]" { t.Errorf("Use = %q, want deploy [file]", cmd.Use) } diff --git a/internal/cmd/serverless/wait.go b/internal/cmd/serverless/wait.go new file mode 100644 index 0000000..defc08e --- /dev/null +++ b/internal/cmd/serverless/wait.go @@ -0,0 +1,42 @@ +package serverless + +import ( + "context" + "errors" + "fmt" + "time" + + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/spf13/cobra" +) + +func addAppWaitFlags(cmd *cobra.Command, wait *bool, timeout, pollInterval *time.Duration) { + cmd.Flags().BoolVar(wait, "wait", false, "Poll until the application is active or failed") + cmd.Flags().DurationVar(timeout, "timeout", 0, "Maximum time to wait (0 = no limit)") + cmd.Flags().DurationVar(pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for the application") +} + +func waitContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return parent, func() {} + } + return context.WithTimeout(parent, timeout) +} + +func waitTimeoutErr(err error, subject string, timeout time.Duration) error { + if err == nil || !errors.Is(err, context.DeadlineExceeded) { + return err + } + if timeout > 0 { + return fmt.Errorf("timed out waiting for %s after %s", subject, timeout) + } + return fmt.Errorf("timed out waiting for %s", subject) +} + +func waitForApp(ctx context.Context, client *serverlessapi.Client, appID string, interval, timeout time.Duration) (*serverlessapi.App, error) { + waitCtx, cancel := waitContext(ctx, timeout) + defer cancel() + + app, err := client.WaitApp(waitCtx, appID, interval) + return app, waitTimeoutErr(err, "application "+appID, timeout) +} diff --git a/internal/cmd/serverless/wait_test.go b/internal/cmd/serverless/wait_test.go new file mode 100644 index 0000000..2db1d07 --- /dev/null +++ b/internal/cmd/serverless/wait_test.go @@ -0,0 +1,114 @@ +package serverless + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/spf13/cobra" +) + +func TestWaitFlagsRegistered(t *testing.T) { + cmds := []*cobra.Command{ + newDeployCmd(nil), + newAppsInvokeCmd(nil), + newAppsResumeCmd(nil), + newAppsVersionsActivateCmd(nil), + } + for _, cmd := range cmds { + if cmd.Flags().Lookup("wait") == nil { + t.Errorf("%s is missing --wait", cmd.Name()) + } + if cmd.Flags().Lookup("timeout") == nil { + t.Errorf("%s is missing --timeout", cmd.Name()) + } + if cmd.Flags().Lookup("poll-interval") == nil { + t.Errorf("%s is missing --poll-interval", cmd.Name()) + } + } +} + +func TestAddAppWaitFlags(t *testing.T) { + cmd := &cobra.Command{Use: "resume"} + var ( + wait bool + timeout time.Duration + pollInterval time.Duration + ) + addAppWaitFlags(cmd, &wait, &timeout, &pollInterval) + if err := cmd.ParseFlags([]string{"--wait", "--timeout", "30s", "--poll-interval", "1s"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if !wait || timeout != 30*time.Second || pollInterval != time.Second { + t.Fatalf("wait=%v timeout=%s poll=%s", wait, timeout, pollInterval) + } +} + +func TestWaitContext_NoTimeoutUsesParent(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx, stop := waitContext(parent, 0) + defer stop() + if ctx != parent { + t.Fatal("zero timeout should reuse the parent context") + } +} + +func TestWaitContext_TimeoutCancels(t *testing.T) { + ctx, cancel := waitContext(context.Background(), time.Millisecond) + defer cancel() + select { + case <-ctx.Done(): + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("err = %v", ctx.Err()) + } + case <-time.After(time.Second): + t.Fatal("expected the wait context to expire") + } +} + +func TestWaitTimeoutErr(t *testing.T) { + if err := waitTimeoutErr(nil, "application my-app", time.Second); err != nil { + t.Fatalf("nil: %v", err) + } + if err := waitTimeoutErr(errors.New("boom"), "application my-app", time.Second); err == nil || err.Error() != "boom" { + t.Fatalf("passthrough: %v", err) + } + + err := waitTimeoutErr(context.DeadlineExceeded, "application my-app", 30*time.Second) + if err == nil || !strings.Contains(err.Error(), "timed out waiting for application my-app after 30s") { + t.Fatalf("timeout: %v", err) + } + err = waitTimeoutErr(context.DeadlineExceeded, "task t1", 0) + if err == nil || !strings.Contains(err.Error(), "timed out waiting for task t1") { + t.Fatalf("zero timeout: %v", err) + } +} + +func TestWaitFlagsNotOnStopOrDelete(t *testing.T) { + for _, cmd := range []*cobra.Command{newAppsStopCmd(nil), newAppsDeleteCmd(nil)} { + if cmd.Flags().Lookup("wait") != nil || cmd.Flags().Lookup("timeout") != nil { + t.Errorf("%s should not have --wait/--timeout", cmd.Name()) + } + } +} + +func TestWaitForApp_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"appId":"` + testAppID + `","appName":"My App","status":"initializing","configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"computeType":"gpu","gpuType":"h100"},"environmentVariables":[],"secrets":[],"createdAt":"2026-07-30T12:00:00Z","updatedAt":"2026-07-30T12:00:00Z"}`)) + })) + defer srv.Close() + + client := serverlessapi.NewClient("test-key", srv.URL, slog.Default()) + _, err := waitForApp(context.Background(), client, testAppID, time.Millisecond, 20*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "timed out waiting for application "+testAppID+" after 20ms") { + t.Fatalf("timeout: %v", err) + } +}