fix: prevent parallel OpenCode conversations from hanging

Starting another conversation could replace a busy local OpenCode server
after a brief health-check failure, leaving the existing conversation
reconnecting to a dead endpoint.

Keep active conversations and native Compact operations on their current
server. Propagate server exit and shutdown to bound requests and cancel
event-stream retry waits so failures are explicit instead of hanging.
Do not automatically replay commands.

Release note: 修复同时使用多个本机 OpenCode 会话时旧会话可能一直等待的问题。服务异常退出时会明确提示失败,不会自动重复执行命令。
This commit is contained in:
mesalogo
2026-09-08 21:10:35 +08:00
parent 28de0c45ba
commit c1f4fd0580
3 changed files with 246 additions and 6 deletions
+173
View File
@@ -1227,6 +1227,179 @@ describe("OpenCodeRuntime embedded launcher", () => {
}
});
it("does not replace a shared server while another conversation is running", async () => {
const setup = runClient([
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
vi.mocked(setup.event.subscribe).mockImplementationOnce(async () => ({
stream: (async function* () {
await gate;
yield { type: "session.idle", properties: { sessionID: "session-1" } };
})(),
}) as never);
const child = fakeChild();
const replacement = fakeChild(43);
const { deps, checkServerHealth, spawnMock } = dependencies(child, {
createClient: () => setup.client,
});
spawnMock.mockReturnValueOnce(child).mockReturnValue(replacement);
const runtime = new OpenCodeRuntime(options(), deps);
const first = collectRun(runtime);
try {
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce());
checkServerHealth.mockResolvedValueOnce(false);
const peerEvents: RuntimeEvent[] = [];
for await (const event of runtime.run(
{
requestId: "peer", conversationId: "peer-conversation",
prompt: "test", workMode: "execute",
},
new AbortController().signal,
)) peerEvents.push(event);
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
expect(peerEvents.at(-1)).toMatchObject({ type: "done" });
expect(checkServerHealth).toHaveBeenCalledOnce();
expect(child.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledOnce();
release();
await expect(first).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
expect(child.kill).toHaveBeenCalledOnce();
expect(spawnMock).toHaveBeenCalledTimes(2);
} finally {
release();
await first.catch(() => undefined);
await runtime.dispose();
}
});
it.each(["exit", "dispose"])(
"settles an active request when its embedded server ends through %s",
async (ending) => {
const setup = runClient([]);
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
stream: (async function* () {
const signal = options!.signal!;
await new Promise<void>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
yield { type: "session.idle", properties: { sessionID: "session-1" } };
})(),
}) as never);
const child = fakeChild();
const { deps } = dependencies(child, { createClient: () => setup.client });
const runtime = new OpenCodeRuntime(options(), deps);
const controller = new AbortController();
let settled = false;
const events: RuntimeEvent[] = [];
const result = (async () => {
try {
for await (const event of runtime.run(
{ requestId: "active", conversationId: "active", prompt: "test", workMode: "execute" },
controller.signal,
)) events.push(event);
} catch (error) {
return error;
} finally {
settled = true;
}
})();
try {
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce());
if (ending === "exit") closeChild(child, 1);
else await runtime.dispose();
await vi.waitFor(() => expect(settled).toBe(true), { timeout: 300 });
await expect(result).resolves.toMatchObject({
message: expect.stringMatching(/OpenCode 服务.*(?:退出|关闭)/u),
});
expect(controller.signal.aborted).toBe(false);
expect(events.some((event) => event.type === "done")).toBe(false);
} finally {
controller.abort();
await result;
await runtime.dispose();
}
},
);
it("cancels an event-stream retry delay when its server exits", async () => {
const setup = runClient([]);
let retrying = false;
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
stream: (async function* () {
retrying = true;
await (options as { sseSleepFn: (milliseconds: number) => Promise<void> })
.sseSleepFn(30_000);
yield { type: "session.idle", properties: { sessionID: "session-1" } };
})(),
}) as never);
const child = fakeChild();
const { deps } = dependencies(child, { createClient: () => setup.client });
const runtime = new OpenCodeRuntime(options(), deps);
const result = collectRun(runtime).catch((error: unknown) => error);
try {
await vi.waitFor(() => expect(retrying).toBe(true));
closeChild(child, 1);
await expect(result).resolves.toMatchObject({
message: expect.stringContaining("OpenCode 服务已退出"),
});
} finally {
await runtime.dispose();
}
});
it.each(["complete", "exit"])(
"protects an active Compact until it ends through %s",
async (ending) => {
const setup = runClient([
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
let release!: () => void;
const summarize = vi.fn((_input, options: { signal: AbortSignal }) =>
new Promise((resolve, reject) => {
release = () => resolve({ data: true });
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
}));
Object.assign(setup.client, {
v2: { session: { context: vi.fn().mockResolvedValue({
data: { data: [{ type: "assistant", model: { providerID: "test", id: "test" } }] },
}) } },
});
Object.assign(setup.client.session, { summarize });
const child = fakeChild();
const { deps, checkServerHealth } = dependencies(child, { createClient: () => setup.client });
const runtime = new OpenCodeRuntime(options(), deps);
try {
await collectRun(runtime);
const compact = runtime.compactConversation({
requestId: "compact", conversationId: "conversation-1",
runtimeSelection: { provider: "opencode" }, history: [], historyMessageIds: [],
}, new AbortController().signal).catch((error: unknown) => error);
await vi.waitFor(() => expect(summarize).toHaveBeenCalledOnce());
const probes = checkServerHealth.mock.calls.length;
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
expect(checkServerHealth).toHaveBeenCalledTimes(probes);
expect(child.kill).not.toHaveBeenCalled();
if (ending === "exit") {
closeChild(child, 1);
await expect(compact).resolves.toMatchObject({
message: expect.stringContaining("OpenCode 服务已退出"),
});
} else {
release();
await expect(compact).resolves.toMatchObject({ result: { compacted: true } });
await runtime.testConnection();
expect(checkServerHealth).toHaveBeenCalledTimes(probes + 1);
}
} finally {
release?.();
await runtime.dispose();
}
},
);
it.each(["unhealthy", "rejected", "hung"])(
"replaces a %s cached server once for concurrent callers",
async (failure) => {