167 lines
5.7 KiB
Go
167 lines
5.7 KiB
Go
package instance
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/server/config"
|
|
"github.com/kandev/kandev/pkg/agent"
|
|
)
|
|
|
|
// TestCreateInstanceAbandonsWhenCallerGaveUp covers the leak that made instance
|
|
// creation collapse under load. Creation is serialised on m.mu and the control
|
|
// client gives up after 30s, so a queue fills with requests nobody awaits; each
|
|
// one used to build a full instance — port, HTTP server, workspace trackers —
|
|
// that no caller could ever stop, and the polling those trackers did made the
|
|
// next creation slower still.
|
|
func TestCreateInstanceAbandonsWhenCallerGaveUp(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
mgr := NewManager(&config.Config{
|
|
Ports: config.PortConfig{Base: 0, Max: 0},
|
|
Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP},
|
|
}, log)
|
|
t.Cleanup(func() { _ = mgr.Shutdown(context.Background()) })
|
|
|
|
// The caller timed out while this request sat in the queue.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
resp, err := mgr.CreateInstance(ctx, &CreateRequest{WorkspacePath: t.TempDir()})
|
|
if err == nil {
|
|
t.Fatalf("expected an error for a caller that had gone, got instance %+v", resp)
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("error = %v, want it to wrap context.Canceled", err)
|
|
}
|
|
if resp != nil {
|
|
t.Errorf("returned a response %+v alongside the error; the caller would treat the instance as live", resp)
|
|
}
|
|
|
|
mgr.mu.RLock()
|
|
live := len(mgr.instances)
|
|
mgr.mu.RUnlock()
|
|
if live != 0 {
|
|
t.Errorf("registered %d instances for a caller that had gone, want 0", live)
|
|
}
|
|
}
|
|
|
|
// TestAbandonPartialInstanceReleasesPort pins the unwind path taken when the
|
|
// caller disappears mid-creation: the port must go back to the allocator and
|
|
// the listener must close, or the pool drains one entry per abandoned request.
|
|
func TestAbandonPartialInstanceReleasesPort(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
mgr := NewManager(&config.Config{
|
|
Ports: config.PortConfig{Base: 0, Max: 0},
|
|
Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP},
|
|
}, log)
|
|
t.Cleanup(func() { _ = mgr.Shutdown(context.Background()) })
|
|
|
|
port, listener, err := mgr.allocatePortAndListener("abandoned")
|
|
if err != nil {
|
|
t.Fatalf("allocatePortAndListener: %v", err)
|
|
}
|
|
addr := listener.Addr().String()
|
|
|
|
// A nil process manager stands in for abandonment before one exists; the
|
|
// port and the listener still have to be given back.
|
|
mgr.abandonPartialInstance("abandoned", port, listener, nil)
|
|
|
|
// The listener is closed, so the address is bindable again.
|
|
reopened, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
t.Fatalf("port %d still bound after abandon: %v", port, err)
|
|
}
|
|
_ = reopened.Close()
|
|
|
|
// And the allocator handed the port back rather than holding it as in use.
|
|
next, nextListener, err := mgr.allocatePortAndListener("next")
|
|
if err != nil {
|
|
t.Fatalf("allocatePortAndListener after abandon: %v", err)
|
|
}
|
|
_ = nextListener.Close()
|
|
mgr.portAlloc.Release(next)
|
|
}
|
|
|
|
// TestCreateInstanceAbandonsAfterTrackerStartup drives the second context check
|
|
// deterministically. The caller's context is live when CreateInstance takes the
|
|
// lock and is cancelled exactly once the trackers have started, via the
|
|
// afterTrackerStart seam — so the abandonment branch is guaranteed to run
|
|
// rather than merely likely to.
|
|
func TestCreateInstanceAbandonsAfterTrackerStartup(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
mgr := NewManager(&config.Config{
|
|
Ports: config.PortConfig{Base: 0, Max: 0},
|
|
Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP},
|
|
}, log)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
mgr.afterTrackerStart = cancel
|
|
|
|
resp, err := mgr.CreateInstance(ctx, &CreateRequest{WorkspacePath: t.TempDir()})
|
|
if err == nil {
|
|
t.Fatalf("expected an error once the caller was cancelled mid-creation, got %+v", resp)
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("error = %v, want it to wrap context.Canceled", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "during startup") {
|
|
t.Errorf("error = %v, want the post-startup branch rather than the queued one", err)
|
|
}
|
|
if resp != nil {
|
|
t.Errorf("returned response %+v alongside the error", resp)
|
|
}
|
|
|
|
mgr.mu.RLock()
|
|
live := len(mgr.instances)
|
|
mgr.mu.RUnlock()
|
|
if live != 0 {
|
|
t.Errorf("registered %d instances after abandoning creation, want 0", live)
|
|
}
|
|
|
|
// Shutdown must wait for the teardown goroutine rather than racing it.
|
|
if err := mgr.Shutdown(context.Background()); err != nil {
|
|
t.Fatalf("Shutdown: %v", err)
|
|
}
|
|
|
|
// With teardown drained, the port is back in the pool and bindable.
|
|
port, listener, err := mgr.allocatePortAndListener("after-abandon")
|
|
if err != nil {
|
|
t.Fatalf("port was not released by the abandoned creation: %v", err)
|
|
}
|
|
_ = listener.Close()
|
|
mgr.portAlloc.Release(port)
|
|
}
|
|
|
|
// TestCreateInstanceRefusedAfterShutdown closes the ordering window Shutdown
|
|
// would otherwise leave: it could observe abandonWG at zero and a creation
|
|
// already past its own checks could register a teardown behind the Wait.
|
|
func TestCreateInstanceRefusedAfterShutdown(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
mgr := NewManager(&config.Config{
|
|
Ports: config.PortConfig{Base: 0, Max: 0},
|
|
Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP},
|
|
}, log)
|
|
|
|
if err := mgr.Shutdown(context.Background()); err != nil {
|
|
t.Fatalf("Shutdown: %v", err)
|
|
}
|
|
|
|
resp, err := mgr.CreateInstance(context.Background(), &CreateRequest{WorkspacePath: t.TempDir()})
|
|
if !errors.Is(err, ErrManagerShuttingDown) {
|
|
t.Fatalf("error = %v, want ErrManagerShuttingDown", err)
|
|
}
|
|
if resp != nil {
|
|
t.Errorf("returned response %+v after shutdown", resp)
|
|
}
|
|
|
|
mgr.mu.RLock()
|
|
live := len(mgr.instances)
|
|
mgr.mu.RUnlock()
|
|
if live != 0 {
|
|
t.Errorf("registered %d instances after shutdown, want 0", live)
|
|
}
|
|
}
|