go-sandbox-pool-never-created
Root cause: Manager.GetOrCreatePool was dead code. The router's /execute path called only Acquire, which performs a map lookup for an existing pool. Since nothing ever created the pool, every request hit ErrNoSandboxAvailable → 503 NO_SANDBOX_AVAILABLE.
Fix strategy: (1) make /execute call GetOrCreatePool first (idempotent, uses the app's own pool config), and (2) ensure DeleteApp tears the pool down through a nil-safe sandbox interface so pool lifecycle is symmetric and admin ops never panic when sandboxing is disabled.
sandbox.Manager — make the pool map the single source of truth// sandbox/manager.go
type Manager struct {
mu sync.Mutex
pools map[string]*Pool
}
// GetOrCreatePool is idempotent: returns the existing pool for appID
// or creates one from the app's ContainerPool config. It is safe for
// concurrent first requests — only one pool is ever created.
func (m *Manager) GetOrCreatePool(ctx context.Context, appID string, cfg ContainerConfig) (*Pool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if p, ok := m.pools[appID]; ok {
return p, nil
}
p, err := m.newPool(ctx, appID, cfg) // docker create, pull image, etc.
if err != nil {
return nil, fmt.Errorf("create pool for %q: %w", appID, err)
}
m.pools[appID] = p
return p, nil
}
// Acquire only needs an existing pool; it never creates one.
func (m *Manager) Acquire(ctx context.Context, appID string) (*Sandbox, error) {
m.mu.Lock()
p, ok := m.pools[appID]
m.mu.Unlock()
if !ok {
return nil, ErrNoSandboxAvailable
}
return p.acquire(ctx)
}
// DestroyPool is a no-op for unknown apps (no error) so delete paths
// can call it unconditionally.
func (m *Manager) DestroyPool(ctx context.Context, appID string) error {
m.mu.Lock()
p, ok := m.pools[appID]
delete(m.pools, appID) // remove first so Acquire fails fast mid-teardown
m.mu.Unlock()
if !ok {
return nil
}
return p.destroy(ctx)
}
GetOrCreatePool before AcquireBefore:
sb, err := h.sandbox.Acquire(r.Context(), app.ID)
if err != nil { http.Error(w, "NO_SANDBOX_AVAILABLE", 503); return }
After:
func (h *Handler) handleExecute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := appFromContext(ctx)
// First call site for GetOrCreatePool. Idempotent and cheap when the
// pool already exists; creates it on first /execute for the app.
if _, err := h.sandbox.GetOrCreatePool(ctx, app.ID, app.Config.ContainerPool); err != nil {
http.Error(w, "POOL_CREATE_FAILED: "+err.Error(), http.StatusInternalServerError)
return
}
sb, err := h.sandbox.Acquire(ctx, app.ID)
if err != nil {
if errors.Is(err, sandbox.ErrNoSandboxAvailable) {
http.Error(w, "NO_SANDBOX_AVAILABLE", http.StatusServiceUnavailable) // pool exists but is exhausted — still correct
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer h.sandbox.Release(ctx, app.ID, sb)
// ... run the request in the sandbox ...
}
DestroyPool wired into DeleteApp via a nil-safe interfaceThe admin handler holds a SandboxManager interface that may be nil (unit tests, sandboxing-disabled deployments). A plain h.sandbox != nil check is insufficient if the field ever holds a typed nil (*sandbox.Manager), which makes an interface compare non-nil — so the safe pattern is a no-op stub injected at construction time:
// admin/handler.go
type SandboxManager interface {
GetOrCreatePool(ctx context.Context, appID string, cfg ContainerConfig) (*Pool, error)
Acquire(ctx context.Context, appID string) (*Sandbox, error)
DestroyPool(ctx context.Context, appID string) error
}
type noopSandbox struct{}
func (noopSandbox) GetOrCreatePool(context.Context, string, ContainerConfig) (*Pool, error) { return nil, nil }
func (noopSandbox) Acquire(context.Context, string) (*Sandbox, error) { return nil, sandbox.ErrNoSandboxAvailable }
func (noopSandbox) DestroyPool(context.Context, string) error { return nil }
func NewAdminHandler(store AppStore, sb SandboxManager) *AdminHandler {
if sb == nil { sb = noopSandbox{} } // nil-safe: never a typed-nil interface
return &AdminHandler{store: store, sandbox: sb}
}
func (h *AdminHandler) DeleteApp(ctx context.Context, appID string) error {
if err := h.store.Delete(ctx, appID); err != nil {
return err
}
// No-op for unknown apps and for the noopSandbox — safe either way.
return h.sandbox.DestroyPool(ctx, appID)
}
Verification (live, Docker-based per the report): - `provision.sh` now passes **7/7** (previously the `/execute` health check failed because no pool existed). - First `/execute` against a fresh app: `GetOrCreatePool` creates the Docker pool; subsequent requests reuse it (verified via the manager's pool map + `docker ps` showing a single pool for the app). - `DeleteApp` removes the pool: `docker ps -a` shows no orphaned containers/networks after deletion — **no leaks**. - Full lifecycle observed live: create → execute → delete → pool gone; re-create on next execute. Edge cases covered: - **Concurrent first requests:** two simultaneous `/execute` calls race `GetOrCreatePool`; the mutex guarantees a single pool creation and both callers share it. - **Pool exhausted:** with a pool present but all sandboxes busy, `Acquire` still returns `ErrNoSandboxAvailable` → 503, preserving the correct semantics for capacity limits. - **Pool creation failure:** bad config/image → explicit `500 POOL_CREATE_FAILED`, not a misleading 503. - **DeleteApp with nil sandbox (tests / sandboxing disabled):** `NewAdminHandler(store, nil)` injects `noopSandbox`; no panic, delete succeeds. - **DeleteApp for an app that never had a pool:** `DestroyPool` is a documented no-op returning `nil`. - **Multiple apps:** pools are keyed by `appID` → isolation between apps, and deleting one app never touches another app's pool. - **Delete-then-execute:** after `DestroyPool`, a fresh `GetOrCreatePool` rebuilds the pool from the app's current config (no stale state). Unit tests added mirror these cases: `TestExecuteCreatesPool`, `TestGetOrCreatePoolIdempotent` (concurrent), `TestAcquireWithoutPool` (503), `TestDeleteAppDestroysPool`, `TestDeleteAppNilSandbox` (no panic), `TestDestroyPoolUnknownApp` (no-op). ---
{"model": "deepseek-v4-flash", "problem_class": "go-sandbox-pool-never-created", "result": "passed", "tests": 7}