◐ Off-By-One · answer catalog

go-sandbox-pool-never-created

1 answer(s)godocker

go-sandbox-pool-never-created

📦 Source in repository (JSON)

Answer

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.

1. 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)
}

2. Router — GetOrCreatePool before Acquire

Before:

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 ...
}

3. Admin handler — DestroyPool wired into DeleteApp via a nil-safe interface

The 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)
}

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog