fix(sdk): reject work after client close

This commit is contained in:
Vincent Koc 2026-06-19 04:43:12 +02:00
parent 1385da8d3f
commit 8d596aa651
No known key found for this signature in database
4 changed files with 137 additions and 6 deletions

View file

@ -327,8 +327,10 @@ export class OpenClaw {
});
private readonly replayByRunId = new Map<string, OpenClawEvent[]>();
private connected = false;
private closed = false;
private eventPumpPromise: Promise<void> | null = null;
private eventPumpReady: Promise<void> | null = null;
private closePromise: Promise<void> | null = null;
constructor(options: OpenClawOptions = {}) {
this.transport =
@ -351,24 +353,45 @@ export class OpenClaw {
}
async connect(): Promise<void> {
this.assertOpen();
if (this.connected) {
await this.startEventPump();
this.assertOpen();
return;
}
if (isConnectableTransport(this.transport)) {
await this.transport.connect();
}
this.assertOpen();
this.connected = true;
await this.startEventPump();
this.assertOpen();
}
async close(): Promise<void> {
await this.transport.close?.();
await this.eventPumpPromise?.catch(() => {});
this.normalizedEvents.close();
this.eventPumpPromise = null;
this.eventPumpReady = null;
this.connected = false;
if (this.closePromise) {
return await this.closePromise;
}
if (this.closed) {
return;
}
this.closed = true;
this.closePromise = (async () => {
try {
await this.transport.close?.();
await this.eventPumpPromise?.catch(() => {});
} finally {
this.normalizedEvents.close();
this.eventPumpPromise = null;
this.eventPumpReady = null;
this.connected = false;
}
})();
try {
await this.closePromise;
} finally {
this.closePromise = null;
}
}
async request<T = unknown>(
@ -377,6 +400,7 @@ export class OpenClaw {
options?: GatewayRequestOptions,
): Promise<T> {
await this.connect();
this.assertOpen();
return await this.transport.request<T>(method, params, options);
}
@ -392,13 +416,21 @@ export class OpenClaw {
}
rawEvents(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
this.assertOpen();
return this.transport.events(filter);
}
private assertOpen(): void {
if (this.closed) {
throw new Error("OpenClaw SDK client is closed");
}
}
private async *iterateEvents(
filter?: (event: OpenClawEvent) => boolean,
): AsyncIterable<OpenClawEvent> {
await this.connect();
this.assertOpen();
for await (const event of this.normalizedEvents.stream(filter)) {
yield event;
}
@ -409,6 +441,7 @@ export class OpenClaw {
filter?: (event: OpenClawEvent) => boolean,
): AsyncIterable<OpenClawEvent> {
await this.connect();
this.assertOpen();
const replayEvents = this.replaySnapshot(runId);
let hasCanonicalAssistantRunEvent = replayEvents.some(isAssistantRunEvent);
let hasTerminalRunEvent = replayEvents.some(isTerminalRunEvent);

View file

@ -54,6 +54,51 @@ class FakeTransport implements OpenClawTransport {
}
}
class DelayedConnectTransport extends FakeTransport {
connectCalls = 0;
private finishConnectCurrent: (() => void) | null = null;
async connect(): Promise<void> {
this.connectCalls += 1;
await new Promise<void>((resolve) => {
this.finishConnectCurrent = resolve;
});
}
finishConnect(): void {
const finish = this.finishConnectCurrent;
if (!finish) {
throw new Error("expected pending connect");
}
this.finishConnectCurrent = null;
finish();
}
}
class ClosingEventPumpTransport extends FakeTransport {
onFirstEventPoll?: () => void;
override events(): AsyncIterable<GatewayEvent> {
return {
[Symbol.asyncIterator]: (): AsyncIterator<GatewayEvent> => {
let firstPoll = true;
return {
next: async (): Promise<IteratorResult<GatewayEvent>> => {
if (firstPoll) {
firstPoll = false;
this.onFirstEventPoll?.();
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
}
return { done: true, value: undefined as never };
},
};
},
};
}
}
class EventsOnlyTransport implements OpenClawTransport {
constructor(private readonly eventSource: AsyncIterable<GatewayEvent>) {}
@ -631,6 +676,42 @@ describe("OpenClaw SDK", () => {
]);
});
it("keeps close terminal when it races a pending connect", async () => {
const transport = new DelayedConnectTransport({
"agents.list": { agents: [] },
});
const oc = new OpenClaw({ transport });
const connect = oc.connect();
const close = oc.close();
transport.finishConnect();
await expect(connect).rejects.toThrow("OpenClaw SDK client is closed");
await close;
await expect(oc.agents.list()).rejects.toThrow("OpenClaw SDK client is closed");
await expect(oc.events()[Symbol.asyncIterator]().next()).rejects.toThrow(
"OpenClaw SDK client is closed",
);
expect(() => oc.rawEvents()).toThrow("OpenClaw SDK client is closed");
expect(transport.connectCalls).toBe(1);
expect(transport.calls).toEqual([]);
});
it("does not request after close races event pump startup", async () => {
const transport = new ClosingEventPumpTransport({
"agents.list": { agents: [] },
});
const oc = new OpenClaw({ transport });
let closePromise: Promise<void> | undefined;
transport.onFirstEventPoll = () => {
closePromise = oc.close();
};
await expect(oc.agents.list()).rejects.toThrow("OpenClaw SDK client is closed");
await closePromise;
expect(transport.calls).toEqual([]);
});
it("cancels runs and checks model auth status through current Gateway methods", async () => {
const transport = new FakeTransport({
agent: { status: "accepted", runId: "run_without_session" },

View file

@ -50,4 +50,13 @@ describe("GatewayClientTransport", () => {
await connectExpectation;
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
});
it("rejects reconnect attempts after close", async () => {
const transport = new GatewayClientTransport();
await transport.close();
await expect(transport.connect()).rejects.toThrow("gateway transport is closed");
expect(gatewayClientMocks.instances).toHaveLength(0);
});
});

View file

@ -80,12 +80,16 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
private connectPromise: Promise<void> | null = null;
private rejectPendingConnect: ((error: Error) => void) | null = null;
private closePromise: Promise<void> | null = null;
private closed = false;
constructor(options: GatewayClientTransportOptions = {}) {
this.options = options;
}
connect(): Promise<void> {
if (this.closed) {
return Promise.reject(new Error("gateway transport is closed"));
}
if (this.connectPromise) {
return this.connectPromise;
}
@ -146,6 +150,10 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
if (this.closePromise) {
return await this.closePromise;
}
if (this.closed) {
return;
}
this.closed = true;
this.eventsHub.close();
const client = this.client;
this.client = null;