fix(server): await OpenCode event stream shutdown (#2015)
* fix(server): await OpenCode event stream shutdown * Update opencode-agent.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
parent
92d7066601
commit
461be47c91
@@ -1197,6 +1197,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
|||||||
test("keeps a turn active while OpenCode is retrying", async () => {
|
test("keeps a turn active while OpenCode is retrying", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const eventsGate = createTestDeferred<void>();
|
const eventsGate = createTestDeferred<void>();
|
||||||
|
let eventStreamSignal: AbortSignal | undefined;
|
||||||
const retryStream: AsyncIterable<unknown> = {
|
const retryStream: AsyncIterable<unknown> = {
|
||||||
[Symbol.asyncIterator]: () => {
|
[Symbol.asyncIterator]: () => {
|
||||||
let emitted = false;
|
let emitted = false;
|
||||||
@@ -1222,14 +1223,27 @@ describe("OpenCode adapter startTurn error handling", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return new Promise(() => {});
|
return new Promise<IteratorResult<unknown>>((resolve) => {
|
||||||
|
if (eventStreamSignal?.aborted) {
|
||||||
|
resolve({ done: true, value: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eventStreamSignal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => resolve({ done: true, value: undefined }),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const fakeClient = {
|
const fakeClient = {
|
||||||
global: {
|
global: {
|
||||||
event: vi.fn().mockResolvedValue({ stream: retryStream }),
|
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
|
||||||
|
eventStreamSignal = signal;
|
||||||
|
return { stream: retryStream };
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
abort: vi.fn().mockResolvedValue({ error: null }),
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||||
@@ -1319,6 +1333,52 @@ describe("OpenCode adapter startTurn error handling", () => {
|
|||||||
expect(fakeClient.session.delete).not.toHaveBeenCalled();
|
expect(fakeClient.session.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("waits for the OpenCode event stream to finish after close aborts it", async () => {
|
||||||
|
const streamAborted = createTestDeferred<void>();
|
||||||
|
const finishStreamCleanup = createTestDeferred<void>();
|
||||||
|
const fakeClient = {
|
||||||
|
global: {
|
||||||
|
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
||||||
|
stream: {
|
||||||
|
[Symbol.asyncIterator]: () => ({
|
||||||
|
next: async () => {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
await waitForAbort(signal);
|
||||||
|
}
|
||||||
|
streamAborted.resolve();
|
||||||
|
await finishStreamCleanup.promise;
|
||||||
|
return { done: true, value: undefined };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||||
|
update: vi.fn().mockResolvedValue({ error: null }),
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||||
|
{ provider: "opencode", cwd: "/tmp/test" },
|
||||||
|
fakeClient,
|
||||||
|
"ses_unit_test",
|
||||||
|
createTestLogger(),
|
||||||
|
);
|
||||||
|
let closeSettled = false;
|
||||||
|
|
||||||
|
const closePromise = session.close().then(() => {
|
||||||
|
closeSettled = true;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
await streamAborted.promise;
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(closeSettled).toBe(false);
|
||||||
|
|
||||||
|
finishStreamCleanup.resolve();
|
||||||
|
await closePromise;
|
||||||
|
});
|
||||||
|
|
||||||
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
||||||
const fakeClient = {
|
const fakeClient = {
|
||||||
session: {
|
session: {
|
||||||
@@ -2178,6 +2238,15 @@ function createTestDeferred<T>(): {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForAbort(signal: AbortSignal): Promise<void> {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
|
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
|
||||||
return {
|
return {
|
||||||
[Symbol.asyncIterator]: () => {
|
[Symbol.asyncIterator]: () => {
|
||||||
|
|||||||
@@ -2789,6 +2789,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private releaseServer: (() => void) | null;
|
private releaseServer: (() => void) | null;
|
||||||
private eventStreamAbortController: AbortController | null = null;
|
private eventStreamAbortController: AbortController | null = null;
|
||||||
private eventStreamReady: Deferred<void> | null = null;
|
private eventStreamReady: Deferred<void> | null = null;
|
||||||
|
private eventStreamTask: Promise<void> | null = null;
|
||||||
private suppressTerminalUntilNextUserMessage = false;
|
private suppressTerminalUntilNextUserMessage = false;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private readonly persistSession: boolean;
|
private readonly persistSession: boolean;
|
||||||
@@ -3160,11 +3161,24 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const eventStreamReady = createDeferred<void>();
|
const eventStreamReady = createDeferred<void>();
|
||||||
this.eventStreamAbortController = eventStreamAbortController;
|
this.eventStreamAbortController = eventStreamAbortController;
|
||||||
this.eventStreamReady = eventStreamReady;
|
this.eventStreamReady = eventStreamReady;
|
||||||
void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => {
|
const eventStreamTask = this.consumeEventStream(
|
||||||
|
eventStreamAbortController,
|
||||||
|
eventStreamReady,
|
||||||
|
).finally(() => {
|
||||||
if (this.eventStreamAbortController === eventStreamAbortController) {
|
if (this.eventStreamAbortController === eventStreamAbortController) {
|
||||||
this.eventStreamAbortController = null;
|
this.eventStreamAbortController = null;
|
||||||
this.eventStreamReady = null;
|
this.eventStreamReady = null;
|
||||||
}
|
}
|
||||||
|
if (this.eventStreamTask === eventStreamTask) {
|
||||||
|
this.eventStreamTask = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.eventStreamTask = eventStreamTask;
|
||||||
|
void eventStreamTask.catch((error) => {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, sessionId: this.sessionId },
|
||||||
|
"OpenCode event stream task failed",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return eventStreamReady.promise;
|
return eventStreamReady.promise;
|
||||||
@@ -3561,9 +3575,19 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
// unhandled rejection in whichever test the daemon hops to next.
|
// unhandled rejection in whichever test the daemon hops to next.
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.abortController?.abort();
|
this.abortController?.abort();
|
||||||
|
const eventStreamTask = this.eventStreamTask;
|
||||||
this.eventStreamAbortController?.abort();
|
this.eventStreamAbortController?.abort();
|
||||||
|
if (eventStreamTask) {
|
||||||
|
await eventStreamTask.catch((error) => {
|
||||||
|
this.logger.debug(
|
||||||
|
{ err: error, sessionId: this.sessionId },
|
||||||
|
"OpenCode event stream failed during close",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
this.eventStreamAbortController = null;
|
this.eventStreamAbortController = null;
|
||||||
this.eventStreamReady = null;
|
this.eventStreamReady = null;
|
||||||
|
this.eventStreamTask = null;
|
||||||
this.subscribers.clear();
|
this.subscribers.clear();
|
||||||
await reconcileOpenCodeSessionClose({
|
await reconcileOpenCodeSessionClose({
|
||||||
client: this.client,
|
client: this.client,
|
||||||
|
|||||||
+36
-1
@@ -156,7 +156,10 @@ export class TestOpenCodeClient {
|
|||||||
global: {
|
global: {
|
||||||
event: async (options: unknown) => {
|
event: async (options: unknown) => {
|
||||||
this.calls.globalEvent.push(options);
|
this.calls.globalEvent.push(options);
|
||||||
return { stream: this.eventStream };
|
const signal = (options as { signal?: AbortSignal }).signal;
|
||||||
|
return {
|
||||||
|
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
@@ -247,6 +250,38 @@ export class TestOpenCodeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopEventStreamOnAbort(
|
||||||
|
stream: AsyncIterable<unknown>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): AsyncIterable<unknown> {
|
||||||
|
return {
|
||||||
|
[Symbol.asyncIterator]: () => {
|
||||||
|
const iterator = stream[Symbol.asyncIterator]();
|
||||||
|
return {
|
||||||
|
next: () => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return Promise.resolve({ done: true, value: undefined });
|
||||||
|
}
|
||||||
|
return new Promise<IteratorResult<unknown>>((resolve, reject) => {
|
||||||
|
const onAbort = () => resolve({ done: true, value: undefined });
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
void iterator.next().then(
|
||||||
|
(result) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
return resolve(result);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
return reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
|
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
|
||||||
return (async function* () {
|
return (async function* () {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
|||||||
Reference in New Issue
Block a user