From 4e258c80b1e0baae87207bb5bfe4dbc6cb0a977f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 04:01:52 +0000 Subject: [PATCH] feat(cli): add runtime run helpers --- pkg/cli/runtime.go | 52 +++++++++++++++++++++++- pkg/cli/runtime_run_test.go | 79 +++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 pkg/cli/runtime_run_test.go diff --git a/pkg/cli/runtime.go b/pkg/cli/runtime.go index b4aab7e..065e269 100644 --- a/pkg/cli/runtime.go +++ b/pkg/cli/runtime.go @@ -19,6 +19,7 @@ import ( "os/signal" "sync" "syscall" + "time" "dappco.re/go/core" "github.com/spf13/cobra" @@ -145,6 +146,55 @@ func Execute() error { return instance.root.Execute() } +// Run executes the CLI and watches an external context for cancellation. +// If the context is cancelled first, the runtime is shut down and the +// command error is returned if execution failed during shutdown. +func Run(ctx context.Context) error { + mustInit() + if ctx == nil { + ctx = context.Background() + } + + errCh := make(chan error, 1) + go func() { + errCh <- Execute() + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + Shutdown() + if err := <-errCh; err != nil { + return err + } + return ctx.Err() + } +} + +// RunWithTimeout returns a shutdown helper that waits for the runtime to stop +// for up to timeout before giving up. It is intended for deferred cleanup. +func RunWithTimeout(timeout time.Duration) func() { + return func() { + if timeout <= 0 { + Shutdown() + return + } + + done := make(chan struct{}) + go func() { + Shutdown() + close(done) + }() + + select { + case <-done: + case <-time.After(timeout): + // Give up waiting, but let the shutdown goroutine finish in the background. + } + } +} + // Context returns the CLI's root context. // Cancelled on SIGINT/SIGTERM. func Context() context.Context { @@ -158,7 +208,7 @@ func Shutdown() { return } instance.cancel() - _ = instance.core.ServiceShutdown(instance.ctx) + _ = instance.core.ServiceShutdown(context.WithoutCancel(instance.ctx)) } // --- Signal Srv (internal) --- diff --git a/pkg/cli/runtime_run_test.go b/pkg/cli/runtime_run_test.go new file mode 100644 index 0000000..95ba2bd --- /dev/null +++ b/pkg/cli/runtime_run_test.go @@ -0,0 +1,79 @@ +package cli + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRun_Good_ReturnsCommandError(t *testing.T) { + resetGlobals(t) + + require.NoError(t, Init(Options{AppName: "test"})) + + RootCmd().AddCommand(NewCommand("boom", "Boom", "", func(_ *Command, _ []string) error { + return errors.New("boom") + })) + RootCmd().SetArgs([]string{"boom"}) + + err := Run(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestRun_Good_CancelledContext(t *testing.T) { + resetGlobals(t) + + require.NoError(t, Init(Options{AppName: "test"})) + + RootCmd().AddCommand(NewCommand("wait", "Wait", "", func(_ *Command, _ []string) error { + <-Context().Done() + return nil + })) + RootCmd().SetArgs([]string{"wait"}) + + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(25*time.Millisecond, cancel) + + err := Run(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestRunWithTimeout_Good_ReturnsHelper(t *testing.T) { + resetGlobals(t) + + finished := make(chan struct{}) + var finishedOnce sync.Once + require.NoError(t, Init(Options{ + AppName: "test", + Services: []core.Service{ + { + Name: "slow-stop", + OnStop: func() core.Result { + time.Sleep(100 * time.Millisecond) + finishedOnce.Do(func() { + close(finished) + }) + return core.Result{OK: true} + }, + }, + }, + })) + + start := time.Now() + RunWithTimeout(20 * time.Millisecond)() + require.Less(t, time.Since(start), 80*time.Millisecond) + + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("shutdown did not complete") + } +}