Skip to content

Commit ff09467

Browse files
committed
feat(shells): give every background shell an owner, a deadline, and a fenced exit
Cursor background shells now belong to the session that spawned them: the transport threads its stable id through both dispatch paths, stdin and termination check ownership, and a cross-session write is refused without naming the owner. Admission runs through the shared gate with the lease held in the registry entry until the child's close is actually confirmed — a failed kill quarantines the entry instead of forgetting the process. Idle and absolute deadlines bound every shell's life, the transport's async close awaits its session's cleanup, and shutdown installs a synchronous spawn fence before draining so a queued frame can never start a shell the drain will miss.
1 parent b925854 commit ff09467

10 files changed

Lines changed: 1065 additions & 77 deletions

src/adapters/cursor/live-transport.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ import {
7474
requestedCursorToolUseCount,
7575
} from "./tool-definitions";
7676
import type { CursorNativeToolDeps } from "./native-exec-tools";
77+
import {
78+
terminateBackgroundShellsForSession,
79+
type BackgroundShellTerminationReport,
80+
} from "./native-exec-shell";
7781
import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "./types";
7882
import type { CursorTransport, CursorTransportFactoryInput } from "./transport";
7983

@@ -418,6 +422,7 @@ class LiveCursorTransport implements CursorTransport {
418422
private mcpPrepared?: Promise<void>;
419423
private releaseMcpObservation?: () => void;
420424
private blobRequestScope?: CursorBlobRequestScopeToken;
425+
private shellCleanup?: Promise<BackgroundShellTerminationReport>;
421426
// Per-turn diagnostic counters/timestamps when provider debug is on (`ocx debug provider on`). Stamped in open(), cleared on
422427
// close; safe to read after a stream failure because open() owns the only writer before run().
423428
private turnStartedAt = 0;
@@ -437,7 +442,11 @@ class LiveCursorTransport implements CursorTransport {
437442
this.activeClientToolFinalizeGraceMs = this.clientToolFinalizeGraceMs;
438443
// Desktop (computer-use / record-screen) executors are available even with no MCP servers.
439444
this.desktopDeps = desktopDepsFromConfig(input.provider.desktopExecutor);
440-
this.execContext = { ...this.desktopDeps, unsafeAllowNativeLocalExec: effectiveCursorNativeExecAllow(input.provider, input.requestDeclaresFullAccess === true) };
445+
this.execContext = {
446+
...this.desktopDeps,
447+
sessionId: this.sessionId,
448+
unsafeAllowNativeLocalExec: effectiveCursorNativeExecAllow(input.provider, input.requestDeclaresFullAccess === true),
449+
};
441450
const servers = resolveMcpServers(input.provider);
442451
if (servers.length > 0) {
443452
this.mcpManager = new CursorMcpManager(servers, {
@@ -470,6 +479,7 @@ class LiveCursorTransport implements CursorTransport {
470479
...this.desktopDeps,
471480
...mcpDepsFromManager(this.mcpManager!),
472481
mcpToolDefs,
482+
sessionId: this.sessionId,
473483
unsafeAllowNativeLocalExec: effectiveCursorNativeExecAllow(this.input.provider, this.input.requestDeclaresFullAccess === true),
474484
};
475485
} catch (err) {
@@ -648,7 +658,11 @@ class LiveCursorTransport implements CursorTransport {
648658
}
649659
}
650660

651-
close(): void {
661+
private startShellCleanup(): Promise<BackgroundShellTerminationReport> {
662+
return this.shellCleanup ??= terminateBackgroundShellsForSession(this.sessionId);
663+
}
664+
665+
async close(): Promise<void> {
652666
if (this.heartbeat) clearInterval(this.heartbeat);
653667
this.clearPendingFinalize();
654668
this.clearFirstFrameTimer();
@@ -658,6 +672,7 @@ class LiveCursorTransport implements CursorTransport {
658672
this.releaseMcpObservation?.();
659673
this.releaseMcpObservation = undefined;
660674
void this.mcpManager?.dispose();
675+
await this.startShellCleanup();
661676
}
662677

663678
private cancelCursorRun(): void {
@@ -675,6 +690,7 @@ class LiveCursorTransport implements CursorTransport {
675690
this.releaseMcpObservation?.();
676691
this.releaseMcpObservation = undefined;
677692
void this.mcpManager?.dispose();
693+
void this.startShellCleanup().catch(() => { /* close() observes the same cleanup promise */ });
678694
}
679695

680696
private releaseBlobRequestScope(): void {
@@ -850,16 +866,17 @@ class LiveCursorTransport implements CursorTransport {
850866
return;
851867
}
852868
const previousPayloadBytes = connectBufferedPayloadBytes(pending);
853-
// Transfer the retained-byte lease from the aggregate pending buffer to decoded frame and
854-
// residual owners. Charging both copies transiently would cut the 32 MiB frame contract in half.
855-
this.releaseTransportBytes(previousPayloadBytes);
869+
// The decoder materializes payload and residual copies. Keep the aggregate pending owner
870+
// charged until every replacement has been admitted and committed; a failed admission must
871+
// leave that predecessor lease intact for deterministic cleanup.
856872
const decoded = decodeAvailableConnectFrames(
857873
pending,
858874
CURSOR_MAX_EFFECTIVE_CONNECT_PAYLOAD_BYTES,
859875
availableSlots,
860876
reservePayloadCopy,
861877
);
862878
pending = decoded.remainder;
879+
this.releaseTransportBytes(previousPayloadBytes);
863880
for (const frame of decoded.frames) {
864881
this.pendingTransportFrames += 1;
865882
this.updateTransportFlowControl();
@@ -882,18 +899,26 @@ class LiveCursorTransport implements CursorTransport {
882899
debugProviderDiagnostic("cursor", "first-frame", { latencyMs: this.firstFrameAt - this.turnStartedAt });
883900
}
884901
const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
902+
let incomingPayloadBytes = 0;
903+
let incomingCharged = false;
904+
let replacement: ReturnType<typeof reservePayloadCopy> | undefined;
885905
try {
886906
const previousPendingPayloadBytes = connectBufferedPayloadBytes(pending);
907+
const nextPendingPayloadBytes = connectBufferedPayloadBytesAcross(pending, bytes);
908+
incomingPayloadBytes = Math.max(0, nextPendingPayloadBytes - previousPendingPayloadBytes);
909+
this.reserveTransportBytes(incomingPayloadBytes);
910+
incomingCharged = true;
911+
replacement = reservePayloadCopy(nextPendingPayloadBytes);
887912
const nextPending = concatBytes(pending, bytes);
888-
const nextPendingPayloadBytes = connectBufferedPayloadBytes(nextPending);
889-
// The incoming HTTP/2 chunk is externally owned. Replace the prior pending lease with the
890-
// concatenated buffer lease instead of charging old + new simultaneously.
891-
this.releaseTransportBytes(previousPendingPayloadBytes);
892-
const replacement = reservePayloadCopy(nextPendingPayloadBytes);
893913
pending = nextPending;
894914
replacement.commitRetained();
915+
replacement = undefined;
916+
this.releaseTransportBytes(previousPendingPayloadBytes + incomingPayloadBytes);
917+
incomingCharged = false;
895918
drainPendingFrames();
896919
} catch (err) {
920+
replacement?.release();
921+
if (incomingCharged) this.releaseTransportBytes(incomingPayloadBytes);
897922
failAndClear(err instanceof Error ? err : new Error(String(err)));
898923
}
899924
});
@@ -1135,6 +1160,27 @@ function connectBufferedPayloadBytes(input: Uint8Array): number {
11351160
return payloadBytes;
11361161
}
11371162

1163+
/** Payload-byte count for a virtual concatenation, without allocating the concatenated buffer. */
1164+
function connectBufferedPayloadBytesAcross(first: Uint8Array, second: Uint8Array): number {
1165+
const totalBytes = first.byteLength + second.byteLength;
1166+
const byteAt = (index: number): number => index < first.byteLength
1167+
? first[index]!
1168+
: second[index - first.byteLength]!;
1169+
let offset = 0;
1170+
let payloadBytes = 0;
1171+
while (totalBytes - offset >= 5) {
1172+
const length = (byteAt(offset + 1) * 0x1000000)
1173+
+ (byteAt(offset + 2) << 16)
1174+
+ (byteAt(offset + 3) << 8)
1175+
+ byteAt(offset + 4);
1176+
const available = Math.min(length, totalBytes - offset - 5);
1177+
payloadBytes += available;
1178+
if (available < length) break;
1179+
offset += 5 + length;
1180+
}
1181+
return payloadBytes;
1182+
}
1183+
11381184
/** Host-only label for Cursor transport diagnostics — never leaks path/query/credentials. */
11391185
function cursorHostLabel(baseUrl: string): string {
11401186
try {

0 commit comments

Comments
 (0)