Skip to content

Run host resource destructors for live handles during Instance.Close #3

Description

@JairusSW

Problem

Instance.Close cancels an outstanding async call, reaps parked tasks, closes core modules/sub-instances, and returns. It never drains in.resources, so any still-live owned host resource bypasses the callback registered with WithHostResourceDtor.

Current close path:

func safeExportedFunction(mod api.Module, name string) (fn api.Function) {
defer func() { _ = recover() }()
return mod.ExportedFunction(name)
}
// Close releases every module instantiated for this component (in reverse
// order of instantiation). It does not close the Runtime passed to
// Instantiate, which the caller owns.
func (in *Instance) Close(ctx context.Context) error {
in.amu.Lock()
p := in.pending
in.amu.Unlock()
if p != nil {
in.finishAsync(p, nil, errCallAsyncCancelled)
}
// Reap every parked goroutine (stackful task or promoted callback-task
// segment) in the shared scheduler BEFORE closing core modules
// (docs/component-model-async-stackful-design.md §8, Feature 1
// docs/component-model-async-final3-fable.md §1.5): aborting a parked
// task unwinds guest frames still inside the engine, which needs the
// core modules to still be alive. sched is shared per composition tree,
// so whichever Close in the tree runs first reaps all of them; later
// calls (subInstances' own Close, below) find nothing left to reap.
if in.sched != nil {
in.reapParkedGoroutines()
}
if reproAssertThreads {
in.assertThreadsQuiescent("Close/after-reap")
}
var firstErr error
for i := len(in.closers) - 1; i >= 0; i-- {
if err := in.closers[i].Close(ctx); err != nil && firstErr == nil {
firstErr = err
}
}

The handle table stores per-resource-type destructor callbacks in dtors, and canonical resource.drop uses that information for explicit guest drops:

// tag is dropped by canon resource.drop. A GUEST-defined resource's dtor
// invokes its core func (registered lazily -- see resourceDtor -- because the
// dtor's own module may not be instantiated when a `start` section triggers
// the first drop); a HOST-provided resource's dtor is a Go callback (drop
// accounting). nil callback means drop just removes the entry.
dtors map[uint32]func(ctx context.Context, rep uint32) error
// names maps a resource type tag to the WIT name it was registered under
// (withResourceTag), used only to make this table's errors legible: a
// tag is an internal number that means nothing to someone reading a trap,
// whereas "network (wasi:sockets/network)" names the thing the guest
// actually asked for. Populated once per instance by setResourceNames;
// nil (every tag unnamed) is valid and degrades to bare numbers.
names map[uint32]string
}
// setResourceNames records tag -> WIT name for this table's error messages,
// inverting the config's (iface, name) -> tag registrations. A tag registered
// under several interfaces keeps the first name seen; the point is a legible
// label, not a canonical one.
func (t *handleTable) setResourceNames(tags map[importKey]uint32) {
t.mu.Lock()
defer t.mu.Unlock()
if t.names == nil {
t.names = make(map[uint32]string, len(tags))

Impact

Host resources that remain live when an instance is closed are leaked instead of released. For a WASI host this includes open files, sockets/listeners, HTTP bodies, directory iterators, and other state whose backing representation is owned by the host. Closing the Wago core modules does not close those unrelated Go values.

This also breaks the deterministic-teardown property expected from an instance/store boundary: resource cleanup currently depends on the guest explicitly dropping every handle before shutdown.

Suggested fix

Add a handle-table shutdown/drain operation that:

  1. atomically prevents new entries and snapshots/removes all live entries;
  2. runs registered host resource destructors for live owned resource entries exactly once;
  3. releases async table entries/waitables as needed;
  4. preserves the first error while continuing cleanup of the remaining resources;
  5. runs before core modules are closed.

Guest-defined resource destructors may need separate handling because they execute guest code; the teardown policy should be explicit about whether they run during normal close and how traps are handled. Host callbacks must at minimum be invoked so external resources are not leaked.

Regression test

Register a host resource destructor that increments a counter, mint several live owned handles, explicitly drop one, then call Instance.Close. Assert:

  • the explicit drop invokes its destructor once;
  • close invokes the remaining destructors once each;
  • repeated close does not invoke any destructor again;
  • cleanup continues after one destructor returns an error.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions