From 4c109314c01e582c58efd3b2b77705cbeabfeb00 Mon Sep 17 00:00:00 2001 From: jtenner Date: Mon, 5 Oct 2026 01:01:30 -0400 Subject: [PATCH 1/2] test: reproduce host resource leaks during graph initialization failure --- initialization_rollback_test.go | 83 ++++++++++++++++++ testdata/initialization_resource_failure.wasm | Bin 0 -> 407 bytes testdata/initialization_resource_failure.wat | 24 +++++ 3 files changed, 107 insertions(+) create mode 100644 initialization_rollback_test.go create mode 100644 testdata/initialization_resource_failure.wasm create mode 100644 testdata/initialization_resource_failure.wat diff --git a/initialization_rollback_test.go b/initialization_rollback_test.go new file mode 100644 index 0000000..0b20c16 --- /dev/null +++ b/initialization_rollback_test.go @@ -0,0 +1,83 @@ +package component_test + +import ( + "context" + _ "embed" + "errors" + "os" + "path/filepath" + "testing" + + component "github.com/wago-org/component-model" +) + +//go:embed testdata/initialization_resource_failure.wasm +var initializationResourceFailure []byte + +func TestGraphInitializationFailureClosesHostResources(t *testing.T) { + for _, cleanupFails := range []bool{false, true} { + name := "successful-cleanup" + if cleanupFails { + name = "cleanup-error" + } + t.Run(name, func(t *testing.T) { + rt, ref := loadService(t, nil) + defer rt.Close() + setupErr := errors.New("initializer setup failed") + cleanupErr := errors.New("resource cleanup failed") + const iface = "test:initialization/resources" + const tag uint32 = 700 + root := t.TempDir() + var files []*os.File + var dropped []uint32 + opts := []component.Option{ + component.WithResourceTag(iface, "resource", tag), + component.WithImport(iface, "acquire", func(context.Context, []component.Value) ([]component.Value, error) { + file, err := os.CreateTemp(root, "resource-") + if err != nil { + return nil, err + } + files = append(files, file) + t.Cleanup(func() { _ = file.Close() }) + return []component.Value{uint32(len(files))}, nil + }, nil, []component.TypeDesc{component.OwnDesc{ResourceType: tag}}), + component.WithImport(iface, "fail", func(context.Context, []component.Value) ([]component.Value, error) { return nil, setupErr }, nil, nil), + component.WithHostResourceDtor(tag, func(ctx context.Context, rep uint32) error { + if ctx.Err() != nil { + t.Errorf("resource cleanup context canceled: %v", ctx.Err()) + } + dropped = append(dropped, rep) + err := files[rep-1].Close() + if cleanupFails && rep == 1 { + return errors.Join(err, cleanupErr) + } + return err + }), + } + entered := false + err := ref.With(func(service component.Service) error { + return service.WithInstance(context.Background(), initializationResourceFailure, func(*component.Instance) error { entered = true; return nil }, opts...) + }) + if entered { + t.Fatal("callback ran after initialization failure") + } + if !errors.Is(err, setupErr) { + t.Fatalf("initialization error = %v, want setup error", err) + } + if len(files) != 2 { + t.Fatalf("acquired resources = %d, want 2", len(files)) + } + if len(dropped) != 2 || dropped[0] == dropped[1] { + t.Errorf("destructed resources = %v, want both resources exactly once", dropped) + } + for _, file := range files { + if _, statErr := file.Stat(); statErr == nil || file.Fd() != ^uintptr(0) { + t.Errorf("resource %s after failed initialization = %v, want closed", filepath.Base(file.Name()), statErr) + } + } + if cleanupFails && !errors.Is(err, cleanupErr) { + t.Errorf("initialization error = %v, want joined cleanup error", err) + } + }) + } +} diff --git a/testdata/initialization_resource_failure.wasm b/testdata/initialization_resource_failure.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a7eb3c613ff10898474d2a2ac241d59a7f3dac85 GIT binary patch literal 407 zcmY*VK~BRk5S-b#O_~JMR=t42g?okI$O-Y1TEPfQO#-ni5Es6Voiwyad+_e;&Ws1z zH5VY#2R_dgvfjAv(AOp*E%*dbA=UNA!S;p{284oo)ppM$za?(GasJV^)?2-|-`d-* zy_-nf4D*c;CkBkS*&c*ff{v4W~RdO;kTuXj}r3k7Vgx0|4W!*LJU29sujZCb950uA1wsjX$ mS;REP=MpR8pVk%Dg470%-aDQ_FI+JoQ?z5G*(}5xKlKY=+h2nK literal 0 HcmV?d00001 diff --git a/testdata/initialization_resource_failure.wat b/testdata/initialization_resource_failure.wat new file mode 100644 index 0000000..28768ab --- /dev/null +++ b/testdata/initialization_resource_failure.wat @@ -0,0 +1,24 @@ +;; The initializer acquires two ordinary host resources, then reports a +;; setup error. The runtime must destroy both resources before returning. +(component + (import "test:initialization/resources" (instance $host + (export "resource" (type (sub resource))) + (export "acquire" (func (result (own 0)))) + (export "fail" (func)))) + (alias export $host "acquire" (func $acquire)) + (alias export $host "fail" (func $fail)) + (core func $acquire-core (canon lower (func $acquire))) + (core func $fail-core (canon lower (func $fail))) + (core instance $imports + (export "acquire" (func $acquire-core)) + (export "fail" (func $fail-core))) + (core module $initializer + (import "host" "acquire" (func $acquire (result i32))) + (import "host" "fail" (func $fail)) + (func $initialize + call $acquire drop + call $acquire drop + call $fail) + (start $initialize)) + (core instance $initialized + (instantiate $initializer (with "host" (instance $imports))))) From e067af7fd38d35ccf4599183ccef8aa0953eeacd Mon Sep 17 00:00:00 2001 From: jtenner Date: Mon, 5 Oct 2026 01:02:56 -0400 Subject: [PATCH 2/2] fix: drain host resources when graph initialization fails --- internal/instance/graph.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/instance/graph.go b/internal/instance/graph.go index 3785b14..eb7b866 100644 --- a/internal/instance/graph.go +++ b/internal/instance/graph.go @@ -2,6 +2,7 @@ package instance import ( "context" + "errors" "fmt" "strconv" "strings" @@ -315,7 +316,14 @@ func instantiateGraph(ctx context.Context, r wazy.Runtime, comp *binary.Componen } } fail := func(err error) (*Instance, error) { + // Initializers can acquire host resources before graph construction + // fails. Drain those resources before closing their core modules, just + // as Instance.Close does, even when the caller canceled setup. + closeErr := resources.close(context.WithoutCancel(ctx)) closeAll() + if closeErr != nil { + return nil, errors.Join(err, closeErr) + } return nil, err }