From 9d71542bb9ea404d2503f961e496176f88c00806 Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Sun, 12 Jul 2026 11:59:55 +0200 Subject: [PATCH 1/6] copilot: preserved uncommitted work Session: Cursor API root cause Project: composer-api Branch: fix-cursor-api-lifecycle Workspace: b6de4c1d-a4b3-4b67-aaf1-053ecb648b00 Files: 12 changed Auto-saved before the worktree was removed so no work is lost. Restore it as a branch with `git branch `. --- README.md | 23 +- docs/production.md | 57 +++ docs/todos/cursor-api-lifecycle/plan.md | 143 +++++++ docs/todos/cursor-api-lifecycle/todo.md | 116 +++++ .../CursorAPICore/CursorSDKBridgeServer.swift | 90 +++- .../CursorAPICore/CursorSDKHarness.swift | 275 +++++++++--- .../ConnectivityCheckTests.swift | 257 +++++++++++ macos/CursorAPI/xctest-shim.abi.json | 9 + macos/CursorAPI/xctest-shim.swiftdoc | Bin 0 -> 412 bytes macos/CursorAPI/xctest-shim.swiftsourceinfo | Bin 0 -> 2756 bytes scripts/cursor-sdk-local-agent-bridge.mjs | 279 ++++++++++-- .../cursor-sdk-local-agent-bridge.test.mjs | 403 +++++++++++++++++- 12 files changed, 1555 insertions(+), 97 deletions(-) create mode 100644 docs/todos/cursor-api-lifecycle/plan.md create mode 100644 docs/todos/cursor-api-lifecycle/todo.md create mode 100644 macos/CursorAPI/xctest-shim.abi.json create mode 100644 macos/CursorAPI/xctest-shim.swiftdoc create mode 100644 macos/CursorAPI/xctest-shim.swiftsourceinfo diff --git a/README.md b/README.md index eebc292..62641ec 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,27 @@ Run the optional SDK local-agent bridge in a local Bun or Node environment: npm run sdk:opencode-bridge ``` -The bridge process also accepts `CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`; the default is -`180000`. +The bridge process also accepts optional runtime knobs; defaults shown: + +```bash +CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS=180000 # per-request run timeout (packaged app overrides to 120000) +CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES=3 # SDK-run retries per bridge call (4 total attempts) +CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS=15000 # Node drain bound during shutdown +CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS=1000 # parent-PID watchdog cadence (packaged app) +``` + +In the packaged macOS app the bridge runs as a child process owned by the app. The bridge +follows parent lifetime: it stops accepting new requests when the app begins shutdown, then +awaits in-flight SDK agent asynchronous disposal before exiting. + +Two retry layers apply. Inside the bridge, `maxRunRetries` (default 3) controls SDK-run +retries: `attempt` starts at 0 and increments after each retryable SDK-run error that +occurs before any event is emitted, allowing up to 3 retries (4 total SDK-run attempts) +per bridge call. At the Swift app layer, `bridgeTransportAttempts = 2` means a retryable +pre-output transport failure, 503, or `cursor_sdk_unavailable` error causes the app to +recycle the owned bridge exactly once and issue one final bridge request. Auth failures, +arbitrary upstream errors, and any failure after output has begun are never retried at +either layer. The public OpenAI-compatible response and error contract is unchanged. Release packages prefer a bundled Node runtime for the local SDK bridge and fall back to Bun when Node is unavailable. diff --git a/docs/production.md b/docs/production.md index 37826c3..5310798 100644 --- a/docs/production.md +++ b/docs/production.md @@ -13,6 +13,63 @@ API for Cursor ships as a signed macOS DMG and updates through Sparkle. - The release workflow uploads the versioned DMG, latest DMG alias, and appcast to Cloudflare R2. - The Worker serves `/download`, `/releases/...`, and `/appcast.xml` from Cloudflare. +## Local Bridge Lifecycle + +In the packaged macOS app the SDK bridge runs as a managed child process. + +**Ownership and shutdown sequence:** + +- The macOS app launches the bridge child and holds its lifetime. +- The child polls the original parent PID at a configurable cadence (default 1000 ms; + `CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS`). When the parent exits or the PID + changes, shutdown begins. +- During shutdown the bridge rejects new incoming requests immediately. +- In-flight request creation and any cached or pending SDK agents are drained with awaited + `Symbol.asyncDispose`; a synchronous `close()` call is used only when `asyncDispose` is + absent. +- Node is given a bounded drain window (default 15000 ms; + `CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS`) before exiting. +- The run timeout is 180000 ms by default (`CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`); the packaged + macOS app explicitly overrides this to 120000 ms. +- The Swift app waits 16000 ms (the Node drain bound of 15000 ms plus 1000 ms extra grace) + before sending SIGKILL. + +**Recovery:** + +Two retry layers are active. + +Inside the bridge, SDK-run errors that occur before any event is emitted are retried up to +`maxRunRetries` times (default 3; configurable via `CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES`). The +`attempt` counter starts at 0; retries continue while `attempt < maxRunRetries`, yielding at +most 4 SDK-run attempts per bridge call. + +At the Swift app layer (`bridgeTransportAttempts = 2`), only retryable pre-output failures +recycle the owned bridge once: transport-level failures and known +`503`/`cursor_sdk_unavailable` stale-state errors that occur before any text or tool output +has been produced. The recycled bridge handles one final attempt. If that also fails, the +`CursorAPIError`/OpenAI-compatible error is returned unchanged. + +Auth failures, arbitrary upstream errors, and any failure after output has begun are never +retried at either layer. + +**Verification:** + +- Node lifecycle suite: `npx vitest run scripts/cursor-sdk-local-agent-bridge.test.mjs` + must pass. +- Swift build: `swift build -c release` from `macos/CursorAPI` (core and production-seam + typecheck green). +- Swift XCTest: requires a full Xcode installation with the XCTest framework; Command Line + Tools alone cannot execute `.xctest` bundles. Run via Xcode or `xcodebuild test`. +- No live credentialed model call was performed during lifecycle fix verification. + +**Dependency decision recorded 2026-07-12:** + +Lockfile remains at `@cursor/sdk 1.0.13`. Official `1.0.23` was evaluated: it retains a +non-awaited `close()` path while `asyncDispose` correctly awaits, and it raises the Node +engine requirement to `>=22.13`. The update was deferred; it does not fix the lifecycle root +cause alone and requires a runtime version bump. No package or lockfile change was made. +Re-evaluate this decision when changing the SDK lockfile or the bundled Node runtime. + ## Required GitHub Secrets - `MACOS_DEVELOPER_ID_CERTIFICATE_BASE64`: base64-encoded Developer ID Application `.p12`. diff --git a/docs/todos/cursor-api-lifecycle/plan.md b/docs/todos/cursor-api-lifecycle/plan.md new file mode 100644 index 0000000..77bf003 --- /dev/null +++ b/docs/todos/cursor-api-lifecycle/plan.md @@ -0,0 +1,143 @@ +# Cursor API Lifecycle Stability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the local Cursor SDK bridge self-cleaning and recoverable without changing the OpenAI-compatible API. + +**Architecture:** The Node/Bun bridge owns SDK agents and must await their asynchronous disposal before replacement or exit. The bridge also watches its launching parent, while the Swift owner coordinates graceful termination and recycles the bridge once for retryable pre-output failures. + +**Tech Stack:** Node.js ESM, `@cursor/sdk`, Vitest, Swift 6, Foundation `Process`, Swift Testing/XCTest. + +**Commit Policy:** Commits are not authorized. The handoff must list all uncommitted task-owned changes. + +--- + +### Task 1: Prove the cleanup failure locally + +**Files:** +- Modify: `scripts/cursor-sdk-local-agent-bridge.test.mjs` +- Modify: `scripts/cursor-sdk-local-agent-bridge.mjs` + +**Verifier:** +- Commands that must be fresh: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` +- Expected evidence: the new fixture initially fails because shutdown returns before asynchronous SDK disposal completes. +- Notes: the same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Export a test seam for bridge resource cleanup** + +Expose a cleanup function that accepts explicit fake agent entries and an HTTP-server close callback without starting a real SDK agent. + +- [x] **Step 2: Write the failing delayed-disposal fixture** + +Use a fake agent with `Symbol.asyncDispose` that resolves only after the test releases it, then assert bridge cleanup remains pending until that release. + +- [x] **Step 3: Run the focused test and record the expected failure** + +Run: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` + +Expected: FAIL because the current implementation calls `close()` and does not await SDK disposal. + +### Task 2: Implement deterministic bridge ownership + +**Files:** +- Modify: `scripts/cursor-sdk-local-agent-bridge.mjs` +- Modify: `scripts/cursor-sdk-local-agent-bridge.test.mjs` + +**Verifier:** +- Commands that must be fresh: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` +- Expected evidence: delayed disposal, eviction ordering, and parent-loss fixtures pass. +- Notes: the same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Await SDK disposal** + +Prefer `await agent[Symbol.asyncDispose]()` and fall back to `agent.close()` only when the async protocol is unavailable. + +- [x] **Step 2: Serialize eviction with cleanup** + +Make retry and cache-eviction paths await disposal before creating a replacement SDK agent. + +- [x] **Step 3: Add parent-loss detection** + +Capture the initial parent PID and request one graceful shutdown if the parent changes; disable this check when the bridge starts as PID 1's child. + +- [x] **Step 4: Coordinate process exit** + +Stop accepting new HTTP work, dispose all cached agents, close the server, and retain a bounded force-exit fallback. + +- [x] **Step 5: Run the focused verifier** + +Run: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` + +Expected: PASS. + +### Task 3: Recycle poisoned bridge state in the macOS harness + +**Files:** +- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift` +- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift` +- Modify: `macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift` + +**Verifier:** +- Commands that must be fresh: `cd macos/CursorAPI && swift test --filter ConnectivityCheckTests` +- Expected evidence: a retryable 503 or `cursor_sdk_unavailable` event is marked for one pre-output recycle, while auth and non-retryable upstream errors retain their existing mappings. +- Notes: the same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Write retry-policy tests** + +Cover retryable opaque SDK failures, explicit authentication failures, and non-retryable upstream failures. + +- [x] **Step 2: Preserve the outward error contract** + +Wrap retryable bridge failures internally, recycle the bridge before the second attempt, and unwrap the existing `CursorAPIError.upstream` if the retry also fails. + +- [x] **Step 3: Extend graceful process shutdown** + +Allow the bridge's bounded asynchronous cleanup window to finish before the existing force-kill fallback. + +- [x] **Step 4: Run the focused Swift verifier** + +Run: `cd macos/CursorAPI && swift test --filter ConnectivityCheckTests` + +Expected: PASS. + +### Task 4: Document and verify the integrated fix + +**Files:** +- Modify: `README.md` +- Modify: `docs/production.md` +- Modify: `docs/todos/cursor-api-lifecycle/todo.md` + +**Verifier:** +- Commands that must be fresh: `npm test`, `npm run typecheck`, `npm run build`, `cd macos/CursorAPI && swift test` +- Expected evidence: all repository-native checks pass and lifecycle documentation matches the implemented behavior. +- Notes: run the repository security gate if present; otherwise run required Semgrep. OSV is not required unless dependency files change. The same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Document bridge ownership and recovery** + +Explain that the bridge follows the app parent, awaits SDK disposal, and is recycled once for retryable pre-output failures. + +- [x] **Step 2: Record the dependency intake decision** + +Document that 1.0.23 keeps non-awaiting `close()` semantics, requires Node 22.13, and is deferred because it is not the root-cause fix. + +- [x] **Step 3: Run focused and full verification** + +Run the verifier commands plus the available security gates, then update the task matrix with exact evidence. + +- [x] **Step 4: Prepare the parent handoff** + +Report root cause, changed files, commands, residual uncertainty, and that a credentialed live proof still requires explicit approval. + +### Task 5: Install the fixed local app + +**Files and paths:** +- Build: `macos/CursorAPI/dist/API for Cursor.app` +- Replace: `/Applications/API for Cursor.app` + +**Verifier:** +- Commands that must be fresh: `macos/CursorAPI/Scripts/verify-package.sh`, `codesign --verify --deep --strict`, bundle metadata checks, and installed bridge checksum comparison. +- Expected evidence: version `0.1.10` build `13`, fixed bridge payload matches the worktree, and no old helper process remains. + +- [ ] **Step 1: Stop the installed app and orphan helpers** +- [ ] **Step 2: Build and verify the development package** +- [ ] **Step 3: Replace and statically verify the installed app** diff --git a/docs/todos/cursor-api-lifecycle/todo.md b/docs/todos/cursor-api-lifecycle/todo.md new file mode 100644 index 0000000..0aacdef --- /dev/null +++ b/docs/todos/cursor-api-lifecycle/todo.md @@ -0,0 +1,116 @@ +# Cursor API Lifecycle Stability + +## Goal + +Eliminate recurring local Responses API failures caused by stale or incompletely disposed Cursor SDK bridge state, while preserving the existing OpenAI-compatible API and security boundaries. + +## Scope + +- Trace the macOS app, local API, SDK harness, Node/Bun bridge, and `@cursor/sdk` lifecycle end to end. +- Reproduce cleanup and restart behavior with local fixtures only. +- Fix bridge ownership, asynchronous SDK disposal, and retry-time bridge recycling. +- Keep current public request and response shapes unchanged. +- Document the lifecycle contract and dependency decision. + +## Non-goals + +- No credentialed model calls, real app restarts, or termination of user-owned processes. +- No hosted Worker, auth, persistence, routing, or schema changes. +- No dependency update without evidence that it addresses the observed failure. +- No commit, push, pull request, deployment, or release. + +## Acceptance criteria + +- SDK agents are asynchronously disposed before replacement or bridge exit. +- A bridge exits when its launching parent disappears. +- The macOS owner allows enough time for graceful bridge cleanup before force termination. +- A retryable pre-output SDK bridge failure recycles the bridge once and preserves the existing outward error contract if recovery fails. +- Focused lifecycle fixtures fail before the fix and pass after it. +- Repository-native JS and Swift checks pass. +- Required local security gates pass, or unavailable tooling is reported as a blocker. + +## Assumptions and boundaries + +- The observed `run.wait()` result with `status=error` and no details is retryable only before any response content has been emitted. +- `@cursor/sdk` 1.0.13 exposes `Symbol.asyncDispose`; its documented `close()` starts asynchronous disposal without waiting. +- The bridge runtime is a direct child of the macOS app in packaged operation. +- Live behavior still requires an explicitly approved credentialed proof after local verification. + +## Stop conditions + +- Stop implementation if the local fixture falsifies the asynchronous-disposal or parent-ownership hypothesis. +- Stop after the same verifier rejection key occurs twice and report the evidence instead of stacking another speculative fix. +- Stop before any live call, app restart, or termination of an existing process unless the coordinating parent explicitly approves it. + +## Root-cause evidence + +### Known facts + +- Before the fix, the bridge called `agent.close()` when evicting agents and during process shutdown. +- In `@cursor/sdk` 1.0.13, `close()` calls the asynchronous executor-release path without awaiting it; `Symbol.asyncDispose` awaits that path. +- Before the fix, the bridge forced `process.exit()` after 500 ms. +- Before the fix, the macOS bridge owner sent `SIGTERM` and force-killed the bridge after one second. +- Before the fix, the bridge had no parent-loss watchdog, so an abnormal app exit could leave it running. +- The SDK dependency was introduced at 1.0.13 and remains lockfile-resolved to 1.0.13; current official release is 1.0.23. +- Version 1.0.23 retains the same non-awaiting `close()` semantics and raises the Node requirement to 22.13, so an update alone does not address cleanup. + +### Confirmed repository root cause + +The bridge did not await SDK executor disposal before agent replacement or process exit, and it did not terminate when its launching parent disappeared. The macOS retry path also reused the same bridge after a retryable pre-output SDK failure. Deterministic fixtures proved each lifecycle gap: cleanup resolved before delayed disposal, replacement started before old disposal, parent loss did not request shutdown, and retry did not recycle the bridge. These defects explain how stale helper or executor state could survive app lifecycles and why terminating orphan helpers temporarily restored service. A credentialed live run is still required to prove the complete production failure chain against Cursor's upstream service. + +### Minimal falsification tests + +- A fake SDK agent whose async disposal completes after a delay must be fully disposed before bridge resource shutdown resolves. +- Parent PID change detection must request bridge shutdown exactly once. +- A retryable bridge stream error emitted before output must recycle the bridge before the second attempt. + +## Feature / Verification Matrix + +| Change | Verification | Status | Evidence | +| --- | --- | --- | --- | +| Await SDK disposal | Focused Vitest lifecycle fixture | Pass | 65 focused tests pass; cleanup awaits delayed disposal, in-flight creation, and pending per-key eviction | +| Parent-loss cleanup | Focused Vitest watchdog fixture | Pass | PID change requests the shared graceful shutdown path once; PID 1 parent disables the watchdog | +| Graceful macOS ownership | Swift lifecycle test | Pass with substitute | Core build, standalone verifier, and permanent-test typecheck prove 15-second bridge cleanup plus 1-second force-kill grace | +| Retry-time bridge recycle | Swift harness policy test | Pass with substitute | Five recovery scenarios prove one pre-output recycle and unchanged outward errors | +| Public API preservation | Existing Responses API tests | Pass | Full Vitest suite passes 236 tests; public request, response, auth, routing, schema, and outward error contracts are unchanged | + +## Integrated verification + +- `npm test`: 236 tests passed across 9 files. +- `npm run typecheck`: passed. +- `npm run build`: passed. +- Focused bridge suite: 65 tests passed; `node --check` passed. +- `swift build --target CursorAPICore`: passed. +- Standalone Swift recovery verifier: passed. +- `ConnectivityCheckTests.swift` typecheck with the scratch XCTest declaration module: passed. +- `swift test --filter ConnectivityCheckTests`: blocked because the host has Command Line Tools but no XCTest/Xcode runtime. +- Full Semgrep fallback: 26 pre-existing findings and exit 1. +- Semgrep baseline diff scan: 0 findings introduced by this task. +- OSV was not required because dependency and lockfile surfaces did not change. +- Three scratch XCTest metadata files remain untracked under `macos/CursorAPI`; deletion was not authorized while the user was unavailable. +- No live credentialed model call, app restart, or termination of an existing process was performed. + +## Authorized local installation follow-up + +On 2026-07-12 the user explicitly authorized terminating the installed app and its helper processes, building the current worktree, and replacing `/Applications/API for Cursor.app`. + +- Scope: terminate only processes running from the installed app bundle, package a development app from this worktree, verify the bundle, and replace the installed app. +- Non-goal: no credentialed model request, release signing, notarization, publication, or remote update. +- Acceptance: old helper PIDs are gone; the package verifier passes; the replacement keeps bundle id `ai.standardagents.cursorapi`, uses version `0.1.10` build `13`, contains the fixed bridge script and rebuilt Swift executable, and passes static code-signature verification. +- Boundary: do not launch the replacement or make a live API request unless separately requested. + +## Subagent ledger + +| Workstream | Scope | Edits | Result | Residual risk | +| --- | --- | --- | --- | --- | +| RED lifecycle fixture | Bridge script and focused Vitest file | Allowed | Deterministic early-resolution failure reproduced | Test seam expands the module export surface | +| RED verifier and reviews | Same two files and focused command evidence | Read-only | Verifier accepted; spec and quality reviews passed | Green phase must make `closeAndExit` await cleanup | +| Deterministic bridge cleanup | Bridge script and focused Vitest file | Allowed | Async disposal, serialized replacement, parent watchdog, shutdown drain, and LRU behavior implemented | Real SDK disposal latency remains bounded by the 15-second force-exit fallback | +| Bridge cleanup verification | Current task-owned diff and focused commands | Read-only | Final verifier accepted 65 tests and syntax check; no Critical/Important review findings | Live recovery still requires explicit approval | +| Swift bridge recovery | Harness, bridge owner, connectivity tests, standalone verifier | Allowed | Retryable pre-output failures recycle once; stop polling is async and reentrancy-safe | Real bridge process behavior remains unproved without live-call approval | +| Swift recovery verification | Current Swift diff, core build, standalone verifier, test typecheck | Read-only | Final verifier accepted all nine requirements; no Critical/Important review findings | `swift test` cannot load XCTest because only Command Line Tools are installed | +| Lifecycle documentation | README and production runbook | Allowed | Ownership, two retry layers, timeout derivation, verification limits, and dependency decision documented | Historical dependency note must be re-evaluated with SDK or runtime changes | +| Documentation verification | Current documentation diff | Read-only | Final verifier accepted implementation facts, privacy boundaries, and diff hygiene | None | +| Simplification pass | Active Node and Swift production diff | Allowed | Removed dead branching and narrowed shutdown state without changing behavior | Focused checks passed after cleanup | +| Final implementation review | Production lifecycle and test coverage lanes | Read-only | Both lanes accepted with no Critical/Important findings | XCTest runtime and live upstream behavior remain unverified | +| Integrated verification | Entire task-owned diff and repo-native commands | Read-only | All task-owned checks passed; Semgrep diff introduced zero findings | Full XCTest unavailable; full Semgrep remains red on 26 pre-existing findings | diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift index 7d92e7e..ed885a3 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift @@ -10,20 +10,26 @@ struct CursorSDKBridgeEndpoint: Sendable { actor CursorSDKBridgeServer { static let shared = CursorSDKBridgeServer() + static let bridgeShutdownTimeoutMilliseconds = 15_000 + static let forceTerminationExtraGraceMilliseconds = 1_000 + static let forceTerminationGraceMilliseconds = bridgeShutdownTimeoutMilliseconds + forceTerminationExtraGraceMilliseconds private var process: Process? private var endpoint: CursorSDKBridgeEndpoint? private var logHandle: FileHandle? + private var isStopping = false + private var stopWaiters: [CheckedContinuation] = [] private let token = UUID().uuidString.replacingOccurrences(of: "-", with: "") func endpoint(settings: CursorAPISettings) async throws -> CursorSDKBridgeEndpoint { + await waitForInFlightStop() if let endpoint, process?.isRunning == true { return endpoint } if let endpoint, await isHealthy(endpoint.healthURL) { return endpoint } - stop() + await stop() let script = try bridgeScriptURL() let port = try await start(script: script, settings: settings) let endpoint = CursorSDKBridgeEndpoint( @@ -50,10 +56,10 @@ actor CursorSDKBridgeServer { } try await Task.sleep(nanoseconds: 50_000_000) } - stop() + await stop() lastError = CursorAPIError.transport("Cursor SDK bridge did not become ready.") } catch { - stop() + await stop() lastError = error } } @@ -70,6 +76,7 @@ actor CursorSDKBridgeServer { environment["CURSOR_SDK_BRIDGE_PORT"] = String(port) environment["CURSOR_SDK_BRIDGE_TOKEN"] = token environment["CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS"] = "120000" + environment["CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS"] = String(Self.bridgeShutdownTimeoutMilliseconds) process.environment = environment process.currentDirectoryURL = script.deletingLastPathComponent() let logHandle = try bridgeLogHandle() @@ -121,27 +128,84 @@ actor CursorSDKBridgeServer { return URL(fileURLWithPath: path) } - func shutdown() { - stop() + func shutdown() async { + await stop() } - private func stop() { + func recycle() async { + await stop() + } + + static func waitForExitPolling( + timeoutNanoseconds: UInt64, + pollIntervalNanoseconds: UInt64 = 20_000_000, + isRunning: () -> Bool, + sleep: (_ durationNanoseconds: UInt64) async -> Void + ) async -> Bool { + let pollInterval = max(1, pollIntervalNanoseconds) + var elapsed: UInt64 = 0 + while isRunning() { + guard elapsed < timeoutNanoseconds else { + return false + } + let remaining = timeoutNanoseconds - elapsed + let delay = min(pollInterval, remaining) + await sleep(delay) + elapsed += delay + } + return true + } + + private func stop() async { + if isStopping { + await waitForInFlightStop() + return + } + isStopping = true let process = self.process + let logHandle = self.logHandle self.process = nil - endpoint = nil + self.endpoint = nil + self.logHandle = nil + defer { finishStop() } + if let process, process.isRunning { process.terminate() - let deadline = Date().addingTimeInterval(1) - while process.isRunning, Date() < deadline { - Thread.sleep(forTimeInterval: 0.02) - } - if process.isRunning { + let timeoutNanoseconds = UInt64(Self.forceTerminationGraceMilliseconds) * 1_000_000 + let exited = await Self.waitForExitPolling( + timeoutNanoseconds: timeoutNanoseconds, + isRunning: { process.isRunning }, + sleep: Self.cooperativeSleep + ) + if !exited && process.isRunning { Darwin.kill(process.processIdentifier, SIGKILL) process.waitUntilExit() } } + try? logHandle?.close() - logHandle = nil + } + + private func waitForInFlightStop() async { + guard isStopping else { return } + await withCheckedContinuation { continuation in + stopWaiters.append(continuation) + } + } + + private func finishStop() { + isStopping = false + let waiters = stopWaiters + stopWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } + + private static func cooperativeSleep(_ durationNanoseconds: UInt64) async { + do { + try await Task.sleep(nanoseconds: durationNanoseconds) + } catch {} } private func bridgeLogHandle() throws -> FileHandle { diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift index faa1dce..36ffb9d 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift @@ -41,6 +41,43 @@ public extension CursorSDKHarness { } } +enum CursorSDKRetryDecision { + case retry(recycleBridge: Bool) + case fail(any Error) +} + +struct CursorSDKRetryExecutor { + static func execute( + attempts: Int, + operation: (_ attempt: Int) async throws -> T, + actionForFailure: (_ error: any Error, _ attempt: Int) -> CursorSDKRetryDecision, + recycle: () async -> Void, + delay: () async -> Void + ) async throws -> T { + let totalAttempts = max(1, attempts) + var attempt = 1 + while true { + do { + return try await operation(attempt) + } catch { + switch actionForFailure(error, attempt) { + case .retry(let recycleBridge): + guard attempt < totalAttempts else { + throw error + } + if recycleBridge { + await recycle() + } + await delay() + attempt += 1 + case .fail(let terminalError): + throw terminalError + } + } + } + } +} + public struct LocalCursorSDKHarness: CursorSDKHarness { private static let sessionStore = CursorSDKSessionStore(maxEntries: 512) private static let toolRetryAttempts = 3 @@ -64,6 +101,69 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { var emittedToolCalls: [CursorToolCall] } + struct BridgeRecoveryError: Error, Equatable, Sendable { + var outwardError: CursorAPIError + var status: Int? + var code: String? + var emittedOutput: Bool + + init(outwardError: CursorAPIError, status: Int? = nil, code: String? = nil, emittedOutput: Bool = false) { + self.outwardError = outwardError + self.status = status + self.code = code + self.emittedOutput = emittedOutput + } + + var normalizedCode: String? { + Self.normalizedCode(code) + } + + func withEmittedOutput(_ value: Bool) -> BridgeRecoveryError { + var updated = self + updated.emittedOutput = emittedOutput || value + return updated + } + + var isRetryableBeforeOutput: Bool { + guard !emittedOutput else { return false } + switch outwardError { + case .transport: + return true + case .upstream: + return status == 503 || normalizedCode == "cursor_sdk_unavailable" + case .unauthorized: + return false + default: + return false + } + } + + private static func normalizedCode(_ value: String?) -> String? { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized?.isEmpty == false ? normalized : nil + } + } + + static func executeBridgeRecoveryRetry( + operation: @escaping (_ attempt: Int) async throws -> T, + recycle: @escaping () async -> Void, + delay: @escaping () async -> Void + ) async throws -> T { + do { + return try await CursorSDKRetryExecutor.execute( + attempts: bridgeTransportAttempts, + operation: operation, + actionForFailure: { error, _ in + bridgeRetryDecision(for: error) + }, + recycle: recycle, + delay: delay + ) + } catch { + throw outwardBridgeError(from: error) + } + } + public func validate(settings: CursorAPISettings, authorization: String?) throws { let apiKey = try Self.resolvedCursorAPIKeyForRequest(from: authorization, settings: settings) guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -245,32 +345,35 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { settings: CursorAPISettings, onEvent: @escaping @Sendable (CursorSDKStreamEvent) -> Void ) async throws -> BridgeRunResult { - var lastError: (any Error)? - for attempt in 1...Self.bridgeTransportAttempts { - let emittedDuringAttempt = LockedFlag() - do { - return try await runActualSDKBridgeRequest( - apiKey: apiKey, - agentID: agentID, - runID: attempt == 1 ? runID : Self.newRunID(), - prepared: prepared, - settings: settings, - onEvent: { event in - emittedDuringAttempt.set() - onEvent(event) + try await Self.executeBridgeRecoveryRetry( + operation: { attempt in + let emittedDuringAttempt = LockedFlag() + do { + return try await runActualSDKBridgeRequest( + apiKey: apiKey, + agentID: agentID, + runID: attempt == 1 ? runID : Self.newRunID(), + prepared: prepared, + settings: settings, + onEvent: { event in + emittedDuringAttempt.set() + onEvent(event) + } + ) + } catch { + guard let failure = Self.bridgeRecoveryError(from: error) else { + throw error } - ) - } catch { - lastError = error - guard attempt < Self.bridgeTransportAttempts, - !emittedDuringAttempt.value(), - isRetryableBridgeTransportError(error) else { - throw error + throw failure.withEmittedOutput(emittedDuringAttempt.value()) } + }, + recycle: { + await CursorSDKBridgeServer.shared.recycle() + }, + delay: { try? await Task.sleep(nanoseconds: 150_000_000) } - } - throw lastError ?? CursorAPIError.transport("Cursor SDK bridge request failed.") + ) } private func runActualSDKBridgeRequest( @@ -305,22 +408,37 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { do { (bytes, response) = try await URLSession.shared.bytes(for: request) } catch { - throw CursorAPIError.transport("Cursor SDK bridge request failed: \(error.localizedDescription)") + throw BridgeRecoveryError(outwardError: .transport("Cursor SDK bridge request failed: \(error.localizedDescription)")) } guard let http = response as? HTTPURLResponse else { - throw CursorAPIError.transport("Cursor SDK bridge did not return an HTTP response.") + throw BridgeRecoveryError(outwardError: .transport("Cursor SDK bridge did not return an HTTP response.")) } guard (200..<300).contains(http.statusCode) else { let data = try await collectData(bytes) - let text = String(data: data, encoding: .utf8) ?? "status \(http.statusCode)" - throw http.statusCode == 401 ? CursorAPIError.unauthorized : CursorAPIError.upstream(text) + throw bridgeHTTPError(statusCode: http.statusCode, data: data) } var emittedText = "" var emittedToolCalls: [CursorToolCall] = [] var finalOutput: CursorSDKOutput? var line = Data() - for try await byte in bytes { - if byte == 10 { + do { + for try await byte in bytes { + if byte == 10 { + try handleBridgeEventLine( + line, + agentID: agentID, + runID: runID, + emittedText: &emittedText, + emittedToolCalls: &emittedToolCalls, + finalOutput: &finalOutput, + onEvent: onEvent + ) + line.removeAll(keepingCapacity: true) + } else if byte != 13 { + line.append(byte) + } + } + if !line.isEmpty { try handleBridgeEventLine( line, agentID: agentID, @@ -330,27 +448,15 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { finalOutput: &finalOutput, onEvent: onEvent ) - line.removeAll(keepingCapacity: true) - } else if byte != 13 { - line.append(byte) } - } - if !line.isEmpty { - try handleBridgeEventLine( - line, - agentID: agentID, - runID: runID, - emittedText: &emittedText, - emittedToolCalls: &emittedToolCalls, - finalOutput: &finalOutput, - onEvent: onEvent - ) + } catch { + throw Self.bridgeStreamReadFailure(from: error) } if finalOutput == nil, !emittedToolCalls.isEmpty { finalOutput = CursorSDKOutput(text: "", toolCalls: emittedToolCalls, agentID: agentID, runID: runID) } guard let finalOutput else { - throw CursorAPIError.transport("Cursor SDK bridge stream ended without a final output.") + throw BridgeRecoveryError(outwardError: .transport("Cursor SDK bridge stream ended without a final output.")) } return BridgeRunResult(output: finalOutput, emittedText: emittedText, emittedToolCalls: emittedToolCalls) } @@ -394,20 +500,25 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { finalOutput = output case "error": let error = object["error"] as? [String: Any] - throw Self.bridgeStreamError(from: error) + throw Self.bridgeStreamFailure(from: error) default: break } } static func bridgeStreamError(from error: [String: Any]?) -> CursorAPIError { + bridgeStreamFailure(from: error).outwardError + } + + private static func bridgeStreamFailure(from error: [String: Any]?) -> BridgeRecoveryError { let status = intValue(error?["status"]) - let code = (error?["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let code = normalizedBridgeErrorCode(error?["code"]) if status == 401 || code == "unauthorized" { - return .unauthorized + return BridgeRecoveryError(outwardError: .unauthorized, status: status, code: code) } let message = (error?["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - return .upstream((message?.isEmpty == false ? message : nil) ?? "Cursor SDK bridge stream failed.") + let resolved = (message?.isEmpty == false ? message : nil) ?? "Cursor SDK bridge stream failed." + return BridgeRecoveryError(outwardError: .upstream(resolved), status: status, code: code) } private static func intValue(_ value: Any?) -> Int? { @@ -417,6 +528,69 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { return nil } + private static func normalizedBridgeErrorCode(_ value: Any?) -> String? { + let code = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return code?.isEmpty == false ? code : nil + } + + private static func bridgeRecoveryError(from error: any Error) -> BridgeRecoveryError? { + if let failure = error as? BridgeRecoveryError { + return failure + } + if let cursorError = error as? CursorAPIError { + return BridgeRecoveryError(outwardError: cursorError) + } + return nil + } + + static func bridgeStreamReadFailure(from error: any Error) -> any Error { + if error is CancellationError { + return error + } + if let failure = bridgeRecoveryError(from: error) { + return failure + } + return BridgeRecoveryError( + outwardError: .transport("Cursor SDK bridge stream failed: \(error.localizedDescription)") + ) + } + + private static func bridgeRetryDecision(for error: any Error) -> CursorSDKRetryDecision { + guard let failure = bridgeRecoveryError(from: error) else { + return .fail(error) + } + if failure.isRetryableBeforeOutput { + return .retry(recycleBridge: true) + } + return .fail(failure.outwardError) + } + + private static func outwardBridgeError(from error: any Error) -> any Error { + if let failure = error as? BridgeRecoveryError { + return failure.outwardError + } + return error + } + + private func bridgeHTTPError(statusCode: Int, data: Data) -> BridgeRecoveryError { + let code = Self.bridgeHTTPErrorCode(from: data) + if statusCode == 401 || code == "unauthorized" { + return BridgeRecoveryError(outwardError: .unauthorized, status: statusCode, code: code) + } + let text = String(data: data, encoding: .utf8) ?? "status \(statusCode)" + return BridgeRecoveryError(outwardError: .upstream(text), status: statusCode, code: code) + } + + private static func bridgeHTTPErrorCode(from data: Data) -> String? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + if let nested = object["error"] as? [String: Any] { + return normalizedBridgeErrorCode(nested["code"]) + } + return normalizedBridgeErrorCode(object["code"]) + } + private func bridgeOutput(from value: Any?, agentID: String, runID: String) -> CursorSDKOutput? { guard let object = value as? [String: Any] else { return nil } let text = object["text"] as? String ?? "" @@ -435,13 +609,6 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { return CursorToolCall(name: name, arguments: arguments) } - private func isRetryableBridgeTransportError(_ error: any Error) -> Bool { - if case .transport = error as? CursorAPIError { - return true - } - return false - } - private static func bridgeToolObjects(_ prepared: PreparedChatRequest) -> [[String: Any]] { let tools = OpenAICompatibility.bridgeToolSpecs(for: prepared) return tools.map { tool in diff --git a/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift b/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift index c671f41..405cb34 100644 --- a/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift +++ b/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift @@ -1,4 +1,5 @@ @testable import CursorAPICore +import Foundation import XCTest final class ConnectivityCheckTests: XCTestCase { @@ -98,6 +99,240 @@ final class ConnectivityCheckTests: XCTestCase { .unauthorized ) } + + func testBridgeRecoveryRetriesAndRecyclesForRetryablePreOutputFailure() async throws { + let probe = BridgeRecoveryProbe() + + let value = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + if attempt == 1 { + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("stale bridge"), + status: nil, + code: " CURSOR_SDK_UNAVAILABLE ", + emittedOutput: false + ) + } + return "ok" + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + + XCTAssertEqual(value, "ok") + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1, 2]) + XCTAssertEqual(snapshot.recycleCount, 1) + XCTAssertEqual(snapshot.delayCount, 1) + } + + func testBridgeRecoveryDoesNotRetryAfterOutputEmission() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("stream failed after output"), + status: 503, + code: "cursor_sdk_unavailable", + emittedOutput: true + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected bridge recovery to fail after emitted output.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .upstream("stream failed after output")) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1]) + XCTAssertEqual(snapshot.recycleCount, 0) + XCTAssertEqual(snapshot.delayCount, 0) + } + + func testBridgeRecoveryUnauthorizedDoesNotRecycleOrRetry() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .unauthorized, + status: 401, + code: "unauthorized", + emittedOutput: false + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected unauthorized bridge error.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .unauthorized) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1]) + XCTAssertEqual(snapshot.recycleCount, 0) + XCTAssertEqual(snapshot.delayCount, 0) + } + + func testBridgeRecoveryNonRetryableUpstreamDoesNotRecycleOrRetry() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("internal failure"), + status: 500, + code: "internal", + emittedOutput: false + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected non-retryable upstream bridge error.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .upstream("internal failure")) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1]) + XCTAssertEqual(snapshot.recycleCount, 0) + XCTAssertEqual(snapshot.delayCount, 0) + } + + func testBridgeRecoveryTwoRetryableFailuresRecyclesOnceAndPreservesFinalUpstream() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("retryable failure \(attempt)"), + status: 503, + code: nil, + emittedOutput: false + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected retryable bridge recovery to fail after final attempt.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .upstream("retryable failure 2")) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1, 2]) + XCTAssertEqual(snapshot.recycleCount, 1) + XCTAssertEqual(snapshot.delayCount, 1) + } + + func testBridgeStopWaitForExitPollingSleepsUntilProcessStops() async { + var runningStates = [true, true, false] + var sleepDurations: [UInt64] = [] + + let stopped = await CursorSDKBridgeServer.waitForExitPolling( + timeoutNanoseconds: 1_000, + pollIntervalNanoseconds: 100, + isRunning: { + guard !runningStates.isEmpty else { return false } + return runningStates.removeFirst() + }, + sleep: { duration in + sleepDurations.append(duration) + } + ) + + XCTAssertTrue(stopped) + XCTAssertEqual(sleepDurations, [100, 100]) + } + + func testBridgeStopWaitForExitPollingTimesOutWhenProcessKeepsRunning() async { + var sleepDurations: [UInt64] = [] + + let stopped = await CursorSDKBridgeServer.waitForExitPolling( + timeoutNanoseconds: 250, + pollIntervalNanoseconds: 100, + isRunning: { true }, + sleep: { duration in + sleepDurations.append(duration) + } + ) + + XCTAssertFalse(stopped) + XCTAssertEqual(sleepDurations, [100, 100, 50]) + } + + func testBridgeStreamReadFailureMapsURLErrorToRetryableTransport() { + let source = URLError(.networkConnectionLost) + + let mapped = LocalCursorSDKHarness.bridgeStreamReadFailure(from: source) + + let failure = mapped as? LocalCursorSDKHarness.BridgeRecoveryError + XCTAssertEqual( + failure?.outwardError, + .transport("Cursor SDK bridge stream failed: \(source.localizedDescription)") + ) + XCTAssertTrue(failure?.isRetryableBeforeOutput == true) + } + + func testBridgeStreamReadFailurePreservesWrappedBridgeRecoveryError() { + let wrapped = LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("already wrapped"), + status: 503, + code: "cursor_sdk_unavailable", + emittedOutput: true + ) + + let mapped = LocalCursorSDKHarness.bridgeStreamReadFailure(from: wrapped) + + XCTAssertEqual(mapped as? LocalCursorSDKHarness.BridgeRecoveryError, wrapped) + } + + func testBridgeStreamReadFailurePreservesCancellation() { + let mapped = LocalCursorSDKHarness.bridgeStreamReadFailure(from: CancellationError()) + + XCTAssertTrue(mapped is CancellationError) + } + + func testBridgeForceTerminationGraceExceedsNodeShutdownTimeout() { + XCTAssertGreaterThan( + CursorSDKBridgeServer.forceTerminationGraceMilliseconds, + CursorSDKBridgeServer.bridgeShutdownTimeoutMilliseconds + ) + } } private actor ConnectivityRecorder { @@ -126,3 +361,25 @@ private struct ConnectivityHarness: CursorSDKHarness { } } } + +private actor BridgeRecoveryProbe { + private var attempts: [Int] = [] + private var recycleCount = 0 + private var delayCount = 0 + + func recordAttempt(_ attempt: Int) { + attempts.append(attempt) + } + + func recordRecycle() { + recycleCount += 1 + } + + func recordDelay() { + delayCount += 1 + } + + func snapshot() -> (attempts: [Int], recycleCount: Int, delayCount: Int) { + (attempts, recycleCount, delayCount) + } +} diff --git a/macos/CursorAPI/xctest-shim.abi.json b/macos/CursorAPI/xctest-shim.abi.json new file mode 100644 index 0000000..d2f988e --- /dev/null +++ b/macos/CursorAPI/xctest-shim.abi.json @@ -0,0 +1,9 @@ +{ + "ABIRoot": { + "kind": "Root", + "name": "NO_MODULE", + "printedName": "NO_MODULE", + "json_format_version": 8 + }, + "ConstValues": [] +} \ No newline at end of file diff --git a/macos/CursorAPI/xctest-shim.swiftdoc b/macos/CursorAPI/xctest-shim.swiftdoc new file mode 100644 index 0000000000000000000000000000000000000000..450ca832e43de62cf86e44a211418c8a5bfb21b5 GIT binary patch literal 412 zcmaDfX9YVW2Lpp90|Ns)qlJ#c+7Dby0=U-%aP4>CT5rI$Ux91Q2d>2%xY!*xUFbQa zy@N^j#3Ahshx9v`wD%m+SaZl(Xp58P1t*OOP8vYc#=uE?50mBb+2rD zzS?lFFz#lIs=!Rwc0R@K-D3s^OnE`eMi8{zV5h=kU!zZ#a8z8~%j^wVLo% zEB?8Jf6=(dZY9Y3h7Yt}ZvXPu_Cmrp4+IYXx7Zf*m&iFIuw;-`hi}mU!*tDdAV%)a9~goA2F#t*q9eFv&j~ec1LAFS?I%hwINnJReK7CtNT0j7;bppdhS*f+A?NpBbh~al0eCE39vH_< zr9ASAGdV@~P9s&z6$@S=$OSnsQWPZXxoJd&gDm4EHLR1D3gc7_53-;m*XALXVPFbB zhV=m_4{dZXjWnGg?!Z`5Ck398uqA3zl8f3b%Lf?%KssDqL>&C?IZXb9^ZiV^H zb%%C|%zrEQ>+lTw_pFUh#KfTd8w9U^9~w?YE59Ycam;ppx59Gfx>kOl2b}}Z4nngE z_H7Cp#_at58J^=c?a-`Z&B>nt;C$F||7K!#+?OF4*ymQhCHsOw$m>u_vPJ-y&gPR1M<4w4GP0Wtl3CX~9*eWO80OE6Cm+ylnX2<;(VB8+u zxVXbjxc`Cm7_;N1pm5`W!Im_LY?;MCXAtIgen(8qj(Zpm&PiBj#~pHab)Fk?E}Pd3 zoO}8DrH2X7)?N|n7a+^Qe_Yd$s;AW{l*#Z)E|2(pu7Jv7wmdRAk`YCX4T-v>=VVb= zbA|CU;*_4m=ltGU&ziS^jR&^Vg&9>U4XbKVJrf@+%ZdzJ3jTH6`uG>#svQC4q?F4y z*c3B%)*!jy{f&A0kCc^x#T0e#-zw_EA52O4OhX8>wFqjxZ5ZO)@0`ykab8cBq@Cxi z+)SJwlr)6*1f0Km>cCCW3Wu0SrcbCy(vg~;kP1&}{Y`8g-!5&$siT))#kL;r49;ae zwxpETo?>h8HR&LP4?nFwZzbFfsJ!Zm8CVfOu1eocI8pl zm?0)p8Xea2NjDhqLVNQ4gDVru#aiFIGXQ!GlK$C~2i3CVp#?h&5q*oJ0{PYaug~CH z5Lhr=N{C>Kk)d{L)0q~Q-GN`q62XgQQs!U~E|oE(i(ye!L9 qG!&63fs>d>lwlDY?Wggv*J#*(_$m@7a+AT@=M>oLCUHGIl79g)3;gK- literal 0 HcmV?d00001 diff --git a/scripts/cursor-sdk-local-agent-bridge.mjs b/scripts/cursor-sdk-local-agent-bridge.mjs index bd01f79..0ac843c 100644 --- a/scripts/cursor-sdk-local-agent-bridge.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.mjs @@ -21,6 +21,8 @@ const maxAgents = parseInteger(process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS, 128); const runTimeoutMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS, 180 * 1000); const maxRunRetries = parseInteger(process.env.CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES, 3); const retryBaseDelayMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_RETRY_BASE_DELAY_MS, 500); +const bridgeShutdownTimeoutMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS, 15 * 1000); +const parentLossPollIntervalMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS, 1000); const defaultCwd = process.env.CURSOR_SDK_WORKING_DIRECTORY || process.cwd(); const clientMcpServerName = "client"; const clientMcpServerMode = "--client-mcp-server"; @@ -30,7 +32,14 @@ const agentCache = new Map(); const agentRunQueues = new Map(); const activeClientToolCaptures = new Map(); const forceNextRunAgentKeys = new Set(); +const pendingAgentDisposals = new Map(); +const pendingAgentCreations = new Set(); +const agentDisposals = new WeakMap(); +const initialParentPid = process.ppid; let server = null; +let parentLossWatchdog = null; +let isShuttingDown = false; +let closeAndExitPromise = null; if (isMainModule()) { installBridgeProcessHandlers(); @@ -38,15 +47,23 @@ if (isMainModule()) { await runClientForwardingMcpServerFromEnvironment(); } else { startServer(); - process.on("SIGINT", () => closeAndExit(0)); - process.on("SIGTERM", () => closeAndExit(0)); + parentLossWatchdog = startParentLossWatchdog({ + initialParentPid, + intervalMs: parentLossPollIntervalMs, + onParentLost: () => requestBridgeShutdown(0) + }); } } export { bridgePrompt, + closeAndExit, + cleanupBridgeResources, clientMcpToolDefinitions, clientForwardingMcpServerSource, + evictAgent, + evictCachedAgent, + getAgent, localAgentCreateOptions, localAgentSendOptions, isForwardableSDKToolCall, @@ -56,6 +73,7 @@ export { openAiError, runExclusiveForAgent, sdkRunFailureSummary, + startParentLossWatchdog, statusFromError, startServer, validateClientMcpToolCall, @@ -76,6 +94,10 @@ function startServer() { } async function handleRequest(request, response) { + if (isShuttingDown) { + writeJson(response, openAiError(bridgeShuttingDownError()), 503); + return; + } const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`); if (request.method === "GET" && url.pathname === "/health") { @@ -216,7 +238,7 @@ async function runLocalAgentUnlocked(input, onEvent) { const shouldRetry = attempt < maxRunRetries && !emittedEvent && isRetryableSDKRunError(error); if (!shouldRetry) throw error; if (activeRun) activeRun.cancel().catch(() => {}); - evictCachedAgent(input); + await evictCachedAgent(input); console.warn(`Retrying Cursor SDK run after retryable upstream error (${attempt + 1}/${maxRunRetries}).`); await sleep(retryDelayMs(attempt)); } finally { @@ -335,7 +357,7 @@ async function runLocalAgentBody(input, onRun, onEvent) { const result = await run.wait(); if (result.status === "error") { - if (agentEntry) evictAgent(agentEntry.cacheKey, agentEntry.agent); + if (agentEntry) await evictAgent(agentEntry.cacheKey, agentEntry.agent); throw sdkRunFailureError(result); } if (!text && typeof result.result === "string") text = result.result; @@ -350,33 +372,88 @@ async function runLocalAgentBody(input, onRun, onEvent) { async function getAgent(input) { const cacheKey = agentCacheKey(input); + await waitForPendingAgentDisposal(cacheKey); const cached = agentCache.get(cacheKey); if (cached) { cached.touchedAt = Date.now(); return { agent: cached.agent, cacheKey, cached: true }; } - const agent = await Agent.create(localAgentCreateOptions(input)); - agentCache.set(cacheKey, { agent, touchedAt: Date.now() }); - evictAgents(); - return { agent, cacheKey, cached: false }; + if (isShuttingDown) throw bridgeShuttingDownError(); + + const creation = (async () => { + const agent = await Agent.create(localAgentCreateOptions(input)); + agentCache.set(cacheKey, { agent, touchedAt: Date.now() }); + await evictAgents(); + return { agent, cacheKey, cached: false }; + })(); + pendingAgentCreations.add(creation); + try { + return await creation; + } finally { + pendingAgentCreations.delete(creation); + } } -function evictAgent(cacheKey, agent) { +async function evictAgent(cacheKey, agent) { const cached = agentCache.get(cacheKey); if (cached?.agent === agent) { agentCache.delete(cacheKey); } forceNextRunAgentKeys.delete(cacheKey); - try { - agent.close(); - } catch {} + await scheduleAgentDisposal(cacheKey, () => disposeAgent(agent)); } -function evictCachedAgent(input) { +async function evictCachedAgent(input) { const cacheKey = agentCacheKey(input); const cached = agentCache.get(cacheKey); - if (cached) evictAgent(cacheKey, cached.agent); + if (cached) { + await evictAgent(cacheKey, cached.agent); + return; + } + await waitForPendingAgentDisposal(cacheKey); +} + +function scheduleAgentDisposal(cacheKey, disposalWork) { + const previous = pendingAgentDisposals.get(cacheKey) ?? Promise.resolve(); + const scheduled = previous + .catch(() => {}) + .then(() => disposalWork()) + .catch(() => {}) + .finally(() => { + if (pendingAgentDisposals.get(cacheKey) === scheduled) { + pendingAgentDisposals.delete(cacheKey); + } + }); + pendingAgentDisposals.set(cacheKey, scheduled); + return scheduled; +} + +async function waitForPendingAgentDisposal(cacheKey) { + const pending = pendingAgentDisposals.get(cacheKey); + if (pending) await pending.catch(() => {}); +} + +async function disposeAgent(agent) { + if (!agent || (typeof agent !== "object" && typeof agent !== "function")) return; + const existing = agentDisposals.get(agent); + if (existing) { + await existing; + return; + } + + const disposal = (async () => { + if (typeof agent[Symbol.asyncDispose] === "function") { + await agent[Symbol.asyncDispose](); + return; + } + if (typeof agent.close === "function") { + await agent.close(); + } + })().catch(() => {}); + + agentDisposals.set(agent, disposal); + await disposal; } function registerActiveClientToolCapture(cacheKey, handler) { @@ -1984,15 +2061,14 @@ function agentCacheKey(input) { return digest; } -function evictAgents() { +async function evictAgents() { while (agentCache.size > maxAgents) { const oldest = [...agentCache.entries()].sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0]; if (!oldest) return; - agentCache.delete(oldest[0]); - forceNextRunAgentKeys.delete(oldest[0]); - try { - oldest[1].agent.close(); - } catch {} + const [cacheKey, { agent }] = oldest; + agentCache.delete(cacheKey); + forceNextRunAgentKeys.delete(cacheKey); + scheduleAgentDisposal(cacheKey, () => disposeAgent(agent)); } } @@ -2089,6 +2165,8 @@ function writeNdjson(response, body) { } function installBridgeProcessHandlers() { + process.on("SIGINT", () => requestBridgeShutdown(0)); + process.on("SIGTERM", () => requestBridgeShutdown(0)); process.on("unhandledRejection", (reason) => { if (isBenignCancellationError(reason) || isBenignPipeError(reason)) return; if (isRetryableSDKRunError(reason)) { @@ -2096,7 +2174,7 @@ function installBridgeProcessHandlers() { return; } console.error(reason); - closeAndExit(1); + requestBridgeShutdown(1); }); process.on("uncaughtException", (error) => { if (isBenignCancellationError(error) || isBenignPipeError(error)) return; @@ -2105,7 +2183,51 @@ function installBridgeProcessHandlers() { return; } console.error(error); - closeAndExit(1); + requestBridgeShutdown(1); + }); +} + +function startParentLossWatchdog({ + initialParentPid: parentPid = process.ppid, + getParentPid = () => process.ppid, + onParentLost = () => requestBridgeShutdown(0), + setIntervalFn = setInterval, + clearIntervalFn = clearInterval, + intervalMs = parentLossPollIntervalMs +} = {}) { + if (!(Number.isFinite(parentPid) && parentPid > 1)) { + return { stop() {}, active: false, initialParentPid: parentPid }; + } + + let stopped = false; + let shutdownRequested = false; + let timer = null; + const stop = () => { + if (stopped) return; + stopped = true; + if (timer) clearIntervalFn(timer); + }; + + timer = setIntervalFn(() => { + if (stopped || shutdownRequested) return; + const observedParentPid = Number(getParentPid()); + if (!Number.isFinite(observedParentPid) || observedParentPid <= 0) return; + if (observedParentPid === parentPid) return; + shutdownRequested = true; + stop(); + onParentLost(); + }, intervalMs); + if (timer && typeof timer.unref === "function") { + timer.unref(); + } + + return { stop, active: true, initialParentPid: parentPid }; +} + +function requestBridgeShutdown(code) { + closeAndExit(code).catch((error) => { + console.error(error); + process.exit(code); }); } @@ -2329,13 +2451,116 @@ function loadEnvFile(filePath) { } async function closeAndExit(code) { - for (const entry of agentCache.values()) { + if (closeAndExitPromise) return closeAndExitPromise; + + const exitCode = Number.isInteger(code) ? code : 0; + isShuttingDown = true; + if (parentLossWatchdog) { + parentLossWatchdog.stop(); + parentLossWatchdog = null; + } + + const forceExitTimer = setTimeout(() => process.exit(exitCode), bridgeShutdownTimeoutMs); + if (forceExitTimer && typeof forceExitTimer.unref === "function") { + forceExitTimer.unref(); + } + + closeAndExitPromise = (async () => { try { - entry.agent.close(); - } catch {} + await cleanupBridgeResources({ + closeServer: (onClose) => { + const activeServer = server; + server = null; + if (!activeServer) { + if (typeof onClose === "function") onClose(); + return; + } + activeServer.close(() => { + if (typeof onClose === "function") onClose(); + }); + if (typeof activeServer.closeIdleConnections === "function") { + activeServer.closeIdleConnections(); + } + } + }); + } catch (error) { + console.error(error); + } finally { + clearTimeout(forceExitTimer); + } + + process.exit(exitCode); + })(); + + return closeAndExitPromise; +} + +async function cleanupBridgeResources({ agentEntries, closeServer } = {}) { + const serverClosePromise = closeServerWithPromise(closeServer); + forceNextRunAgentKeys.clear(); + activeClientToolCaptures.clear(); + agentRunQueues.clear(); + await waitForPendingAgentCreations(); + + if (agentEntries !== undefined) { + agentCache.clear(); + await disposeAgentEntries(agentEntries); + } else { + while (true) { + const entries = [...agentCache.values()]; + if (entries.length === 0) break; + agentCache.clear(); + await disposeAgentEntries(entries); + await waitForPendingAgentCreations(); + } } - server?.close(() => process.exit(code)); - setTimeout(() => process.exit(code), 500).unref(); + + const disposalPromises = [...pendingAgentDisposals.values()]; + for (const pending of disposalPromises) await pending.catch(() => {}); + pendingAgentDisposals.clear(); + + await serverClosePromise; +} + +async function waitForPendingAgentCreations() { + while (pendingAgentCreations.size > 0) { + const pending = [...pendingAgentCreations]; + await Promise.allSettled(pending); + } +} + +async function disposeAgentEntries(agentEntries) { + const entries = [...agentEntries]; + for (const entry of entries) { + await disposeAgent(entry?.agent ?? entry); + } +} + +function closeServerWithPromise(closeServer) { + if (typeof closeServer !== "function") return Promise.resolve(); + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; + + try { + if (closeServer.length > 0) { + closeServer(() => finish()); + } else { + closeServer(); + finish(); + } + } catch { + finish(); + } + }); +} + +function bridgeShuttingDownError() { + return new HttpError("Bridge is shutting down", 503, "bridge_shutting_down"); } function isMainModule() { diff --git a/scripts/cursor-sdk-local-agent-bridge.test.mjs b/scripts/cursor-sdk-local-agent-bridge.test.mjs index d08a2d6..0fbaf27 100644 --- a/scripts/cursor-sdk-local-agent-bridge.test.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.test.mjs @@ -1,11 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { Agent } from "@cursor/sdk"; import { spawn, spawnSync } from "node:child_process"; import http from "node:http"; import { fileURLToPath } from "node:url"; import { bridgePrompt, + cleanupBridgeResources, clientForwardingMcpServerSource, clientMcpToolDefinitions, + evictAgent, + evictCachedAgent, + getAgent, localAgentCreateOptions, localAgentSendOptions, isForwardableSDKToolCall, @@ -15,6 +20,7 @@ import { openAiError, runExclusiveForAgent, sdkRunFailureSummary, + startParentLossWatchdog, statusFromError, toolCallFromDelta, validateClientMcpToolCall @@ -156,6 +162,401 @@ describe("Cursor SDK local-agent bridge", () => { await expect(second).resolves.toBe("second"); }); + it("keeps bridge cleanup pending until SDK async disposal settles", async () => { + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + let asyncDisposeCalls = 0; + let serverClosed = false; + const fakeAgent = { + close() { + throw new Error("close should not be used when Symbol.asyncDispose is available"); + }, + async [Symbol.asyncDispose]() { + asyncDisposeCalls += 1; + await asyncDisposeGate; + } + }; + + let cleanupResolved = false; + const cleanupPromise = cleanupBridgeResources({ + agentEntries: [{ agent: fakeAgent }], + closeServer: () => { + serverClosed = true; + } + }).then(() => { + cleanupResolved = true; + }); + + try { + await Promise.resolve(); + expect(serverClosed).toBe(true); + expect(asyncDisposeCalls).toBe(1); + expect(cleanupResolved).toBe(false); + } finally { + releaseAsyncDispose(); + await cleanupPromise; + } + }); + + it("serializes per-key SDK async disposals before starting the next disposal", async () => { + const cacheKey = `serial-dispose-${Date.now()}`; + let releaseFirstDispose; + const firstDisposeGate = new Promise((resolve) => { + releaseFirstDispose = resolve; + }); + let firstDisposeStarts = 0; + let secondDisposeStarts = 0; + const firstAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + firstDisposeStarts += 1; + await firstDisposeGate; + } + }; + const secondAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + secondDisposeStarts += 1; + } + }; + + const firstEviction = evictAgent(cacheKey, firstAgent); + const secondEviction = evictAgent(cacheKey, secondAgent); + + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(firstDisposeStarts).toBe(1); + expect(secondDisposeStarts).toBe(0); + + releaseFirstDispose(); + await firstEviction; + await secondEviction; + expect(secondDisposeStarts).toBe(1); + } finally { + releaseFirstDispose?.(); + await firstEviction.catch(() => {}); + await secondEviction.catch(() => {}); + } + }); + + it("waits for in-flight SDK agent creation and disposal before bridge cleanup resolves", async () => { + const input = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `cleanup-create-race-${Date.now()}`, + clientTools: [] + }; + let releaseCreate; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + let asyncDisposeCalls = 0; + const createdAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + asyncDisposeCalls += 1; + await asyncDisposeGate; + } + }; + const originalCreate = Agent.create; + let createCalls = 0; + Agent.create = async () => { + createCalls += 1; + await createGate; + return createdAgent; + }; + + let agentPromise; + let cleanupPromise; + let cleanupResolved = false; + try { + agentPromise = getAgent(input); + await Promise.resolve(); + expect(createCalls).toBe(1); + + cleanupPromise = cleanupBridgeResources({ + closeServer: () => {} + }).then(() => { + cleanupResolved = true; + }); + + await Promise.resolve(); + expect(cleanupResolved).toBe(false); + + releaseCreate(); + const entry = await agentPromise; + expect(entry.agent).toBe(createdAgent); + + await new Promise((resolve) => setImmediate(resolve)); + expect(asyncDisposeCalls).toBe(1); + expect(cleanupResolved).toBe(false); + + releaseAsyncDispose(); + await cleanupPromise; + expect(cleanupResolved).toBe(true); + } finally { + releaseCreate?.(); + releaseAsyncDispose?.(); + await agentPromise?.catch(() => {}); + await cleanupPromise?.catch(() => {}); + await evictCachedAgent(input); + Agent.create = originalCreate; + } + }); + + it("blocks replacement creation until cached SDK async disposal completes", async () => { + const input = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `replacement-gate-${Date.now()}`, + clientTools: [] + }; + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + const oldAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + await asyncDisposeGate; + } + }; + const replacementAgent = { + close() {} + }; + const originalCreate = Agent.create; + let createCount = 0; + Agent.create = async () => { + createCount += 1; + return createCount === 1 ? oldAgent : replacementAgent; + }; + + try { + await getAgent(input); + const evictionPromise = evictCachedAgent(input); + const replacementPromise = getAgent(input); + + await Promise.resolve(); + expect(createCount).toBe(1); + + releaseAsyncDispose(); + await evictionPromise; + + const replacement = await replacementPromise; + expect(replacement.agent).toBe(replacementAgent); + expect(createCount).toBe(2); + } finally { + releaseAsyncDispose?.(); + await evictCachedAgent(input); + Agent.create = originalCreate; + } + }); + + it("does not block new-key agent creation on unrelated LRU async disposal", async () => { + const moduleUrl = new URL(`./cursor-sdk-local-agent-bridge.mjs?lru-eviction-latency=${Date.now()}`, import.meta.url); + const previousMaxAgents = process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS; + process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS = "1"; + const bridge = await import(moduleUrl.href); + const oldInput = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `lru-old-${Date.now()}`, + clientTools: [] + }; + const newInput = { + ...oldInput, + sessionKey: `${oldInput.sessionKey}-new` + }; + let releaseOldDispose; + const oldDisposeGate = new Promise((resolve) => { + releaseOldDispose = resolve; + }); + let signalOldDisposeStart; + const oldDisposeStarted = new Promise((resolve) => { + signalOldDisposeStart = resolve; + }); + const oldAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + signalOldDisposeStart?.(); + await oldDisposeGate; + } + }; + const newAgent = { + close() {} + }; + const originalCreate = Agent.create; + let createCount = 0; + Agent.create = async () => { + createCount += 1; + return createCount === 1 ? oldAgent : newAgent; + }; + + let newKeySettled = false; + let newKeyPromise; + try { + await bridge.getAgent(oldInput); + newKeyPromise = bridge.getAgent(newInput).then((entry) => { + newKeySettled = true; + return entry; + }); + + await oldDisposeStarted; + await Promise.resolve(); + + expect(newKeySettled).toBe(true); + const newKeyEntry = await newKeyPromise; + expect(newKeyEntry.agent).toBe(newAgent); + expect(createCount).toBe(2); + } finally { + releaseOldDispose?.(); + await newKeyPromise?.catch(() => {}); + await bridge.evictCachedAgent(oldInput).catch(() => {}); + await bridge.evictCachedAgent(newInput).catch(() => {}); + await bridge.cleanupBridgeResources({ closeServer: () => {} }).catch(() => {}); + Agent.create = originalCreate; + if (previousMaxAgents === undefined) { + delete process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS; + } else { + process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS = previousMaxAgents; + } + } + }); + + it("requests graceful shutdown once when parent ownership is lost", () => { + let tick; + let clearedHandle = null; + let shutdownRequests = 0; + let parentPid = 100; + const scheduledHandle = { + unrefCalls: 0, + unref() { + this.unrefCalls += 1; + } + }; + + const watchdog = startParentLossWatchdog({ + initialParentPid: 100, + getParentPid: () => parentPid, + setIntervalFn: (callback) => { + tick = callback; + return scheduledHandle; + }, + clearIntervalFn: (handle) => { + clearedHandle = handle; + }, + onParentLost: () => { + shutdownRequests += 1; + } + }); + + expect(typeof tick).toBe("function"); + expect(scheduledHandle.unrefCalls).toBe(1); + + tick(); + parentPid = 200; + tick(); + tick(); + + expect(shutdownRequests).toBe(1); + expect(clearedHandle).toBe(scheduledHandle); + + watchdog.stop(); + expect(clearedHandle).toBe(scheduledHandle); + }); + + it("disables parent-loss watchdog when started as pid1 child", () => { + let scheduled = false; + let shutdownRequests = 0; + const watchdog = startParentLossWatchdog({ + initialParentPid: 1, + setIntervalFn: () => { + scheduled = true; + return { + unref() {} + }; + }, + onParentLost: () => { + shutdownRequests += 1; + } + }); + + expect(scheduled).toBe(false); + expect(shutdownRequests).toBe(0); + watchdog.stop(); + }); + + it("shares shutdown work across repeated closeAndExit requests and keeps the first exit code", async () => { + const moduleUrl = new URL(`./cursor-sdk-local-agent-bridge.mjs?shutdown-idempotency=${Date.now()}`, import.meta.url); + const bridge = await import(moduleUrl.href); + const input = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `close-and-exit-${Date.now()}`, + clientTools: [] + }; + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + let asyncDisposeCalls = 0; + const fakeAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + asyncDisposeCalls += 1; + await asyncDisposeGate; + } + }; + const originalCreate = Agent.create; + const exitCodes = []; + Agent.create = async () => fakeAgent; + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + exitCodes.push(code); + }); + + try { + await bridge.getAgent(input); + const firstShutdown = bridge.closeAndExit(9); + const secondShutdown = bridge.closeAndExit(2); + + await new Promise((resolve) => setImmediate(resolve)); + expect(asyncDisposeCalls).toBe(1); + + releaseAsyncDispose(); + await firstShutdown; + await secondShutdown; + + expect(asyncDisposeCalls).toBe(1); + expect(exitCodes).toEqual([9]); + } finally { + releaseAsyncDispose?.(); + Agent.create = originalCreate; + exitSpy.mockRestore(); + } + }); + it("does not cancel SDK glob calls on directory-only partial arguments", () => { const partial = normalizeSDKToolCall({ type: "glob", From 53eca75731f7e98d37c2a84b61fbcb24d1ffc33d Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Sun, 12 Jul 2026 13:54:54 +0200 Subject: [PATCH 2/6] fix: stabilize Cursor bridge lifecycle Await SDK disposal, terminate orphaned bridge processes, and recycle retryable stale bridge state before response output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 23 +- docs/production.md | 57 +++ docs/todos/cursor-api-lifecycle/plan.md | 143 +++++++ docs/todos/cursor-api-lifecycle/todo.md | 132 ++++++ .../CursorAPICore/CursorSDKBridgeServer.swift | 90 +++- .../CursorAPICore/CursorSDKHarness.swift | 275 +++++++++--- .../ConnectivityCheckTests.swift | 257 +++++++++++ scripts/cursor-sdk-local-agent-bridge.mjs | 279 ++++++++++-- .../cursor-sdk-local-agent-bridge.test.mjs | 403 +++++++++++++++++- 9 files changed, 1562 insertions(+), 97 deletions(-) create mode 100644 docs/todos/cursor-api-lifecycle/plan.md create mode 100644 docs/todos/cursor-api-lifecycle/todo.md diff --git a/README.md b/README.md index eebc292..62641ec 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,27 @@ Run the optional SDK local-agent bridge in a local Bun or Node environment: npm run sdk:opencode-bridge ``` -The bridge process also accepts `CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`; the default is -`180000`. +The bridge process also accepts optional runtime knobs; defaults shown: + +```bash +CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS=180000 # per-request run timeout (packaged app overrides to 120000) +CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES=3 # SDK-run retries per bridge call (4 total attempts) +CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS=15000 # Node drain bound during shutdown +CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS=1000 # parent-PID watchdog cadence (packaged app) +``` + +In the packaged macOS app the bridge runs as a child process owned by the app. The bridge +follows parent lifetime: it stops accepting new requests when the app begins shutdown, then +awaits in-flight SDK agent asynchronous disposal before exiting. + +Two retry layers apply. Inside the bridge, `maxRunRetries` (default 3) controls SDK-run +retries: `attempt` starts at 0 and increments after each retryable SDK-run error that +occurs before any event is emitted, allowing up to 3 retries (4 total SDK-run attempts) +per bridge call. At the Swift app layer, `bridgeTransportAttempts = 2` means a retryable +pre-output transport failure, 503, or `cursor_sdk_unavailable` error causes the app to +recycle the owned bridge exactly once and issue one final bridge request. Auth failures, +arbitrary upstream errors, and any failure after output has begun are never retried at +either layer. The public OpenAI-compatible response and error contract is unchanged. Release packages prefer a bundled Node runtime for the local SDK bridge and fall back to Bun when Node is unavailable. diff --git a/docs/production.md b/docs/production.md index 37826c3..5310798 100644 --- a/docs/production.md +++ b/docs/production.md @@ -13,6 +13,63 @@ API for Cursor ships as a signed macOS DMG and updates through Sparkle. - The release workflow uploads the versioned DMG, latest DMG alias, and appcast to Cloudflare R2. - The Worker serves `/download`, `/releases/...`, and `/appcast.xml` from Cloudflare. +## Local Bridge Lifecycle + +In the packaged macOS app the SDK bridge runs as a managed child process. + +**Ownership and shutdown sequence:** + +- The macOS app launches the bridge child and holds its lifetime. +- The child polls the original parent PID at a configurable cadence (default 1000 ms; + `CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS`). When the parent exits or the PID + changes, shutdown begins. +- During shutdown the bridge rejects new incoming requests immediately. +- In-flight request creation and any cached or pending SDK agents are drained with awaited + `Symbol.asyncDispose`; a synchronous `close()` call is used only when `asyncDispose` is + absent. +- Node is given a bounded drain window (default 15000 ms; + `CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS`) before exiting. +- The run timeout is 180000 ms by default (`CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`); the packaged + macOS app explicitly overrides this to 120000 ms. +- The Swift app waits 16000 ms (the Node drain bound of 15000 ms plus 1000 ms extra grace) + before sending SIGKILL. + +**Recovery:** + +Two retry layers are active. + +Inside the bridge, SDK-run errors that occur before any event is emitted are retried up to +`maxRunRetries` times (default 3; configurable via `CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES`). The +`attempt` counter starts at 0; retries continue while `attempt < maxRunRetries`, yielding at +most 4 SDK-run attempts per bridge call. + +At the Swift app layer (`bridgeTransportAttempts = 2`), only retryable pre-output failures +recycle the owned bridge once: transport-level failures and known +`503`/`cursor_sdk_unavailable` stale-state errors that occur before any text or tool output +has been produced. The recycled bridge handles one final attempt. If that also fails, the +`CursorAPIError`/OpenAI-compatible error is returned unchanged. + +Auth failures, arbitrary upstream errors, and any failure after output has begun are never +retried at either layer. + +**Verification:** + +- Node lifecycle suite: `npx vitest run scripts/cursor-sdk-local-agent-bridge.test.mjs` + must pass. +- Swift build: `swift build -c release` from `macos/CursorAPI` (core and production-seam + typecheck green). +- Swift XCTest: requires a full Xcode installation with the XCTest framework; Command Line + Tools alone cannot execute `.xctest` bundles. Run via Xcode or `xcodebuild test`. +- No live credentialed model call was performed during lifecycle fix verification. + +**Dependency decision recorded 2026-07-12:** + +Lockfile remains at `@cursor/sdk 1.0.13`. Official `1.0.23` was evaluated: it retains a +non-awaited `close()` path while `asyncDispose` correctly awaits, and it raises the Node +engine requirement to `>=22.13`. The update was deferred; it does not fix the lifecycle root +cause alone and requires a runtime version bump. No package or lockfile change was made. +Re-evaluate this decision when changing the SDK lockfile or the bundled Node runtime. + ## Required GitHub Secrets - `MACOS_DEVELOPER_ID_CERTIFICATE_BASE64`: base64-encoded Developer ID Application `.p12`. diff --git a/docs/todos/cursor-api-lifecycle/plan.md b/docs/todos/cursor-api-lifecycle/plan.md new file mode 100644 index 0000000..37d5742 --- /dev/null +++ b/docs/todos/cursor-api-lifecycle/plan.md @@ -0,0 +1,143 @@ +# Cursor API Lifecycle Stability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the local Cursor SDK bridge self-cleaning and recoverable without changing the OpenAI-compatible API. + +**Architecture:** The Node/Bun bridge owns SDK agents and must await their asynchronous disposal before replacement or exit. The bridge also watches its launching parent, while the Swift owner coordinates graceful termination and recycles the bridge once for retryable pre-output failures. + +**Tech Stack:** Node.js ESM, `@cursor/sdk`, Vitest, Swift 6, Foundation `Process`, Swift Testing/XCTest. + +**Commit Policy:** The initial implementation did not authorize commits. On 2026-07-12, the user separately authorized one local commit and accepted the documented XCTest and pre-existing Semgrep blockers; push, pull request, merge, deployment, and release remain unauthorized. + +--- + +### Task 1: Prove the cleanup failure locally + +**Files:** +- Modify: `scripts/cursor-sdk-local-agent-bridge.test.mjs` +- Modify: `scripts/cursor-sdk-local-agent-bridge.mjs` + +**Verifier:** +- Commands that must be fresh: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` +- Expected evidence: the new fixture initially fails because shutdown returns before asynchronous SDK disposal completes. +- Notes: the same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Export a test seam for bridge resource cleanup** + +Expose a cleanup function that accepts explicit fake agent entries and an HTTP-server close callback without starting a real SDK agent. + +- [x] **Step 2: Write the failing delayed-disposal fixture** + +Use a fake agent with `Symbol.asyncDispose` that resolves only after the test releases it, then assert bridge cleanup remains pending until that release. + +- [x] **Step 3: Run the focused test and record the expected failure** + +Run: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` + +Expected: FAIL because the current implementation calls `close()` and does not await SDK disposal. + +### Task 2: Implement deterministic bridge ownership + +**Files:** +- Modify: `scripts/cursor-sdk-local-agent-bridge.mjs` +- Modify: `scripts/cursor-sdk-local-agent-bridge.test.mjs` + +**Verifier:** +- Commands that must be fresh: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` +- Expected evidence: delayed disposal, eviction ordering, and parent-loss fixtures pass. +- Notes: the same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Await SDK disposal** + +Prefer `await agent[Symbol.asyncDispose]()` and fall back to `agent.close()` only when the async protocol is unavailable. + +- [x] **Step 2: Serialize eviction with cleanup** + +Make retry and cache-eviction paths await disposal before creating a replacement SDK agent. + +- [x] **Step 3: Add parent-loss detection** + +Capture the initial parent PID and request one graceful shutdown if the parent changes; disable this check when the bridge starts as PID 1's child. + +- [x] **Step 4: Coordinate process exit** + +Stop accepting new HTTP work, dispose all cached agents, close the server, and retain a bounded force-exit fallback. + +- [x] **Step 5: Run the focused verifier** + +Run: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs` + +Expected: PASS. + +### Task 3: Recycle poisoned bridge state in the macOS harness + +**Files:** +- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift` +- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift` +- Modify: `macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift` + +**Verifier:** +- Commands that must be fresh: `cd macos/CursorAPI && swift test --filter ConnectivityCheckTests` +- Expected evidence: a retryable 503 or `cursor_sdk_unavailable` event is marked for one pre-output recycle, while auth and non-retryable upstream errors retain their existing mappings. +- Notes: the same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Write retry-policy tests** + +Cover retryable opaque SDK failures, explicit authentication failures, and non-retryable upstream failures. + +- [x] **Step 2: Preserve the outward error contract** + +Wrap retryable bridge failures internally, recycle the bridge before the second attempt, and unwrap the existing `CursorAPIError.upstream` if the retry also fails. + +- [x] **Step 3: Extend graceful process shutdown** + +Allow the bridge's bounded asynchronous cleanup window to finish before the existing force-kill fallback. + +- [x] **Step 4: Run the focused Swift verifier** + +Run: `cd macos/CursorAPI && swift test --filter ConnectivityCheckTests` + +Expected: PASS. + +### Task 4: Document and verify the integrated fix + +**Files:** +- Modify: `README.md` +- Modify: `docs/production.md` +- Modify: `docs/todos/cursor-api-lifecycle/todo.md` + +**Verifier:** +- Commands that must be fresh: `npm test`, `npm run typecheck`, `npm run build`, `cd macos/CursorAPI && swift test` +- Expected evidence: all repository-native checks pass and lifecycle documentation matches the implemented behavior. +- Notes: run the repository security gate if present; otherwise run required Semgrep. OSV is not required unless dependency files change. The same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Document bridge ownership and recovery** + +Explain that the bridge follows the app parent, awaits SDK disposal, and is recycled once for retryable pre-output failures. + +- [x] **Step 2: Record the dependency intake decision** + +Document that 1.0.23 keeps non-awaiting `close()` semantics, requires Node 22.13, and is deferred because it is not the root-cause fix. + +- [x] **Step 3: Run focused and full verification** + +Run the verifier commands plus the available security gates, then update the task matrix with exact evidence. + +- [x] **Step 4: Prepare the parent handoff** + +Report root cause, changed files, commands, residual uncertainty, and that a credentialed live proof still requires explicit approval. + +### Task 5: Install the fixed local app + +**Files and paths:** +- Build: `macos/CursorAPI/dist/API for Cursor.app` +- Replace: `/Applications/API for Cursor.app` + +**Verifier:** +- Commands that must be fresh: `macos/CursorAPI/Scripts/verify-package.sh`, `codesign --verify --deep --strict`, bundle metadata checks, and installed bridge checksum comparison. +- Expected evidence: version `0.1.10` build `13`, fixed bridge payload matches the worktree, and no old helper process remains. + +- [x] **Step 1: Stop the installed app and orphan helpers** +- [x] **Step 2: Build and verify the development package** +- [x] **Step 3: Replace and statically verify the installed app** diff --git a/docs/todos/cursor-api-lifecycle/todo.md b/docs/todos/cursor-api-lifecycle/todo.md new file mode 100644 index 0000000..1120ce0 --- /dev/null +++ b/docs/todos/cursor-api-lifecycle/todo.md @@ -0,0 +1,132 @@ +# Cursor API Lifecycle Stability + +## Goal + +Eliminate recurring local Responses API failures caused by stale or incompletely disposed Cursor SDK bridge state, while preserving the existing OpenAI-compatible API and security boundaries. + +## Scope + +- Trace the macOS app, local API, SDK harness, Node/Bun bridge, and `@cursor/sdk` lifecycle end to end. +- Reproduce cleanup and restart behavior with local fixtures only. +- Fix bridge ownership, asynchronous SDK disposal, and retry-time bridge recycling. +- Keep current public request and response shapes unchanged. +- Document the lifecycle contract and dependency decision. + +## Non-goals + +- No credentialed model calls, real app restarts, or termination of user-owned processes. +- No hosted Worker, auth, persistence, routing, or schema changes. +- No dependency update without evidence that it addresses the observed failure. +- No commit, push, pull request, deployment, or release. + +## Acceptance criteria + +- SDK agents are asynchronously disposed before replacement or bridge exit. +- A bridge exits when its launching parent disappears. +- The macOS owner allows enough time for graceful bridge cleanup before force termination. +- A retryable pre-output SDK bridge failure recycles the bridge once and preserves the existing outward error contract if recovery fails. +- Focused lifecycle fixtures fail before the fix and pass after it. +- Repository-native JS and Swift checks pass. +- Required local security gates pass, or unavailable tooling is reported as a blocker. + +## Assumptions and boundaries + +- The observed `run.wait()` result with `status=error` and no details is retryable only before any response content has been emitted. +- `@cursor/sdk` 1.0.13 exposes `Symbol.asyncDispose`; its documented `close()` starts asynchronous disposal without waiting. +- The bridge runtime is a direct child of the macOS app in packaged operation. +- Live behavior still requires an explicitly approved credentialed proof after local verification. + +## Stop conditions + +- Stop implementation if the local fixture falsifies the asynchronous-disposal or parent-ownership hypothesis. +- Stop after the same verifier rejection key occurs twice and report the evidence instead of stacking another speculative fix. +- Stop before any live call, app restart, or termination of an existing process unless the coordinating parent explicitly approves it. + +## Root-cause evidence + +### Known facts + +- Before the fix, the bridge called `agent.close()` when evicting agents and during process shutdown. +- In `@cursor/sdk` 1.0.13, `close()` calls the asynchronous executor-release path without awaiting it; `Symbol.asyncDispose` awaits that path. +- Before the fix, the bridge forced `process.exit()` after 500 ms. +- Before the fix, the macOS bridge owner sent `SIGTERM` and force-killed the bridge after one second. +- Before the fix, the bridge had no parent-loss watchdog, so an abnormal app exit could leave it running. +- The SDK dependency was introduced at 1.0.13 and remains lockfile-resolved to 1.0.13; current official release is 1.0.23. +- Version 1.0.23 retains the same non-awaiting `close()` semantics and raises the Node requirement to 22.13, so an update alone does not address cleanup. + +### Confirmed repository root cause + +The bridge did not await SDK executor disposal before agent replacement or process exit, and it did not terminate when its launching parent disappeared. The macOS retry path also reused the same bridge after a retryable pre-output SDK failure. Deterministic fixtures proved each lifecycle gap: cleanup resolved before delayed disposal, replacement started before old disposal, parent loss did not request shutdown, and retry did not recycle the bridge. These defects explain how stale helper or executor state could survive app lifecycles and why terminating orphan helpers temporarily restored service. A credentialed live run is still required to prove the complete production failure chain against Cursor's upstream service. + +### Minimal falsification tests + +- A fake SDK agent whose async disposal completes after a delay must be fully disposed before bridge resource shutdown resolves. +- Parent PID change detection must request bridge shutdown exactly once. +- A retryable bridge stream error emitted before output must recycle the bridge before the second attempt. + +## Feature / Verification Matrix + +| Change | Verification | Status | Evidence | +| --- | --- | --- | --- | +| Await SDK disposal | Focused Vitest lifecycle fixture | Pass | 65 focused tests pass; cleanup awaits delayed disposal, in-flight creation, and pending per-key eviction | +| Parent-loss cleanup | Focused Vitest watchdog fixture | Pass | PID change requests the shared graceful shutdown path once; PID 1 parent disables the watchdog | +| Graceful macOS ownership | Swift lifecycle test | Pass with substitute | Core build, standalone verifier, and permanent-test typecheck prove 15-second bridge cleanup plus 1-second force-kill grace | +| Retry-time bridge recycle | Swift harness policy test | Pass with substitute | Five recovery scenarios prove one pre-output recycle and unchanged outward errors | +| Public API preservation | Existing Responses API tests | Pass | Full Vitest suite passes 236 tests; public request, response, auth, routing, schema, and outward error contracts are unchanged | + +## Integrated verification + +- `npm test`: 236 tests passed across 9 files. +- `npm run typecheck`: passed. +- `npm run build`: passed. +- Focused bridge suite: 65 tests passed; `node --check` passed. +- `swift build --target CursorAPICore`: passed. +- Standalone Swift recovery verifier: passed. +- `ConnectivityCheckTests.swift` typecheck with the scratch XCTest declaration module: passed. +- `swift test --filter ConnectivityCheckTests`: blocked because the host has Command Line Tools but no XCTest/Xcode runtime. +- Full Semgrep fallback: 26 pre-existing findings and exit 1. +- Semgrep baseline mode reports 0 findings but does not inspect uncommitted files. The full working-tree scan reports three findings in the changed bridge file; each is byte-for-byte pre-existing at base commit `6090652` (JSON pointer indexing at former lines 1048 and 1050, and schema-pattern construction at former line 1096). The other 23 findings are outside changed files, so this task introduces no Semgrep finding. +- OSV was not required because dependency and lockfile surfaces did not change. +- Three scratch XCTest metadata files remain untracked under `macos/CursorAPI`; deletion was not authorized while the user was unavailable. +- Before the later authorized installation follow-up, no live credentialed model call, app restart, or termination of an existing process was performed. + +## Authorized local installation follow-up + +On 2026-07-12 the user explicitly authorized terminating the installed app and its helper processes, building the current worktree, and replacing `/Applications/API for Cursor.app`. + +- Scope: terminate only processes running from the installed app bundle, package a development app from this worktree, verify the bundle, and replace the installed app. +- Non-goal: no credentialed model request, release signing, notarization, publication, or remote update. +- Acceptance: old helper PIDs are gone; the package verifier passes; the replacement keeps bundle id `ai.standardagents.cursorapi`, uses version `0.1.10` build `13`, contains the fixed bridge script and rebuilt Swift executable, and passes static code-signature verification. +- Boundary: do not launch the replacement or make a live API request unless separately requested. + +### Installation result + +- The app process was already stopped. Five orphan bridge helpers from the installed bundle had parent PID 1 and were terminated by their verified numeric PIDs. +- Locked Node dependencies were materialized with `npm ci`; package and lock files remained unchanged. +- `package-app.sh --development` built version `0.1.10` build `13`, and the repository package verifier passed. +- The app was staged under `/Applications`, code-signature checked, and atomically swapped into `/Applications/API for Cursor.app`. +- The installed bridge and Swift executable checksums match the verified build. The bundled runtime can import `node:http2` and `@cursor/sdk`. +- The old bundle backup was removed only after the installed replacement passed all static checks. +- The replacement remains stopped. No credentialed model call or live endpoint request was made. + +## Authorized commit follow-up + +On 2026-07-12 the user explicitly authorized one local commit and accepted the two documented verification blockers: XCTest cannot run without a full Xcode installation, and the full Semgrep fallback remains red on 26 pre-existing findings while the task diff introduces none. The three untracked `xctest-shim.*` metadata files remain outside the commit. No push, pull request, merge, deployment, or release was authorized. + +Fresh pre-commit verification on the authorized commit surface passed 236 JavaScript tests, TypeScript typecheck, Vite build, Node syntax checking, Swift core build, the standalone bridge-recovery verifier, and permanent Swift-test typechecking through the scratch XCTest declaration module. `swift test --filter ConnectivityCheckTests` freshly reproduced the accepted missing-XCTest blocker. + +## Subagent ledger + +| Workstream | Scope | Edits | Result | Residual risk | +| --- | --- | --- | --- | --- | +| RED lifecycle fixture | Bridge script and focused Vitest file | Allowed | Deterministic early-resolution failure reproduced | Test seam expands the module export surface | +| RED verifier and reviews | Same two files and focused command evidence | Read-only | Verifier accepted; spec and quality reviews passed | Green phase must make `closeAndExit` await cleanup | +| Deterministic bridge cleanup | Bridge script and focused Vitest file | Allowed | Async disposal, serialized replacement, parent watchdog, shutdown drain, and LRU behavior implemented | Real SDK disposal latency remains bounded by the 15-second force-exit fallback | +| Bridge cleanup verification | Current task-owned diff and focused commands | Read-only | Final verifier accepted 65 tests and syntax check; no Critical/Important review findings | Live recovery still requires explicit approval | +| Swift bridge recovery | Harness, bridge owner, connectivity tests, standalone verifier | Allowed | Retryable pre-output failures recycle once; stop polling is async and reentrancy-safe | Real bridge process behavior remains unproved without live-call approval | +| Swift recovery verification | Current Swift diff, core build, standalone verifier, test typecheck | Read-only | Final verifier accepted all nine requirements; no Critical/Important review findings | `swift test` cannot load XCTest because only Command Line Tools are installed | +| Lifecycle documentation | README and production runbook | Allowed | Ownership, two retry layers, timeout derivation, verification limits, and dependency decision documented | Historical dependency note must be re-evaluated with SDK or runtime changes | +| Documentation verification | Current documentation diff | Read-only | Final verifier accepted implementation facts, privacy boundaries, and diff hygiene | None | +| Simplification pass | Active Node and Swift production diff | Allowed | Removed dead branching and narrowed shutdown state without changing behavior | Focused checks passed after cleanup | +| Final implementation review | Production lifecycle and test coverage lanes | Read-only | Both lanes accepted with no Critical/Important findings | XCTest runtime and live upstream behavior remain unverified | +| Integrated verification | Entire task-owned diff and repo-native commands | Read-only | All task-owned checks passed; Semgrep diff introduced zero findings | Full XCTest unavailable; full Semgrep remains red on 26 pre-existing findings | diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift index 7d92e7e..ed885a3 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift @@ -10,20 +10,26 @@ struct CursorSDKBridgeEndpoint: Sendable { actor CursorSDKBridgeServer { static let shared = CursorSDKBridgeServer() + static let bridgeShutdownTimeoutMilliseconds = 15_000 + static let forceTerminationExtraGraceMilliseconds = 1_000 + static let forceTerminationGraceMilliseconds = bridgeShutdownTimeoutMilliseconds + forceTerminationExtraGraceMilliseconds private var process: Process? private var endpoint: CursorSDKBridgeEndpoint? private var logHandle: FileHandle? + private var isStopping = false + private var stopWaiters: [CheckedContinuation] = [] private let token = UUID().uuidString.replacingOccurrences(of: "-", with: "") func endpoint(settings: CursorAPISettings) async throws -> CursorSDKBridgeEndpoint { + await waitForInFlightStop() if let endpoint, process?.isRunning == true { return endpoint } if let endpoint, await isHealthy(endpoint.healthURL) { return endpoint } - stop() + await stop() let script = try bridgeScriptURL() let port = try await start(script: script, settings: settings) let endpoint = CursorSDKBridgeEndpoint( @@ -50,10 +56,10 @@ actor CursorSDKBridgeServer { } try await Task.sleep(nanoseconds: 50_000_000) } - stop() + await stop() lastError = CursorAPIError.transport("Cursor SDK bridge did not become ready.") } catch { - stop() + await stop() lastError = error } } @@ -70,6 +76,7 @@ actor CursorSDKBridgeServer { environment["CURSOR_SDK_BRIDGE_PORT"] = String(port) environment["CURSOR_SDK_BRIDGE_TOKEN"] = token environment["CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS"] = "120000" + environment["CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS"] = String(Self.bridgeShutdownTimeoutMilliseconds) process.environment = environment process.currentDirectoryURL = script.deletingLastPathComponent() let logHandle = try bridgeLogHandle() @@ -121,27 +128,84 @@ actor CursorSDKBridgeServer { return URL(fileURLWithPath: path) } - func shutdown() { - stop() + func shutdown() async { + await stop() } - private func stop() { + func recycle() async { + await stop() + } + + static func waitForExitPolling( + timeoutNanoseconds: UInt64, + pollIntervalNanoseconds: UInt64 = 20_000_000, + isRunning: () -> Bool, + sleep: (_ durationNanoseconds: UInt64) async -> Void + ) async -> Bool { + let pollInterval = max(1, pollIntervalNanoseconds) + var elapsed: UInt64 = 0 + while isRunning() { + guard elapsed < timeoutNanoseconds else { + return false + } + let remaining = timeoutNanoseconds - elapsed + let delay = min(pollInterval, remaining) + await sleep(delay) + elapsed += delay + } + return true + } + + private func stop() async { + if isStopping { + await waitForInFlightStop() + return + } + isStopping = true let process = self.process + let logHandle = self.logHandle self.process = nil - endpoint = nil + self.endpoint = nil + self.logHandle = nil + defer { finishStop() } + if let process, process.isRunning { process.terminate() - let deadline = Date().addingTimeInterval(1) - while process.isRunning, Date() < deadline { - Thread.sleep(forTimeInterval: 0.02) - } - if process.isRunning { + let timeoutNanoseconds = UInt64(Self.forceTerminationGraceMilliseconds) * 1_000_000 + let exited = await Self.waitForExitPolling( + timeoutNanoseconds: timeoutNanoseconds, + isRunning: { process.isRunning }, + sleep: Self.cooperativeSleep + ) + if !exited && process.isRunning { Darwin.kill(process.processIdentifier, SIGKILL) process.waitUntilExit() } } + try? logHandle?.close() - logHandle = nil + } + + private func waitForInFlightStop() async { + guard isStopping else { return } + await withCheckedContinuation { continuation in + stopWaiters.append(continuation) + } + } + + private func finishStop() { + isStopping = false + let waiters = stopWaiters + stopWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } + + private static func cooperativeSleep(_ durationNanoseconds: UInt64) async { + do { + try await Task.sleep(nanoseconds: durationNanoseconds) + } catch {} } private func bridgeLogHandle() throws -> FileHandle { diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift index faa1dce..36ffb9d 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift @@ -41,6 +41,43 @@ public extension CursorSDKHarness { } } +enum CursorSDKRetryDecision { + case retry(recycleBridge: Bool) + case fail(any Error) +} + +struct CursorSDKRetryExecutor { + static func execute( + attempts: Int, + operation: (_ attempt: Int) async throws -> T, + actionForFailure: (_ error: any Error, _ attempt: Int) -> CursorSDKRetryDecision, + recycle: () async -> Void, + delay: () async -> Void + ) async throws -> T { + let totalAttempts = max(1, attempts) + var attempt = 1 + while true { + do { + return try await operation(attempt) + } catch { + switch actionForFailure(error, attempt) { + case .retry(let recycleBridge): + guard attempt < totalAttempts else { + throw error + } + if recycleBridge { + await recycle() + } + await delay() + attempt += 1 + case .fail(let terminalError): + throw terminalError + } + } + } + } +} + public struct LocalCursorSDKHarness: CursorSDKHarness { private static let sessionStore = CursorSDKSessionStore(maxEntries: 512) private static let toolRetryAttempts = 3 @@ -64,6 +101,69 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { var emittedToolCalls: [CursorToolCall] } + struct BridgeRecoveryError: Error, Equatable, Sendable { + var outwardError: CursorAPIError + var status: Int? + var code: String? + var emittedOutput: Bool + + init(outwardError: CursorAPIError, status: Int? = nil, code: String? = nil, emittedOutput: Bool = false) { + self.outwardError = outwardError + self.status = status + self.code = code + self.emittedOutput = emittedOutput + } + + var normalizedCode: String? { + Self.normalizedCode(code) + } + + func withEmittedOutput(_ value: Bool) -> BridgeRecoveryError { + var updated = self + updated.emittedOutput = emittedOutput || value + return updated + } + + var isRetryableBeforeOutput: Bool { + guard !emittedOutput else { return false } + switch outwardError { + case .transport: + return true + case .upstream: + return status == 503 || normalizedCode == "cursor_sdk_unavailable" + case .unauthorized: + return false + default: + return false + } + } + + private static func normalizedCode(_ value: String?) -> String? { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized?.isEmpty == false ? normalized : nil + } + } + + static func executeBridgeRecoveryRetry( + operation: @escaping (_ attempt: Int) async throws -> T, + recycle: @escaping () async -> Void, + delay: @escaping () async -> Void + ) async throws -> T { + do { + return try await CursorSDKRetryExecutor.execute( + attempts: bridgeTransportAttempts, + operation: operation, + actionForFailure: { error, _ in + bridgeRetryDecision(for: error) + }, + recycle: recycle, + delay: delay + ) + } catch { + throw outwardBridgeError(from: error) + } + } + public func validate(settings: CursorAPISettings, authorization: String?) throws { let apiKey = try Self.resolvedCursorAPIKeyForRequest(from: authorization, settings: settings) guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -245,32 +345,35 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { settings: CursorAPISettings, onEvent: @escaping @Sendable (CursorSDKStreamEvent) -> Void ) async throws -> BridgeRunResult { - var lastError: (any Error)? - for attempt in 1...Self.bridgeTransportAttempts { - let emittedDuringAttempt = LockedFlag() - do { - return try await runActualSDKBridgeRequest( - apiKey: apiKey, - agentID: agentID, - runID: attempt == 1 ? runID : Self.newRunID(), - prepared: prepared, - settings: settings, - onEvent: { event in - emittedDuringAttempt.set() - onEvent(event) + try await Self.executeBridgeRecoveryRetry( + operation: { attempt in + let emittedDuringAttempt = LockedFlag() + do { + return try await runActualSDKBridgeRequest( + apiKey: apiKey, + agentID: agentID, + runID: attempt == 1 ? runID : Self.newRunID(), + prepared: prepared, + settings: settings, + onEvent: { event in + emittedDuringAttempt.set() + onEvent(event) + } + ) + } catch { + guard let failure = Self.bridgeRecoveryError(from: error) else { + throw error } - ) - } catch { - lastError = error - guard attempt < Self.bridgeTransportAttempts, - !emittedDuringAttempt.value(), - isRetryableBridgeTransportError(error) else { - throw error + throw failure.withEmittedOutput(emittedDuringAttempt.value()) } + }, + recycle: { + await CursorSDKBridgeServer.shared.recycle() + }, + delay: { try? await Task.sleep(nanoseconds: 150_000_000) } - } - throw lastError ?? CursorAPIError.transport("Cursor SDK bridge request failed.") + ) } private func runActualSDKBridgeRequest( @@ -305,22 +408,37 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { do { (bytes, response) = try await URLSession.shared.bytes(for: request) } catch { - throw CursorAPIError.transport("Cursor SDK bridge request failed: \(error.localizedDescription)") + throw BridgeRecoveryError(outwardError: .transport("Cursor SDK bridge request failed: \(error.localizedDescription)")) } guard let http = response as? HTTPURLResponse else { - throw CursorAPIError.transport("Cursor SDK bridge did not return an HTTP response.") + throw BridgeRecoveryError(outwardError: .transport("Cursor SDK bridge did not return an HTTP response.")) } guard (200..<300).contains(http.statusCode) else { let data = try await collectData(bytes) - let text = String(data: data, encoding: .utf8) ?? "status \(http.statusCode)" - throw http.statusCode == 401 ? CursorAPIError.unauthorized : CursorAPIError.upstream(text) + throw bridgeHTTPError(statusCode: http.statusCode, data: data) } var emittedText = "" var emittedToolCalls: [CursorToolCall] = [] var finalOutput: CursorSDKOutput? var line = Data() - for try await byte in bytes { - if byte == 10 { + do { + for try await byte in bytes { + if byte == 10 { + try handleBridgeEventLine( + line, + agentID: agentID, + runID: runID, + emittedText: &emittedText, + emittedToolCalls: &emittedToolCalls, + finalOutput: &finalOutput, + onEvent: onEvent + ) + line.removeAll(keepingCapacity: true) + } else if byte != 13 { + line.append(byte) + } + } + if !line.isEmpty { try handleBridgeEventLine( line, agentID: agentID, @@ -330,27 +448,15 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { finalOutput: &finalOutput, onEvent: onEvent ) - line.removeAll(keepingCapacity: true) - } else if byte != 13 { - line.append(byte) } - } - if !line.isEmpty { - try handleBridgeEventLine( - line, - agentID: agentID, - runID: runID, - emittedText: &emittedText, - emittedToolCalls: &emittedToolCalls, - finalOutput: &finalOutput, - onEvent: onEvent - ) + } catch { + throw Self.bridgeStreamReadFailure(from: error) } if finalOutput == nil, !emittedToolCalls.isEmpty { finalOutput = CursorSDKOutput(text: "", toolCalls: emittedToolCalls, agentID: agentID, runID: runID) } guard let finalOutput else { - throw CursorAPIError.transport("Cursor SDK bridge stream ended without a final output.") + throw BridgeRecoveryError(outwardError: .transport("Cursor SDK bridge stream ended without a final output.")) } return BridgeRunResult(output: finalOutput, emittedText: emittedText, emittedToolCalls: emittedToolCalls) } @@ -394,20 +500,25 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { finalOutput = output case "error": let error = object["error"] as? [String: Any] - throw Self.bridgeStreamError(from: error) + throw Self.bridgeStreamFailure(from: error) default: break } } static func bridgeStreamError(from error: [String: Any]?) -> CursorAPIError { + bridgeStreamFailure(from: error).outwardError + } + + private static func bridgeStreamFailure(from error: [String: Any]?) -> BridgeRecoveryError { let status = intValue(error?["status"]) - let code = (error?["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let code = normalizedBridgeErrorCode(error?["code"]) if status == 401 || code == "unauthorized" { - return .unauthorized + return BridgeRecoveryError(outwardError: .unauthorized, status: status, code: code) } let message = (error?["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - return .upstream((message?.isEmpty == false ? message : nil) ?? "Cursor SDK bridge stream failed.") + let resolved = (message?.isEmpty == false ? message : nil) ?? "Cursor SDK bridge stream failed." + return BridgeRecoveryError(outwardError: .upstream(resolved), status: status, code: code) } private static func intValue(_ value: Any?) -> Int? { @@ -417,6 +528,69 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { return nil } + private static func normalizedBridgeErrorCode(_ value: Any?) -> String? { + let code = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return code?.isEmpty == false ? code : nil + } + + private static func bridgeRecoveryError(from error: any Error) -> BridgeRecoveryError? { + if let failure = error as? BridgeRecoveryError { + return failure + } + if let cursorError = error as? CursorAPIError { + return BridgeRecoveryError(outwardError: cursorError) + } + return nil + } + + static func bridgeStreamReadFailure(from error: any Error) -> any Error { + if error is CancellationError { + return error + } + if let failure = bridgeRecoveryError(from: error) { + return failure + } + return BridgeRecoveryError( + outwardError: .transport("Cursor SDK bridge stream failed: \(error.localizedDescription)") + ) + } + + private static func bridgeRetryDecision(for error: any Error) -> CursorSDKRetryDecision { + guard let failure = bridgeRecoveryError(from: error) else { + return .fail(error) + } + if failure.isRetryableBeforeOutput { + return .retry(recycleBridge: true) + } + return .fail(failure.outwardError) + } + + private static func outwardBridgeError(from error: any Error) -> any Error { + if let failure = error as? BridgeRecoveryError { + return failure.outwardError + } + return error + } + + private func bridgeHTTPError(statusCode: Int, data: Data) -> BridgeRecoveryError { + let code = Self.bridgeHTTPErrorCode(from: data) + if statusCode == 401 || code == "unauthorized" { + return BridgeRecoveryError(outwardError: .unauthorized, status: statusCode, code: code) + } + let text = String(data: data, encoding: .utf8) ?? "status \(statusCode)" + return BridgeRecoveryError(outwardError: .upstream(text), status: statusCode, code: code) + } + + private static func bridgeHTTPErrorCode(from data: Data) -> String? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + if let nested = object["error"] as? [String: Any] { + return normalizedBridgeErrorCode(nested["code"]) + } + return normalizedBridgeErrorCode(object["code"]) + } + private func bridgeOutput(from value: Any?, agentID: String, runID: String) -> CursorSDKOutput? { guard let object = value as? [String: Any] else { return nil } let text = object["text"] as? String ?? "" @@ -435,13 +609,6 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { return CursorToolCall(name: name, arguments: arguments) } - private func isRetryableBridgeTransportError(_ error: any Error) -> Bool { - if case .transport = error as? CursorAPIError { - return true - } - return false - } - private static func bridgeToolObjects(_ prepared: PreparedChatRequest) -> [[String: Any]] { let tools = OpenAICompatibility.bridgeToolSpecs(for: prepared) return tools.map { tool in diff --git a/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift b/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift index c671f41..405cb34 100644 --- a/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift +++ b/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift @@ -1,4 +1,5 @@ @testable import CursorAPICore +import Foundation import XCTest final class ConnectivityCheckTests: XCTestCase { @@ -98,6 +99,240 @@ final class ConnectivityCheckTests: XCTestCase { .unauthorized ) } + + func testBridgeRecoveryRetriesAndRecyclesForRetryablePreOutputFailure() async throws { + let probe = BridgeRecoveryProbe() + + let value = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + if attempt == 1 { + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("stale bridge"), + status: nil, + code: " CURSOR_SDK_UNAVAILABLE ", + emittedOutput: false + ) + } + return "ok" + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + + XCTAssertEqual(value, "ok") + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1, 2]) + XCTAssertEqual(snapshot.recycleCount, 1) + XCTAssertEqual(snapshot.delayCount, 1) + } + + func testBridgeRecoveryDoesNotRetryAfterOutputEmission() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("stream failed after output"), + status: 503, + code: "cursor_sdk_unavailable", + emittedOutput: true + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected bridge recovery to fail after emitted output.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .upstream("stream failed after output")) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1]) + XCTAssertEqual(snapshot.recycleCount, 0) + XCTAssertEqual(snapshot.delayCount, 0) + } + + func testBridgeRecoveryUnauthorizedDoesNotRecycleOrRetry() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .unauthorized, + status: 401, + code: "unauthorized", + emittedOutput: false + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected unauthorized bridge error.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .unauthorized) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1]) + XCTAssertEqual(snapshot.recycleCount, 0) + XCTAssertEqual(snapshot.delayCount, 0) + } + + func testBridgeRecoveryNonRetryableUpstreamDoesNotRecycleOrRetry() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("internal failure"), + status: 500, + code: "internal", + emittedOutput: false + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected non-retryable upstream bridge error.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .upstream("internal failure")) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1]) + XCTAssertEqual(snapshot.recycleCount, 0) + XCTAssertEqual(snapshot.delayCount, 0) + } + + func testBridgeRecoveryTwoRetryableFailuresRecyclesOnceAndPreservesFinalUpstream() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("retryable failure \(attempt)"), + status: 503, + code: nil, + emittedOutput: false + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected retryable bridge recovery to fail after final attempt.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .upstream("retryable failure 2")) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1, 2]) + XCTAssertEqual(snapshot.recycleCount, 1) + XCTAssertEqual(snapshot.delayCount, 1) + } + + func testBridgeStopWaitForExitPollingSleepsUntilProcessStops() async { + var runningStates = [true, true, false] + var sleepDurations: [UInt64] = [] + + let stopped = await CursorSDKBridgeServer.waitForExitPolling( + timeoutNanoseconds: 1_000, + pollIntervalNanoseconds: 100, + isRunning: { + guard !runningStates.isEmpty else { return false } + return runningStates.removeFirst() + }, + sleep: { duration in + sleepDurations.append(duration) + } + ) + + XCTAssertTrue(stopped) + XCTAssertEqual(sleepDurations, [100, 100]) + } + + func testBridgeStopWaitForExitPollingTimesOutWhenProcessKeepsRunning() async { + var sleepDurations: [UInt64] = [] + + let stopped = await CursorSDKBridgeServer.waitForExitPolling( + timeoutNanoseconds: 250, + pollIntervalNanoseconds: 100, + isRunning: { true }, + sleep: { duration in + sleepDurations.append(duration) + } + ) + + XCTAssertFalse(stopped) + XCTAssertEqual(sleepDurations, [100, 100, 50]) + } + + func testBridgeStreamReadFailureMapsURLErrorToRetryableTransport() { + let source = URLError(.networkConnectionLost) + + let mapped = LocalCursorSDKHarness.bridgeStreamReadFailure(from: source) + + let failure = mapped as? LocalCursorSDKHarness.BridgeRecoveryError + XCTAssertEqual( + failure?.outwardError, + .transport("Cursor SDK bridge stream failed: \(source.localizedDescription)") + ) + XCTAssertTrue(failure?.isRetryableBeforeOutput == true) + } + + func testBridgeStreamReadFailurePreservesWrappedBridgeRecoveryError() { + let wrapped = LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("already wrapped"), + status: 503, + code: "cursor_sdk_unavailable", + emittedOutput: true + ) + + let mapped = LocalCursorSDKHarness.bridgeStreamReadFailure(from: wrapped) + + XCTAssertEqual(mapped as? LocalCursorSDKHarness.BridgeRecoveryError, wrapped) + } + + func testBridgeStreamReadFailurePreservesCancellation() { + let mapped = LocalCursorSDKHarness.bridgeStreamReadFailure(from: CancellationError()) + + XCTAssertTrue(mapped is CancellationError) + } + + func testBridgeForceTerminationGraceExceedsNodeShutdownTimeout() { + XCTAssertGreaterThan( + CursorSDKBridgeServer.forceTerminationGraceMilliseconds, + CursorSDKBridgeServer.bridgeShutdownTimeoutMilliseconds + ) + } } private actor ConnectivityRecorder { @@ -126,3 +361,25 @@ private struct ConnectivityHarness: CursorSDKHarness { } } } + +private actor BridgeRecoveryProbe { + private var attempts: [Int] = [] + private var recycleCount = 0 + private var delayCount = 0 + + func recordAttempt(_ attempt: Int) { + attempts.append(attempt) + } + + func recordRecycle() { + recycleCount += 1 + } + + func recordDelay() { + delayCount += 1 + } + + func snapshot() -> (attempts: [Int], recycleCount: Int, delayCount: Int) { + (attempts, recycleCount, delayCount) + } +} diff --git a/scripts/cursor-sdk-local-agent-bridge.mjs b/scripts/cursor-sdk-local-agent-bridge.mjs index bd01f79..0ac843c 100644 --- a/scripts/cursor-sdk-local-agent-bridge.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.mjs @@ -21,6 +21,8 @@ const maxAgents = parseInteger(process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS, 128); const runTimeoutMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS, 180 * 1000); const maxRunRetries = parseInteger(process.env.CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES, 3); const retryBaseDelayMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_RETRY_BASE_DELAY_MS, 500); +const bridgeShutdownTimeoutMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS, 15 * 1000); +const parentLossPollIntervalMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS, 1000); const defaultCwd = process.env.CURSOR_SDK_WORKING_DIRECTORY || process.cwd(); const clientMcpServerName = "client"; const clientMcpServerMode = "--client-mcp-server"; @@ -30,7 +32,14 @@ const agentCache = new Map(); const agentRunQueues = new Map(); const activeClientToolCaptures = new Map(); const forceNextRunAgentKeys = new Set(); +const pendingAgentDisposals = new Map(); +const pendingAgentCreations = new Set(); +const agentDisposals = new WeakMap(); +const initialParentPid = process.ppid; let server = null; +let parentLossWatchdog = null; +let isShuttingDown = false; +let closeAndExitPromise = null; if (isMainModule()) { installBridgeProcessHandlers(); @@ -38,15 +47,23 @@ if (isMainModule()) { await runClientForwardingMcpServerFromEnvironment(); } else { startServer(); - process.on("SIGINT", () => closeAndExit(0)); - process.on("SIGTERM", () => closeAndExit(0)); + parentLossWatchdog = startParentLossWatchdog({ + initialParentPid, + intervalMs: parentLossPollIntervalMs, + onParentLost: () => requestBridgeShutdown(0) + }); } } export { bridgePrompt, + closeAndExit, + cleanupBridgeResources, clientMcpToolDefinitions, clientForwardingMcpServerSource, + evictAgent, + evictCachedAgent, + getAgent, localAgentCreateOptions, localAgentSendOptions, isForwardableSDKToolCall, @@ -56,6 +73,7 @@ export { openAiError, runExclusiveForAgent, sdkRunFailureSummary, + startParentLossWatchdog, statusFromError, startServer, validateClientMcpToolCall, @@ -76,6 +94,10 @@ function startServer() { } async function handleRequest(request, response) { + if (isShuttingDown) { + writeJson(response, openAiError(bridgeShuttingDownError()), 503); + return; + } const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`); if (request.method === "GET" && url.pathname === "/health") { @@ -216,7 +238,7 @@ async function runLocalAgentUnlocked(input, onEvent) { const shouldRetry = attempt < maxRunRetries && !emittedEvent && isRetryableSDKRunError(error); if (!shouldRetry) throw error; if (activeRun) activeRun.cancel().catch(() => {}); - evictCachedAgent(input); + await evictCachedAgent(input); console.warn(`Retrying Cursor SDK run after retryable upstream error (${attempt + 1}/${maxRunRetries}).`); await sleep(retryDelayMs(attempt)); } finally { @@ -335,7 +357,7 @@ async function runLocalAgentBody(input, onRun, onEvent) { const result = await run.wait(); if (result.status === "error") { - if (agentEntry) evictAgent(agentEntry.cacheKey, agentEntry.agent); + if (agentEntry) await evictAgent(agentEntry.cacheKey, agentEntry.agent); throw sdkRunFailureError(result); } if (!text && typeof result.result === "string") text = result.result; @@ -350,33 +372,88 @@ async function runLocalAgentBody(input, onRun, onEvent) { async function getAgent(input) { const cacheKey = agentCacheKey(input); + await waitForPendingAgentDisposal(cacheKey); const cached = agentCache.get(cacheKey); if (cached) { cached.touchedAt = Date.now(); return { agent: cached.agent, cacheKey, cached: true }; } - const agent = await Agent.create(localAgentCreateOptions(input)); - agentCache.set(cacheKey, { agent, touchedAt: Date.now() }); - evictAgents(); - return { agent, cacheKey, cached: false }; + if (isShuttingDown) throw bridgeShuttingDownError(); + + const creation = (async () => { + const agent = await Agent.create(localAgentCreateOptions(input)); + agentCache.set(cacheKey, { agent, touchedAt: Date.now() }); + await evictAgents(); + return { agent, cacheKey, cached: false }; + })(); + pendingAgentCreations.add(creation); + try { + return await creation; + } finally { + pendingAgentCreations.delete(creation); + } } -function evictAgent(cacheKey, agent) { +async function evictAgent(cacheKey, agent) { const cached = agentCache.get(cacheKey); if (cached?.agent === agent) { agentCache.delete(cacheKey); } forceNextRunAgentKeys.delete(cacheKey); - try { - agent.close(); - } catch {} + await scheduleAgentDisposal(cacheKey, () => disposeAgent(agent)); } -function evictCachedAgent(input) { +async function evictCachedAgent(input) { const cacheKey = agentCacheKey(input); const cached = agentCache.get(cacheKey); - if (cached) evictAgent(cacheKey, cached.agent); + if (cached) { + await evictAgent(cacheKey, cached.agent); + return; + } + await waitForPendingAgentDisposal(cacheKey); +} + +function scheduleAgentDisposal(cacheKey, disposalWork) { + const previous = pendingAgentDisposals.get(cacheKey) ?? Promise.resolve(); + const scheduled = previous + .catch(() => {}) + .then(() => disposalWork()) + .catch(() => {}) + .finally(() => { + if (pendingAgentDisposals.get(cacheKey) === scheduled) { + pendingAgentDisposals.delete(cacheKey); + } + }); + pendingAgentDisposals.set(cacheKey, scheduled); + return scheduled; +} + +async function waitForPendingAgentDisposal(cacheKey) { + const pending = pendingAgentDisposals.get(cacheKey); + if (pending) await pending.catch(() => {}); +} + +async function disposeAgent(agent) { + if (!agent || (typeof agent !== "object" && typeof agent !== "function")) return; + const existing = agentDisposals.get(agent); + if (existing) { + await existing; + return; + } + + const disposal = (async () => { + if (typeof agent[Symbol.asyncDispose] === "function") { + await agent[Symbol.asyncDispose](); + return; + } + if (typeof agent.close === "function") { + await agent.close(); + } + })().catch(() => {}); + + agentDisposals.set(agent, disposal); + await disposal; } function registerActiveClientToolCapture(cacheKey, handler) { @@ -1984,15 +2061,14 @@ function agentCacheKey(input) { return digest; } -function evictAgents() { +async function evictAgents() { while (agentCache.size > maxAgents) { const oldest = [...agentCache.entries()].sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0]; if (!oldest) return; - agentCache.delete(oldest[0]); - forceNextRunAgentKeys.delete(oldest[0]); - try { - oldest[1].agent.close(); - } catch {} + const [cacheKey, { agent }] = oldest; + agentCache.delete(cacheKey); + forceNextRunAgentKeys.delete(cacheKey); + scheduleAgentDisposal(cacheKey, () => disposeAgent(agent)); } } @@ -2089,6 +2165,8 @@ function writeNdjson(response, body) { } function installBridgeProcessHandlers() { + process.on("SIGINT", () => requestBridgeShutdown(0)); + process.on("SIGTERM", () => requestBridgeShutdown(0)); process.on("unhandledRejection", (reason) => { if (isBenignCancellationError(reason) || isBenignPipeError(reason)) return; if (isRetryableSDKRunError(reason)) { @@ -2096,7 +2174,7 @@ function installBridgeProcessHandlers() { return; } console.error(reason); - closeAndExit(1); + requestBridgeShutdown(1); }); process.on("uncaughtException", (error) => { if (isBenignCancellationError(error) || isBenignPipeError(error)) return; @@ -2105,7 +2183,51 @@ function installBridgeProcessHandlers() { return; } console.error(error); - closeAndExit(1); + requestBridgeShutdown(1); + }); +} + +function startParentLossWatchdog({ + initialParentPid: parentPid = process.ppid, + getParentPid = () => process.ppid, + onParentLost = () => requestBridgeShutdown(0), + setIntervalFn = setInterval, + clearIntervalFn = clearInterval, + intervalMs = parentLossPollIntervalMs +} = {}) { + if (!(Number.isFinite(parentPid) && parentPid > 1)) { + return { stop() {}, active: false, initialParentPid: parentPid }; + } + + let stopped = false; + let shutdownRequested = false; + let timer = null; + const stop = () => { + if (stopped) return; + stopped = true; + if (timer) clearIntervalFn(timer); + }; + + timer = setIntervalFn(() => { + if (stopped || shutdownRequested) return; + const observedParentPid = Number(getParentPid()); + if (!Number.isFinite(observedParentPid) || observedParentPid <= 0) return; + if (observedParentPid === parentPid) return; + shutdownRequested = true; + stop(); + onParentLost(); + }, intervalMs); + if (timer && typeof timer.unref === "function") { + timer.unref(); + } + + return { stop, active: true, initialParentPid: parentPid }; +} + +function requestBridgeShutdown(code) { + closeAndExit(code).catch((error) => { + console.error(error); + process.exit(code); }); } @@ -2329,13 +2451,116 @@ function loadEnvFile(filePath) { } async function closeAndExit(code) { - for (const entry of agentCache.values()) { + if (closeAndExitPromise) return closeAndExitPromise; + + const exitCode = Number.isInteger(code) ? code : 0; + isShuttingDown = true; + if (parentLossWatchdog) { + parentLossWatchdog.stop(); + parentLossWatchdog = null; + } + + const forceExitTimer = setTimeout(() => process.exit(exitCode), bridgeShutdownTimeoutMs); + if (forceExitTimer && typeof forceExitTimer.unref === "function") { + forceExitTimer.unref(); + } + + closeAndExitPromise = (async () => { try { - entry.agent.close(); - } catch {} + await cleanupBridgeResources({ + closeServer: (onClose) => { + const activeServer = server; + server = null; + if (!activeServer) { + if (typeof onClose === "function") onClose(); + return; + } + activeServer.close(() => { + if (typeof onClose === "function") onClose(); + }); + if (typeof activeServer.closeIdleConnections === "function") { + activeServer.closeIdleConnections(); + } + } + }); + } catch (error) { + console.error(error); + } finally { + clearTimeout(forceExitTimer); + } + + process.exit(exitCode); + })(); + + return closeAndExitPromise; +} + +async function cleanupBridgeResources({ agentEntries, closeServer } = {}) { + const serverClosePromise = closeServerWithPromise(closeServer); + forceNextRunAgentKeys.clear(); + activeClientToolCaptures.clear(); + agentRunQueues.clear(); + await waitForPendingAgentCreations(); + + if (agentEntries !== undefined) { + agentCache.clear(); + await disposeAgentEntries(agentEntries); + } else { + while (true) { + const entries = [...agentCache.values()]; + if (entries.length === 0) break; + agentCache.clear(); + await disposeAgentEntries(entries); + await waitForPendingAgentCreations(); + } } - server?.close(() => process.exit(code)); - setTimeout(() => process.exit(code), 500).unref(); + + const disposalPromises = [...pendingAgentDisposals.values()]; + for (const pending of disposalPromises) await pending.catch(() => {}); + pendingAgentDisposals.clear(); + + await serverClosePromise; +} + +async function waitForPendingAgentCreations() { + while (pendingAgentCreations.size > 0) { + const pending = [...pendingAgentCreations]; + await Promise.allSettled(pending); + } +} + +async function disposeAgentEntries(agentEntries) { + const entries = [...agentEntries]; + for (const entry of entries) { + await disposeAgent(entry?.agent ?? entry); + } +} + +function closeServerWithPromise(closeServer) { + if (typeof closeServer !== "function") return Promise.resolve(); + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; + + try { + if (closeServer.length > 0) { + closeServer(() => finish()); + } else { + closeServer(); + finish(); + } + } catch { + finish(); + } + }); +} + +function bridgeShuttingDownError() { + return new HttpError("Bridge is shutting down", 503, "bridge_shutting_down"); } function isMainModule() { diff --git a/scripts/cursor-sdk-local-agent-bridge.test.mjs b/scripts/cursor-sdk-local-agent-bridge.test.mjs index d08a2d6..0fbaf27 100644 --- a/scripts/cursor-sdk-local-agent-bridge.test.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.test.mjs @@ -1,11 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { Agent } from "@cursor/sdk"; import { spawn, spawnSync } from "node:child_process"; import http from "node:http"; import { fileURLToPath } from "node:url"; import { bridgePrompt, + cleanupBridgeResources, clientForwardingMcpServerSource, clientMcpToolDefinitions, + evictAgent, + evictCachedAgent, + getAgent, localAgentCreateOptions, localAgentSendOptions, isForwardableSDKToolCall, @@ -15,6 +20,7 @@ import { openAiError, runExclusiveForAgent, sdkRunFailureSummary, + startParentLossWatchdog, statusFromError, toolCallFromDelta, validateClientMcpToolCall @@ -156,6 +162,401 @@ describe("Cursor SDK local-agent bridge", () => { await expect(second).resolves.toBe("second"); }); + it("keeps bridge cleanup pending until SDK async disposal settles", async () => { + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + let asyncDisposeCalls = 0; + let serverClosed = false; + const fakeAgent = { + close() { + throw new Error("close should not be used when Symbol.asyncDispose is available"); + }, + async [Symbol.asyncDispose]() { + asyncDisposeCalls += 1; + await asyncDisposeGate; + } + }; + + let cleanupResolved = false; + const cleanupPromise = cleanupBridgeResources({ + agentEntries: [{ agent: fakeAgent }], + closeServer: () => { + serverClosed = true; + } + }).then(() => { + cleanupResolved = true; + }); + + try { + await Promise.resolve(); + expect(serverClosed).toBe(true); + expect(asyncDisposeCalls).toBe(1); + expect(cleanupResolved).toBe(false); + } finally { + releaseAsyncDispose(); + await cleanupPromise; + } + }); + + it("serializes per-key SDK async disposals before starting the next disposal", async () => { + const cacheKey = `serial-dispose-${Date.now()}`; + let releaseFirstDispose; + const firstDisposeGate = new Promise((resolve) => { + releaseFirstDispose = resolve; + }); + let firstDisposeStarts = 0; + let secondDisposeStarts = 0; + const firstAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + firstDisposeStarts += 1; + await firstDisposeGate; + } + }; + const secondAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + secondDisposeStarts += 1; + } + }; + + const firstEviction = evictAgent(cacheKey, firstAgent); + const secondEviction = evictAgent(cacheKey, secondAgent); + + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(firstDisposeStarts).toBe(1); + expect(secondDisposeStarts).toBe(0); + + releaseFirstDispose(); + await firstEviction; + await secondEviction; + expect(secondDisposeStarts).toBe(1); + } finally { + releaseFirstDispose?.(); + await firstEviction.catch(() => {}); + await secondEviction.catch(() => {}); + } + }); + + it("waits for in-flight SDK agent creation and disposal before bridge cleanup resolves", async () => { + const input = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `cleanup-create-race-${Date.now()}`, + clientTools: [] + }; + let releaseCreate; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + let asyncDisposeCalls = 0; + const createdAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + asyncDisposeCalls += 1; + await asyncDisposeGate; + } + }; + const originalCreate = Agent.create; + let createCalls = 0; + Agent.create = async () => { + createCalls += 1; + await createGate; + return createdAgent; + }; + + let agentPromise; + let cleanupPromise; + let cleanupResolved = false; + try { + agentPromise = getAgent(input); + await Promise.resolve(); + expect(createCalls).toBe(1); + + cleanupPromise = cleanupBridgeResources({ + closeServer: () => {} + }).then(() => { + cleanupResolved = true; + }); + + await Promise.resolve(); + expect(cleanupResolved).toBe(false); + + releaseCreate(); + const entry = await agentPromise; + expect(entry.agent).toBe(createdAgent); + + await new Promise((resolve) => setImmediate(resolve)); + expect(asyncDisposeCalls).toBe(1); + expect(cleanupResolved).toBe(false); + + releaseAsyncDispose(); + await cleanupPromise; + expect(cleanupResolved).toBe(true); + } finally { + releaseCreate?.(); + releaseAsyncDispose?.(); + await agentPromise?.catch(() => {}); + await cleanupPromise?.catch(() => {}); + await evictCachedAgent(input); + Agent.create = originalCreate; + } + }); + + it("blocks replacement creation until cached SDK async disposal completes", async () => { + const input = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `replacement-gate-${Date.now()}`, + clientTools: [] + }; + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + const oldAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + await asyncDisposeGate; + } + }; + const replacementAgent = { + close() {} + }; + const originalCreate = Agent.create; + let createCount = 0; + Agent.create = async () => { + createCount += 1; + return createCount === 1 ? oldAgent : replacementAgent; + }; + + try { + await getAgent(input); + const evictionPromise = evictCachedAgent(input); + const replacementPromise = getAgent(input); + + await Promise.resolve(); + expect(createCount).toBe(1); + + releaseAsyncDispose(); + await evictionPromise; + + const replacement = await replacementPromise; + expect(replacement.agent).toBe(replacementAgent); + expect(createCount).toBe(2); + } finally { + releaseAsyncDispose?.(); + await evictCachedAgent(input); + Agent.create = originalCreate; + } + }); + + it("does not block new-key agent creation on unrelated LRU async disposal", async () => { + const moduleUrl = new URL(`./cursor-sdk-local-agent-bridge.mjs?lru-eviction-latency=${Date.now()}`, import.meta.url); + const previousMaxAgents = process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS; + process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS = "1"; + const bridge = await import(moduleUrl.href); + const oldInput = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `lru-old-${Date.now()}`, + clientTools: [] + }; + const newInput = { + ...oldInput, + sessionKey: `${oldInput.sessionKey}-new` + }; + let releaseOldDispose; + const oldDisposeGate = new Promise((resolve) => { + releaseOldDispose = resolve; + }); + let signalOldDisposeStart; + const oldDisposeStarted = new Promise((resolve) => { + signalOldDisposeStart = resolve; + }); + const oldAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + signalOldDisposeStart?.(); + await oldDisposeGate; + } + }; + const newAgent = { + close() {} + }; + const originalCreate = Agent.create; + let createCount = 0; + Agent.create = async () => { + createCount += 1; + return createCount === 1 ? oldAgent : newAgent; + }; + + let newKeySettled = false; + let newKeyPromise; + try { + await bridge.getAgent(oldInput); + newKeyPromise = bridge.getAgent(newInput).then((entry) => { + newKeySettled = true; + return entry; + }); + + await oldDisposeStarted; + await Promise.resolve(); + + expect(newKeySettled).toBe(true); + const newKeyEntry = await newKeyPromise; + expect(newKeyEntry.agent).toBe(newAgent); + expect(createCount).toBe(2); + } finally { + releaseOldDispose?.(); + await newKeyPromise?.catch(() => {}); + await bridge.evictCachedAgent(oldInput).catch(() => {}); + await bridge.evictCachedAgent(newInput).catch(() => {}); + await bridge.cleanupBridgeResources({ closeServer: () => {} }).catch(() => {}); + Agent.create = originalCreate; + if (previousMaxAgents === undefined) { + delete process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS; + } else { + process.env.CURSOR_SDK_BRIDGE_MAX_AGENTS = previousMaxAgents; + } + } + }); + + it("requests graceful shutdown once when parent ownership is lost", () => { + let tick; + let clearedHandle = null; + let shutdownRequests = 0; + let parentPid = 100; + const scheduledHandle = { + unrefCalls: 0, + unref() { + this.unrefCalls += 1; + } + }; + + const watchdog = startParentLossWatchdog({ + initialParentPid: 100, + getParentPid: () => parentPid, + setIntervalFn: (callback) => { + tick = callback; + return scheduledHandle; + }, + clearIntervalFn: (handle) => { + clearedHandle = handle; + }, + onParentLost: () => { + shutdownRequests += 1; + } + }); + + expect(typeof tick).toBe("function"); + expect(scheduledHandle.unrefCalls).toBe(1); + + tick(); + parentPid = 200; + tick(); + tick(); + + expect(shutdownRequests).toBe(1); + expect(clearedHandle).toBe(scheduledHandle); + + watchdog.stop(); + expect(clearedHandle).toBe(scheduledHandle); + }); + + it("disables parent-loss watchdog when started as pid1 child", () => { + let scheduled = false; + let shutdownRequests = 0; + const watchdog = startParentLossWatchdog({ + initialParentPid: 1, + setIntervalFn: () => { + scheduled = true; + return { + unref() {} + }; + }, + onParentLost: () => { + shutdownRequests += 1; + } + }); + + expect(scheduled).toBe(false); + expect(shutdownRequests).toBe(0); + watchdog.stop(); + }); + + it("shares shutdown work across repeated closeAndExit requests and keeps the first exit code", async () => { + const moduleUrl = new URL(`./cursor-sdk-local-agent-bridge.mjs?shutdown-idempotency=${Date.now()}`, import.meta.url); + const bridge = await import(moduleUrl.href); + const input = { + apiKey: "test-key", + model: "default", + workingDirectory: "/project", + sessionKey: `close-and-exit-${Date.now()}`, + clientTools: [] + }; + let releaseAsyncDispose; + const asyncDisposeGate = new Promise((resolve) => { + releaseAsyncDispose = resolve; + }); + let asyncDisposeCalls = 0; + const fakeAgent = { + close() { + return this[Symbol.asyncDispose](); + }, + async [Symbol.asyncDispose]() { + asyncDisposeCalls += 1; + await asyncDisposeGate; + } + }; + const originalCreate = Agent.create; + const exitCodes = []; + Agent.create = async () => fakeAgent; + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + exitCodes.push(code); + }); + + try { + await bridge.getAgent(input); + const firstShutdown = bridge.closeAndExit(9); + const secondShutdown = bridge.closeAndExit(2); + + await new Promise((resolve) => setImmediate(resolve)); + expect(asyncDisposeCalls).toBe(1); + + releaseAsyncDispose(); + await firstShutdown; + await secondShutdown; + + expect(asyncDisposeCalls).toBe(1); + expect(exitCodes).toEqual([9]); + } finally { + releaseAsyncDispose?.(); + Agent.create = originalCreate; + exitSpy.mockRestore(); + } + }); + it("does not cancel SDK glob calls on directory-only partial arguments", () => { const partial = normalizeSDKToolCall({ type: "glob", From 7a10a6f0ae02121302d63e43b3eb2b6aca5c9ee4 Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Sun, 12 Jul 2026 14:31:50 +0200 Subject: [PATCH 3/6] fix: reuse sdk bridge port Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/todos/stable-sdk-bridge-port/plan.md | 417 ++++++++++++++++++ docs/todos/stable-sdk-bridge-port/todo.md | 95 ++++ .../CursorSDKBridgePortPreference.swift | 47 ++ .../CursorAPICore/CursorSDKBridgeServer.swift | 62 ++- .../CursorSDKBridgeServerTests.swift | 219 +++++++++ 5 files changed, 834 insertions(+), 6 deletions(-) create mode 100644 docs/todos/stable-sdk-bridge-port/plan.md create mode 100644 docs/todos/stable-sdk-bridge-port/todo.md create mode 100644 macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift create mode 100644 macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift diff --git a/docs/todos/stable-sdk-bridge-port/plan.md b/docs/todos/stable-sdk-bridge-port/plan.md new file mode 100644 index 0000000..a1b8f4a --- /dev/null +++ b/docs/todos/stable-sdk-bridge-port/plan.md @@ -0,0 +1,417 @@ +# Stable SDK Bridge Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reuse the last healthy internal Cursor SDK bridge port across clean macOS app restarts and retain bounded fallback when that port is occupied. + +**Architecture:** Add a bridge-only preference object backed by a dedicated `UserDefaults` key; it validates persisted values against the existing 8792...8892 range and puts the remembered port first without changing the range. Give `CursorSDKBridgeServer` narrow injectable port-check, launch, health-check, and sleep dependencies so deterministic tests exercise its real startup loop without spawning Node. The server records a candidate only after its health check succeeds, leaving `CursorAPISettings.port` and bridge authentication untouched. + +**Tech Stack:** Swift 6, Foundation `UserDefaults`, XCTest, Swift Package Manager + +**Commit Policy:** The current request authorizes a local commit. Keep implementation changes uncommitted until final verifier acceptance and let the mandatory `prep-merge-to-local-main` phase run security gates, stage only task-owned files, and create the Conventional Commit with the required Copilot co-author trailer. + +**Source of truth:** `docs/todos/stable-sdk-bridge-port/todo.md` and the current user request. No separate specification exists. + +**Task-owned files:** + +- Create `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift` for bridge-only preference persistence and candidate ordering. +- Create `macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift` for server-level restart, collision, health-gating, bounds, and separation regressions. +- Modify `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift` to add internal test dependencies, choose remembered-first candidates, and record only a healthy port. +- Update `docs/todos/stable-sdk-bridge-port/todo.md` and this plan with verification/review evidence. + +**Preserved boundaries:** + +- Do not add the bridge port to `CursorAPISettings` or `AppSettingsStore`. +- Do not persist or change the bridge token, API/auth/routing behavior, public API port, dependencies, scripts, or external services. +- Do not inspect or modify another worktree, launch the installed app, terminate user processes, make live model calls, or perform remote writes. +- The app lifecycle fix is outside this branch. If clean bridge shutdown cannot be established from `main`, report that integration dependency rather than expanding scope. +- Same verifier `rejection_key` twice stops implementation and surfaces the rejection. + +**Feature / Verification Matrix:** + +| Change | Verification | Status | Evidence | +| --- | --- | --- | --- | +| Reuse healthy bridge port across server instances | `testCleanRestartReusesLastHealthyBridgePort` | Pass | Real startup-loop harness selected and reused 8793 after 8792 became free | +| Fall back when the preferred port is occupied | `testOccupiedPersistedPreferenceFallsBackAndRewritesPreference` | Pass | Occupied 8841 fell back to 8792; 101 unique candidates remained within 8792...8892 | +| Persist only health-confirmed ports | `testHealthGateOnlyPersistsPortAfterHealthyLaunch` | Pass | Preference stayed 8841 for 40 unhealthy polls and changed only after healthy 8792 | +| Keep public and bridge port persistence separate | `testBridgePreferencePersistenceDoesNotOverwritePublicSettings` plus diff review | Pass | Dedicated bridge-key write left `CursorAPI.settings.v1` unchanged | + +**Subagent Ledger:** + +| Workstream | Scope | Edits | Expected output | Verification responsibility | +| --- | --- | --- | --- | --- | +| Pre-implementation evaluator | Task record, plan, current bridge server, neighboring settings tests | No | Acceptance/boundary/sequence findings with inspected files and residual risks | Test reviewer rejected helper-only coverage with `missing-production-wiring-regression`; plan corrected to server-level injected tests | +| Implementation worker | Three task-owned Swift files | Yes, only assigned Swift files | TDD evidence, changed files, commands, uncertainties, residual risks | DONE_WITH_CONCERNS: build passed; XCTest unavailable in local Command Line Tools | +| Independent verifier | Current task-owned diff and fresh command output | No | Accept or reject with a stable `rejection_key` and evidence | ACCEPT: builds passed; temporary real-code harness passed 21 assertions | +| Spec reviewer | Current task-owned diff and Sprint Contract | No | Requirement and scope decision | ACCEPT | +| Code-quality reviewer | Entire task-owned diff after simplification | No | High-confidence correctness findings only | ACCEPT; no simplification edit warranted | +| Final verifier | Final task-owned diff and fresh evidence | No | Final acceptance before completion | ACCEPT: builds passed; current production-path harness passed 23 assertions | + +--- + +### Task 1: Persist and Reuse the Internal Bridge Port + +**Files:** + +- Create: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift` +- Create: `macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift` +- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift:11-61` +- Modify: `docs/todos/stable-sdk-bridge-port/todo.md` +- Modify: `docs/todos/stable-sdk-bridge-port/plan.md` + +**Verifier:** + +- Commands that must be fresh: + - `swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests` + - `swift test --package-path macos/CursorAPI --filter SettingsTests` + - `swift test --package-path macos/CursorAPI` + - `swift build --package-path macos/CursorAPI` +- Expected evidence: server-level tests fail before the preference and injected startup seams exist, then pass against the production startup loop; the full Swift suite and build pass; and the final independent verifier accepts the task-owned diff. +- Notes: the verifier must confirm that `CursorAPISettings.port`, bridge token handling, and the allowed port range are unchanged. The same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Run the focused existing baseline** + +Run: + +```bash +swift test --package-path macos/CursorAPI --filter ConnectivityCheckTests +swift test --package-path macos/CursorAPI --filter SettingsTests +``` + +Expected: both existing suites pass before task-owned Swift changes. + +Actual: both commands were attempted, but package test compilation is blocked on `main` and this branch because the installed Command Line Tools lack the `XCTest` module. This is an environment blocker, not a regression. + +- [x] **Step 2: Write the failing regression tests** + +Create `macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift`: + +```swift +@testable import CursorAPICore +import Foundation +import XCTest + +final class CursorSDKBridgeServerTests: XCTestCase { + func testCleanRestartReusesLastHealthyBridgePort() async throws { + let defaults = isolatedDefaults() + let firstRun = testServer( + defaults: defaults, + portIsOpen: { $0 == 8792 }, + healthCheck: { _ in true } + ) + + let firstPort = try await firstRun.start(script: scriptURL, settings: CursorAPISettings()) + await firstRun.shutdown() + let restarted = testServer( + defaults: defaults, + portIsOpen: { _ in false }, + healthCheck: { _ in true } + ) + let restartedPort = try await restarted.start(script: scriptURL, settings: CursorAPISettings()) + await restarted.shutdown() + + XCTAssertEqual(firstPort, 8793) + XCTAssertEqual(restartedPort, firstPort) + } + + func testOccupiedPreferredPortFallsBackAndRemembersHealthyPort() async throws { + let defaults = isolatedDefaults() + let preference = CursorSDKBridgePortPreference(defaults: defaults) + preference.recordSuccessfulPort(8841) + let server = testServer( + defaults: defaults, + portIsOpen: { $0 == 8841 }, + healthCheck: { _ in true } + ) + + let selected = try await server.start(script: scriptURL, settings: CursorAPISettings()) + await server.shutdown() + let nextRun = CursorSDKBridgePortPreference(defaults: defaults) + + XCTAssertEqual(selected, 8792) + XCTAssertEqual(nextRun.candidatePorts().first, selected) + XCTAssertEqual(nextRun.candidatePorts().count, 101) + XCTAssertEqual(Set(nextRun.candidatePorts()), Set(CursorSDKBridgePortPreference.allowedPorts)) + } + + func testUnhealthyPreferredPortIsNotRecordedBeforeFallback() async throws { + let defaults = isolatedDefaults() + let preference = CursorSDKBridgePortPreference(defaults: defaults) + preference.recordSuccessfulPort(8841) + let observedPreferenceBeforeFallback = LockedPort() + let server = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, port, _ in + if port == 8792 { + observedPreferenceBeforeFallback.set(preference.candidatePorts().first) + } + }, + healthCheck: { url in url.port == 8792 }, + sleep: {} + ) + ) + + let selected = try await server.start(script: scriptURL, settings: CursorAPISettings()) + await server.shutdown() + + XCTAssertEqual(observedPreferenceBeforeFallback.value(), 8841) + XCTAssertEqual(selected, 8792) + XCTAssertEqual(CursorSDKBridgePortPreference(defaults: defaults).candidatePorts().first, selected) + } + + func testBridgePreferenceDoesNotOverwritePublicAPISettings() { + let defaults = isolatedDefaults() + let publicSettings = Data(#"{"port":8787}"#.utf8) + defaults.set(publicSettings, forKey: "CursorAPI.settings.v1") + + CursorSDKBridgePortPreference(defaults: defaults).recordSuccessfulPort(8841) + + XCTAssertEqual(defaults.data(forKey: "CursorAPI.settings.v1"), publicSettings) + } + + private var scriptURL: URL { + URL(fileURLWithPath: "/tmp/cursor-sdk-local-agent-bridge.mjs") + } + + private func testServer( + defaults: UserDefaults, + portIsOpen: @escaping @Sendable (UInt16) async -> Bool, + healthCheck: @escaping @Sendable (URL) async -> Bool + ) -> CursorSDKBridgeServer { + CursorSDKBridgeServer( + portPreference: CursorSDKBridgePortPreference(defaults: defaults), + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: portIsOpen, + launch: { _, _, _ in }, + healthCheck: healthCheck, + sleep: {} + ) + ) + } + + private func isolatedDefaults() -> UserDefaults { + let suiteName = "CursorAPI.BridgePortTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + +private final class LockedPort: @unchecked Sendable { + private let lock = NSLock() + private var port: UInt16? + + func set(_ port: UInt16?) { + lock.withLock { + self.port = port + } + } + + func value() -> UInt16? { + lock.withLock { + port + } + } +} +``` + +- [x] **Step 3: Run the new tests to prove the behavior is missing** + +Run: + +```bash +swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests +``` + +Expected: compilation fails because `CursorSDKBridgePortPreference`, `CursorSDKBridgeServerDependencies`, and the injectable server initializer/startup seam do not exist on `main`; preserve the concise compiler error as deterministic red-test evidence for missing production behavior. + +Actual: tests were written before production code, but the red command stopped earlier at the pre-existing missing-`XCTest` toolchain error. The final verifier therefore uses a no-XCTest harness against the actual startup loop as the executable substitute. + +- [x] **Step 4: Implement the bridge-only port preference** + +Create `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift`: + +```swift +import Foundation + +final class CursorSDKBridgePortPreference: @unchecked Sendable { + static let defaultPort: UInt16 = 8792 + static let allowedPorts: ClosedRange = 8792...8892 + + private static let defaultsKey = "CursorAPI.sdkBridgePort.v1" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func candidatePorts() -> [UInt16] { + let preferred = storedPort ?? Self.defaultPort + return [preferred] + Self.allowedPorts.filter { $0 != preferred } + } + + func recordSuccessfulPort(_ port: UInt16) { + precondition(Self.allowedPorts.contains(port)) + defaults.set(Int(port), forKey: Self.defaultsKey) + } + + private var storedPort: UInt16? { + guard let number = defaults.object(forKey: Self.defaultsKey) as? NSNumber, + let port = UInt16(exactly: number.intValue), + Self.allowedPorts.contains(port) else { + return nil + } + return port + } +} +``` + +This stores only a non-secret internal bridge preference. Invalid or out-of-range persisted values fall back to 8792, and candidate ordering remains bounded to the existing 101 ports. + +- [x] **Step 5: Wire the preference into healthy bridge startup** + +In `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift`, add narrow internal dependencies before the actor: + +```swift +struct CursorSDKBridgeServerDependencies: Sendable { + var portIsOpen: (@Sendable (UInt16) async -> Bool)? + var launch: (@Sendable (URL, UInt16, CursorAPISettings) throws -> Void)? + var healthCheck: (@Sendable (URL) async -> Bool)? + var sleep: (@Sendable () async throws -> Void)? +} +``` + +Add stored state and an internal initializer to the actor: + +```swift +private let portPreference: CursorSDKBridgePortPreference +private let dependencies: CursorSDKBridgeServerDependencies + +init( + portPreference: CursorSDKBridgePortPreference = CursorSDKBridgePortPreference(), + dependencies: CursorSDKBridgeServerDependencies = CursorSDKBridgeServerDependencies() +) { + self.portPreference = portPreference + self.dependencies = dependencies +} +``` + +Make `start(script:settings:)` internal so `@testable` server-level tests can exercise it, and replace the hard-coded integer scan with: + +```swift +for candidate in portPreference.candidatePorts() { + let isOpen = if let portIsOpen = dependencies.portIsOpen { + await portIsOpen(candidate) + } else { + await tcpPortIsOpen(candidate) + } + guard !isOpen else { + continue + } + do { + if let launch = dependencies.launch { + try launch(script, candidate, settings) + } else { + try launch(script: script, port: candidate, settings: settings) + } + let health = URL(string: "http://127.0.0.1:\(candidate)/health")! + for _ in 0..<40 { + let healthy = if let healthCheck = dependencies.healthCheck { + await healthCheck(health) + } else { + await isHealthy(health) + } + if healthy { + portPreference.recordSuccessfulPort(candidate) + return candidate + } + if let sleep = dependencies.sleep { + try await sleep() + } else { + try await Task.sleep(nanoseconds: 50_000_000) + } + } + stop() + lastError = CursorAPIError.transport("Cursor SDK bridge did not become ready.") + } catch { + stop() + lastError = error + } +} +``` + +The injected closures default to `nil`, so production keeps the existing process launch, TCP probe, 40 health checks, 50 ms polling delay, shutdown, and error behavior. Do not alter token generation or the public API port setting. + +- [x] **Step 6: Run focused green tests** + +Run: + +```bash +swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests +swift test --package-path macos/CursorAPI --filter SettingsTests +``` + +Expected: both suites pass. The first drives the production server startup loop with deterministic fakes to prove clean-restart reuse, occupied-port fallback, health-gated recording, bounded candidates, and separate persistence; the second guards existing public settings behavior. + +Actual: both remain blocked by missing `XCTest`. A temporary substitute compiled the actual core sources with testing enabled and passed all 21 assertions over the same startup-loop behaviors. + +- [x] **Step 7: Run the independent task verifier** + +Verifier input: + +- Requirements: remember only a healthy internal bridge port; try it first on a fresh app instance; skip it safely when occupied; retain ports 8792...8892; do not touch the public API port or auth/routing. +- Commands that must be fresh: + - `swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests` + - `swift test --package-path macos/CursorAPI --filter SettingsTests` +- Expected evidence: verifier accepts the current task-owned diff and confirms the production startup loop consumes the tested ordering. +- Rejection policy: same `rejection_key` twice stops and surfaces the task. + +Result: ACCEPT. No task-verifier rejection keys were recorded. + +- [x] **Step 8: Simplify touched code without changing behavior** + +Review only the new preference, server test dependencies, startup integration, and tests. Remove duplication or needless abstraction while preserving the dedicated key, validated range, production defaults, remembered-first order, and health-gated write. Re-run both focused test commands afterward. + +Result: no edit. The explicit dependency seam and lock-protected test recorders reduce rather than increase reader risk; shortening them would obscure the production-path assertions. + +- [x] **Step 9: Run full local verification** + +Run: + +```bash +swift test --package-path macos/CursorAPI +swift build --package-path macos/CursorAPI +``` + +Expected: the full CursorAPI Swift test suite and debug build pass. OSV is not required because no dependency, lockfile, container, or vendored surface changes. + +Actual: `swift build --package-path macos/CursorAPI --target CursorAPICore` and `swift build --package-path macos/CursorAPI` passed. Swift tests are blocked by missing `XCTest`; the focused real-code harness passed 21 assertions. OSV remains not required. + +- [x] **Step 10: Update task evidence and obtain final review** + +Update `docs/todos/stable-sdk-bridge-port/todo.md` and this plan: + +- Mark each Feature / Verification Matrix row passing with exact command evidence. +- Record files inspected/changed, review results, verifier acceptance, rejection count, lifecycle dependency, and residual risks. +- Record that the implementation worker changed only task-owned files. + +Then request a read-only final review of the current task-owned diff. Resolve any high-confidence issue and repeat the focused/full commands affected by the fix. + +Result: spec-compliance and code-quality reviewers both returned ACCEPT. Final task-verifier acceptance remains required before Step 11. + +Final verifier result: ACCEPT. Both builds passed, the known missing-`XCTest` blocker was unchanged, and a fresh harness against the current production startup path passed 23 assertions. + +- [ ] **Step 11: Run mandatory merge preparation and commit** + +Invoke `prep-merge-to-local-main` with the task record, this plan, preserved dirty paths, verification evidence, persistence boundary, and the stable task-owned diff. It must: + +- capture and merge the current local `refs/heads/main` within feature-loop authorization; +- run the repo-native security gate if one exists, otherwise run required Semgrep for the persistence/code change; +- stage only the task-owned files and run `gitleaks protect --staged --redact`; +- skip OSV with the recorded no-dependency-change reason; +- create a Conventional Commit such as `fix: reuse sdk bridge port` with `Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>`; +- run post-merge focused and full verification before reporting success. + +No fetch, pull, push, publish, deploy, installed-app launch, user-process termination, or remote write is authorized. diff --git a/docs/todos/stable-sdk-bridge-port/todo.md b/docs/todos/stable-sdk-bridge-port/todo.md new file mode 100644 index 0000000..c043dba --- /dev/null +++ b/docs/todos/stable-sdk-bridge-port/todo.md @@ -0,0 +1,95 @@ +# Stable SDK Bridge Port + +## Sprint Contract + +**Goal:** Reuse the same internal Cursor SDK bridge port across clean macOS app restarts while retaining a bounded fallback when that port is occupied. + +**Source of truth:** The current user request. No separate specification exists. + +**Scope:** + +- Keep an internal preferred bridge port separate from `CursorAPISettings.port`. +- Try the remembered bridge port first, then the existing bounded bridge range. +- Remember only a bridge port that launched and passed its health check. +- Add deterministic regression coverage for restart reuse and occupied-port fallback. + +**Non-goals:** + +- Do not change the public local API port, API/auth/routing behavior, bridge token handling, or external service configuration. +- Do not launch or replace the installed macOS app, terminate user processes, make live model calls, push, publish, deploy, or modify another worktree. +- Do not duplicate the separate app lifecycle fix that is absent from `main`. + +**Acceptance criteria:** + +1. A deterministic test reproduces the missing preferred-port reuse. +2. A clean restart uses the previously successful internal bridge port. +3. An occupied preferred port falls back within ports 8792 through 8892. +4. The successful fallback becomes the next preferred bridge port. +5. The public API port persistence remains unchanged. + +**Assumptions:** + +- A clean restart releases the prior bridge listener. On `main`, graceful app termination already awaits `model.shutdown()` and the bridge shutdown before replying to macOS; force-quit and crash recovery remain out of scope. +- `UserDefaults` is appropriate for a non-secret, app-local bridge port preference. + +**Known boundaries:** + +- Adds app-local persistence for a non-secret internal bridge preference under a dedicated key. +- Does not persist or change the bridge authentication token. +- Full feature-loop authorization includes task-owned merge preparation against the captured local `main`, but not fetch, pull, push, publish, deploy, or destructive cleanup. + +**Intended verification:** + +- Run the new focused Swift tests red before implementation and green afterward. +- Run the relevant CursorAPI Swift test target and build. +- Run the repository-required security gates for the touched persistence/code surface. +- Obtain independent implementation review and final verifier acceptance. + +**Stop conditions:** + +- Stop before any auth, routing, schema, external service, or public API persistence change. +- Stop if satisfying the behavior requires inspecting or modifying another worktree. +- Stop after the same verifier rejection occurs twice. +- Report any dependency on lifecycle code absent from `main`. + +## Feature / Verification Matrix + +| Change | Verification | Status | Evidence | +| --- | --- | --- | --- | +| Reuse successful bridge port after restart | `testCleanRestartReusesLastHealthyBridgePort` and production-path harness | Pass | Harness selected 8793 on the first run and reused 8793 after 8792 became free | +| Fall back when preferred bridge port is occupied | `testOccupiedPersistedPreferenceFallsBackAndRewritesPreference` and production-path harness | Pass | Occupied 8841 fell back to 8792; all 101 candidates stayed unique within 8792...8892 | +| Persist only a healthy bridge port | `testHealthGateOnlyPersistsPortAfterHealthyLaunch` and production-path harness | Pass | Preference remained 8841 for 40 failed polls and changed to 8792 only after healthy fallback | +| Keep public API port separate | `testBridgePreferencePersistenceDoesNotOverwritePublicSettings`, diff review, and harness | Pass | `CursorAPI.settings.v1`, `CursorAPISettings`, `AppSettingsStore`, auth, and routing remained unchanged | + +## Subagent Ledger + +| Workstream | Allowed scope | Edits | Expected output | Result | Residual risk | +| --- | --- | --- | --- | --- | --- | +| Pre-implementation test review | Task record, plan, bridge server, and tests | No | Acceptance and verification-gap findings | Rejected helper-only coverage with `missing-production-wiring-regression`; plan corrected to drive the real server startup loop | None after correction | +| Pre-implementation architecture review | Revised plan and current bridge lifecycle/settings code | No | Architecture and Swift 6 viability decision | ACCEPT; baseline build passed and equivalent dependency shapes compiled under Swift 6 | Clean-restart scope excludes crashes and force-quit | +| Implementation worker | Three task-owned Swift files | Yes | TDD implementation and focused evidence | DONE_WITH_CONCERNS; changed only assigned files, production build passed, XCTest unavailable | Repo XCTest commands need a full Xcode/XCTest toolchain | +| Initial independent verifier | Task-owned diff and fresh command evidence | No | Acceptance or actionable rejection | ACCEPT; both builds passed and a temporary harness executed the real startup loop with 21 passing assertions | XCTest remains unavailable locally | +| Spec-compliance review | Task-owned diff and Sprint Contract | No | Requirement coverage decision | ACCEPT | Installed app was not launched, per scope | +| Code-quality review | Task-owned Swift diff and neighboring conventions | No | Correctness, concurrency, and maintainability decision | ACCEPT; full build passed | XCTest remains unavailable locally | +| Final independent verifier | Final task-owned diff and fresh evidence | No | Final acceptance before completion | ACCEPT; both builds passed and a fresh real-code harness passed 23 assertions | Checked-in XCTest remains unexecuted until a full Xcode/XCTest toolchain is available | + +## Verification Evidence + +- `swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests` cannot compile on either `main` or this branch because the installed Command Line Tools lack the `XCTest` module. +- `swift build --package-path macos/CursorAPI --target CursorAPICore` passed. +- `swift build --package-path macos/CursorAPI` passed. +- The initial verifier's temporary no-XCTest harness exercised `CursorSDKBridgeServer.start` with 21 passing assertions. The final verifier repeated the current-diff check with 23 passing assertions, including three invalid-value cases. Both scratch harnesses were removed. +- `git diff --check` passed. +- Simplification pass found no behavior-preserving reduction that improved clarity enough to justify changing the accepted diff. + +## Review And Security Evidence + +- Focused Security/Privacy and Integration/Test reviews both returned ACCEPT. +- The authoritative fresh read-only implementation review returned `NO FINDINGS`. +- Passive secure-default review found no major or critical issue in the dedicated non-secret preference, loopback binding, token handling, subprocess path, or public API boundary. +- Task-scoped Semgrep scanned all three changed Swift files with 49 rules and returned zero findings. +- Whole-repository Semgrep reported 26 pre-existing findings only in unchanged files; none overlap the task-owned diff. +- OSV is not applicable because dependencies, lockfiles, containers, vendored code, and package surfaces did not change. +- Automated `codex review --uncommitted` could not run: the installed Codex CLI rejects the configured model as requiring a newer CLI, and its supported fallback rejects ChatGPT-account use. No CLI upgrade or login change was attempted. +- Staged Gitleaks scanned approximately 40.29 KB and found no leaks. +- Captured local `main` is `60906522e8cd620b5ad1a1823a1b20f92d0f2ab9`, identical to the branch base; local-main integration is expected to be a no-op after the task commit. diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift new file mode 100644 index 0000000..5e8704f --- /dev/null +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift @@ -0,0 +1,47 @@ +import Foundation + +final class CursorSDKBridgePortPreference: @unchecked Sendable { + static let defaultsKey = "CursorAPI.sdkBridgePort.v1" + static let defaultPort: UInt16 = 8792 + private static let allowedPorts: [UInt16] = Array(8792...8892).compactMap(UInt16.init(exactly:)) + + private let defaults: UserDefaults + private let lock = NSLock() + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func preferredPort() -> UInt16 { + lock.withLock { + resolvedPort(from: defaults.object(forKey: Self.defaultsKey)) ?? Self.defaultPort + } + } + + func candidatePorts() -> [UInt16] { + let preferred = preferredPort() + return [preferred] + Self.allowedPorts.filter { $0 != preferred } + } + + func recordSuccessfulPort(_ port: UInt16) { + guard Self.allowedPorts.contains(port) else { return } + lock.withLock { + defaults.set(Int(port), forKey: Self.defaultsKey) + } + } + + private func resolvedPort(from rawValue: Any?) -> UInt16? { + if let number = rawValue as? NSNumber, + let port = UInt16(exactly: number.intValue), + Self.allowedPorts.contains(port) { + return port + } + if let text = rawValue as? String, + let value = Int(text), + let port = UInt16(exactly: value), + Self.allowedPorts.contains(port) { + return port + } + return nil + } +} diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift index 7d92e7e..eb7f232 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift @@ -8,6 +8,25 @@ struct CursorSDKBridgeEndpoint: Sendable { var token: String } +struct CursorSDKBridgeServerDependencies: Sendable { + var portIsOpen: (@Sendable (UInt16) async -> Bool)? + var launch: (@Sendable (URL, UInt16, CursorAPISettings) throws -> Void)? + var healthCheck: (@Sendable (URL) async -> Bool)? + var sleep: (@Sendable () async throws -> Void)? + + init( + portIsOpen: (@Sendable (UInt16) async -> Bool)? = nil, + launch: (@Sendable (URL, UInt16, CursorAPISettings) throws -> Void)? = nil, + healthCheck: (@Sendable (URL) async -> Bool)? = nil, + sleep: (@Sendable () async throws -> Void)? = nil + ) { + self.portIsOpen = portIsOpen + self.launch = launch + self.healthCheck = healthCheck + self.sleep = sleep + } +} + actor CursorSDKBridgeServer { static let shared = CursorSDKBridgeServer() @@ -15,6 +34,16 @@ actor CursorSDKBridgeServer { private var endpoint: CursorSDKBridgeEndpoint? private var logHandle: FileHandle? private let token = UUID().uuidString.replacingOccurrences(of: "-", with: "") + private let portPreference: CursorSDKBridgePortPreference + private let dependencies: CursorSDKBridgeServerDependencies + + init( + portPreference: CursorSDKBridgePortPreference = CursorSDKBridgePortPreference(), + dependencies: CursorSDKBridgeServerDependencies = CursorSDKBridgeServerDependencies() + ) { + self.portPreference = portPreference + self.dependencies = dependencies + } func endpoint(settings: CursorAPISettings) async throws -> CursorSDKBridgeEndpoint { if let endpoint, process?.isRunning == true { @@ -35,20 +64,41 @@ actor CursorSDKBridgeServer { return endpoint } - private func start(script: URL, settings: CursorAPISettings) async throws -> UInt16 { + func start(script: URL, settings: CursorAPISettings) async throws -> UInt16 { var lastError: (any Error)? - for port in 8792...8892 { - guard let candidate = UInt16(exactly: port), await !tcpPortIsOpen(candidate) else { + for candidate in portPreference.candidatePorts() { + let isOpen: Bool + if let portIsOpen = dependencies.portIsOpen { + isOpen = await portIsOpen(candidate) + } else { + isOpen = await tcpPortIsOpen(candidate) + } + guard !isOpen else { continue } do { - try launch(script: script, port: candidate, settings: settings) + if let launch = dependencies.launch { + try launch(script, candidate, settings) + } else { + try launch(script: script, port: candidate, settings: settings) + } let health = URL(string: "http://127.0.0.1:\(candidate)/health")! for _ in 0..<40 { - if await isHealthy(health) { + let healthy: Bool + if let healthCheck = dependencies.healthCheck { + healthy = await healthCheck(health) + } else { + healthy = await isHealthy(health) + } + if healthy { + portPreference.recordSuccessfulPort(candidate) return candidate } - try await Task.sleep(nanoseconds: 50_000_000) + if let sleep = dependencies.sleep { + try await sleep() + } else { + try await Task.sleep(nanoseconds: 50_000_000) + } } stop() lastError = CursorAPIError.transport("Cursor SDK bridge did not become ready.") diff --git a/macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift b/macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift new file mode 100644 index 0000000..3a47bec --- /dev/null +++ b/macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift @@ -0,0 +1,219 @@ +@testable import CursorAPICore +import XCTest + +final class CursorSDKBridgeServerTests: XCTestCase { + func testCleanRestartReusesLastHealthyBridgePort() async throws { + let defaults = isolatedDefaults() + let preference = CursorSDKBridgePortPreference(defaults: defaults) + let firstLaunches = LockedLaunchRecorder() + + let firstServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { $0 == 8792 }, + launch: { _, port, _ in firstLaunches.recordLaunch(port) }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let firstPort = try await firstServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(firstPort, 8793) + XCTAssertEqual(firstLaunches.launchedPorts(), [8793]) + XCTAssertEqual(preference.preferredPort(), 8793) + + let secondLaunches = LockedLaunchRecorder() + let secondServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, port, _ in secondLaunches.recordLaunch(port) }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let secondPort = try await secondServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(secondPort, 8793) + XCTAssertEqual(secondLaunches.launchedPorts(), [8793]) + } + + func testOccupiedPersistedPreferenceFallsBackAndRewritesPreference() async throws { + let defaults = isolatedDefaults() + defaults.set(8841, forKey: CursorSDKBridgePortPreference.defaultsKey) + let preference = CursorSDKBridgePortPreference(defaults: defaults) + + let initialCandidates = preference.candidatePorts() + XCTAssertEqual(initialCandidates.count, 101) + XCTAssertEqual(Set(initialCandidates).count, 101) + XCTAssertEqual(initialCandidates.first, 8841) + XCTAssertEqual(initialCandidates.last, 8892) + XCTAssertTrue(initialCandidates.allSatisfy { (8792...8892).contains(Int($0)) }) + + let launches = LockedLaunchRecorder() + let firstServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { $0 == 8841 }, + launch: { _, port, _ in launches.recordLaunch(port) }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let firstPort = try await firstServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(firstPort, 8792) + XCTAssertEqual(launches.launchedPorts(), [8792]) + XCTAssertEqual(preference.preferredPort(), 8792) + + let secondServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, _, _ in }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let secondPort = try await secondServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(secondPort, 8792) + + let updatedCandidates = preference.candidatePorts() + XCTAssertEqual(updatedCandidates.count, 101) + XCTAssertEqual(Set(updatedCandidates).count, 101) + XCTAssertTrue(updatedCandidates.allSatisfy { (8792...8892).contains(Int($0)) }) + } + + func testHealthGateOnlyPersistsPortAfterHealthyLaunch() async throws { + let defaults = isolatedDefaults() + defaults.set(8841, forKey: CursorSDKBridgePortPreference.defaultsKey) + let preference = CursorSDKBridgePortPreference(defaults: defaults) + let recorder = LockedHealthFallbackRecorder() + + let server = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, port, _ in + recorder.recordLaunch(port: port, persistedPreference: preference.preferredPort()) + }, + healthCheck: { _ in + guard let current = recorder.currentLaunchPort() else { return false } + recorder.recordHealthPoll(for: current) + if current == 8841 { + return false + } + return current == 8792 + }, + sleep: { } + ) + ) + + let selected = try await server.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + + XCTAssertEqual(selected, 8792) + XCTAssertEqual(recorder.launchedPorts(), [8841, 8792]) + XCTAssertEqual(recorder.persistedPreferenceAtLaunch(), [8841, 8841]) + XCTAssertEqual(recorder.healthPollCount(for: 8841), 40) + XCTAssertEqual(preference.preferredPort(), 8792) + } + + func testBridgePreferencePersistenceDoesNotOverwritePublicSettings() throws { + let defaults = isolatedDefaults() + let settings = CursorAPISettings(port: 8822, cursorAPIBaseURL: "https://exchange.example") + let persistedSettings = try JSONEncoder.cursorAPIPretty.encode(settings) + defaults.set(persistedSettings, forKey: "CursorAPI.settings.v1") + + let preference = CursorSDKBridgePortPreference(defaults: defaults) + preference.recordSuccessfulPort(8844) + + XCTAssertEqual(defaults.data(forKey: "CursorAPI.settings.v1"), persistedSettings) + XCTAssertEqual(defaults.integer(forKey: CursorSDKBridgePortPreference.defaultsKey), 8844) + } + + func testInvalidOrOutOfRangePersistedBridgePortFallsBackToDefault() { + let defaults = isolatedDefaults() + defaults.set(8791, forKey: CursorSDKBridgePortPreference.defaultsKey) + + let preference = CursorSDKBridgePortPreference(defaults: defaults) + + XCTAssertEqual(preference.preferredPort(), 8792) + XCTAssertEqual(preference.candidatePorts().first, 8792) + } + + private func isolatedDefaults() -> UserDefaults { + let suiteName = "CursorAPI.CursorSDKBridgeServerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + +private final class LockedLaunchRecorder: @unchecked Sendable { + private let lock = NSLock() + private var launches: [UInt16] = [] + + func recordLaunch(_ port: UInt16) { + lock.lock() + launches.append(port) + lock.unlock() + } + + func launchedPorts() -> [UInt16] { + lock.lock() + let snapshot = launches + lock.unlock() + return snapshot + } +} + +private final class LockedHealthFallbackRecorder: @unchecked Sendable { + private let lock = NSLock() + private var launches: [UInt16] = [] + private var preferenceSnapshots: [UInt16] = [] + private var healthPolls: [UInt16: Int] = [:] + private var current: UInt16? + + func recordLaunch(port: UInt16, persistedPreference: UInt16) { + lock.lock() + launches.append(port) + preferenceSnapshots.append(persistedPreference) + current = port + lock.unlock() + } + + func currentLaunchPort() -> UInt16? { + lock.lock() + let value = current + lock.unlock() + return value + } + + func recordHealthPoll(for port: UInt16) { + lock.lock() + healthPolls[port, default: 0] += 1 + lock.unlock() + } + + func healthPollCount(for port: UInt16) -> Int { + lock.lock() + let value = healthPolls[port, default: 0] + lock.unlock() + return value + } + + func launchedPorts() -> [UInt16] { + lock.lock() + let snapshot = launches + lock.unlock() + return snapshot + } + + func persistedPreferenceAtLaunch() -> [UInt16] { + lock.lock() + let snapshot = preferenceSnapshots + lock.unlock() + return snapshot + } +} From febf69b25afc9b8bca2f309bdf6dc2f66ee58e7b Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Sun, 12 Jul 2026 14:31:50 +0200 Subject: [PATCH 4/6] fix: reuse sdk bridge port Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/todos/stable-sdk-bridge-port/plan.md | 417 ++++++++++++++++++ docs/todos/stable-sdk-bridge-port/todo.md | 95 ++++ .../CursorSDKBridgePortPreference.swift | 47 ++ .../CursorAPICore/CursorSDKBridgeServer.swift | 62 ++- .../CursorSDKBridgeServerTests.swift | 219 +++++++++ 5 files changed, 834 insertions(+), 6 deletions(-) create mode 100644 docs/todos/stable-sdk-bridge-port/plan.md create mode 100644 docs/todos/stable-sdk-bridge-port/todo.md create mode 100644 macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift create mode 100644 macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift diff --git a/docs/todos/stable-sdk-bridge-port/plan.md b/docs/todos/stable-sdk-bridge-port/plan.md new file mode 100644 index 0000000..a1b8f4a --- /dev/null +++ b/docs/todos/stable-sdk-bridge-port/plan.md @@ -0,0 +1,417 @@ +# Stable SDK Bridge Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reuse the last healthy internal Cursor SDK bridge port across clean macOS app restarts and retain bounded fallback when that port is occupied. + +**Architecture:** Add a bridge-only preference object backed by a dedicated `UserDefaults` key; it validates persisted values against the existing 8792...8892 range and puts the remembered port first without changing the range. Give `CursorSDKBridgeServer` narrow injectable port-check, launch, health-check, and sleep dependencies so deterministic tests exercise its real startup loop without spawning Node. The server records a candidate only after its health check succeeds, leaving `CursorAPISettings.port` and bridge authentication untouched. + +**Tech Stack:** Swift 6, Foundation `UserDefaults`, XCTest, Swift Package Manager + +**Commit Policy:** The current request authorizes a local commit. Keep implementation changes uncommitted until final verifier acceptance and let the mandatory `prep-merge-to-local-main` phase run security gates, stage only task-owned files, and create the Conventional Commit with the required Copilot co-author trailer. + +**Source of truth:** `docs/todos/stable-sdk-bridge-port/todo.md` and the current user request. No separate specification exists. + +**Task-owned files:** + +- Create `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift` for bridge-only preference persistence and candidate ordering. +- Create `macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift` for server-level restart, collision, health-gating, bounds, and separation regressions. +- Modify `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift` to add internal test dependencies, choose remembered-first candidates, and record only a healthy port. +- Update `docs/todos/stable-sdk-bridge-port/todo.md` and this plan with verification/review evidence. + +**Preserved boundaries:** + +- Do not add the bridge port to `CursorAPISettings` or `AppSettingsStore`. +- Do not persist or change the bridge token, API/auth/routing behavior, public API port, dependencies, scripts, or external services. +- Do not inspect or modify another worktree, launch the installed app, terminate user processes, make live model calls, or perform remote writes. +- The app lifecycle fix is outside this branch. If clean bridge shutdown cannot be established from `main`, report that integration dependency rather than expanding scope. +- Same verifier `rejection_key` twice stops implementation and surfaces the rejection. + +**Feature / Verification Matrix:** + +| Change | Verification | Status | Evidence | +| --- | --- | --- | --- | +| Reuse healthy bridge port across server instances | `testCleanRestartReusesLastHealthyBridgePort` | Pass | Real startup-loop harness selected and reused 8793 after 8792 became free | +| Fall back when the preferred port is occupied | `testOccupiedPersistedPreferenceFallsBackAndRewritesPreference` | Pass | Occupied 8841 fell back to 8792; 101 unique candidates remained within 8792...8892 | +| Persist only health-confirmed ports | `testHealthGateOnlyPersistsPortAfterHealthyLaunch` | Pass | Preference stayed 8841 for 40 unhealthy polls and changed only after healthy 8792 | +| Keep public and bridge port persistence separate | `testBridgePreferencePersistenceDoesNotOverwritePublicSettings` plus diff review | Pass | Dedicated bridge-key write left `CursorAPI.settings.v1` unchanged | + +**Subagent Ledger:** + +| Workstream | Scope | Edits | Expected output | Verification responsibility | +| --- | --- | --- | --- | --- | +| Pre-implementation evaluator | Task record, plan, current bridge server, neighboring settings tests | No | Acceptance/boundary/sequence findings with inspected files and residual risks | Test reviewer rejected helper-only coverage with `missing-production-wiring-regression`; plan corrected to server-level injected tests | +| Implementation worker | Three task-owned Swift files | Yes, only assigned Swift files | TDD evidence, changed files, commands, uncertainties, residual risks | DONE_WITH_CONCERNS: build passed; XCTest unavailable in local Command Line Tools | +| Independent verifier | Current task-owned diff and fresh command output | No | Accept or reject with a stable `rejection_key` and evidence | ACCEPT: builds passed; temporary real-code harness passed 21 assertions | +| Spec reviewer | Current task-owned diff and Sprint Contract | No | Requirement and scope decision | ACCEPT | +| Code-quality reviewer | Entire task-owned diff after simplification | No | High-confidence correctness findings only | ACCEPT; no simplification edit warranted | +| Final verifier | Final task-owned diff and fresh evidence | No | Final acceptance before completion | ACCEPT: builds passed; current production-path harness passed 23 assertions | + +--- + +### Task 1: Persist and Reuse the Internal Bridge Port + +**Files:** + +- Create: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift` +- Create: `macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift` +- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift:11-61` +- Modify: `docs/todos/stable-sdk-bridge-port/todo.md` +- Modify: `docs/todos/stable-sdk-bridge-port/plan.md` + +**Verifier:** + +- Commands that must be fresh: + - `swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests` + - `swift test --package-path macos/CursorAPI --filter SettingsTests` + - `swift test --package-path macos/CursorAPI` + - `swift build --package-path macos/CursorAPI` +- Expected evidence: server-level tests fail before the preference and injected startup seams exist, then pass against the production startup loop; the full Swift suite and build pass; and the final independent verifier accepts the task-owned diff. +- Notes: the verifier must confirm that `CursorAPISettings.port`, bridge token handling, and the allowed port range are unchanged. The same `rejection_key` twice stops and surfaces the task. + +- [x] **Step 1: Run the focused existing baseline** + +Run: + +```bash +swift test --package-path macos/CursorAPI --filter ConnectivityCheckTests +swift test --package-path macos/CursorAPI --filter SettingsTests +``` + +Expected: both existing suites pass before task-owned Swift changes. + +Actual: both commands were attempted, but package test compilation is blocked on `main` and this branch because the installed Command Line Tools lack the `XCTest` module. This is an environment blocker, not a regression. + +- [x] **Step 2: Write the failing regression tests** + +Create `macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift`: + +```swift +@testable import CursorAPICore +import Foundation +import XCTest + +final class CursorSDKBridgeServerTests: XCTestCase { + func testCleanRestartReusesLastHealthyBridgePort() async throws { + let defaults = isolatedDefaults() + let firstRun = testServer( + defaults: defaults, + portIsOpen: { $0 == 8792 }, + healthCheck: { _ in true } + ) + + let firstPort = try await firstRun.start(script: scriptURL, settings: CursorAPISettings()) + await firstRun.shutdown() + let restarted = testServer( + defaults: defaults, + portIsOpen: { _ in false }, + healthCheck: { _ in true } + ) + let restartedPort = try await restarted.start(script: scriptURL, settings: CursorAPISettings()) + await restarted.shutdown() + + XCTAssertEqual(firstPort, 8793) + XCTAssertEqual(restartedPort, firstPort) + } + + func testOccupiedPreferredPortFallsBackAndRemembersHealthyPort() async throws { + let defaults = isolatedDefaults() + let preference = CursorSDKBridgePortPreference(defaults: defaults) + preference.recordSuccessfulPort(8841) + let server = testServer( + defaults: defaults, + portIsOpen: { $0 == 8841 }, + healthCheck: { _ in true } + ) + + let selected = try await server.start(script: scriptURL, settings: CursorAPISettings()) + await server.shutdown() + let nextRun = CursorSDKBridgePortPreference(defaults: defaults) + + XCTAssertEqual(selected, 8792) + XCTAssertEqual(nextRun.candidatePorts().first, selected) + XCTAssertEqual(nextRun.candidatePorts().count, 101) + XCTAssertEqual(Set(nextRun.candidatePorts()), Set(CursorSDKBridgePortPreference.allowedPorts)) + } + + func testUnhealthyPreferredPortIsNotRecordedBeforeFallback() async throws { + let defaults = isolatedDefaults() + let preference = CursorSDKBridgePortPreference(defaults: defaults) + preference.recordSuccessfulPort(8841) + let observedPreferenceBeforeFallback = LockedPort() + let server = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, port, _ in + if port == 8792 { + observedPreferenceBeforeFallback.set(preference.candidatePorts().first) + } + }, + healthCheck: { url in url.port == 8792 }, + sleep: {} + ) + ) + + let selected = try await server.start(script: scriptURL, settings: CursorAPISettings()) + await server.shutdown() + + XCTAssertEqual(observedPreferenceBeforeFallback.value(), 8841) + XCTAssertEqual(selected, 8792) + XCTAssertEqual(CursorSDKBridgePortPreference(defaults: defaults).candidatePorts().first, selected) + } + + func testBridgePreferenceDoesNotOverwritePublicAPISettings() { + let defaults = isolatedDefaults() + let publicSettings = Data(#"{"port":8787}"#.utf8) + defaults.set(publicSettings, forKey: "CursorAPI.settings.v1") + + CursorSDKBridgePortPreference(defaults: defaults).recordSuccessfulPort(8841) + + XCTAssertEqual(defaults.data(forKey: "CursorAPI.settings.v1"), publicSettings) + } + + private var scriptURL: URL { + URL(fileURLWithPath: "/tmp/cursor-sdk-local-agent-bridge.mjs") + } + + private func testServer( + defaults: UserDefaults, + portIsOpen: @escaping @Sendable (UInt16) async -> Bool, + healthCheck: @escaping @Sendable (URL) async -> Bool + ) -> CursorSDKBridgeServer { + CursorSDKBridgeServer( + portPreference: CursorSDKBridgePortPreference(defaults: defaults), + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: portIsOpen, + launch: { _, _, _ in }, + healthCheck: healthCheck, + sleep: {} + ) + ) + } + + private func isolatedDefaults() -> UserDefaults { + let suiteName = "CursorAPI.BridgePortTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + +private final class LockedPort: @unchecked Sendable { + private let lock = NSLock() + private var port: UInt16? + + func set(_ port: UInt16?) { + lock.withLock { + self.port = port + } + } + + func value() -> UInt16? { + lock.withLock { + port + } + } +} +``` + +- [x] **Step 3: Run the new tests to prove the behavior is missing** + +Run: + +```bash +swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests +``` + +Expected: compilation fails because `CursorSDKBridgePortPreference`, `CursorSDKBridgeServerDependencies`, and the injectable server initializer/startup seam do not exist on `main`; preserve the concise compiler error as deterministic red-test evidence for missing production behavior. + +Actual: tests were written before production code, but the red command stopped earlier at the pre-existing missing-`XCTest` toolchain error. The final verifier therefore uses a no-XCTest harness against the actual startup loop as the executable substitute. + +- [x] **Step 4: Implement the bridge-only port preference** + +Create `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift`: + +```swift +import Foundation + +final class CursorSDKBridgePortPreference: @unchecked Sendable { + static let defaultPort: UInt16 = 8792 + static let allowedPorts: ClosedRange = 8792...8892 + + private static let defaultsKey = "CursorAPI.sdkBridgePort.v1" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func candidatePorts() -> [UInt16] { + let preferred = storedPort ?? Self.defaultPort + return [preferred] + Self.allowedPorts.filter { $0 != preferred } + } + + func recordSuccessfulPort(_ port: UInt16) { + precondition(Self.allowedPorts.contains(port)) + defaults.set(Int(port), forKey: Self.defaultsKey) + } + + private var storedPort: UInt16? { + guard let number = defaults.object(forKey: Self.defaultsKey) as? NSNumber, + let port = UInt16(exactly: number.intValue), + Self.allowedPorts.contains(port) else { + return nil + } + return port + } +} +``` + +This stores only a non-secret internal bridge preference. Invalid or out-of-range persisted values fall back to 8792, and candidate ordering remains bounded to the existing 101 ports. + +- [x] **Step 5: Wire the preference into healthy bridge startup** + +In `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift`, add narrow internal dependencies before the actor: + +```swift +struct CursorSDKBridgeServerDependencies: Sendable { + var portIsOpen: (@Sendable (UInt16) async -> Bool)? + var launch: (@Sendable (URL, UInt16, CursorAPISettings) throws -> Void)? + var healthCheck: (@Sendable (URL) async -> Bool)? + var sleep: (@Sendable () async throws -> Void)? +} +``` + +Add stored state and an internal initializer to the actor: + +```swift +private let portPreference: CursorSDKBridgePortPreference +private let dependencies: CursorSDKBridgeServerDependencies + +init( + portPreference: CursorSDKBridgePortPreference = CursorSDKBridgePortPreference(), + dependencies: CursorSDKBridgeServerDependencies = CursorSDKBridgeServerDependencies() +) { + self.portPreference = portPreference + self.dependencies = dependencies +} +``` + +Make `start(script:settings:)` internal so `@testable` server-level tests can exercise it, and replace the hard-coded integer scan with: + +```swift +for candidate in portPreference.candidatePorts() { + let isOpen = if let portIsOpen = dependencies.portIsOpen { + await portIsOpen(candidate) + } else { + await tcpPortIsOpen(candidate) + } + guard !isOpen else { + continue + } + do { + if let launch = dependencies.launch { + try launch(script, candidate, settings) + } else { + try launch(script: script, port: candidate, settings: settings) + } + let health = URL(string: "http://127.0.0.1:\(candidate)/health")! + for _ in 0..<40 { + let healthy = if let healthCheck = dependencies.healthCheck { + await healthCheck(health) + } else { + await isHealthy(health) + } + if healthy { + portPreference.recordSuccessfulPort(candidate) + return candidate + } + if let sleep = dependencies.sleep { + try await sleep() + } else { + try await Task.sleep(nanoseconds: 50_000_000) + } + } + stop() + lastError = CursorAPIError.transport("Cursor SDK bridge did not become ready.") + } catch { + stop() + lastError = error + } +} +``` + +The injected closures default to `nil`, so production keeps the existing process launch, TCP probe, 40 health checks, 50 ms polling delay, shutdown, and error behavior. Do not alter token generation or the public API port setting. + +- [x] **Step 6: Run focused green tests** + +Run: + +```bash +swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests +swift test --package-path macos/CursorAPI --filter SettingsTests +``` + +Expected: both suites pass. The first drives the production server startup loop with deterministic fakes to prove clean-restart reuse, occupied-port fallback, health-gated recording, bounded candidates, and separate persistence; the second guards existing public settings behavior. + +Actual: both remain blocked by missing `XCTest`. A temporary substitute compiled the actual core sources with testing enabled and passed all 21 assertions over the same startup-loop behaviors. + +- [x] **Step 7: Run the independent task verifier** + +Verifier input: + +- Requirements: remember only a healthy internal bridge port; try it first on a fresh app instance; skip it safely when occupied; retain ports 8792...8892; do not touch the public API port or auth/routing. +- Commands that must be fresh: + - `swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests` + - `swift test --package-path macos/CursorAPI --filter SettingsTests` +- Expected evidence: verifier accepts the current task-owned diff and confirms the production startup loop consumes the tested ordering. +- Rejection policy: same `rejection_key` twice stops and surfaces the task. + +Result: ACCEPT. No task-verifier rejection keys were recorded. + +- [x] **Step 8: Simplify touched code without changing behavior** + +Review only the new preference, server test dependencies, startup integration, and tests. Remove duplication or needless abstraction while preserving the dedicated key, validated range, production defaults, remembered-first order, and health-gated write. Re-run both focused test commands afterward. + +Result: no edit. The explicit dependency seam and lock-protected test recorders reduce rather than increase reader risk; shortening them would obscure the production-path assertions. + +- [x] **Step 9: Run full local verification** + +Run: + +```bash +swift test --package-path macos/CursorAPI +swift build --package-path macos/CursorAPI +``` + +Expected: the full CursorAPI Swift test suite and debug build pass. OSV is not required because no dependency, lockfile, container, or vendored surface changes. + +Actual: `swift build --package-path macos/CursorAPI --target CursorAPICore` and `swift build --package-path macos/CursorAPI` passed. Swift tests are blocked by missing `XCTest`; the focused real-code harness passed 21 assertions. OSV remains not required. + +- [x] **Step 10: Update task evidence and obtain final review** + +Update `docs/todos/stable-sdk-bridge-port/todo.md` and this plan: + +- Mark each Feature / Verification Matrix row passing with exact command evidence. +- Record files inspected/changed, review results, verifier acceptance, rejection count, lifecycle dependency, and residual risks. +- Record that the implementation worker changed only task-owned files. + +Then request a read-only final review of the current task-owned diff. Resolve any high-confidence issue and repeat the focused/full commands affected by the fix. + +Result: spec-compliance and code-quality reviewers both returned ACCEPT. Final task-verifier acceptance remains required before Step 11. + +Final verifier result: ACCEPT. Both builds passed, the known missing-`XCTest` blocker was unchanged, and a fresh harness against the current production startup path passed 23 assertions. + +- [ ] **Step 11: Run mandatory merge preparation and commit** + +Invoke `prep-merge-to-local-main` with the task record, this plan, preserved dirty paths, verification evidence, persistence boundary, and the stable task-owned diff. It must: + +- capture and merge the current local `refs/heads/main` within feature-loop authorization; +- run the repo-native security gate if one exists, otherwise run required Semgrep for the persistence/code change; +- stage only the task-owned files and run `gitleaks protect --staged --redact`; +- skip OSV with the recorded no-dependency-change reason; +- create a Conventional Commit such as `fix: reuse sdk bridge port` with `Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>`; +- run post-merge focused and full verification before reporting success. + +No fetch, pull, push, publish, deploy, installed-app launch, user-process termination, or remote write is authorized. diff --git a/docs/todos/stable-sdk-bridge-port/todo.md b/docs/todos/stable-sdk-bridge-port/todo.md new file mode 100644 index 0000000..c043dba --- /dev/null +++ b/docs/todos/stable-sdk-bridge-port/todo.md @@ -0,0 +1,95 @@ +# Stable SDK Bridge Port + +## Sprint Contract + +**Goal:** Reuse the same internal Cursor SDK bridge port across clean macOS app restarts while retaining a bounded fallback when that port is occupied. + +**Source of truth:** The current user request. No separate specification exists. + +**Scope:** + +- Keep an internal preferred bridge port separate from `CursorAPISettings.port`. +- Try the remembered bridge port first, then the existing bounded bridge range. +- Remember only a bridge port that launched and passed its health check. +- Add deterministic regression coverage for restart reuse and occupied-port fallback. + +**Non-goals:** + +- Do not change the public local API port, API/auth/routing behavior, bridge token handling, or external service configuration. +- Do not launch or replace the installed macOS app, terminate user processes, make live model calls, push, publish, deploy, or modify another worktree. +- Do not duplicate the separate app lifecycle fix that is absent from `main`. + +**Acceptance criteria:** + +1. A deterministic test reproduces the missing preferred-port reuse. +2. A clean restart uses the previously successful internal bridge port. +3. An occupied preferred port falls back within ports 8792 through 8892. +4. The successful fallback becomes the next preferred bridge port. +5. The public API port persistence remains unchanged. + +**Assumptions:** + +- A clean restart releases the prior bridge listener. On `main`, graceful app termination already awaits `model.shutdown()` and the bridge shutdown before replying to macOS; force-quit and crash recovery remain out of scope. +- `UserDefaults` is appropriate for a non-secret, app-local bridge port preference. + +**Known boundaries:** + +- Adds app-local persistence for a non-secret internal bridge preference under a dedicated key. +- Does not persist or change the bridge authentication token. +- Full feature-loop authorization includes task-owned merge preparation against the captured local `main`, but not fetch, pull, push, publish, deploy, or destructive cleanup. + +**Intended verification:** + +- Run the new focused Swift tests red before implementation and green afterward. +- Run the relevant CursorAPI Swift test target and build. +- Run the repository-required security gates for the touched persistence/code surface. +- Obtain independent implementation review and final verifier acceptance. + +**Stop conditions:** + +- Stop before any auth, routing, schema, external service, or public API persistence change. +- Stop if satisfying the behavior requires inspecting or modifying another worktree. +- Stop after the same verifier rejection occurs twice. +- Report any dependency on lifecycle code absent from `main`. + +## Feature / Verification Matrix + +| Change | Verification | Status | Evidence | +| --- | --- | --- | --- | +| Reuse successful bridge port after restart | `testCleanRestartReusesLastHealthyBridgePort` and production-path harness | Pass | Harness selected 8793 on the first run and reused 8793 after 8792 became free | +| Fall back when preferred bridge port is occupied | `testOccupiedPersistedPreferenceFallsBackAndRewritesPreference` and production-path harness | Pass | Occupied 8841 fell back to 8792; all 101 candidates stayed unique within 8792...8892 | +| Persist only a healthy bridge port | `testHealthGateOnlyPersistsPortAfterHealthyLaunch` and production-path harness | Pass | Preference remained 8841 for 40 failed polls and changed to 8792 only after healthy fallback | +| Keep public API port separate | `testBridgePreferencePersistenceDoesNotOverwritePublicSettings`, diff review, and harness | Pass | `CursorAPI.settings.v1`, `CursorAPISettings`, `AppSettingsStore`, auth, and routing remained unchanged | + +## Subagent Ledger + +| Workstream | Allowed scope | Edits | Expected output | Result | Residual risk | +| --- | --- | --- | --- | --- | --- | +| Pre-implementation test review | Task record, plan, bridge server, and tests | No | Acceptance and verification-gap findings | Rejected helper-only coverage with `missing-production-wiring-regression`; plan corrected to drive the real server startup loop | None after correction | +| Pre-implementation architecture review | Revised plan and current bridge lifecycle/settings code | No | Architecture and Swift 6 viability decision | ACCEPT; baseline build passed and equivalent dependency shapes compiled under Swift 6 | Clean-restart scope excludes crashes and force-quit | +| Implementation worker | Three task-owned Swift files | Yes | TDD implementation and focused evidence | DONE_WITH_CONCERNS; changed only assigned files, production build passed, XCTest unavailable | Repo XCTest commands need a full Xcode/XCTest toolchain | +| Initial independent verifier | Task-owned diff and fresh command evidence | No | Acceptance or actionable rejection | ACCEPT; both builds passed and a temporary harness executed the real startup loop with 21 passing assertions | XCTest remains unavailable locally | +| Spec-compliance review | Task-owned diff and Sprint Contract | No | Requirement coverage decision | ACCEPT | Installed app was not launched, per scope | +| Code-quality review | Task-owned Swift diff and neighboring conventions | No | Correctness, concurrency, and maintainability decision | ACCEPT; full build passed | XCTest remains unavailable locally | +| Final independent verifier | Final task-owned diff and fresh evidence | No | Final acceptance before completion | ACCEPT; both builds passed and a fresh real-code harness passed 23 assertions | Checked-in XCTest remains unexecuted until a full Xcode/XCTest toolchain is available | + +## Verification Evidence + +- `swift test --package-path macos/CursorAPI --filter CursorSDKBridgeServerTests` cannot compile on either `main` or this branch because the installed Command Line Tools lack the `XCTest` module. +- `swift build --package-path macos/CursorAPI --target CursorAPICore` passed. +- `swift build --package-path macos/CursorAPI` passed. +- The initial verifier's temporary no-XCTest harness exercised `CursorSDKBridgeServer.start` with 21 passing assertions. The final verifier repeated the current-diff check with 23 passing assertions, including three invalid-value cases. Both scratch harnesses were removed. +- `git diff --check` passed. +- Simplification pass found no behavior-preserving reduction that improved clarity enough to justify changing the accepted diff. + +## Review And Security Evidence + +- Focused Security/Privacy and Integration/Test reviews both returned ACCEPT. +- The authoritative fresh read-only implementation review returned `NO FINDINGS`. +- Passive secure-default review found no major or critical issue in the dedicated non-secret preference, loopback binding, token handling, subprocess path, or public API boundary. +- Task-scoped Semgrep scanned all three changed Swift files with 49 rules and returned zero findings. +- Whole-repository Semgrep reported 26 pre-existing findings only in unchanged files; none overlap the task-owned diff. +- OSV is not applicable because dependencies, lockfiles, containers, vendored code, and package surfaces did not change. +- Automated `codex review --uncommitted` could not run: the installed Codex CLI rejects the configured model as requiring a newer CLI, and its supported fallback rejects ChatGPT-account use. No CLI upgrade or login change was attempted. +- Staged Gitleaks scanned approximately 40.29 KB and found no leaks. +- Captured local `main` is `60906522e8cd620b5ad1a1823a1b20f92d0f2ab9`, identical to the branch base; local-main integration is expected to be a no-op after the task commit. diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift new file mode 100644 index 0000000..5e8704f --- /dev/null +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgePortPreference.swift @@ -0,0 +1,47 @@ +import Foundation + +final class CursorSDKBridgePortPreference: @unchecked Sendable { + static let defaultsKey = "CursorAPI.sdkBridgePort.v1" + static let defaultPort: UInt16 = 8792 + private static let allowedPorts: [UInt16] = Array(8792...8892).compactMap(UInt16.init(exactly:)) + + private let defaults: UserDefaults + private let lock = NSLock() + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func preferredPort() -> UInt16 { + lock.withLock { + resolvedPort(from: defaults.object(forKey: Self.defaultsKey)) ?? Self.defaultPort + } + } + + func candidatePorts() -> [UInt16] { + let preferred = preferredPort() + return [preferred] + Self.allowedPorts.filter { $0 != preferred } + } + + func recordSuccessfulPort(_ port: UInt16) { + guard Self.allowedPorts.contains(port) else { return } + lock.withLock { + defaults.set(Int(port), forKey: Self.defaultsKey) + } + } + + private func resolvedPort(from rawValue: Any?) -> UInt16? { + if let number = rawValue as? NSNumber, + let port = UInt16(exactly: number.intValue), + Self.allowedPorts.contains(port) { + return port + } + if let text = rawValue as? String, + let value = Int(text), + let port = UInt16(exactly: value), + Self.allowedPorts.contains(port) { + return port + } + return nil + } +} diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift index ed885a3..bf6788f 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift @@ -8,6 +8,25 @@ struct CursorSDKBridgeEndpoint: Sendable { var token: String } +struct CursorSDKBridgeServerDependencies: Sendable { + var portIsOpen: (@Sendable (UInt16) async -> Bool)? + var launch: (@Sendable (URL, UInt16, CursorAPISettings) throws -> Void)? + var healthCheck: (@Sendable (URL) async -> Bool)? + var sleep: (@Sendable () async throws -> Void)? + + init( + portIsOpen: (@Sendable (UInt16) async -> Bool)? = nil, + launch: (@Sendable (URL, UInt16, CursorAPISettings) throws -> Void)? = nil, + healthCheck: (@Sendable (URL) async -> Bool)? = nil, + sleep: (@Sendable () async throws -> Void)? = nil + ) { + self.portIsOpen = portIsOpen + self.launch = launch + self.healthCheck = healthCheck + self.sleep = sleep + } +} + actor CursorSDKBridgeServer { static let shared = CursorSDKBridgeServer() static let bridgeShutdownTimeoutMilliseconds = 15_000 @@ -20,6 +39,16 @@ actor CursorSDKBridgeServer { private var isStopping = false private var stopWaiters: [CheckedContinuation] = [] private let token = UUID().uuidString.replacingOccurrences(of: "-", with: "") + private let portPreference: CursorSDKBridgePortPreference + private let dependencies: CursorSDKBridgeServerDependencies + + init( + portPreference: CursorSDKBridgePortPreference = CursorSDKBridgePortPreference(), + dependencies: CursorSDKBridgeServerDependencies = CursorSDKBridgeServerDependencies() + ) { + self.portPreference = portPreference + self.dependencies = dependencies + } func endpoint(settings: CursorAPISettings) async throws -> CursorSDKBridgeEndpoint { await waitForInFlightStop() @@ -41,20 +70,41 @@ actor CursorSDKBridgeServer { return endpoint } - private func start(script: URL, settings: CursorAPISettings) async throws -> UInt16 { + func start(script: URL, settings: CursorAPISettings) async throws -> UInt16 { var lastError: (any Error)? - for port in 8792...8892 { - guard let candidate = UInt16(exactly: port), await !tcpPortIsOpen(candidate) else { + for candidate in portPreference.candidatePorts() { + let isOpen: Bool + if let portIsOpen = dependencies.portIsOpen { + isOpen = await portIsOpen(candidate) + } else { + isOpen = await tcpPortIsOpen(candidate) + } + guard !isOpen else { continue } do { - try launch(script: script, port: candidate, settings: settings) + if let launch = dependencies.launch { + try launch(script, candidate, settings) + } else { + try launch(script: script, port: candidate, settings: settings) + } let health = URL(string: "http://127.0.0.1:\(candidate)/health")! for _ in 0..<40 { - if await isHealthy(health) { + let healthy: Bool + if let healthCheck = dependencies.healthCheck { + healthy = await healthCheck(health) + } else { + healthy = await isHealthy(health) + } + if healthy { + portPreference.recordSuccessfulPort(candidate) return candidate } - try await Task.sleep(nanoseconds: 50_000_000) + if let sleep = dependencies.sleep { + try await sleep() + } else { + try await Task.sleep(nanoseconds: 50_000_000) + } } await stop() lastError = CursorAPIError.transport("Cursor SDK bridge did not become ready.") diff --git a/macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift b/macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift new file mode 100644 index 0000000..3a47bec --- /dev/null +++ b/macos/CursorAPI/Tests/CursorAPITests/CursorSDKBridgeServerTests.swift @@ -0,0 +1,219 @@ +@testable import CursorAPICore +import XCTest + +final class CursorSDKBridgeServerTests: XCTestCase { + func testCleanRestartReusesLastHealthyBridgePort() async throws { + let defaults = isolatedDefaults() + let preference = CursorSDKBridgePortPreference(defaults: defaults) + let firstLaunches = LockedLaunchRecorder() + + let firstServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { $0 == 8792 }, + launch: { _, port, _ in firstLaunches.recordLaunch(port) }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let firstPort = try await firstServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(firstPort, 8793) + XCTAssertEqual(firstLaunches.launchedPorts(), [8793]) + XCTAssertEqual(preference.preferredPort(), 8793) + + let secondLaunches = LockedLaunchRecorder() + let secondServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, port, _ in secondLaunches.recordLaunch(port) }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let secondPort = try await secondServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(secondPort, 8793) + XCTAssertEqual(secondLaunches.launchedPorts(), [8793]) + } + + func testOccupiedPersistedPreferenceFallsBackAndRewritesPreference() async throws { + let defaults = isolatedDefaults() + defaults.set(8841, forKey: CursorSDKBridgePortPreference.defaultsKey) + let preference = CursorSDKBridgePortPreference(defaults: defaults) + + let initialCandidates = preference.candidatePorts() + XCTAssertEqual(initialCandidates.count, 101) + XCTAssertEqual(Set(initialCandidates).count, 101) + XCTAssertEqual(initialCandidates.first, 8841) + XCTAssertEqual(initialCandidates.last, 8892) + XCTAssertTrue(initialCandidates.allSatisfy { (8792...8892).contains(Int($0)) }) + + let launches = LockedLaunchRecorder() + let firstServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { $0 == 8841 }, + launch: { _, port, _ in launches.recordLaunch(port) }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let firstPort = try await firstServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(firstPort, 8792) + XCTAssertEqual(launches.launchedPorts(), [8792]) + XCTAssertEqual(preference.preferredPort(), 8792) + + let secondServer = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, _, _ in }, + healthCheck: { _ in true }, + sleep: { } + ) + ) + + let secondPort = try await secondServer.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + XCTAssertEqual(secondPort, 8792) + + let updatedCandidates = preference.candidatePorts() + XCTAssertEqual(updatedCandidates.count, 101) + XCTAssertEqual(Set(updatedCandidates).count, 101) + XCTAssertTrue(updatedCandidates.allSatisfy { (8792...8892).contains(Int($0)) }) + } + + func testHealthGateOnlyPersistsPortAfterHealthyLaunch() async throws { + let defaults = isolatedDefaults() + defaults.set(8841, forKey: CursorSDKBridgePortPreference.defaultsKey) + let preference = CursorSDKBridgePortPreference(defaults: defaults) + let recorder = LockedHealthFallbackRecorder() + + let server = CursorSDKBridgeServer( + portPreference: preference, + dependencies: CursorSDKBridgeServerDependencies( + portIsOpen: { _ in false }, + launch: { _, port, _ in + recorder.recordLaunch(port: port, persistedPreference: preference.preferredPort()) + }, + healthCheck: { _ in + guard let current = recorder.currentLaunchPort() else { return false } + recorder.recordHealthPoll(for: current) + if current == 8841 { + return false + } + return current == 8792 + }, + sleep: { } + ) + ) + + let selected = try await server.start(script: URL(fileURLWithPath: "/dev/null"), settings: CursorAPISettings()) + + XCTAssertEqual(selected, 8792) + XCTAssertEqual(recorder.launchedPorts(), [8841, 8792]) + XCTAssertEqual(recorder.persistedPreferenceAtLaunch(), [8841, 8841]) + XCTAssertEqual(recorder.healthPollCount(for: 8841), 40) + XCTAssertEqual(preference.preferredPort(), 8792) + } + + func testBridgePreferencePersistenceDoesNotOverwritePublicSettings() throws { + let defaults = isolatedDefaults() + let settings = CursorAPISettings(port: 8822, cursorAPIBaseURL: "https://exchange.example") + let persistedSettings = try JSONEncoder.cursorAPIPretty.encode(settings) + defaults.set(persistedSettings, forKey: "CursorAPI.settings.v1") + + let preference = CursorSDKBridgePortPreference(defaults: defaults) + preference.recordSuccessfulPort(8844) + + XCTAssertEqual(defaults.data(forKey: "CursorAPI.settings.v1"), persistedSettings) + XCTAssertEqual(defaults.integer(forKey: CursorSDKBridgePortPreference.defaultsKey), 8844) + } + + func testInvalidOrOutOfRangePersistedBridgePortFallsBackToDefault() { + let defaults = isolatedDefaults() + defaults.set(8791, forKey: CursorSDKBridgePortPreference.defaultsKey) + + let preference = CursorSDKBridgePortPreference(defaults: defaults) + + XCTAssertEqual(preference.preferredPort(), 8792) + XCTAssertEqual(preference.candidatePorts().first, 8792) + } + + private func isolatedDefaults() -> UserDefaults { + let suiteName = "CursorAPI.CursorSDKBridgeServerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + +private final class LockedLaunchRecorder: @unchecked Sendable { + private let lock = NSLock() + private var launches: [UInt16] = [] + + func recordLaunch(_ port: UInt16) { + lock.lock() + launches.append(port) + lock.unlock() + } + + func launchedPorts() -> [UInt16] { + lock.lock() + let snapshot = launches + lock.unlock() + return snapshot + } +} + +private final class LockedHealthFallbackRecorder: @unchecked Sendable { + private let lock = NSLock() + private var launches: [UInt16] = [] + private var preferenceSnapshots: [UInt16] = [] + private var healthPolls: [UInt16: Int] = [:] + private var current: UInt16? + + func recordLaunch(port: UInt16, persistedPreference: UInt16) { + lock.lock() + launches.append(port) + preferenceSnapshots.append(persistedPreference) + current = port + lock.unlock() + } + + func currentLaunchPort() -> UInt16? { + lock.lock() + let value = current + lock.unlock() + return value + } + + func recordHealthPoll(for port: UInt16) { + lock.lock() + healthPolls[port, default: 0] += 1 + lock.unlock() + } + + func healthPollCount(for port: UInt16) -> Int { + lock.lock() + let value = healthPolls[port, default: 0] + lock.unlock() + return value + } + + func launchedPorts() -> [UInt16] { + lock.lock() + let snapshot = launches + lock.unlock() + return snapshot + } + + func persistedPreferenceAtLaunch() -> [UInt16] { + lock.lock() + let snapshot = preferenceSnapshots + lock.unlock() + return snapshot + } +} From ff95397129c591f7eb0f0c09f4d15d9efde93edd Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Sun, 12 Jul 2026 15:05:33 +0200 Subject: [PATCH 5/6] chore: finalize cursor api lifecycle rollout Document the combined lifecycle and stable bridge-port installation, record the process-wide recycle trade-off, and remove generated XCTest metadata artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 ++- docs/production.md | 6 ++++ docs/todos/cursor-api-lifecycle/plan.md | 24 +++++++++++-- docs/todos/cursor-api-lifecycle/todo.md | 38 ++++++++++++++++++-- docs/todos/stable-sdk-bridge-port/todo.md | 2 ++ macos/CursorAPI/xctest-shim.abi.json | 9 ----- macos/CursorAPI/xctest-shim.swiftdoc | Bin 412 -> 0 bytes macos/CursorAPI/xctest-shim.swiftsourceinfo | Bin 2756 -> 0 bytes 8 files changed, 69 insertions(+), 15 deletions(-) delete mode 100644 macos/CursorAPI/xctest-shim.abi.json delete mode 100644 macos/CursorAPI/xctest-shim.swiftdoc delete mode 100644 macos/CursorAPI/xctest-shim.swiftsourceinfo diff --git a/README.md b/README.md index 62641ec..c26e79e 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,10 @@ per bridge call. At the Swift app layer, `bridgeTransportAttempts = 2` means a r pre-output transport failure, 503, or `cursor_sdk_unavailable` error causes the app to recycle the owned bridge exactly once and issue one final bridge request. Auth failures, arbitrary upstream errors, and any failure after output has begun are never retried at -either layer. The public OpenAI-compatible response and error contract is unchanged. +either layer. Recycling is process-wide: because all local SDK sessions share the owned +bridge process, a recycle disposes every cached agent and may interrupt other concurrent +requests. This trade-off favors clearing poisoned local SDK state; affected callers can +retry through the unchanged public OpenAI-compatible response and error contract. Release packages prefer a bundled Node runtime for the local SDK bridge and fall back to Bun when Node is unavailable. diff --git a/docs/production.md b/docs/production.md index 5310798..015a790 100644 --- a/docs/production.md +++ b/docs/production.md @@ -49,6 +49,12 @@ recycle the owned bridge once: transport-level failures and known has been produced. The recycled bridge handles one final attempt. If that also fails, the `CursorAPIError`/OpenAI-compatible error is returned unchanged. +The bridge is shared by all local SDK sessions. Recycling it is therefore process-wide: +shutdown disposes every cached agent and can interrupt unrelated concurrent requests. +This is an intentional recovery trade-off for the local macOS service so stale or +poisoned SDK process state is removed completely; interrupted callers receive the normal +error contract and can retry. + Auth failures, arbitrary upstream errors, and any failure after output has begun are never retried at either layer. diff --git a/docs/todos/cursor-api-lifecycle/plan.md b/docs/todos/cursor-api-lifecycle/plan.md index 77bf003..6424511 100644 --- a/docs/todos/cursor-api-lifecycle/plan.md +++ b/docs/todos/cursor-api-lifecycle/plan.md @@ -138,6 +138,24 @@ Report root cause, changed files, commands, residual uncertainty, and that a cre - Commands that must be fresh: `macos/CursorAPI/Scripts/verify-package.sh`, `codesign --verify --deep --strict`, bundle metadata checks, and installed bridge checksum comparison. - Expected evidence: version `0.1.10` build `13`, fixed bridge payload matches the worktree, and no old helper process remains. -- [ ] **Step 1: Stop the installed app and orphan helpers** -- [ ] **Step 2: Build and verify the development package** -- [ ] **Step 3: Replace and statically verify the installed app** +- [x] **Step 1: Stop the installed app and orphan helpers** +- [x] **Step 2: Build and verify the development package** +- [x] **Step 3: Replace and statically verify the installed app** + +### Task 6: Integrate and install the stable bridge-port fix + +**Files and paths:** +- Lifecycle base: commit `9d71542` +- Stable bridge port: commit `febf69b` +- Build: `macos/CursorAPI/dist/API for Cursor.app` +- Replace: `/Applications/API for Cursor.app` + +**Verifier:** +- Commands that must be fresh: full JS tests, TypeScript typecheck, Swift target and package builds, package verification, installed signature and metadata checks, and two clean app start/quit cycles. +- Expected evidence: version `0.1.10` build `14`, the installed bridge uses the same internal listening port after a clean restart, and no app or bridge process remains after either shutdown. + +- [x] **Step 1: Verify the combined lifecycle and bridge-port branch** +- [x] **Step 2: Build, package, and verify build 14** +- [x] **Step 3: Atomically replace and verify the installed app** +- [x] **Step 4: Prove bridge-port reuse across two clean restarts** +- [x] **Step 5: Run final security checks and prepare the remaining task-owned changes for commit** diff --git a/docs/todos/cursor-api-lifecycle/todo.md b/docs/todos/cursor-api-lifecycle/todo.md index 0aacdef..131329f 100644 --- a/docs/todos/cursor-api-lifecycle/todo.md +++ b/docs/todos/cursor-api-lifecycle/todo.md @@ -34,6 +34,7 @@ Eliminate recurring local Responses API failures caused by stale or incompletely - The observed `run.wait()` result with `status=error` and no details is retryable only before any response content has been emitted. - `@cursor/sdk` 1.0.13 exposes `Symbol.asyncDispose`; its documented `close()` starts asynchronous disposal without waiting. - The bridge runtime is a direct child of the macOS app in packaged operation. +- Bridge recycling is intentionally process-wide because all local SDK sessions share one owned bridge process. A recycle disposes every cached agent and may interrupt unrelated concurrent requests; complete removal of poisoned bridge state takes precedence, and affected callers retain the existing retryable public error contract. - Live behavior still requires an explicitly approved credentialed proof after local verification. ## Stop conditions @@ -87,8 +88,8 @@ The bridge did not await SDK executor disposal before agent replacement or proce - Full Semgrep fallback: 26 pre-existing findings and exit 1. - Semgrep baseline diff scan: 0 findings introduced by this task. - OSV was not required because dependency and lockfile surfaces did not change. -- Three scratch XCTest metadata files remain untracked under `macos/CursorAPI`; deletion was not authorized while the user was unavailable. -- No live credentialed model call, app restart, or termination of an existing process was performed. +- Three tracked scratch XCTest metadata files under `macos/CursorAPI` were removed during the authorized closure follow-up. +- No live credentialed model call was performed during the initial implementation verification. ## Authorized local installation follow-up @@ -99,6 +100,39 @@ On 2026-07-12 the user explicitly authorized terminating the installed app and i - Acceptance: old helper PIDs are gone; the package verifier passes; the replacement keeps bundle id `ai.standardagents.cursorapi`, uses version `0.1.10` build `13`, contains the fixed bridge script and rebuilt Swift executable, and passes static code-signature verification. - Boundary: do not launch the replacement or make a live API request unless separately requested. +### 2026-07-12 Task 5 execution evidence + +- Process pre-check (before replacement): `python3` scan over `ps -axo pid,ppid,command` for `/Applications/API for Cursor.app` returned `matching_processes=none`; no termination was needed. +- Build command: `TMPDIR="$PWD/macos/CursorAPI/.build/tmp" CURSOR_API_APP_VERSION=0.1.10 CURSOR_API_APP_BUILD=13 macos/CursorAPI/Scripts/package-app.sh --development` → pass, produced `macos/CursorAPI/dist/API for Cursor.app`. +- Explicit package verifier: `TMPDIR="$PWD/macos/CursorAPI/.build/tmp" macos/CursorAPI/Scripts/verify-package.sh "macos/CursorAPI/dist/API for Cursor.app"` → pass. +- Dist metadata and signature: `CFBundleDisplayName=API for Cursor`, `CFBundleIdentifier=ai.standardagents.cursorapi`, `CFBundleShortVersionString=0.1.10`, `CFBundleVersion=13`; `codesign --verify --deep --strict` passed. +- Bridge payload integrity: `shasum -a 256` for `scripts/cursor-sdk-local-agent-bridge.mjs`, dist payload, and installed payload all matched: `b4c49350aba20a2ead1afae38bf660377dd36263297d29b47d224822e14cb033`. +- Swift executable evidence: `macos/CursorAPI/dist/API for Cursor.app/Contents/MacOS/API for Cursor` exists and is executable (`size=2571216`, `mtime=2026-07-12T12:10:44+0200`), newer than the prior installed executable (`size=2504144`, `mtime=2026-07-09T19:36:25+0200`). +- Atomic-style replacement: staged copy at `/Applications/API for Cursor.app.__stage_task5_20260712` verified (metadata/signature/checksum), current app moved to rollback sibling `/Applications/API for Cursor.app.__rollback_task5_20260712`, staged app promoted to `/Applications/API for Cursor.app`, installed app re-verified, then rollback sibling removed. +- Installed verification after promotion: bundle id `ai.standardagents.cursorapi`, version `0.1.10`, build `13`, `codesign --verify --deep --strict` pass, executable present/executable, and post-install process scan returned `post_install_matching_processes=none`. +- Boundary kept: no app launch and no live API/model request were performed. + +### 2026-07-12 closure follow-up + +- The installed build launched successfully and reported `ready` on its localhost health endpoint with the saved key unlocked and one SDK bridge child. +- A live streaming `/v1/responses` request using `composer-2.5` returned `response.created`, `response.in_progress`, output events, `response.completed`, and the expected `LIVE_OK` text. +- The same request using `composer-2.5-fast` reproducibly returned `response.created`, `response.in_progress`, then `upstream_error` without output. +- Bridge logs confirmed all four Node SDK-run attempts and one Swift bridge recycle to a fresh bridge before the final fast-model failure. This proves the lifecycle recovery path executes, but does not make the upstream fast variant succeed. +- Changing `composer-2.5-fast` to fall back to the non-fast variant would alter externally visible model behavior and requires an explicit product decision. +- The three unintended tracked `xctest-shim` compiler metadata files were removed. + +### 2026-07-12 combined lifecycle and stable-port installation + +- The stable bridge-port fix was integrated on top of the lifecycle fix: `9d71542` followed by `febf69b`. +- Combined verification passed: 236/236 JavaScript tests, TypeScript typecheck, `swift build --package-path macos/CursorAPI --target CursorAPICore`, and `swift build --package-path macos/CursorAPI`. +- Build command: `TMPDIR="$PWD/macos/CursorAPI/.build/tmp" CURSOR_API_APP_VERSION=0.1.10 CURSOR_API_APP_BUILD=14 macos/CursorAPI/Scripts/package-app.sh --development` passed. +- The package verifier and `codesign --verify --deep --strict` passed; the packaged bridge script matched the worktree checksum `b4c49350aba20a2ead1afae38bf660377dd36263297d29b47d224822e14cb033`. +- `/Applications/API for Cursor.app` was replaced using a verified staged copy and rollback sibling, then re-verified as version `0.1.10` build `14`. +- Real restart proof passed: the first clean start used app PID `49390`, bridge PID `49561`, and internal port `8792`; the second used app PID `49695`, bridge PID `49735`, and the same internal port `8792`. +- Both app exits were requested through the bundle identifier, and both the app and bridge processes exited after each run. No credentialed model request was made during the build 14 restart check. +- Final combined review found no Critical issue. Its one Important concurrency observation was accepted and documented explicitly: bridge recycling is process-wide and can interrupt unrelated concurrent requests because all SDK sessions share the owned bridge process. The focused follow-up review returned `ACCEPT`. +- Final Semgrep scanning of all seven changed JavaScript and Swift code/test files reported three blocking findings. All three are unchanged `main` findings at the JSON pointer/schema validation code (`d67dbf54` and `bb0515eb`); none was introduced by this task. + ## Subagent ledger | Workstream | Scope | Edits | Result | Residual risk | diff --git a/docs/todos/stable-sdk-bridge-port/todo.md b/docs/todos/stable-sdk-bridge-port/todo.md index c043dba..d015dc7 100644 --- a/docs/todos/stable-sdk-bridge-port/todo.md +++ b/docs/todos/stable-sdk-bridge-port/todo.md @@ -79,6 +79,8 @@ - `swift build --package-path macos/CursorAPI --target CursorAPICore` passed. - `swift build --package-path macos/CursorAPI` passed. - The initial verifier's temporary no-XCTest harness exercised `CursorSDKBridgeServer.start` with 21 passing assertions. The final verifier repeated the current-diff check with 23 passing assertions, including three invalid-value cases. Both scratch harnesses were removed. +- Parent integration cherry-picked the fix as `febf69b` on top of lifecycle commit `9d71542`, then built and installed app version `0.1.10` build `14`. +- Two real installed-app start/quit cycles both selected internal bridge port `8792`; the app and bridge processes exited after each cycle. - `git diff --check` passed. - Simplification pass found no behavior-preserving reduction that improved clarity enough to justify changing the accepted diff. diff --git a/macos/CursorAPI/xctest-shim.abi.json b/macos/CursorAPI/xctest-shim.abi.json deleted file mode 100644 index d2f988e..0000000 --- a/macos/CursorAPI/xctest-shim.abi.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ABIRoot": { - "kind": "Root", - "name": "NO_MODULE", - "printedName": "NO_MODULE", - "json_format_version": 8 - }, - "ConstValues": [] -} \ No newline at end of file diff --git a/macos/CursorAPI/xctest-shim.swiftdoc b/macos/CursorAPI/xctest-shim.swiftdoc deleted file mode 100644 index 450ca832e43de62cf86e44a211418c8a5bfb21b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 412 zcmaDfX9YVW2Lpp90|Ns)qlJ#c+7Dby0=U-%aP4>CT5rI$Ux91Q2d>2%xY!*xUFbQa zy@N^j#3Ahshx9v`wD%m+SaZl(Xp58P1t*OOP8vYc#=uE?50mBb+2rD zzS?lFFz#lIs=!Rwc0R@K-D3s^OnE`eMi8{zV5h=kU!zZ#a8z8~%j^wVLo% zEB?8Jf6=(dZY9Y3h7Yt}ZvXPu_Cmrp4+IYXx7Zf*m&iFIuw;-`hi}mU!*tDdAV%)a9~goA2F#t*q9eFv&j~ec1LAFS?I%hwINnJReK7CtNT0j7;bppdhS*f+A?NpBbh~al0eCE39vH_< zr9ASAGdV@~P9s&z6$@S=$OSnsQWPZXxoJd&gDm4EHLR1D3gc7_53-;m*XALXVPFbB zhV=m_4{dZXjWnGg?!Z`5Ck398uqA3zl8f3b%Lf?%KssDqL>&C?IZXb9^ZiV^H zb%%C|%zrEQ>+lTw_pFUh#KfTd8w9U^9~w?YE59Ycam;ppx59Gfx>kOl2b}}Z4nngE z_H7Cp#_at58J^=c?a-`Z&B>nt;C$F||7K!#+?OF4*ymQhCHsOw$m>u_vPJ-y&gPR1M<4w4GP0Wtl3CX~9*eWO80OE6Cm+ylnX2<;(VB8+u zxVXbjxc`Cm7_;N1pm5`W!Im_LY?;MCXAtIgen(8qj(Zpm&PiBj#~pHab)Fk?E}Pd3 zoO}8DrH2X7)?N|n7a+^Qe_Yd$s;AW{l*#Z)E|2(pu7Jv7wmdRAk`YCX4T-v>=VVb= zbA|CU;*_4m=ltGU&ziS^jR&^Vg&9>U4XbKVJrf@+%ZdzJ3jTH6`uG>#svQC4q?F4y z*c3B%)*!jy{f&A0kCc^x#T0e#-zw_EA52O4OhX8>wFqjxZ5ZO)@0`ykab8cBq@Cxi z+)SJwlr)6*1f0Km>cCCW3Wu0SrcbCy(vg~;kP1&}{Y`8g-!5&$siT))#kL;r49;ae zwxpETo?>h8HR&LP4?nFwZzbFfsJ!Zm8CVfOu1eocI8pl zm?0)p8Xea2NjDhqLVNQ4gDVru#aiFIGXQ!GlK$C~2i3CVp#?h&5q*oJ0{PYaug~CH z5Lhr=N{C>Kk)d{L)0q~Q-GN`q62XgQQs!U~E|oE(i(ye!L9 qG!&63fs>d>lwlDY?Wggv*J#*(_$m@7a+AT@=M>oLCUHGIl79g)3;gK- From 5a0bb719e40529d6182371f48ef2578e4542ea15 Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Sun, 12 Jul 2026 14:27:58 +0200 Subject: [PATCH 6/6] fix: block bridge retry during shutdown and require health checks Exclude bridge_shutting_down from pre-output recovery, refuse endpoint launches after shutdown, and always verify bridge health before reuse. --- .../CursorAPICore/CursorSDKBridgeServer.swift | 10 +++- .../CursorAPICore/CursorSDKHarness.swift | 3 ++ .../ConnectivityCheckTests.swift | 47 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift index bf6788f..d4af1d8 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift @@ -37,6 +37,7 @@ actor CursorSDKBridgeServer { private var endpoint: CursorSDKBridgeEndpoint? private var logHandle: FileHandle? private var isStopping = false + private var shutdownRequested = false private var stopWaiters: [CheckedContinuation] = [] private let token = UUID().uuidString.replacingOccurrences(of: "-", with: "") private let portPreference: CursorSDKBridgePortPreference @@ -52,8 +53,8 @@ actor CursorSDKBridgeServer { func endpoint(settings: CursorAPISettings) async throws -> CursorSDKBridgeEndpoint { await waitForInFlightStop() - if let endpoint, process?.isRunning == true { - return endpoint + if shutdownRequested { + throw CursorAPIError.transport("Cursor SDK bridge is shutting down.") } if let endpoint, await isHealthy(endpoint.healthURL) { return endpoint @@ -179,6 +180,7 @@ actor CursorSDKBridgeServer { } func shutdown() async { + shutdownRequested = true await stop() } @@ -186,6 +188,10 @@ actor CursorSDKBridgeServer { await stop() } + func resetShutdownStateForTesting() { + shutdownRequested = false + } + static func waitForExitPolling( timeoutNanoseconds: UInt64, pollIntervalNanoseconds: UInt64 = 20_000_000, diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift index 36ffb9d..cf64f61 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift @@ -130,6 +130,9 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { case .transport: return true case .upstream: + if normalizedCode == "bridge_shutting_down" { + return false + } return status == 503 || normalizedCode == "cursor_sdk_unavailable" case .unauthorized: return false diff --git a/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift b/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift index 405cb34..6a6043e 100644 --- a/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift +++ b/macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift @@ -195,6 +195,53 @@ final class ConnectivityCheckTests: XCTestCase { XCTAssertEqual(snapshot.delayCount, 0) } + func testBridgeRecoveryShutdownErrorDoesNotRecycleOrRetry() async throws { + let probe = BridgeRecoveryProbe() + + do { + _ = try await LocalCursorSDKHarness.executeBridgeRecoveryRetry( + operation: { attempt in + await probe.recordAttempt(attempt) + throw LocalCursorSDKHarness.BridgeRecoveryError( + outwardError: .upstream("Bridge is shutting down"), + status: 503, + code: "bridge_shutting_down", + emittedOutput: false + ) + }, + recycle: { + await probe.recordRecycle() + }, + delay: { + await probe.recordDelay() + } + ) + XCTFail("Expected bridge shutdown error.") + } catch { + XCTAssertEqual(error as? CursorAPIError, .upstream("Bridge is shutting down")) + } + + let snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.attempts, [1]) + XCTAssertEqual(snapshot.recycleCount, 0) + XCTAssertEqual(snapshot.delayCount, 0) + } + + func testBridgeServerRefusesEndpointAfterShutdown() async throws { + let settings = CursorAPISettings(cursorAPIKey: "crsr_test") + + await CursorSDKBridgeServer.shared.shutdown() + + do { + _ = try await CursorSDKBridgeServer.shared.endpoint(settings: settings) + XCTFail("Expected shutdown to block bridge launch.") + } catch let error as CursorAPIError { + XCTAssertEqual(error, .transport("Cursor SDK bridge is shutting down.")) + } + + await CursorSDKBridgeServer.shared.resetShutdownStateForTesting() + } + func testBridgeRecoveryNonRetryableUpstreamDoesNotRecycleOrRetry() async throws { let probe = BridgeRecoveryProbe()