Old-instance disposal interrupts a new-instance startup question
2026-09-30 · Bug · Medium priority · Medium effort
REPRODUCED · High confidence in the server-side reload and interaction lifecycle defect.
Tested claim and exact conditions
An isolated integration test invokes the actual plugin install/reload service, plugin requestInput API, pending-interaction lifecycle and real test database. A synthetic plugin raises an ordinary display-color question from its factory. The first instance's question remains pending and can be submitted successfully. After that submission, the test writes a second-generation factory and reloads the same plugin. Reload reports success, but the new factory's question is immediately interrupted with plugin-disposed and its promise resolves cancelled/plugin-disposed.
This directly reproduces cancellation of an admitted new-instance startup request by old-instance disposal. The initial form is deliberately settled before reload: a preliminary attempt left it pending on the same thread and encountered the current one-pending-interaction guard, which rejects the new request before cleanup. That attempt is not counted as the two-run reproduction. The final test includes this admission condition, uses the same settled-first-form sequence in both checkouts, and does not claim that two simultaneous pending forms on one thread are accepted.
No browser renderer is registered or opened. The evidence is the persisted interaction state and settled API promise, not a screenshot of a disappearing form. No production plugin, user runtime, provider inference, authorization decision, plugin update delivery mechanism or separate timeout-documentation claim was tested. The ordinary question has no abort signal, and no timeout is advanced. No production fix was applied.
Expected and actual
| Phase | Expected | Observed in both runs |
|---|---|---|
| First factory | Question pending | Generation 1 pending |
| Submit control | Generation 1 resolves successfully | Resolved; submitted with synthetic blue answer |
| Reload, new factory | Reload succeeds; generation 2 remains pending | Reload succeeds; generation 2 interrupted/plugin-disposed |
| New promise | Still waiting | cancelled, reason plugin-disposed |
Trusted base and environment
Fetched public origin/main: 379cc870fdcd3019c906104d3cb51ad4e3c1e06a. Linux x86_64, Node 24.19.0, repository-pinned pnpm 9.15.0, Vitest 4.1.1. Each clean detached checkout used its own node_modules, writable caches and newly created temporary test data, synthetic thread and plugin instance state. Dependencies were installed from the frozen lockfile; downloads were shared. The trusted test harness creates and migrates a real test database. No database mock, real user data or external service is used. No network port is needed for these direct service calls.
Both frozen installs and server builds succeeded. Both final test invocations used --force and ran the actual test instead of returning cached results. Only the test file was added to either checkout. The test-created local plugin is part of the synthetic fixture, not issue-supplied code.
Exact repeatable steps
git clone https://github.com/get-bb/bb.git bb-repro cd bb-repro git checkout --detach 379cc870fdcd3019c906104d3cb51ad4e3c1e06a corepack pnpm install --frozen-lockfile # Save the complete inline test below as apps/server/test/services/plugins/issue-4432.test.ts. corepack pnpm exec turbo run build --filter=@bb/server --force corepack pnpm exec turbo run test --filter=@bb/server --force -- test/services/plugins/issue-4432.test.ts --silent=false
Use the pinned package manager for subprocesses too. This execution used a task-local Corepack shim and writable disposable npm/native-build caches and pnpm store. Repeat the same test in a second clean checkout at the identical SHA. Expected result on this base: one failed regression with two desired-behavior assertion failures, while the initial-pending, successful-submission and reload-success controls pass.
Complete derived test
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { expect, it } from "vitest";
import { pendingInteractions } from "@bb/db";
import { createTestAppHarness } from "../../helpers/test-app.js";
import { seedEnvironment, seedHostSession, seedProjectWithSource, seedThread } from "../../helpers/seed.js";
it("4432 preserves the new instance question across reload", async () => {
const globals = globalThis as typeof globalThis & {
__fixture4432?: { generation: number; outcome: unknown }[];
};
const previous = globals.__fixture4432;
globals.__fixture4432 = [];
const harness = await createTestAppHarness();
try {
const { host } = seedHostSession(harness.deps, { id: "host-synthetic-reload" });
const { project } = seedProjectWithSource(harness.deps, { hostId: host.id });
const environment = seedEnvironment(harness.deps, { hostId: host.id, projectId: project.id });
const thread = seedThread(harness.deps, { projectId: project.id, environmentId: environment.id });
const pluginId = "synthetic-reload-question";
const root = join(harness.config.dataDir, "fixtures", "bb-plugin-synthetic-reload-question");
await mkdir(root, { recursive: true });
await writeFile(join(root, "package.json"), JSON.stringify({
name: "bb-plugin-synthetic-reload-question", version: "0.1.0",
bb: { name: "Synthetic question", description: "Isolated reload fixture", branding: { icon: "Zap" }, server: "./server.ts" },
}));
const writeFactory = async (generation: number) => {
await writeFile(join(root, "server.ts"), `
export default function start(bb) {
const pending = bb.ui.requestInput({
threadId: ${JSON.stringify(thread.id)}, rendererId: "synthetic-color",
title: "Choose a display color", payload: { generation: ${generation}, choices: ["blue", "green"] }
});
pending.then(outcome => globalThis.__fixture4432.push({ generation: ${generation}, outcome }));
}
`);
};
const rows = () => harness.db.select({ status: pendingInteractions.status, reason: pendingInteractions.statusReason,
payload: pendingInteractions.payload }).from(pendingInteractions).where(eq(pendingInteractions.pluginId, pluginId)).all()
.map(row => ({ status: row.status, reason: row.reason, payload: JSON.parse(row.payload) }));
await writeFactory(1);
await harness.pluginService.installPath(root);
const initial = rows();
console.log(JSON.stringify({ phase: "first-load", rows: initial }));
expect(initial).toHaveLength(1);
expect(initial[0]?.status).toBe("pending");
expect(globals.__fixture4432).toEqual([]);
const initialRow = harness.db.select({ id: pendingInteractions.id }).from(pendingInteractions)
.where(eq(pendingInteractions.pluginId, pluginId)).get();
expect(initialRow).toBeDefined();
await harness.deps.pendingInteractions.respondToPluginInteraction({
interactionId: initialRow!.id, threadId: thread.id, value: { color: "blue" },
});
await new Promise(resolve => setImmediate(resolve));
expect(globals.__fixture4432).toEqual([{ generation: 1, outcome: { outcome: "submitted", value: { color: "blue" } } }]);
console.log(JSON.stringify({ phase: "settled-control", rows: rows(), outcomes: globals.__fixture4432 }));
await writeFactory(2);
const reload = await harness.pluginService.reload(pluginId);
expect(reload.ok).toBe(true);
await new Promise(resolve => setImmediate(resolve));
const after = rows();
const outcomes = globals.__fixture4432;
console.log(JSON.stringify({ phase: "after-reload", reloadOk: reload.ok, rows: after, outcomes }));
expect(after).toHaveLength(2);
expect(outcomes?.find(item => item.generation === 1)?.outcome).toEqual({ outcome: "submitted", value: { color: "blue" } });
expect.soft(after.filter(row => row.status === "pending")).toHaveLength(1);
expect.soft(outcomes?.find(item => item.generation === 2)).toBeUndefined();
} finally {
await harness.cleanup();
globals.__fixture4432 = previous;
}
});
Actual evidence from both runs
First clean checkout
@bb/server:test: {"phase":"first-load","rows":[{"status":"pending","reason":null,"payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":1,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}}]}
@bb/server:test: {"phase":"settled-control","rows":[{"status":"resolved","reason":null,"payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":1,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}}],"outcomes":[{"generation":1,"outcome":{"outcome":"submitted","value":{"color":"blue"}}}]}
@bb/server:test: {"phase":"after-reload","reloadOk":true,"rows":[{"status":"interrupted","reason":"plugin-disposed","payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":2,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}},{"status":"resolved","reason":null,"payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":1,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}}],"outcomes":[{"generation":1,"outcome":{"outcome":"submitted","value":{"color":"blue"}}},{"generation":2,"outcome":{"outcome":"cancelled","reason":"plugin-disposed"}}]}
@bb/server:test: Test Files 1 failed (1)
@bb/server:test: Tests 1 failed (1)
Install exit 0; server build exit 0 (five successful tasks). Focused regression exit 1: expected one pending interaction but found zero, and expected no generation-2 outcome but received cancelled/plugin-disposed. No unhandled error was reported in this final run.
Second clean checkout
@bb/server:test: {"phase":"first-load","rows":[{"status":"pending","reason":null,"payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":1,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}}]}
@bb/server:test: {"phase":"settled-control","rows":[{"status":"resolved","reason":null,"payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":1,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}}],"outcomes":[{"generation":1,"outcome":{"outcome":"submitted","value":{"color":"blue"}}}]}
@bb/server:test: {"phase":"after-reload","reloadOk":true,"rows":[{"status":"interrupted","reason":"plugin-disposed","payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":2,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}},{"status":"resolved","reason":null,"payload":{"kind":"plugin","title":"Choose a display color","data":{"generation":1,"choices":["blue","green"]},"presentation":{"label":{"pending":"Waiting for Choose a display color","completed":"Submitted Choose a display color"},"icon":{"glyph":"Zap"}}}}],"outcomes":[{"generation":1,"outcome":{"outcome":"submitted","value":{"color":"blue"}}},{"generation":2,"outcome":{"outcome":"cancelled","reason":"plugin-disposed"}}]}
@bb/server:test: Test Files 1 failed (1)
@bb/server:test: Tests 1 failed (1)
Install exit 0; server build exit 0 (five successful tasks). Focused regression exit 1: expected one pending interaction but found zero, and expected no generation-2 outcome but received cancelled/plugin-disposed. No unhandled error was reported in this final run.
The same agent personally repeated the final test in the second clean checkout with fresh state at the identical SHA. This is not independent verification. Both runs corroborate the core claim under the explicit admission condition above. SQL row order is not significant; payload generation identifies each instance.
Root cause and verified source permalinks
- New factory executes before previous instance disposal. New interactions can already exist at that point.
- Disposal interrupts by plugin ID does not distinguish the old and new instance.
- Interaction lifecycle interrupt supplies the plugin-disposed reason and settles the interrupted waiters.
- Database interruption predicate matches plugin origin, plugin ID and pending/resolving status, with no instance-generation filter.
Proposed fix and next useful test
Scope disposal cleanup to interactions owned by the outgoing instance or generation, preserving those admitted by the new factory. Disposing before activation is another option, but its effect on rollback and failed-reload behavior needs explicit design and tests. Preserve the successful-submission control and add cases for failed factories, separate-thread pending forms and repeated reloads. No fix or PR was created.
Trust, concurrency and publication checks
All issue text, example code, commands and links were treated as untrusted claims. The test was authored from trusted main’s existing reload harness, API implementation and database contracts. No issue code, commands, branches, external links, user runtime or secrets were executed or accessed. All current comments were read (zero initially); no equivalent report or open PR reference was found in the checked metadata. The repository was confirmed public. Only this HTML and summary metadata are published; the full test and actual results are inline, with raw files retained locally. No screenshot is presented because the experiment does not make a browser-visual claim.