Commit 9ec88925 authored by protolambda's avatar protolambda

op-service: command lifecycle testing with mock app and substitute process signals

parent cb453762
...@@ -4,6 +4,7 @@ import ( ...@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
...@@ -34,23 +35,39 @@ type LifecycleAction func(ctx *cli.Context, close context.CancelCauseFunc) (Life ...@@ -34,23 +35,39 @@ type LifecycleAction func(ctx *cli.Context, close context.CancelCauseFunc) (Life
// The app may continue to run post-processing until fully shutting down. // The app may continue to run post-processing until fully shutting down.
// The user can force an early shut-down during post-processing by sending a second interruption signal. // The user can force an early shut-down during post-processing by sending a second interruption signal.
func LifecycleCmd(fn LifecycleAction) cli.ActionFunc { func LifecycleCmd(fn LifecycleAction) cli.ActionFunc {
return lifecycleCmd(fn, opio.BlockOnInterruptsContext)
}
type waitSignalFn func(ctx context.Context, signals ...os.Signal)
var interruptErr = errors.New("interrupt signal")
func lifecycleCmd(fn LifecycleAction, blockOnInterrupt waitSignalFn) cli.ActionFunc {
return func(ctx *cli.Context) error { return func(ctx *cli.Context) error {
hostCtx := ctx.Context hostCtx := ctx.Context
appCtx, appCancel := context.WithCancelCause(hostCtx) appCtx, appCancel := context.WithCancelCause(hostCtx)
ctx.Context = appCtx ctx.Context = appCtx
go func() { go func() {
opio.BlockOnInterruptsContext(appCtx) blockOnInterrupt(appCtx)
appCancel(errors.New("interrupt signal")) appCancel(interruptErr)
}() }()
appLifecycle, err := fn(ctx, appCancel) appLifecycle, err := fn(ctx, appCancel)
if err != nil { if err != nil {
return fmt.Errorf("failed to setup: %w", err) // join errors to include context cause (nil errors are dropped)
return errors.Join(
fmt.Errorf("failed to setup: %w", err),
context.Cause(appCtx),
)
} }
if err := appLifecycle.Start(appCtx); err != nil { if err := appLifecycle.Start(appCtx); err != nil {
return fmt.Errorf("failed to start: %w", err) // join errors to include context cause (nil errors are dropped)
return errors.Join(
fmt.Errorf("failed to start: %w", err),
context.Cause(appCtx),
)
} }
// wait for app to be closed (through interrupt, or app requests to be stopped by closing the context) // wait for app to be closed (through interrupt, or app requests to be stopped by closing the context)
...@@ -58,17 +75,23 @@ func LifecycleCmd(fn LifecycleAction) cli.ActionFunc { ...@@ -58,17 +75,23 @@ func LifecycleCmd(fn LifecycleAction) cli.ActionFunc {
// Graceful stop context. // Graceful stop context.
// This allows the service to idle before shutdown, if halted. User may interrupt. // This allows the service to idle before shutdown, if halted. User may interrupt.
stopCtx, stopCancel := context.WithCancel(hostCtx) stopCtx, stopCancel := context.WithCancelCause(hostCtx)
go func() { go func() {
opio.BlockOnInterruptsContext(stopCtx) blockOnInterrupt(stopCtx)
stopCancel() stopCancel(interruptErr)
}() }()
// Execute graceful stop. // Execute graceful stop.
stopErr := appLifecycle.Stop(stopCtx) stopErr := appLifecycle.Stop(stopCtx)
stopCancel() stopCancel(nil)
// note: Stop implementation may choose to suppress a context error,
// if it handles it well (e.g. stop idling after a halt).
if stopErr != nil { if stopErr != nil {
return fmt.Errorf("failed to stop app: %w", stopErr) // join errors to include context cause (nil errors are dropped)
return errors.Join(
fmt.Errorf("failed to stop: %w", stopErr),
context.Cause(stopCtx),
)
} }
return nil return nil
......
package cliapp
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
)
type fakeLifecycle struct {
startCh, stopCh chan error
stopped bool
selfClose context.CancelCauseFunc
}
func (f *fakeLifecycle) Start(ctx context.Context) error {
select {
case err := <-f.startCh:
f.stopped = true
return err
case <-ctx.Done():
f.stopped = true
return ctx.Err()
}
}
func (f *fakeLifecycle) Stop(ctx context.Context) error {
select {
case err := <-f.stopCh:
f.stopped = true
return err
case <-ctx.Done():
f.stopped = true
return ctx.Err()
}
}
func (f *fakeLifecycle) Stopped() bool {
return f.stopped
}
var _ Lifecycle = (*fakeLifecycle)(nil)
func TestLifecycleCmd(t *testing.T) {
appSetup := func(t *testing.T, shareApp **fakeLifecycle) (signalCh chan struct{}, initCh, startCh, stopCh, resultCh chan error) {
signalCh = make(chan struct{})
initCh = make(chan error)
startCh = make(chan error)
stopCh = make(chan error)
resultCh = make(chan error)
// mock an application that may fail at different stages of its lifecycle
mockAppFn := func(ctx *cli.Context, close context.CancelCauseFunc) (Lifecycle, error) {
select {
case <-ctx.Context.Done():
return nil, ctx.Context.Err()
case err := <-initCh:
if err != nil {
return nil, err
}
}
app := &fakeLifecycle{
startCh: startCh,
stopCh: stopCh,
stopped: false,
selfClose: close,
}
if shareApp != nil {
*shareApp = app
}
return app, nil
}
// puppeteer a system signal waiter with a test signal channel
fakeSignalWaiter := func(ctx context.Context, signals ...os.Signal) {
select {
case <-ctx.Done():
case <-signalCh:
}
}
// turn our mock app and system signal into a lifecycle-managed command
actionFn := lifecycleCmd(mockAppFn, fakeSignalWaiter)
// try to shut the test down after being locked more than a minute
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
t.Cleanup(cancel)
// create a fake CLI context to run our command with
cliCtx := &cli.Context{
Context: ctx,
App: &cli.App{
Name: "test-app",
Action: actionFn,
},
Command: nil,
}
// run the command async, it may block etc. The result will be sent back to the tester.
go func() {
result := actionFn(cliCtx)
require.NoError(t, ctx.Err(), "expecting test context to be alive after end still")
// collect the result
resultCh <- result
}()
t.Cleanup(func() {
close(signalCh)
close(initCh)
close(startCh)
close(stopCh)
close(resultCh)
})
return
}
t.Run("interrupt int", func(t *testing.T) {
signalCh, _, _, _, resultCh := appSetup(t, nil)
signalCh <- struct{}{}
res := <-resultCh
require.ErrorIs(t, res, interruptErr)
require.ErrorContains(t, res, "failed to setup")
})
t.Run("failed init", func(t *testing.T) {
_, initCh, _, _, resultCh := appSetup(t, nil)
v := errors.New("TEST INIT ERRROR")
initCh <- v
res := <-resultCh
require.ErrorIs(t, res, v)
require.ErrorContains(t, res, "failed to setup")
})
t.Run("interrupt start", func(t *testing.T) {
var app *fakeLifecycle
signalCh, initCh, _, _, resultCh := appSetup(t, &app)
initCh <- nil
require.False(t, app.Stopped())
signalCh <- struct{}{}
res := <-resultCh
require.ErrorIs(t, res, interruptErr)
require.ErrorContains(t, res, "failed to start")
require.True(t, app.Stopped())
})
t.Run("failed start", func(t *testing.T) {
var app *fakeLifecycle
_, initCh, startCh, _, resultCh := appSetup(t, &app)
initCh <- nil
require.False(t, app.Stopped())
v := errors.New("TEST START ERROR")
startCh <- v
res := <-resultCh
require.ErrorIs(t, res, v)
require.ErrorContains(t, res, "failed to start")
require.True(t, app.Stopped())
})
t.Run("graceful shutdown", func(t *testing.T) {
var app *fakeLifecycle
signalCh, initCh, startCh, stopCh, resultCh := appSetup(t, &app)
initCh <- nil
require.False(t, app.Stopped())
startCh <- nil
signalCh <- struct{}{} // interrupt, but at an expected time
stopCh <- nil // graceful shutdown after interrupt
require.NoError(t, <-resultCh, nil)
require.True(t, app.Stopped())
})
t.Run("interrupted shutdown", func(t *testing.T) {
var app *fakeLifecycle
signalCh, initCh, startCh, _, resultCh := appSetup(t, &app)
initCh <- nil
require.False(t, app.Stopped())
startCh <- nil
signalCh <- struct{}{} // start graceful shutdown
signalCh <- struct{}{} // interrupt before the shutdown process is allowed to complete
res := <-resultCh
require.ErrorIs(t, res, interruptErr)
require.ErrorContains(t, res, "failed to stop")
require.True(t, app.Stopped()) // still fully closes, interrupts only accelerate shutdown where possible.
})
t.Run("failed shutdown", func(t *testing.T) {
var app *fakeLifecycle
signalCh, initCh, startCh, stopCh, resultCh := appSetup(t, &app)
initCh <- nil
require.False(t, app.Stopped())
startCh <- nil
signalCh <- struct{}{} // start graceful shutdown
v := errors.New("TEST STOP ERROR")
stopCh <- v
res := <-resultCh
require.ErrorIs(t, res, v)
require.ErrorContains(t, res, "failed to stop")
require.True(t, app.Stopped())
})
t.Run("app self-close", func(t *testing.T) {
var app *fakeLifecycle
_, initCh, startCh, stopCh, resultCh := appSetup(t, &app)
initCh <- nil
require.False(t, app.Stopped())
startCh <- nil
v := errors.New("TEST SELF CLOSE ERROR")
app.selfClose(v)
stopCh <- nil
require.NoError(t, <-resultCh, "self-close is not considered an error")
require.True(t, app.Stopped())
})
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment