← reports

#1919 · storage.database() opens a new SQLite handle on every call; plugins leak fds until dispose

Bug Priority: High Effort: small plugins open on GitHub 2026-08-19 · base d81fee6f

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The plugin host API bb.storage.database() is documented (SDK contract comment) and simulated (the SDK's FakePluginHost) as returning one reused better-sqlite3 handle per plugin. The production implementation in apps/server/src/services/plugins/plugin-api.ts instead constructs new Database(...) on every call and only appends it to a databaseHandles array that is drained on plugin dispose/reload. Each handle holds open file descriptors on data.db and data.db-wal, so a plugin that calls database() inside hot service methods accumulates fds linearly for the life of the server process. I reproduced this on a fresh dev instance at d81fee6f: 300 HTTP calls to a plugin route that calls database() raised the server's open-fd count on data.db from 1 to 301 (601 new fds total), and bb plugin reload dropped it back to 0. A unit test in the server package fails on d81fee6f (200 calls → 200 distinct handles) and passes with a six-line fix that caches the handle per plugin generation; the existing plugin test suite (381 tests) still passes with the fix.

2. Claims vs findings

Claim from the issueStatusEvidence
PluginStorage.database() opens a new connection on every call and pushes it onto databaseHandles; no reuse.Verifiedplugin-api.ts#L599-L608; unit test: 200 calls → 200 distinct handles (log).
Handles are only closed on plugin dispose/reload.VerifiedOnly drain sites are plugin-runtime.ts#L1505, #L1542, #L1576, #L1681 (candidate rollback, host-artifact failure, swap, dispose). Live: bb plugin reload fdleak took data.db fds 301 → 0.
SDK comment says "Open (or reuse the path of)"; the fake host caches one handle; production does not.Verifiedbackend-contract.ts#L117-L123; fake-plugin-host.ts#L890-L902. The comment wording is ambiguous ("reuse the path"), but the fake host and the plugin-authoring SKILL both present it as the plugin database, and every first-party plugin treats the return as a singleton.
Each call leaks fds on data.db and data.db-wal (shm stays small).VerifiedLive repro after 300 calls: 301 × data.db, 300 × data.db-wal, 1 × data.db-shm (log). SQLite WAL mode shares one shm mapping per process, which is why shm stays at 1.
Measured 42,498 fds on a Factory install; ~85,080 open files; spawn EBADF for execFile("ps") for ~2 days.Unverified (plausible)I do not have the Factory plugin or that machine. The mechanism is confirmed and fd growth is unbounded, so exhaustion of the per-process fd limit (and the resulting EBADF from libuv's spawn path) follows. On Linux the dev server's soft limit is 524288, so it would take longer to hit than on macOS (138,240 reported).
The leak restarts immediately after reload.VerifiedNothing changes on reload; the new generation has the same database() implementation. Re-running the live script after reload shows the same growth.
Expected: one reused handle per plugin, close only on dispose.AgreeSix-line change to plugin-api.ts verified below (unit + live).

3. Environment

4. Minimal reproduction

A. Unit test (fails on d81fee6f)

  1. Save the test below as apps/server/test/services/plugins/plugin-database-handle-reuse.test.ts (copy: 1919/repro/plugin-database-handle-reuse.test.ts). It installs a throwaway plugin whose factory calls bb.storage.database() 200 times and then counts distinct handles and the process's open fds under the plugin's data dir (via /proc/self/fd, Linux only).
  2. Run it from apps/server:
    pnpm exec vitest run test/services/plugins/plugin-database-handle-reuse.test.ts
  3. Expected: pass (1 distinct handle, ≤1 fd on data.db). Actual on d81fee6f:
    [#1919] 200 database() calls -> 200 distinct handles; open fds under /tmp/bb-plugin-db-reuse-8Qj7MN/data/plugins/chatty: { 'data.db-wal': 199, 'data.db': 200, 'data.db-shm': 1 }
    
    AssertionError: expected 200 to be 1 // Object.is equality
      ❯ test/services/plugins/plugin-database-handle-reuse.test.ts:123:27
        expect(distinct.size).toBe(1); // FAILS on d81fee6f: 200 distinct handles
    
     Test Files  1 failed (1)
          Tests  1 failed (1)
// Repro for get-bb/bb#1919: storage.database() opens a new better-sqlite3
// connection on every call instead of reusing one handle per plugin, so a
// chatty plugin leaks file descriptors until dispose/reload.
import {
  mkdtemp,
  mkdir,
  readdir,
  readlink,
  rm,
  writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createConnection, migrate, type DbConnection } from "@bb/db";
import type { Logger } from "@bb/logger";
import {
  createPluginService,
  type PluginService,
} from "../../../src/services/plugins/plugin-service.js";
import { testLogger } from "../../helpers/test-app.js";
import { createNoopTelemetryService } from "../../../src/services/system/telemetry.js";

const logger = testLogger as unknown as Logger;

/** Count open fds in this process that point at files under `dir`. */
async function openFdsUnder(dir: string): Promise<Record<string, number>> {
  const counts: Record<string, number> = {};
  for (const fd of await readdir("/proc/self/fd")) {
    let target: string;
    try {
      target = await readlink(join("/proc/self/fd", fd));
    } catch {
      continue;
    }
    if (target.startsWith(dir)) {
      const name = target.slice(dir.length + 1);
      counts[name] = (counts[name] ?? 0) + 1;
    }
  }
  return counts;
}

describe("storage.database() handle reuse (#1919)", () => {
  let db: DbConnection;
  let workDir: string;
  let dataDir: string;
  let service: PluginService;

  beforeEach(async () => {
    db = createConnection(":memory:");
    migrate(db);
    workDir = await mkdtemp(join(tmpdir(), "bb-plugin-db-reuse-"));
    dataDir = join(workDir, "data");
    service = createPluginService({
      telemetry: createNoopTelemetryService(),
      db,
      hub: {
        getDaemonSessionIdForHost: () => null,
        notifyPluginSignal: () => 0,
        notifySystem: () => {},
      },
      logger,
      dataDir,
      appVersion: "0.9.0",
      loadTimeoutMs: 2000,
    });
  });

  afterEach(async () => {
    await service.stop();
    await rm(workDir, { recursive: true, force: true });
  });

  it("returns one reused handle per plugin instead of opening a connection per call", async () => {
    const CALLS = 200;
    const rootDir = join(workDir, "bb-plugin-chatty");
    await mkdir(rootDir, { recursive: true });
    await writeFile(
      join(rootDir, "package.json"),
      JSON.stringify({
        name: "bb-plugin-chatty",
        version: "0.1.0",
        bb: {
          name: "Chatty",
          description: "Calls storage.database() on every service method.",
          branding: { icon: "Zap" },
          server: "./server.ts",
        },
      }),
    );
    await writeFile(
      join(rootDir, "server.ts"),
      `
      export default function plugin(bb: any) {
        const g = globalThis as any;
        g.__chatty = { bb, handles: [] as unknown[] };
        // Simulates a plugin whose every service method calls database().
        for (let i = 0; i < ${CALLS}; i++) {
          const db = bb.storage.database();
          db.prepare("SELECT 1").get();
          g.__chatty.handles.push(db);
        }
      }
      `,
    );
    const entry = await service.installPath(rootDir);
    expect(entry.status).toBe("running");

    const state = (globalThis as Record<string, unknown>).__chatty as {
      handles: unknown[];
    };
    const distinct = new Set(state.handles);
    const pluginDir = join(dataDir, "plugins", "chatty");
    const fds = await openFdsUnder(pluginDir);
    // eslint-disable-next-line no-console
    console.log(
      `[#1919] ${CALLS} database() calls -> ${distinct.size} distinct handles; open fds under ${pluginDir}:`,
      fds,
    );

    // Contract (backend-contract.ts / fake-plugin-host.ts): one reused handle.
    expect(distinct.size).toBe(1); // FAILS on d81fee6f: 200 distinct handles
    expect(fds["data.db"] ?? 0).toBeLessThanOrEqual(1); // FAILS: 200 fds on data.db
  });
});

B. Live repro against a running bb server

  1. Start your dev instance (scripts/bb-dev-app current) and note the Server URL and Data dir.
  2. Create a tiny plugin at /tmp/bb-1919-plugin/bb-plugin-fdleak/ (files in 1919/repro/bb-plugin-fdleak/). Its one route calls database() per request, exactly like a plugin that does so in each service method:
    // Mimics a chatty plugin (e.g. Factory) that calls bb.storage.database()
    // inside every service method instead of caching it at load time.
    export default function plugin(bb: any) {
      bb.http.route(
        "GET",
        "/ping",
        async () => {
          const db = bb.storage.database();
          const row = db.prepare("SELECT 1 AS one").get();
          return Response.json({ ok: true, one: row.one, pid: process.pid });
        },
        { auth: "none" },
      );
    }
    
  3. Install it:
    BB_SERVER_URL=http://localhost:23917 pnpm bb:dev plugin install /tmp/bb-1919-plugin/bb-plugin-fdleak --yes
    # Installed: fdleak@0.1.0  running
  4. Run 1919/repro/live-repro.sh, which hits the route 300 times and counts fds in the server process via /proc/<pid>/fd:
    BB_SERVER_URL=http://localhost:23917 BB_DATA_DIR=~/.bb-dev/<instance> bash live-repro.sh 300
    Expected: fds on data.db stay at 1. Actual (verbatim):
    server pid=3125178  data.db=/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_d5c47f31-487-2-d2c2aa35cd26/plugins/fdleak/data.db
    before: fds on data.db=1  total fds=49  (soft limit: 524288)
    after 300 GET http://localhost:23917/api/v1/plugins/fdleak/http/ping:
    after : fds on data.db=301  total fds=650
    by file:
        301 plugins/fdleak/data.db
          1 plugins/fdleak/data.db-shm
        300 plugins/fdleak/data.db-wal
    
  5. pnpm bb:dev plugin reload fdleak then recount: 0 fds on data.db — reload closes them, matching the issue.

Repro files: 1919/repro/

5. Root cause

plugin-api.ts#L597-L608 (introduced unchanged in e83d58fba, "Plugin SDK 0.3", #716):

  const storage: PluginStorage = {
    kv,
    database() {
      assertLive();
      const dir = join(dataDir, "plugins", pluginId);
      mkdirSync(dir, { recursive: true });
      const database = new Database(join(dir, "data.db"));   // <-- new connection EVERY call
      database.pragma("journal_mode = WAL");
      database.pragma("busy_timeout = 5000");
      databaseHandles.push(database);                          // <-- only drained on dispose/reload
      return database;
    },

Every call constructs a fresh better-sqlite3 Database. Each open connection holds an fd on data.db and, once WAL is enabled by the pragma, an fd on data.db-wal (the -shm mapping is shared per process, so it stays at 1). The handles are retained in databaseHandles so the host can close them later, which is also what keeps them from being garbage-collected, so the fd count is strictly monotonic until one of the four drain sites in plugin-runtime.ts runs (dispose, swap on reload, and the two failed-load rollbacks). Nothing in the normal request path ever closes a handle.

The contract the plugin author sees promises reuse. backend-contract.ts:

  /**
   * Open (or reuse the path of) the plugin's own SQLite database at
   * <dataDir>/plugins/<id>/data.db — the server's better-sqlite3, WAL mode,
   * busy_timeout 5000. Handles are host-tracked and closed on
   * dispose/reload; a closed handle throws on use.
   */
  database(): Database.Database;

And the SDK's fake host, which is what plugin tests run against, caches one handle, so a plugin that calls database() per method passes its tests and leaks only in production:

  // One shared temp-file handle: every database() call sees the same data,
  // like the host's handles over one on-disk file.
  let databaseHandle: Database.Database | undefined;
  const storage: PluginStorage = {
    kv,
    database() {
      assertLive();
      if (!databaseHandle) {
        databaseHandle = new Database(join(storageRoot, "data.db"));
        databaseHandle.pragma("busy_timeout = 5000");
      }
      return databaseHandle;
    },

Why the symptom follows: the server process's fd table fills with SQLite handles; once it reaches the per-process limit, any syscall that needs a new fd fails. libuv's uv_spawn opens pipes/dups before fork, so child_process.execFile("ps") surfaces as spawn EBADF (the issue's symptom); pure-SQLite work on already-open handles keeps working, which matches "SQLite-only schedules stayed ok".

Deeper note: the first-party plugins (automations, docs, github, memory, tasks, workflows) all call database() exactly once at factory load and hold the handle, which is why this was not noticed in-tree. Nothing enforces that pattern, and the fake host actively hides the difference.

6. Proposed fix (first principles)

Make production match the contract and the fake host: cache one handle per plugin generation in createPluginApi, reopen only if the plugin itself closed it (db.open === false), keep pushing into databaseHandles so the existing dispose/reload/rollback paths close it unchanged. Verified diff (proposed-fix.diff):

diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts
index 47e0a2473..62e4de48e 100644
--- a/apps/server/src/services/plugins/plugin-api.ts
+++ b/apps/server/src/services/plugins/plugin-api.ts
@@ -594,15 +594,21 @@ export function createPluginApi(options: {
     },
   };
 
+  // One reused handle per plugin generation (the SDK contract and the fake
+  // host both promise reuse). A plugin that closes the handle itself gets a
+  // fresh one on the next call; the host closes whatever is open on dispose.
+  let databaseHandle: Database.Database | undefined;
   const storage: PluginStorage = {
     kv,
     database() {
       assertLive();
+      if (databaseHandle?.open) return databaseHandle;
       const dir = join(dataDir, "plugins", pluginId);
       mkdirSync(dir, { recursive: true });
       const database = new Database(join(dir, "data.db"));
       database.pragma("journal_mode = WAL");
       database.pragma("busy_timeout = 5000");
+      databaseHandle = database;
       databaseHandles.push(database);
       return database;
     },

Verification: the repro test passes; pnpm exec vitest run test/services/plugins/ passes 35 files / 381 tests (log), including the existing "closes handles on reload" test; turbo typecheck --filter=@bb/server passes; live re-run of the script with the patched server: 300 calls → still 1 fd on data.db (log):

server pid=3138971  data.db=/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_d5c47f31-487-2-d2c2aa35cd26/plugins/fdleak/data.db
before: fds on data.db=1  total fds=51  (soft limit: 524288)
after 300 GET http://localhost:23917/api/v1/plugins/fdleak/http/ping:
after : fds on data.db=1  total fds=51
by file:
      1 plugins/fdleak/data.db
      1 plugins/fdleak/data.db-shm
      1 plugins/fdleak/data.db-wal

What could go wrong / things to decide:

7. PR review

No open PRs are linked to this issue.

8. Related issues

9. Appendix

Commands run

git checkout d81fee6f; git fetch origin main; git log d81fee6f..origin/main --oneline -- apps/server/src/services/plugins/plugin-api.ts packages/plugin-sdk/src/backend-contract.ts   # empty
pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build
cd apps/server && pnpm exec vitest run test/services/plugins/plugin-database-handle-reuse.test.ts   # FAILS on base
scripts/bb-dev-app current   # Server http://localhost:23917
BB_SERVER_URL=http://localhost:23917 pnpm bb:dev plugin install /tmp/bb-1919-plugin/bb-plugin-fdleak --yes
BB_SERVER_URL=http://localhost:23917 BB_DATA_DIR=... bash /tmp/bb-reports/issues/1919/repro/live-repro.sh 300   # 301 fds
BB_SERVER_URL=http://localhost:23917 pnpm bb:dev plugin reload fdleak   # back to 0 fds
# apply proposed-fix.diff, turbo build --filter=@bb/server, restart dev instance
bash live-repro.sh 300   # stays at 1 fd
pnpm exec vitest run test/services/plugins/   # 381 passed
pnpm exec turbo run typecheck --filter=@bb/server   # ok
pnpm dev:stop

Unit test output on d81fee6f (full)

 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_d5c47f31-487-2/apps/server

stdout | test/services/plugins/plugin-database-handle-reuse.test.ts > storage.database() handle reuse (#1919) > returns one reused handle per plugin instead of opening a connection per call
[#1919] 200 database() calls -> 200 distinct handles; open fds under /tmp/bb-plugin-db-reuse-8Qj7MN/data/plugins/chatty: { 'data.db-wal': 199, 'data.db': 200, 'data.db-shm': 1 }

 ❯  @bb/server  test/services/plugins/plugin-database-handle-reuse.test.ts (1 test | 1 failed) 241ms
     × returns one reused handle per plugin instead of opening a connection per call 240ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   @bb/server  test/services/plugins/plugin-database-handle-reuse.test.ts > storage.database() handle reuse (#1919) > returns one reused handle per plugin instead of opening a connection per call
AssertionError: expected 200 to be 1 // Object.is equality

- Expected
+ Received

- 1
+ 200

 ❯ test/services/plugins/plugin-database-handle-reuse.test.ts:123:27
    121|
    122|     // Contract (backend-contract.ts / fake-plugin-host.ts): one reuse…
    123|     expect(distinct.size).toBe(1); // FAILS on d81fee6f: 200 distinct …
       |                           ^
    124|     expect(fds["data.db"] ?? 0).toBeLessThanOrEqual(1); // FAILS: 200 …
    125|   });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  15:03:50
   Duration  2.75s (transform 1.39s, setup 0ms, import 2.43s, tests 241ms, environment 0ms)

Live repro before fix

server pid=3125178  data.db=/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_d5c47f31-487-2-d2c2aa35cd26/plugins/fdleak/data.db
before: fds on data.db=1  total fds=49  (soft limit: 524288)
after 300 GET http://localhost:23917/api/v1/plugins/fdleak/http/ping:
after : fds on data.db=301  total fds=650
by file:
    301 plugins/fdleak/data.db
      1 plugins/fdleak/data.db-shm
    300 plugins/fdleak/data.db-wal

Live repro after fix

server pid=3138971  data.db=/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_d5c47f31-487-2-d2c2aa35cd26/plugins/fdleak/data.db
before: fds on data.db=1  total fds=51  (soft limit: 524288)
after 300 GET http://localhost:23917/api/v1/plugins/fdleak/http/ping:
after : fds on data.db=1  total fds=51
by file:
      1 plugins/fdleak/data.db
      1 plugins/fdleak/data.db-shm
      1 plugins/fdleak/data.db-wal