Skip to content

Commit 2cd556d

Browse files
committed
feat: add lifecycle management for network ownership in engine options and improve test coverage
1 parent 33f8f57 commit 2cd556d

4 files changed

Lines changed: 145 additions & 4 deletions

File tree

engine.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ func New(setters ...Option) (*Engine, error) {
3434
return nil, err
3535
}
3636

37-
ownsNetwork := opts.network == nil
38-
37+
ownsNetwork := opts.hostNetwork == false
3938
boot, err := newBootstrap(opts)
4039
if err != nil {
4140
return nil, fmt.Errorf("bootstrap: %w", err)
@@ -53,7 +52,6 @@ func New(setters ...Option) (*Engine, error) {
5352
}
5453

5554
hooks := boot.hooks.clone()
56-
5755
// Run init hooks after bootstrap is finalized and before returning the engine.
5856
if err := hooks.engine.runInitHooks(); err != nil {
5957
initErr := fmt.Errorf("init hooks: %w", err)
@@ -81,7 +79,6 @@ func (e *Engine) Compile(ctx context.Context, src *source.Source) (*Plan, error)
8179
}
8280

8381
prog, err := e.compiler.Compile(src)
84-
8582
// After-compile hooks always run and receive the compilation error (if any).
8683
if hookErr := e.hooks.plan.runAfterCompileHooks(ctx, err); hookErr != nil {
8784
return nil, errors.Join(err, fmt.Errorf("after compile hooks: %w", hookErr))

engine_lifecycle_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,88 @@ func TestEngineCloseDoesNotCleanInjectedNetwork(t *testing.T) {
179179
}
180180
}
181181

182+
func TestEngineNetworkOwnershipFollowsLastOption(t *testing.T) {
183+
t.Parallel()
184+
185+
tests := []struct {
186+
name string
187+
managedLast bool
188+
}{
189+
{name: "network options last", managedLast: true},
190+
{name: "injected network last", managedLast: false},
191+
}
192+
193+
for _, tt := range tests {
194+
t.Run(tt.name, func(t *testing.T) {
195+
t.Parallel()
196+
197+
managedClient := &recordingHTTPClient{}
198+
injectedClient := &recordingHTTPClient{}
199+
injectedNetwork := mustNewTestNetwork(t, ferretnet.WithHTTPClient(injectedClient))
200+
201+
var setters []Option
202+
if tt.managedLast {
203+
setters = []Option{
204+
WithNetwork(injectedNetwork),
205+
WithNetworkOptions(ferretnet.WithHTTPClient(managedClient)),
206+
}
207+
} else {
208+
setters = []Option{
209+
WithNetworkOptions(ferretnet.WithHTTPClient(managedClient)),
210+
WithNetwork(injectedNetwork),
211+
}
212+
}
213+
214+
eng := mustNewEngine(t, setters...)
215+
if tt.managedLast {
216+
if got := eng.host.network.HTTP(); got != managedClient {
217+
t.Fatalf("expected network options client, got %T", got)
218+
}
219+
} else if eng.host.network != injectedNetwork {
220+
t.Fatalf("expected injected network, got %T", eng.host.network)
221+
}
222+
223+
if err := eng.Close(); err != nil {
224+
t.Fatalf("close engine: %v", err)
225+
}
226+
227+
wantManagedCloses := 0
228+
if tt.managedLast {
229+
wantManagedCloses = 1
230+
}
231+
232+
if got := managedClient.idleCloseCount(); got != wantManagedCloses {
233+
t.Fatalf("expected %d managed network cleanup calls, got %d", wantManagedCloses, got)
234+
}
235+
236+
if got := injectedClient.idleCloseCount(); got != 0 {
237+
t.Fatalf("expected injected network to remain caller-owned, got %d cleanup calls", got)
238+
}
239+
})
240+
}
241+
}
242+
243+
func TestNewCleansNetworkCreatedFromOptionsOnInitFailure(t *testing.T) {
244+
t.Parallel()
245+
246+
initErr := errors.New("init failed")
247+
client := &recordingHTTPClient{}
248+
249+
_, err := New(
250+
WithNetworkOptions(ferretnet.WithHTTPClient(client)),
251+
WithEngineInitHook(func() error {
252+
return initErr
253+
}),
254+
)
255+
if !errors.Is(err, initErr) {
256+
t.Fatalf("expected init error, got %v", err)
257+
}
258+
259+
if got := client.idleCloseCount(); got != 1 {
260+
t.Fatalf("expected construction-failure cleanup, got %d calls", got)
261+
}
262+
}
263+
182264
func TestNewCleansOwnedNetworkOnRegistrationFailure(t *testing.T) {
183265
t.Parallel()
184266

engine_options.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type (
2828
stdlib stdlib.Set
2929
logger []logging.Option
3030
network ferretnet.Network
31+
hostNetwork bool
3132
compiler []compiler.Option
3233
modules []module.Module
3334
maxActiveSessions int
@@ -597,13 +598,37 @@ func WithFSReadOnly() Option {
597598
}
598599

599600
// WithNetwork sets the engine network service used by derived executions.
601+
// If a network is provided, the engine will use it directly and will not manage its lifecycle.
602+
// The host application is responsible for closing the network when it is no longer needed.
600603
func WithNetwork(network ferretnet.Network) Option {
601604
return func(opts *options) error {
602605
if network == nil {
603606
return fmt.Errorf("network cannot be nil")
604607
}
605608

606609
opts.network = network
610+
opts.hostNetwork = true
611+
612+
return nil
613+
}
614+
}
615+
616+
// WithNetworkOptions creates an Option that constructs a new network service using the provided Ferret network options.
617+
// If no options are provided, the engine will use the default network service.
618+
func WithNetworkOptions(setters ...ferretnet.Option) Option {
619+
return func(opts *options) error {
620+
if len(setters) == 0 {
621+
return nil
622+
}
623+
624+
net, err := ferretnet.New(setters...)
625+
626+
if err != nil {
627+
return fmt.Errorf("create network: %w", err)
628+
}
629+
630+
opts.network = net
631+
opts.hostNetwork = false
607632

608633
return nil
609634
}

engine_options_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package ferret
22

33
import (
4+
"errors"
45
"strings"
56
"testing"
67

78
"github.com/MontFerret/ferret/v2/pkg/compiler"
9+
ferretnet "github.com/MontFerret/ferret/v2/pkg/net"
10+
ferrethttp "github.com/MontFerret/ferret/v2/pkg/net/http"
811
"github.com/MontFerret/ferret/v2/pkg/runtime"
912
"github.com/MontFerret/ferret/v2/pkg/stdlib"
1013
)
@@ -172,6 +175,40 @@ func TestNewOptionsRejectsBlankFSRoot(t *testing.T) {
172175
}
173176
}
174177

178+
func TestWithNetworkOptionsWithoutSettersIsNoOp(t *testing.T) {
179+
t.Parallel()
180+
181+
network := mustNewTestNetwork(t)
182+
opts := mustNewOptionsForTest(t, WithNetwork(network), WithNetworkOptions())
183+
184+
if opts.network != network {
185+
t.Fatalf("expected injected network to remain configured, got %T", opts.network)
186+
}
187+
188+
if !opts.hostNetwork {
189+
t.Fatal("expected injected network to remain caller-owned")
190+
}
191+
}
192+
193+
func TestWithNetworkOptionsReturnsNetworkConstructionError(t *testing.T) {
194+
t.Parallel()
195+
196+
_, err := newOptions([]Option{WithNetworkOptions(
197+
ferretnet.WithHTTPPolicies(ferrethttp.WithMaxResponseSize(-1)),
198+
)})
199+
if err == nil {
200+
t.Fatal("expected invalid network options to fail")
201+
}
202+
203+
if !errors.Is(err, ferrethttp.ErrInvalidPolicyConfiguration) {
204+
t.Fatalf("expected invalid policy configuration error, got: %v", err)
205+
}
206+
207+
if !strings.Contains(err.Error(), "create network: http client:") {
208+
t.Fatalf("expected network construction context, got: %v", err)
209+
}
210+
}
211+
175212
func TestWithStdlibSafeRegistersSelectedGroups(t *testing.T) {
176213
t.Parallel()
177214

0 commit comments

Comments
 (0)