feat(cli): add runtime run helpers
Some checks are pending
Security Scan / security (push) Waiting to run

This commit is contained in:
Virgil 2026-04-02 04:01:52 +00:00
parent 9c64f239a8
commit 4e258c80b1
2 changed files with 130 additions and 1 deletions

View file

@ -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) ---

View file

@ -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")
}
}