Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/runtimeuse/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/runtimeuse/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "runtimeuse",
"version": "0.11.0",
"version": "0.11.1",
"description": "AI agent runtime with WebSocket protocol, artifact handling, and secret management",
"license": "FSL",
"type": "module",
Expand Down
41 changes: 25 additions & 16 deletions packages/runtimeuse/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class WebSocketSession {
private requestInFlight = false;
private secrets: string[] = [];
private logger: Logger;
private drainPromise: Promise<void> | null = null;

constructor(ws: WebSocket, config: SessionConfig) {
this.ws = ws;
Expand Down Expand Up @@ -68,22 +69,7 @@ export class WebSocketSession {
}
this.logger.log("WebSocket connection closed");
this.currentAbortController?.abort();

// Give chokidar time to observe files the agent wrote right before
// the session ended. Without this, late `add` events would not fire
// before we stop the watcher, and those artifacts would be lost.
const delayMs = this.config.postInvocationDelayMs ?? 3_000;
if (this.artifactManager && delayMs > 0) {
this.logger.log(`Waiting ${delayMs}ms for artifact watcher to drain...`);
await sleep(delayMs);
}
await this.artifactManager?.stopWatching();
await this.artifactManager?.waitForPendingRequests(
this.config.artifactWaitMs ?? 60_000,
);
await this.config.uploadTracker.waitForAll(
this.config.uploadTimeoutMs ?? 30_000,
);
await this.drain();
resolve();
});

Expand All @@ -97,6 +83,9 @@ export class WebSocketSession {
switch (message.message_type) {
case "end_session_message":
this.logger.log("Received end_session_message. Closing session.");
// Drain the artifact watcher *before* closing so any late chokidar
// events still have an open socket to send upload requests through.
await this.drain();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drain failure prevents ws.close() and caches rejected promise

Low Severity

If drain() rejects (e.g., stopWatching() / chokidar watcher.close() throws), ws.close() on the next line is never reached, leaving the WebSocket open. Worse, the memoized drainPromise caches the rejection permanently, so when the close handler later calls await this.drain(), it re-throws — resolve() is never called and run() hangs forever. Wrapping ws.close() in a finally (or catching drain errors) would ensure the socket is always closed. In the old code, ws.close() was called unconditionally before any drain logic.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 79977a7. Configure here.

this.ws.close();
return;

Expand Down Expand Up @@ -211,6 +200,26 @@ export class WebSocketSession {
}
}

private drain(): Promise<void> {
if (!this.drainPromise) {
this.drainPromise = (async () => {
const delayMs = this.config.postInvocationDelayMs ?? 3_000;
if (this.artifactManager && delayMs > 0) {
this.logger.log(`Waiting ${delayMs}ms for artifact watcher to drain...`);
await sleep(delayMs);
}
await this.artifactManager?.stopWatching();
await this.artifactManager?.waitForPendingRequests(
this.config.artifactWaitMs ?? 60_000,
);
await this.config.uploadTracker.waitForAll(
this.config.uploadTimeoutMs ?? 30_000,
);
})();
}
return this.drainPromise;
}

private ensureArtifactManager(): void {
if (this.artifactManager) {
this.artifactManager.setLogger(this.logger);
Expand Down