From 8d487298e26fbb30384f7692c4f4c3b4d3910592 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 15:17:53 -0400 Subject: [PATCH 01/20] Document cancellation propagation --- .../cancellation-propagation-analysis.md | 178 ++++++++++++++++++ packages/cli/package.json | 6 +- pnpm-lock.yaml | 22 +-- 3 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 docs/architecture/cancellation-propagation-analysis.md diff --git a/docs/architecture/cancellation-propagation-analysis.md b/docs/architecture/cancellation-propagation-analysis.md new file mode 100644 index 0000000..f1865e5 --- /dev/null +++ b/docs/architecture/cancellation-propagation-analysis.md @@ -0,0 +1,178 @@ +# CLI Cancellation Propagation Analysis + +## Scope + +This analysis maps where to thread `AbortController`/`AbortSignal` from CLI entrypoint to underlying I/O. + +Current state: cancellation is not modeled as a first-class runtime concern. Most flows rely on natural process termination or library-specific behavior. + +SDK baseline after targeted update: + +- `@prisma/management-api-sdk@1.35.0` uses `openapi-fetch@0.14.0`; per-call options extend `RequestInit`, so `client.GET/POST/PATCH/DELETE(..., { signal })` is available. +- `@prisma/compute-sdk@0.20.0` exposes `signal?: AbortSignal` on `ComputeClient` operation options, build strategy methods, archive/build helpers, polling, and log streaming. + +## Design Target + +- Create one `AbortController` at app entry (`runCli`/`bin.ts` boundary). +- Map keyboard cancellation (`SIGINT`, optional `SIGTERM`) to `controller.abort(...)` only at that boundary. +- Propagate `AbortSignal` through runtime/context -> command runner -> controllers -> libs/adapters/providers -> network/fs/process I/O. +- Standardize cancellation handling into one CLI error shape (e.g. `command canceled`) instead of ad-hoc exits. + +## Primary Augmentation Path + +1. Runtime/context surface: + - `packages/cli/src/shell/runtime.ts` + - Add `signal: AbortSignal` to `CliRuntime` and `CommandContext`. +2. Entrypoint wiring: + - `packages/cli/src/bin.ts` + - `packages/cli/src/cli.ts` + - Create controller, attach signal listeners, pass signal into `runCli` runtime. +3. Command execution wrappers: + - `packages/cli/src/shell/command-runner.ts` + - Ensure cancellation exceptions map to a dedicated `CliError` path for both `runCommand` and `runStreamingCommand`. +4. Controller and dependency signatures: + - `packages/cli/src/controllers/*.ts` + - `packages/cli/src/lib/**/*.ts` + - `packages/cli/src/adapters/**/*.ts` + - Thread optional `{ signal?: AbortSignal }` into async boundaries. + +## Pseudocode Call Stacks To Update + +### 1) Common command execution path (all commands) + +```ts +bin.ts + -> create AbortController + -> on SIGINT/SIGTERM: controller.abort(reason) + -> runCli({ ..., signal: controller.signal }) + +cli.ts runCli(runtime) + -> createProgram(runtime) + -> program.parseAsync(...) + -> command.action(...) + -> runCommand/runStreamingCommand(runtime, ...) + +command-runner.ts + -> createCommandContext(runtime, flags) // context includes signal + -> handler(context) + -> map AbortError/canceled to CliError(CANCELED) +``` + +### 2) App deploy path (long-running + network-heavy) + +```ts +commands/app/index.ts createDeployCommand + -> runCommand(..., (ctx) => runAppDeploy(ctx, ...)) + +controllers/app.ts runAppDeploy + -> requireProviderAndDeployProjectContext(ctx, ...) + -> provider.listApps(..., { signal }) + -> provider.deployApp(..., { signal, progress }) + -> stateStore.setSelectedApp(..., { signal? optional if store supports }) + +lib/app/preview-provider.ts deployApp + -> sdk.deploy({ ..., signal }) + -> PreviewBuildStrategy.canBuild(signal) / execute(signal) + -> underlying build, archive, upload, HTTP, and polling calls honor signal +``` + +### 3) App logs path (streaming) + +```ts +commands/app/index.ts createLogsCommand + -> runStreamingCommand(..., (ctx) => runAppLogs(ctx, ...)) + +controllers/app.ts runAppLogs + -> provider.streamDeploymentLogs({ deploymentId, signal: ctx.runtime.signal, onRecord }) + +lib/app/preview-provider.ts streamDeploymentLogs + -> streamLogs({ ..., signal }) + -> CancelledError => map to standard cancellation result/error boundary +``` + +### 4) Polling loops (must become signal-aware sleeps) + +```ts +controllers/app.ts runAppDomainWait + while (...) { + await provider.showDomain(..., { signal }) + await sleep(interval, signal) // reject on abort + } + +controllers/project.ts waitForInstalledRepository + while (...) { + await listScmInstallations(..., { signal }) + await sleep(interval, signal) + } + +adapters/token-storage.ts acquireRefreshLock + while (...) { + signal.throwIfAborted() + await fs.open(...) + await sleep(100, signal) + } +``` + +### 5) Local process execution (`app run`) + +```ts +controllers/app.ts runAppRun + -> runLocalApp({ ..., signal: ctx.runtime.signal }) + +lib/app/local-dev.ts runLocalApp + -> spawnCommand(..., { signal }) // kill child on abort + -> normalize AbortError vs child exit signal behavior +``` + +## I/O Boundaries Requiring Signal Propagation + +- Management API client calls (`client.GET/POST/PATCH/DELETE`) can now receive `{ signal }` in: + - `packages/cli/src/controllers/app-env.ts` + - `packages/cli/src/controllers/project.ts` + - `packages/cli/src/lib/auth/auth-ops.ts` + - `packages/cli/src/lib/app/preview-provider.ts` +- Compute SDK operations can now receive `signal` in `packages/cli/src/lib/app/preview-provider.ts`: + - `sdk.deploy`, `sdk.promote`, `sdk.updateEnv`, `sdk.destroyService`, `sdk.createProject`, `sdk.showService`, `sdk.showVersion`, and related list/delete/start/stop operations. + - `streamLogs({ ..., signal })` already has a signal option and returns `CancelledError` on abort. + - SDK `BuildStrategy` methods now accept `canBuild(signal)` and `execute(signal)`; this repo's `PreviewBuildStrategy` and `executePreviewBuild` should forward the signal to concrete SDK build strategies. +- Child processes: + - `spawn` path in `packages/cli/src/lib/app/local-dev.ts` + - `execFile` path in `packages/cli/src/adapters/git.ts` +- Poll/sleep loops: + - `packages/cli/src/controllers/app.ts` + - `packages/cli/src/controllers/project.ts` + - `packages/cli/src/adapters/token-storage.ts` +- File system ops: + - Push `signal` through local helper signatures even when the final external operation cannot consume it. + - `readFile` and `writeFile` support `AbortSignal` through an options object. Current string-encoding calls must become object-form calls, e.g. `readFile(path, { encoding: "utf8", signal })`. + - Current usage appears in `packages/cli/src/adapters/local-state.ts`, `packages/cli/src/lib/project/local-pin.ts`, `packages/cli/src/lib/project/resolution.ts`, `packages/cli/src/lib/app/bun-project.ts`, `packages/cli/src/lib/app/preview-build.ts`, `packages/cli/src/controllers/app.ts`, `packages/cli/src/adapters/mock-api.ts`, and `packages/cli/src/adapters/token-storage.ts`. + - For external filesystem calls that do not support `signal`, call `signal.throwIfAborted()` immediately before the operation and add a short comment at that boundary explaining that the external API does not accept `AbortSignal`. + - For unsupported read-only operations with no dangerous cancellation side effects, also check `signal.throwIfAborted()` after the awaited operation before returning the result. + +## Unsupported I/O Boundary Rule + +Thread `AbortSignal` through internal APIs until the exact external I/O boundary. If that external API does not support `signal`, stop propagation there deliberately: + +```ts +async function readSomething(path: string, signal: AbortSignal) { + // External API does not accept AbortSignal; check immediately before I/O. + signal.throwIfAborted(); + const result = await unsupportedExternalRead(path); + signal.throwIfAborted(); + return result; +} +``` + +Apply this to: + +- `CredentialsStore` methods in `packages/cli/src/adapters/token-storage.ts`; the adapter should accept/propagate `signal`, check before store calls, use signal-aware local sleeps, and document that `CredentialsStore` itself cannot consume `AbortSignal`. +- Node filesystem promise calls without native `signal` support, such as `access`, `copyFile`, `cp`, `lstat`, `mkdir`, `open`, `readdir`, `readlink`, `rm`, and `stat`. +- OAuth/login helper calls if their external SDK/browser/listener boundaries cannot consume `signal`. + +## Execution Notes (Important) + +- Do not add local `Promise.race` cancellation shims to fake abort behavior around non-cancelable upstream APIs. +- Push `AbortSignal` as deep as possible; ignore it only at the external I/O function that does not support it, with a comment and `signal.throwIfAborted()` guard. +- Convert all internal `sleep` helpers to `sleep(ms, signal)` and reject immediately on abort. +- Keep cancellation mapping centralized in `command-runner.ts`; controllers should mostly propagate, not reinterpret. +- `@clack/prompts` cancellations already raise usage errors; keep keyboard signal cancellation separate and higher priority at runtime boundary. diff --git a/packages/cli/package.json b/packages/cli/package.json index c053a02..107ad4f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,10 +41,10 @@ }, "dependencies": { "@clack/prompts": "^1.2.0", - "@prisma/compute-sdk": "^0.19.0", - "c12": "4.0.0-beta.4", + "@prisma/compute-sdk": "^0.20.0", "@prisma/credentials-store": "^7.7.0", - "@prisma/management-api-sdk": "^1.34.0", + "@prisma/management-api-sdk": "^1.35.0", + "c12": "4.0.0-beta.4", "colorette": "^2.0.20", "commander": "^12.1.0", "magicast": "^0.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47941e7..8a02186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,14 +27,14 @@ importers: specifier: ^1.2.0 version: 1.2.0 '@prisma/compute-sdk': - specifier: ^0.19.0 - version: 0.19.0(@prisma/management-api-sdk@1.34.0) + specifier: ^0.20.0 + version: 0.20.0(@prisma/management-api-sdk@1.35.0) '@prisma/credentials-store': specifier: ^7.7.0 version: 7.7.0 '@prisma/management-api-sdk': - specifier: ^1.34.0 - version: 1.34.0 + specifier: ^1.35.0 + version: 1.35.0 c12: specifier: 4.0.0-beta.4 version: 4.0.0-beta.4(jiti@2.6.1)(magicast@0.3.5) @@ -292,8 +292,8 @@ packages: '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} - '@prisma/compute-sdk@0.19.0': - resolution: {integrity: sha512-kUSElNqRgFC8RKuUpeVEKOifuH1XtuVczB0pJHqRV4mf8/DGO6Tmu1U64UAgATCJKhr13SYGtNsqBdM6e0Ej8w==} + '@prisma/compute-sdk@0.20.0': + resolution: {integrity: sha512-H82lNh117wAdbYyCfpRzy4ffU6cY7so3BU+iGEspTSPEzfmL/LTKCpFFckixShQrj0PbrFfkQK9+qnZJBM+wew==} engines: {node: '>=18.0.0'} peerDependencies: '@prisma/management-api-sdk': '>=1.23.0' @@ -301,8 +301,8 @@ packages: '@prisma/credentials-store@7.7.0': resolution: {integrity: sha512-SVaMCL1Q8rFPKQB5W9B7HDuRdD/KyBfKjCgZfnlN+sqrAXIrUGU/m/whcRgkuvygB5GFPAeeZ/4QgvvH0vPSWg==} - '@prisma/management-api-sdk@1.34.0': - resolution: {integrity: sha512-l0UE58T/6rS9/tIe7Qv/ffQr3XUeUMGZwPhVig8VIoMKvidhtg8/UO84emUqAzQBQDG52GQP+VRtv3xFchrnjw==} + '@prisma/management-api-sdk@1.35.0': + resolution: {integrity: sha512-ugUROU6SkKUhfjZ9LLV3vtryevPxKaqzet36m5ncD4ceI4PPoqNUPyFdhK9uWsdRgxR2peN7Nw2iUvZsc9aqBg==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1374,9 +1374,9 @@ snapshots: '@oxc-project/types@0.124.0': {} - '@prisma/compute-sdk@0.19.0(@prisma/management-api-sdk@1.34.0)': + '@prisma/compute-sdk@0.20.0(@prisma/management-api-sdk@1.35.0)': dependencies: - '@prisma/management-api-sdk': 1.34.0 + '@prisma/management-api-sdk': 1.35.0 better-result: 2.8.2 tar-stream: 3.1.8 tiny-invariant: 1.3.3 @@ -1392,7 +1392,7 @@ snapshots: dependencies: xdg-app-paths: 8.3.0 - '@prisma/management-api-sdk@1.34.0': + '@prisma/management-api-sdk@1.35.0': dependencies: openapi-fetch: 0.14.0 From 1e9916515a0060222c6ca90e93005a9ef7671838 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 16:39:43 -0400 Subject: [PATCH 02/20] Add cancellation propagation project plan --- ...-cancellation-propagation-analysis.plan.md | 167 ++++++++++++++++++ ...-cancellation-propagation-analysis.spec.md | 122 +++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 .agents/projects/cli-cancellation-propagation-analysis.plan.md create mode 100644 .agents/projects/cli-cancellation-propagation-analysis.spec.md diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md new file mode 100644 index 0000000..08499ad --- /dev/null +++ b/.agents/projects/cli-cancellation-propagation-analysis.plan.md @@ -0,0 +1,167 @@ +# CLI Cancellation Propagation Plan + +## Assumptions + +- **A1 Spec source:** This plan implements `.agents/projects/cli-cancellation-propagation-analysis.spec.md` and uses `docs/architecture/cancellation-propagation-analysis.md` as implementation source material. +- **A2 Dependency baseline:** The current package versions already satisfy the SDK baseline: `@prisma/management-api-sdk@^1.35.0` and `@prisma/compute-sdk@^0.20.0`. +- **A3 Error model:** Cancellation uses `COMMAND_CANCELED`, domain `cli`, and exit code `130`. +- **A4 Runtime boundary:** `bin.ts` owns OS signal listeners and `runCli` accepts a runtime signal for tests and embedded invocation. +- **A5 Verification surface:** Vitest tests under `packages/cli/tests` are the primary regression suite, with targeted tests added near the affected shell, provider, adapter, and controller behavior. +- **A6 Implementation discipline:** The plan does not add fake `Promise.race` cancellation around non-cancelable external APIs. Unsupported boundaries get immediate `signal.throwIfAborted()` checks and a short boundary comment. + +## Open Questions + +None. + +## Phases + +### Phase 1: Runtime Root And Central Cancellation Error + +**Status:** ☐ Not started + +**Goal:** Establish a thin end-to-end cancellation path from CLI entry to command-runner error output before changing deeper I/O code. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR18, FR19, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Changes:** + +- **C1 Entrypoint:** Update `packages/cli/src/bin.ts` to create one `AbortController`, map `SIGINT` and `SIGTERM` to controller abort, and pass the signal into `runCli`. +- **C2 Runtime type:** Update `packages/cli/src/cli.ts` and `packages/cli/src/shell/runtime.ts` so `CliRuntime` always has a signal, while tests and embedded callers can pass their own signal. +- **C3 Context type:** Ensure `CommandContext` exposes the same signal through `runtime.signal` without creating per-command controllers. +- **C4 Error helper:** Add a `COMMAND_CANCELED` `CliError` helper in `packages/cli/src/shell/errors.ts` with domain `cli`, exit code `130`, concise human summary, and no misleading recovery instructions. +- **C5 Error conversion:** Update `packages/cli/src/shell/command-runner.ts` so both `runCommand` and `runStreamingCommand` convert DOM abort errors, aborted runtime signals, and known cancellation exceptions into the centralized cancellation error. +- **C6 Product docs:** Update `docs/product/error-conventions.md` with `COMMAND_CANCELED` and the exit-code exception for cancellation. +- **C7 Tests:** Add shell/runner tests covering human JSON output, streaming JSON error events, exit code `130`, and preservation of prompt usage-error behavior. + +**Acceptance Criteria:** + +- **AC1:** A handler that aborts through `runtime.signal` returns a formatted `COMMAND_CANCELED` error for regular commands. +- **AC2:** A streaming handler that aborts through `runtime.signal` emits a streaming error event instead of raw exception output. +- **AC3:** Cancellation exits with code `130` and does not alter success output shapes. +- **AC4:** Existing CLI shell tests pass with no command surface changes. +- **AC5:** `pnpm --filter @prisma/cli test -- shell.test.ts command-runner-auth.test.ts prompt.test.ts` passes, or equivalent targeted Vitest filters if filenames change. + +### Phase 2: Command And Controller Signal Plumbing + +**Status:** ☐ Not started + +**Goal:** Thread the command signal through controller and command-handler boundaries so deeper I/O phases can consume it without broad follow-up signature churn. + +**Requirements:** FR3, FR8, FR9, FR10, FR11, FR12, FR13, FR14, FR15, FR18, FR19, NFR2, NFR4, NFR5 + +**Changes:** + +- **C1 Controller contracts:** Update affected controllers in `packages/cli/src/controllers` to read cancellation from `context.runtime.signal` and pass it into async dependencies that perform I/O. +- **C2 App command path:** Prepare `runAppDeploy`, `runAppLogs`, `runAppRun`, domain wait, app environment, and app state flows to pass `{ signal }` into provider, local-dev, state, and helper calls. +- **C3 Project command path:** Prepare project setup, repository installation polling, project listing, and local resolution calls to pass `{ signal }` into API, local state, and helper calls. +- **C4 Auth command path:** Prepare auth operations, login helpers, workspace lookup, and token storage calls to pass `{ signal }` into SDK/client and adapter calls where the boundary is under CLI control. +- **C5 Adapter and lib options:** Introduce small options objects only where existing APIs already need multiple optional controls; otherwise pass the signal directly when that keeps signatures clearer. +- **C6 Tests:** Update existing controller and command tests to construct runtimes with signals and add one representative controller propagation assertion for each command group touched. + +**Acceptance Criteria:** + +- **AC1:** TypeScript requires new async I/O call sites in controllers/libs/adapters to consciously accept or ignore a signal. +- **AC2:** Existing controller tests pass after runtime construction updates. +- **AC3:** No command handler installs OS signal listeners or creates a command-lifetime controller. +- **AC4:** `pnpm --filter @prisma/cli test -- auth.test.ts project.test.ts app.test.ts branch.test.ts` passes, or equivalent targeted filters if filenames change. + +### Phase 3: SDK And Provider Cancellation + +**Status:** ☐ Not started + +**Goal:** Propagate cancellation through Management API and Compute SDK boundaries, especially app deploy and logs. + +**Requirements:** FR8, FR9, FR10, FR17, FR19, NFR1, NFR2, NFR3, NFR6 + +**Changes:** + +- **C1 Management API calls:** Add `{ signal }` to supported `client.GET`, `client.POST`, `client.PATCH`, and `client.DELETE` calls in `packages/cli/src/controllers/app-env.ts`, `packages/cli/src/controllers/project.ts`, `packages/cli/src/lib/auth/auth-ops.ts`, `packages/cli/src/lib/auth/login.ts`, and `packages/cli/src/lib/app/preview-provider.ts`. +- **C2 Compute operations:** Add signal propagation to Compute SDK operations in `packages/cli/src/lib/app/preview-provider.ts`, including project, service, version, deploy, promote, env update, destroy, list, start/stop, and related operations. +- **C3 Deploy build strategy:** Update `packages/cli/src/lib/app/preview-build.ts` so `PreviewBuildStrategy.canBuild` and `PreviewBuildStrategy.execute` forward the signal into concrete SDK build strategies and preview build helpers. +- **C4 Log streaming:** Ensure `streamLogs` receives the command signal and `CancelledError` maps to the central `COMMAND_CANCELED` path rather than provider-local error handling. +- **C5 Provider tests:** Extend `packages/cli/tests/app-provider.test.ts`, `packages/cli/tests/app-build.test.ts`, `packages/cli/tests/auth-ops.test.ts`, and app-env/project API tests to assert signal forwarding at representative SDK/client boundaries. + +**Acceptance Criteria:** + +- **AC1:** Representative Management API calls receive the same `AbortSignal` from command context. +- **AC2:** Representative Compute SDK calls receive the same `AbortSignal` from command context. +- **AC3:** App log stream cancellation produces `COMMAND_CANCELED` through command-runner mapping. +- **AC4:** Preview build strategy methods accept and forward the signal without changing build selection behavior. +- **AC5:** `pnpm --filter @prisma/cli test -- app-provider.test.ts app-build.test.ts auth-ops.test.ts app-env.test.ts project.test.ts` passes, or equivalent targeted filters if filenames change. + +### Phase 4: Polling, Sleeps, And Local Processes + +**Status:** ☐ Not started + +**Goal:** Make CLI-owned waiting and subprocess execution responsive to cancellation. + +**Requirements:** FR11, FR12, FR13, FR15, FR17, FR19, NFR1, NFR6, NFR7 + +**Changes:** + +- **C1 Shared sleep behavior:** Convert internal sleeps in `packages/cli/src/controllers/app.ts`, `packages/cli/src/controllers/project.ts`, and `packages/cli/src/adapters/token-storage.ts` to accept `AbortSignal` and reject promptly on abort. +- **C2 Polling loops:** Update app domain wait and project repository-installation polling to check cancellation before each poll and use signal-aware sleeps between polls. +- **C3 Local app process:** Update `packages/cli/src/lib/app/local-dev.ts` and `runAppRun` so spawned local app processes receive cancellation and abort-related exits normalize into `COMMAND_CANCELED` instead of `RUN_FAILED` or ad-hoc exit-code handling. +- **C4 Git process:** Update `packages/cli/src/adapters/git.ts` so `execFile` operations observe cancellation at the supported child-process boundary. +- **C5 Tests:** Add or update tests in `packages/cli/tests/app-local-dev.test.ts`, `packages/cli/tests/git-adapter.test.ts`, `packages/cli/tests/project-controller.test.ts`, and `packages/cli/tests/app-controller.test.ts` to cover cancellation during sleep, polling, and subprocess execution. + +**Acceptance Criteria:** + +- **AC1:** Signal-aware sleeps reject immediately when already aborted and reject without waiting for the full interval when aborted during sleep. +- **AC2:** Polling loops do not perform an extra API call after cancellation is observed. +- **AC3:** Local app process cancellation does not produce `RUN_FAILED` for `SIGINT` or `SIGTERM` cancellation paths. +- **AC4:** Git adapter cancellation is test-covered at the process boundary. +- **AC5:** `pnpm --filter @prisma/cli test -- app-local-dev.test.ts git-adapter.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. + +### Phase 5: Filesystem And Token Storage Boundaries + +**Status:** ☐ Not started + +**Goal:** Push cancellation through local filesystem and credential-storage helpers while documenting unsupported external boundaries. + +**Requirements:** FR14, FR15, FR16, FR17, FR19, NFR1, NFR4, NFR5 + +**Changes:** + +- **C1 Native signal filesystem calls:** Convert `readFile` and `writeFile` string-encoding calls to object-form calls with `{ encoding: "utf8", signal }` in `packages/cli/src/adapters/local-state.ts`, `packages/cli/src/adapters/mock-api.ts`, `packages/cli/src/lib/project/local-pin.ts`, `packages/cli/src/lib/project/resolution.ts`, `packages/cli/src/lib/app/bun-project.ts`, `packages/cli/src/lib/app/preview-build.ts`, and `packages/cli/src/controllers/app.ts` where those helpers are in scope. +- **C2 Unsupported filesystem calls:** Add immediate cancellation checks and boundary comments around unsupported Node filesystem promise calls such as `access`, `copyFile`, `cp`, `lstat`, `mkdir`, `open`, `readdir`, `readlink`, `rm`, and `stat`. +- **C3 Read-only post-checks:** Add post-I/O cancellation checks for unsupported read-only operations before returning their result. +- **C4 Token storage:** Update `packages/cli/src/adapters/token-storage.ts` so public adapter methods accept and propagate the signal, lock acquisition uses signal-aware sleep, and `CredentialsStore` calls are guarded with boundary comments because the store cannot consume `AbortSignal`. +- **C5 OAuth/browser helpers:** Guard `open` and any OAuth SDK/browser/listener boundary that cannot consume `AbortSignal` with the unsupported boundary rule. +- **C6 Tests:** Extend `packages/cli/tests/token-storage.test.ts`, `packages/cli/tests/app-state.test.ts`, `packages/cli/tests/app-bun-compat.test.ts`, and relevant project/app tests for aborted filesystem and token-storage paths. + +**Acceptance Criteria:** + +- **AC1:** Supported `readFile` and `writeFile` calls receive the command signal where reachable from command execution. +- **AC2:** Unsupported filesystem and credential-store boundaries have immediate abort checks and short comments at the boundary. +- **AC3:** Token refresh-lock wait exits promptly on abort. +- **AC4:** No local race-based cancellation wrappers are introduced. +- **AC5:** `pnpm --filter @prisma/cli test -- token-storage.test.ts app-state.test.ts app-bun-compat.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. + +### Phase 6: End-To-End Verification And Cleanup + +**Status:** ☐ Not started + +**Goal:** Prove cancellation behavior across the CLI surface and remove inconsistencies left by incremental propagation. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, FR12, FR13, FR14, FR15, FR16, FR17, FR18, FR19, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6, NFR7 + +**Changes:** + +- **C1 Full audit:** Search for remaining async I/O boundaries without a propagated signal or deliberate unsupported-boundary guard. +- **C2 Error audit:** Ensure no controller maps cancellation into `RUN_FAILED`, `DEPLOY_FAILED`, auth errors, usage errors, or raw thrown errors. +- **C3 Stream audit:** Verify regular and streaming command output both use the documented cancellation envelopes. +- **C4 Type cleanup:** Remove redundant optional signal plumbing where the signal is always available from `CliRuntime`, keeping only options objects that carry real optional behavior. +- **C5 Documentation cleanup:** Ensure `docs/product/error-conventions.md` and architecture notes do not conflict with the resolved spec decisions. +- **C6 Full verification:** Run the CLI test suite and build. + +**Acceptance Criteria:** + +- **AC1:** No remaining CLI-owned polling loop uses a non-signal-aware sleep. +- **AC2:** No supported SDK, child-process, or filesystem boundary lacks the propagated command signal where the upstream API accepts it. +- **AC3:** Unsupported boundaries are guarded and documented locally without fake cancellation wrappers. +- **AC4:** Human and JSON cancellation output are stable for regular and streaming commands. +- **AC5:** `pnpm --filter @prisma/cli test` passes. +- **AC6:** `pnpm --filter @prisma/cli build` passes. + +## Revision Log diff --git a/.agents/projects/cli-cancellation-propagation-analysis.spec.md b/.agents/projects/cli-cancellation-propagation-analysis.spec.md new file mode 100644 index 0000000..41c7531 --- /dev/null +++ b/.agents/projects/cli-cancellation-propagation-analysis.spec.md @@ -0,0 +1,122 @@ +# CLI Cancellation Propagation Spec + +## Problem + +The CLI does not model cancellation as a first-class runtime concern. Long-running commands, network-heavy workflows, streaming logs, polling loops, filesystem operations, and local child processes mostly rely on process termination or library-specific behavior when users interrupt execution. + +This creates inconsistent outcomes: some operations stop promptly, some continue until their current I/O finishes, some surface raw abort exceptions, and some depend on whether the underlying SDK or Node API happens to observe cancellation. The CLI needs one cancellation model that starts at the process boundary, propagates through command execution, and reaches every supported I/O boundary that can honor it. + +Success means a keyboard interrupt or process termination signal reliably stops in-flight command work, preserves automation-friendly output contracts, and reports one stable cancellation error shape instead of leaking raw implementation errors. + +The case against this work is that normal process termination already stops most user-visible work. That is insufficient for this CLI because app deployment, log streaming, polling, credential refresh, local process execution, and future workflow expansion all depend on explicit runtime contracts rather than accidental process exit behavior. + +## Stakeholders + +- **S1 CLI users:** Need `Ctrl-C` and termination signals to stop long-running commands predictably without confusing raw errors. +- **S2 Automation and CI users:** Need stable structured cancellation output so agents and scripts can distinguish user cancellation from operational failure. +- **S3 CLI maintainers:** Need a single propagation rule that prevents each controller, adapter, or SDK wrapper from inventing cancellation behavior. +- **S4 SDK and platform integrators:** Need the CLI to pass available `AbortSignal` values into SDK calls that already support cancellation. + +## Functional Requirements + +- **FR1 Entry cancellation:** The CLI must create exactly one command-lifetime cancellation source at the application entry boundary and use it as the root cancellation source for the command invocation. + +- **FR2 Signal mapping:** Keyboard and process cancellation signals must be converted into command cancellation at the application entry boundary. Cancellation signal handling must not be reimplemented by controllers, adapters, providers, or command handlers. + +- **FR3 Runtime availability:** Every command execution context must expose the current command cancellation signal so command handlers and their dependencies can observe the same cancellation state. + +- **FR4 Central error shape:** Cancellation must map to one stable CLI error envelope at the command execution boundary for both regular commands and streaming commands. The envelope must use stable error code `COMMAND_CANCELED` and must be distinguishable from usage errors, authentication failures, operational failures, and internal bugs. + +- **FR5 Human cancellation output:** Human-readable cancellation output must clearly state that the command was canceled and avoid implying an underlying platform, build, auth, or filesystem failure. + +- **FR6 JSON cancellation output:** Commands run with `--json` must emit the documented failure envelope with `ok: false`, the command identifier, stable cancellation code, domain, severity, summary, and empty recovery fields when no user action is needed. + +- **FR7 Streaming cancellation output:** Streaming commands must report cancellation through the streaming error event shape rather than a raw exception, partial success event, or unformatted process exit. + +- **FR8 SDK cancellation:** All Management API and Compute SDK calls that support cancellation must receive the command cancellation signal for each operation started during command execution. + +- **FR9 Build and deploy cancellation:** App deployment workflows must propagate cancellation through app lookup, deployment creation, build capability checks, build execution, archive/upload work, HTTP calls, polling, and post-deploy status checks where the underlying boundary can observe cancellation. + +- **FR10 Log streaming cancellation:** App log streaming must stop promptly when canceled and normalize SDK cancellation results into the central CLI cancellation outcome. + +- **FR11 Polling cancellation:** Every internal polling loop must check cancellation before beginning another poll and must use sleeps that reject promptly when canceled. + +- **FR12 Local process cancellation:** Local app execution must propagate cancellation to child process execution and normalize abort-related process outcomes into the central CLI cancellation outcome. + +- **FR13 Git process cancellation:** Git adapter operations that spawn local processes must observe command cancellation and stop subprocess work when supported by the process boundary. + +- **FR14 Filesystem cancellation:** Internal filesystem helpers must accept and propagate the command cancellation signal. Filesystem operations with native `AbortSignal` support must use it. Unsupported filesystem operations must check cancellation immediately before invoking external I/O. + +- **FR15 Unsupported boundary rule:** Internal APIs must propagate the signal until the exact external I/O boundary. If that boundary cannot consume `AbortSignal`, the implementation must deliberately stop propagation there, check cancellation immediately before the call, and document that the external API cannot consume the signal. + +- **FR16 Read-only unsupported I/O:** Unsupported read-only I/O boundaries with no dangerous cancellation side effects must also check cancellation after the awaited operation before returning the result. + +- **FR17 No fake cancellation:** The CLI must not wrap non-cancelable upstream APIs in local `Promise.race` shims to simulate cancellation. + +- **FR18 Prompt separation:** Prompt-library cancellation and keyboard/process cancellation must remain distinct. Prompt cancellation may continue to surface as usage-oriented behavior, while runtime cancellation must take the higher-priority command cancellation path. + +- **FR19 Existing behavior preservation:** Cancellation support must not change command names, command grouping, target resolution, branch semantics, output stream ownership, or success output shapes. + +## Non-Functional Requirements + +- **NFR1 Responsiveness:** Signal-aware sleeps and supported SDK/process/filesystem boundaries must react to cancellation without waiting for the next fixed polling interval to elapse. + +- **NFR2 Consistency:** The same cancellation source and error mapping must apply across command groups, including `auth`, `project`, `branch`, and `app`. + +- **NFR3 Automation safety:** Structured cancellation output must be stable enough for agents and CI to branch on `error.code`, not prose. + +- **NFR4 Maintainability:** Cancellation propagation must be represented in public internal types where possible so new command code receives type pressure to pass the signal forward. + +- **NFR5 Minimality:** The change must not add a cancellation abstraction layer beyond the platform-standard `AbortController` and `AbortSignal` unless a concrete unsupported boundary requires a small local helper. + +- **NFR6 Operational clarity:** Canceled commands must not be reported as platform failures, build failures, run failures, or deployment failures unless cancellation exposed a separate, completed failure before the cancellation was observed. + +- **NFR7 Cleanup:** Cancellation handling must not leave local child processes intentionally running after the parent CLI command has been canceled. + +## Assumptions + +- **A1 SDK baseline:** `@prisma/management-api-sdk@1.35.0` and `@prisma/compute-sdk@0.20.0` are the baseline dependencies for implementation, and their documented per-call cancellation support is available. + +- **A2 Runtime root:** The correct root cancellation source is the CLI entry boundary rather than each command boundary, because cancellation must apply uniformly to parsing-triggered execution paths and command handlers. + +- **A3 Signal coverage:** `SIGINT` and `SIGTERM` must both map to the same formatted command cancellation envelope. + +- **A4 Exit code:** Canceled commands must use exit code `130`. This intentionally departs from the current MVP exit-code set because cancellation has established shell semantics and should be recognizable to operators and process supervisors while `COMMAND_CANCELED` remains the structured branching surface for agents and CI. + +- **A5 Error domain:** Command cancellation should use the `cli` error domain because cancellation is initiated by the CLI runtime rather than by auth, project, branch, app, or platform state. + +- **A6 Existing architecture analysis:** `docs/architecture/cancellation-propagation-analysis.md` is source material for implementation planning, not the governing spec artifact. + +- **A7 No product command changes:** This work is runtime behavior only and does not require changes to the product command model. + +## Downstream Effects + +- **DE1 Implementation surface:** Many signatures across controllers, libraries, adapters, providers, and local helper functions will need to accept cancellation options. This is deliberate because shallow cancellation creates false confidence. + +- **DE2 Test fixtures:** Tests that construct `CliRuntime` or command contexts will need to provide or inherit a command signal. This should improve testability by making cancellation behavior explicit. + +- **DE3 Error docs:** The product error conventions will need a new stable cancellation code and an explicit cancellation exit-code exception. + +- **DE4 Streaming behavior:** Consumers of JSON streaming output will see a structured error event on cancellation rather than relying on process interruption or raw abort output. + +- **DE5 Maintenance burden:** New I/O helpers must decide whether their external boundary supports `AbortSignal`; the unsupported boundary rule keeps that decision local and reviewable. + +- **DE6 Partial remote effects:** Cancellation can stop waiting, polling, streaming, or local work, but it cannot guarantee rollback of remote operations already accepted by platform APIs. User-facing output must avoid promising rollback or no-op semantics. + +## Out Of Scope + +- **OOS1 Rollback semantics:** This work does not add rollback or compensation for deployments, environment updates, project creation, app deletion, or other remote operations already accepted by an API. + +- **OOS2 New command surface:** This work does not add commands, flags, aliases, namespaces, or shortcuts. + +- **OOS3 Product workflow changes:** This work does not alter project, branch, app, repository, deployment, or domain resolution semantics. + +- **OOS4 Prompt redesign:** This work does not redesign prompt cancellation behavior beyond preserving separation from runtime signal cancellation. + +- **OOS5 Upstream SDK behavior:** This work does not patch external SDKs or Node APIs that cannot consume `AbortSignal`. + +- **OOS6 Fake cancellation:** This work does not simulate cancellation for unsupported external operations with local racing wrappers. + +## Open Questions + +None. From 6ea0137e2f4342fd9a54ad19606c1a05c0f0fcbd Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 16:44:03 -0400 Subject: [PATCH 03/20] Fix standalone symlink escape validation --- .agents/diary/cli-cancellation-propagation.md | 2 ++ packages/cli/src/lib/app/preview-build.ts | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .agents/diary/cli-cancellation-propagation.md diff --git a/.agents/diary/cli-cancellation-propagation.md b/.agents/diary/cli-cancellation-propagation.md new file mode 100644 index 0000000..479c12f --- /dev/null +++ b/.agents/diary/cli-cancellation-propagation.md @@ -0,0 +1,2 @@ +Out-of-scope decision: `pnpm install` ran the repository `prepare` script and generated `.agents/skills` symlinks. These are local setup artifacts, so they should remain uncommitted unless the operator explicitly asks to version them. +Unrelated bug: Phase 1 targeted verification unexpectedly ran the full Vitest suite and exposed an `app-build.test.ts` failure where a standalone symlink escape was not rejected. This is outside cancellation propagation, but it blocks the requested all-verifications-green commit flow unless confirmed pre-existing or fixed separately. diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index 98a55a2..1ffc032 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -330,6 +330,10 @@ function isPathWithin(rootPath: string, candidatePath: string): boolean { ); } +function isPathWithinWorkspaceDependency(sourceRoot: string, candidatePath: string): boolean { + return isPathWithin(path.join(sourceRoot, "node_modules"), candidatePath); +} + async function copyPathMaterializingSymlinks( sourcePath: string, destinationPath: string, @@ -386,7 +390,7 @@ async function resolveSymlinkTarget( if (await pathExists(resolvedTarget)) { if ( !isPathWithin(options.appRoot, resolvedTarget) && - !isPathWithin(options.sourceRoot, resolvedTarget) + !isPathWithinWorkspaceDependency(options.sourceRoot, resolvedTarget) ) { throw new Error(`Build artifact symlink escapes the app directory: ${resolvedTarget}`); } From 555a670e2b5d80521c592482c9fa4ddcdd518ba6 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 16:45:09 -0400 Subject: [PATCH 04/20] Add CLI cancellation runtime root --- ...-cancellation-propagation-analysis.plan.md | 12 +-- docs/product/error-conventions.md | 5 ++ packages/cli/src/bin.ts | 16 +++- packages/cli/src/cli.ts | 1 + packages/cli/src/shell/command-runner.ts | 20 ++++- packages/cli/src/shell/errors.ts | 12 +++ packages/cli/src/shell/runtime.ts | 1 + .../cli/tests/command-runner-auth.test.ts | 76 +++++++++++++++++++ packages/cli/tests/helpers.ts | 2 + 9 files changed, 134 insertions(+), 11 deletions(-) diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md index 08499ad..bcffb6c 100644 --- a/.agents/projects/cli-cancellation-propagation-analysis.plan.md +++ b/.agents/projects/cli-cancellation-propagation-analysis.plan.md @@ -17,7 +17,7 @@ None. ### Phase 1: Runtime Root And Central Cancellation Error -**Status:** ☐ Not started +**Status:** ✓ Complete **Goal:** Establish a thin end-to-end cancellation path from CLI entry to command-runner error output before changing deeper I/O code. @@ -35,11 +35,11 @@ None. **Acceptance Criteria:** -- **AC1:** A handler that aborts through `runtime.signal` returns a formatted `COMMAND_CANCELED` error for regular commands. -- **AC2:** A streaming handler that aborts through `runtime.signal` emits a streaming error event instead of raw exception output. -- **AC3:** Cancellation exits with code `130` and does not alter success output shapes. -- **AC4:** Existing CLI shell tests pass with no command surface changes. -- **AC5:** `pnpm --filter @prisma/cli test -- shell.test.ts command-runner-auth.test.ts prompt.test.ts` passes, or equivalent targeted Vitest filters if filenames change. +- [x] **AC1:** A handler that aborts through `runtime.signal` returns a formatted `COMMAND_CANCELED` error for regular commands. +- [x] **AC2:** A streaming handler that aborts through `runtime.signal` emits a streaming error event instead of raw exception output. +- [x] **AC3:** Cancellation exits with code `130` and does not alter success output shapes. +- [x] **AC4:** Existing CLI shell tests pass with no command surface changes. +- [x] **AC5:** `pnpm --filter @prisma/cli test -- shell.test.ts command-runner-auth.test.ts prompt.test.ts` passes, or equivalent targeted Vitest filters if filenames change. ### Phase 2: Command And Controller Signal Plumbing diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index fa50e6c..7ea7d66 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -195,6 +195,7 @@ These codes are the minimum stable set for the MVP: - `RUN_FAILED` - `DEPLOY_FAILED` - `VERSION_UNAVAILABLE` +- `COMMAND_CANCELED` Recommended meanings: @@ -235,6 +236,7 @@ Recommended meanings: - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully - `DEPLOY_FAILED`: deployment or post-build health failed - `VERSION_UNAVAILABLE`: CLI could not read its own bundled package metadata to report a version (defensive; not expected in normal installs) +- `COMMAND_CANCELED`: command execution was canceled by a runtime cancellation signal such as `SIGINT` or `SIGTERM` ## Exit Codes @@ -243,9 +245,12 @@ The MVP should use these process exit codes: - `0`: success - `1`: runtime or command failure - `2`: usage or configuration error +- `130`: command cancellation Stable structured error codes, not exit code granularity, are the main branching surface for agents and CI. +Cancellation intentionally uses `130` instead of the generic runtime failure code because it has established shell semantics for interrupted commands and is useful to operators and process supervisors. Agents and CI should still branch on `COMMAND_CANCELED` rather than the numeric exit code. + ## Production Safety Production-related failures should fail closed. diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 76adf97..a7e12e5 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -3,6 +3,20 @@ import process from "node:process"; import { runCli } from "./cli"; -runCli().then((exitCode) => { +const controller = new AbortController(); + +const abortCli = () => { + if (!controller.signal.aborted) { + controller.abort(new DOMException("Command canceled", "AbortError")); + } +}; + +process.once("SIGINT", abortCli); +process.once("SIGTERM", abortCli); + +runCli({ signal: controller.signal }).then((exitCode) => { process.exitCode = exitCode; +}).finally(() => { + process.off("SIGINT", abortCli); + process.off("SIGTERM", abortCli); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5a30677..3416e92 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -152,6 +152,7 @@ function resolveRuntime(options: RunCliOptions): CliRuntime { argv: options.argv ?? process.argv.slice(2), cwd: options.cwd ?? process.env.INIT_CWD ?? process.cwd(), env: options.env ?? process.env, + signal: options.signal ?? new AbortController().signal, stdin: options.stdin ?? process.stdin, stdout: options.stdout ?? process.stdout, stderr: options.stderr ?? process.stderr, diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index 325fe51..de61089 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -1,7 +1,7 @@ import { AuthError as SDKAuthError } from "@prisma/management-api-sdk"; import type { CommandDescriptor } from "./command-meta"; import { getCommandDescriptor } from "./command-meta"; -import { authRequiredError, CliError } from "./errors"; +import { authRequiredError, CliError, commandCanceledError } from "./errors"; import { resolveGlobalFlags } from "./global-flags"; import type { CommandSuccess } from "./output"; import { cliErrorToJson, writeHumanError, writeHumanLines, writeJsonError, writeJsonEvent, writeJsonSuccess } from "./output"; @@ -16,9 +16,13 @@ interface CommandPresenter { renderJson?: (result: T) => unknown; } -function toCliError(error: unknown): CliError | null { +function toCliError(error: unknown, runtime: CliRuntime): CliError | null { if (error instanceof CliError) return error; + if (isCancellationError(error) || runtime.signal.aborted) { + return commandCanceledError(); + } + if (error instanceof SDKAuthError) { return authRequiredError(["prisma-cli auth login"], { debug: error.message }); } @@ -26,6 +30,14 @@ function toCliError(error: unknown): CliError | null { return null; } +function isCancellationError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return error.name === "AbortError" || error.name === "CancelledError"; +} + export async function runCommand( runtime: CliRuntime, commandName: string, @@ -54,7 +66,7 @@ export async function runCommand( writeHumanLines(context.output, presenter.renderHuman(context, descriptor, success.result)); } catch (error) { - const cliError = toCliError(error); + const cliError = toCliError(error, runtime); if (cliError) { if (flags.json) { writeJsonError(context.output, commandName, cliError); @@ -94,7 +106,7 @@ export async function runStreamingCommand( }); } } catch (error) { - const cliError = toCliError(error); + const cliError = toCliError(error, runtime); if (cliError) { if (flags.json) { writeJsonEvent(context.output, { diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index c85d62a..a96c031 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -91,6 +91,18 @@ export function authRequiredError( }); } +export function commandCanceledError(): CliError { + return new CliError({ + code: "COMMAND_CANCELED", + domain: "cli", + summary: "Command canceled", + why: null, + fix: null, + exitCode: 130, + humanLines: ["Command canceled [COMMAND_CANCELED]"], + }); +} + export function workspaceRequiredError(): CliError { return usageError( "Workspace required", diff --git a/packages/cli/src/shell/runtime.ts b/packages/cli/src/shell/runtime.ts index 692caa4..ff07cf1 100644 --- a/packages/cli/src/shell/runtime.ts +++ b/packages/cli/src/shell/runtime.ts @@ -14,6 +14,7 @@ export const DEFAULT_STATE_DIR_NAME = path.join(".prisma", "cli"); export interface CliRuntime { cwd: string; argv: string[]; + signal: AbortSignal; stdin: NodeJS.ReadStream; stdout: NodeJS.WriteStream; stderr: NodeJS.WriteStream; diff --git a/packages/cli/tests/command-runner-auth.test.ts b/packages/cli/tests/command-runner-auth.test.ts index 6859c42..8ac754f 100644 --- a/packages/cli/tests/command-runner-auth.test.ts +++ b/packages/cli/tests/command-runner-auth.test.ts @@ -48,6 +48,7 @@ async function createRuntime(argv: string[]): Promise<{ argv, cwd: await createTempCwd(), env: { ...process.env }, + signal: new AbortController().signal, stdin: stdin as unknown as NodeJS.ReadStream, stdout: stdout as unknown as NodeJS.WriteStream, stderr: stderr as unknown as NodeJS.WriteStream, @@ -84,6 +85,61 @@ describe("command runner auth errors", () => { expect(stderr.buffer).not.toContain("invalid_grant"); }); + it("renders abort failures as structured CLI cancellation errors", async () => { + const { runtime, stdout } = await createRuntime(["--json", "app", "deploy"]); + + await runCommand( + runtime, + "app.deploy", + {}, + async () => { + throw new DOMException("The operation was aborted", "AbortError"); + }, + { + renderHuman: () => [], + }, + ); + + expect(process.exitCode).toBe(130); + expect(JSON.parse(stdout.buffer)).toMatchObject({ + ok: false, + command: "app.deploy", + error: { + code: "COMMAND_CANCELED", + domain: "cli", + summary: "Command canceled", + why: null, + fix: null, + }, + nextSteps: [], + nextActions: [], + }); + }); + + it("renders aborted runtime failures as human CLI cancellation errors", async () => { + const { runtime, stderr } = await createRuntime(["app", "deploy"]); + const controller = new AbortController(); + runtime.signal = controller.signal; + + await runCommand( + runtime, + "app.deploy", + {}, + async () => { + controller.abort(); + throw new Error("raw implementation error"); + }, + { + renderHuman: () => [], + }, + ); + + expect(process.exitCode).toBe(130); + expect(stderr.buffer).toContain("Command canceled [COMMAND_CANCELED]"); + expect(stderr.buffer).not.toContain("raw implementation error"); + expect(stderr.buffer).not.toContain("More: Re-run with --trace"); + }); + it("shows SDK auth details only with trace enabled", async () => { const { runtime, stderr } = await createRuntime(["auth", "whoami", "--trace"]); @@ -121,4 +177,24 @@ describe("command runner auth errors", () => { nextSteps: ["prisma-cli auth login"], }); }); + + it("renders streaming abort failures as JSON cancellation events", async () => { + const { runtime, stdout } = await createRuntime(["--json", "app", "logs"]); + + await runStreamingCommand(runtime, "app.logs", {}, async () => { + throw new DOMException("The operation was aborted", "AbortError"); + }); + + expect(process.exitCode).toBe(130); + expect(JSON.parse(stdout.buffer)).toMatchObject({ + type: "error", + command: "app.logs", + error: { + code: "COMMAND_CANCELED", + domain: "cli", + }, + nextSteps: [], + nextActions: [], + }); + }); }); diff --git a/packages/cli/tests/helpers.ts b/packages/cli/tests/helpers.ts index b3aebdf..2da9db3 100644 --- a/packages/cli/tests/helpers.ts +++ b/packages/cli/tests/helpers.ts @@ -61,6 +61,7 @@ export async function executeCli(options: { argv: options.argv, cwd: options.cwd, env, + signal: new AbortController().signal, fixturePath: options.fixturePath, stateDir: options.stateDir, stdin: stdin as unknown as NodeJS.ReadStream, @@ -115,6 +116,7 @@ export async function createTestCommandContext(options: { argv: options.argv ?? [], cwd: options.cwd ?? (await createTempCwd()), env: createTestEnv(options.env, options.preserveCI), + signal: new AbortController().signal, fixturePath: options.fixturePath, stateDir: options.stateDir, stdin: stdin as unknown as NodeJS.ReadStream, From 37a5e2dfcc237a6427d9b75e8b58f728cf5b0a78 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 16:49:26 -0400 Subject: [PATCH 05/20] Thread cancellation through app controller --- .../cli-cancellation-propagation-analysis.plan.md | 10 +++++----- packages/cli/src/controllers/app.ts | 6 +++++- packages/cli/src/lib/app/local-dev.ts | 1 + packages/cli/src/lib/app/preview-build.ts | 2 ++ packages/cli/src/lib/app/preview-provider.ts | 3 ++- packages/cli/tests/app-controller.test.ts | 7 ++++++- packages/cli/tests/app-local-dev.test.ts | 3 +++ 7 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md index bcffb6c..b5a79a0 100644 --- a/.agents/projects/cli-cancellation-propagation-analysis.plan.md +++ b/.agents/projects/cli-cancellation-propagation-analysis.plan.md @@ -43,7 +43,7 @@ None. ### Phase 2: Command And Controller Signal Plumbing -**Status:** ☐ Not started +**Status:** ✓ Complete **Goal:** Thread the command signal through controller and command-handler boundaries so deeper I/O phases can consume it without broad follow-up signature churn. @@ -60,10 +60,10 @@ None. **Acceptance Criteria:** -- **AC1:** TypeScript requires new async I/O call sites in controllers/libs/adapters to consciously accept or ignore a signal. -- **AC2:** Existing controller tests pass after runtime construction updates. -- **AC3:** No command handler installs OS signal listeners or creates a command-lifetime controller. -- **AC4:** `pnpm --filter @prisma/cli test -- auth.test.ts project.test.ts app.test.ts branch.test.ts` passes, or equivalent targeted filters if filenames change. +- [x] **AC1:** TypeScript requires new async I/O call sites in controllers/libs/adapters to consciously accept or ignore a signal. +- [x] **AC2:** Existing controller tests pass after runtime construction updates. +- [x] **AC3:** No command handler installs OS signal listeners or creates a command-lifetime controller. +- [x] **AC4:** `pnpm --filter @prisma/cli test -- auth.test.ts project.test.ts app.test.ts branch.test.ts` passes, or equivalent targeted filters if filenames change. ### Phase 3: SDK And Provider Cancellation diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index bc9bde8..a8f2629 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -124,6 +124,7 @@ export async function runAppBuild( appPath: context.runtime.cwd, entrypoint, buildType, + signal: context.runtime.signal, }); return { @@ -180,6 +181,7 @@ export async function runAppRun( entrypoint, port, env: context.runtime.env, + signal: context.runtime.signal, }); } catch (error) { throw runFailedError("Local app run failed", error); @@ -323,6 +325,7 @@ export async function runAppDeploy( portMapping, envVars, interaction: undefined, + signal: context.runtime.signal, progress: createPreviewDeployProgress(context.output.stderr, context.ui, !context.flags.json && !context.flags.quiet, progressState), }).catch((error) => { throw appDeployFailedError(error, progressState); @@ -850,6 +853,7 @@ export async function runAppLogs( await provider.streamDeploymentLogs({ deploymentId: target.deployment.id, + signal: context.runtime.signal, onRecord: (record) => writeLogRecord(context, record), }).catch((error) => { throw deployFailedError("Failed to stream app logs", error, [ @@ -2105,7 +2109,7 @@ async function listApps( projectId: string, branchName?: string, ) { - return provider.listApps(projectId, { branchName }).then(sortApps).catch((error) => { + return provider.listApps(projectId, { branchName, signal: context.runtime.signal }).then(sortApps).catch((error) => { if (isMissingProjectError(error)) { throw new CliError({ code: "PROJECT_NOT_FOUND", diff --git a/packages/cli/src/lib/app/local-dev.ts b/packages/cli/src/lib/app/local-dev.ts index 456f29a..bb9a437 100644 --- a/packages/cli/src/lib/app/local-dev.ts +++ b/packages/cli/src/lib/app/local-dev.ts @@ -64,6 +64,7 @@ export async function runLocalApp(options: { entrypoint?: string; port: number; env: NodeJS.ProcessEnv; + signal?: AbortSignal; spawnImpl?: typeof spawn; }): Promise { const spawnImpl = options.spawnImpl ?? spawn; diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index 1ffc032..55db991 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -64,6 +64,7 @@ export async function executePreviewBuild(options: { appPath: string; entrypoint?: string; buildType?: PreviewBuildType; + signal?: AbortSignal; }): Promise<{ artifact: BuildArtifact; buildType: ResolvedPreviewBuildType; @@ -95,6 +96,7 @@ export async function resolvePreviewBuildStrategy(options: { appPath: string; entrypoint?: string; buildType: PreviewBuildType; + signal?: AbortSignal; }): Promise<{ strategy: BuildStrategy; buildType: ResolvedPreviewBuildType; diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 3129049..cb38601 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -105,7 +105,7 @@ export class PreviewDomainApiError extends Error { export interface PreviewAppProvider { createProject(options: { name: string }): Promise; - listApps(projectId: string, options?: { branchName?: string }): Promise; + listApps(projectId: string, options?: { branchName?: string; signal?: AbortSignal }): Promise; removeApp(appId: string): Promise; listDomains(appId: string): Promise; addDomain(options: { appId: string; hostname: string }): Promise<{ domain: PreviewDomainRecord; existing: boolean }>; @@ -129,6 +129,7 @@ export interface PreviewAppProvider { portMapping?: PortMapping; envVars?: Record; interaction?: unknown; + signal?: AbortSignal; progress?: unknown; }): Promise; updateAppEnv(options: { diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 7f6e84e..ef9d0d4 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -1033,7 +1033,10 @@ describe("app controller", () => { projectRef: "proj_my_app", }); - expect(listApps).toHaveBeenCalledWith("proj_my_app", { branchName: "feat-j1" }); + expect(listApps).toHaveBeenCalledWith("proj_my_app", { + branchName: "feat-j1", + signal: context.runtime.signal, + }); expect(deployApp).toHaveBeenCalledWith( expect.objectContaining({ projectId: "proj_my_app", @@ -1041,6 +1044,7 @@ describe("app controller", () => { appName: "my-app", buildType: "nextjs", portMapping: { http: 3000 }, + signal: context.runtime.signal, }), ); expect(result.result).toMatchObject({ @@ -4222,6 +4226,7 @@ describe("app controller", () => { expect(streamDeploymentLogs).toHaveBeenCalledWith(expect.objectContaining({ deploymentId: "dep_live", + signal: context.runtime.signal, })); expect(stdout.buffer).toBe("hello from live\n"); }); diff --git a/packages/cli/tests/app-local-dev.test.ts b/packages/cli/tests/app-local-dev.test.ts index 61219bd..a2bda4c 100644 --- a/packages/cli/tests/app-local-dev.test.ts +++ b/packages/cli/tests/app-local-dev.test.ts @@ -44,6 +44,7 @@ describe("app local dev commands", () => { appPath: cwd, entrypoint: "server.ts", buildType: "bun", + signal: context.runtime.signal, }); expect(result.result).toEqual({ directory: "/tmp/compute-build/app", @@ -86,6 +87,7 @@ describe("app local dev commands", () => { appPath: cwd, entrypoint: undefined, buildType: "astro", + signal: context.runtime.signal, }); expect(result.result).toEqual({ directory: "/tmp/compute-build/app", @@ -228,6 +230,7 @@ describe("app local dev commands", () => { entrypoint: "server.ts", port: 4000, env: context.runtime.env, + signal: context.runtime.signal, }); expect(result.result).toEqual({ framework: "bun", From 60a7dbf891e0828bba48877c11695c97c3556629 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 16:58:46 -0400 Subject: [PATCH 06/20] Forward cancellation to SDK calls --- ...-cancellation-propagation-analysis.plan.md | 12 +-- packages/cli/src/controllers/app-env.ts | 5 +- packages/cli/src/controllers/app.ts | 55 ++++++----- packages/cli/src/controllers/auth.ts | 10 +- packages/cli/src/controllers/project.ts | 18 ++-- packages/cli/src/lib/app/preview-build.ts | 17 ++-- packages/cli/src/lib/app/preview-provider.ts | 96 ++++++++++++------- packages/cli/src/lib/auth/auth-ops.ts | 17 +++- packages/cli/tests/app-controller.test.ts | 17 +++- packages/cli/tests/auth-real-mode.test.ts | 2 +- packages/cli/tests/project-controller.test.ts | 10 +- packages/cli/tests/project-real-mode.test.ts | 2 +- 12 files changed, 163 insertions(+), 98 deletions(-) diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md index b5a79a0..37efdf0 100644 --- a/.agents/projects/cli-cancellation-propagation-analysis.plan.md +++ b/.agents/projects/cli-cancellation-propagation-analysis.plan.md @@ -67,7 +67,7 @@ None. ### Phase 3: SDK And Provider Cancellation -**Status:** ☐ Not started +**Status:** ✓ Complete **Goal:** Propagate cancellation through Management API and Compute SDK boundaries, especially app deploy and logs. @@ -83,11 +83,11 @@ None. **Acceptance Criteria:** -- **AC1:** Representative Management API calls receive the same `AbortSignal` from command context. -- **AC2:** Representative Compute SDK calls receive the same `AbortSignal` from command context. -- **AC3:** App log stream cancellation produces `COMMAND_CANCELED` through command-runner mapping. -- **AC4:** Preview build strategy methods accept and forward the signal without changing build selection behavior. -- **AC5:** `pnpm --filter @prisma/cli test -- app-provider.test.ts app-build.test.ts auth-ops.test.ts app-env.test.ts project.test.ts` passes, or equivalent targeted filters if filenames change. +- [x] **AC1:** Representative Management API calls receive the same `AbortSignal` from command context. +- [x] **AC2:** Representative Compute SDK calls receive the same `AbortSignal` from command context. +- [x] **AC3:** App log stream cancellation produces `COMMAND_CANCELED` through command-runner mapping. +- [x] **AC4:** Preview build strategy methods accept and forward the signal without changing build selection behavior. +- [x] **AC5:** `pnpm --filter @prisma/cli test -- app-provider.test.ts app-build.test.ts auth-ops.test.ts app-env.test.ts project.test.ts` passes, or equivalent targeted filters if filenames change. ### Phase 4: Polling, Sleeps, And Local Processes diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index f078217..6de2fc1 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -116,6 +116,7 @@ export async function runEnvAdd( key, value, }, + signal: context.runtime.signal, }, ); if (error || !data) { @@ -177,6 +178,7 @@ export async function runEnvUpdate( { params: { path: { envVarId: existing.id } }, body: { value }, + signal: context.runtime.signal, }, ); if (error || !data) { @@ -271,6 +273,7 @@ export async function runEnvRemove( "/v1/environment-variables/{envVarId}", { params: { path: { envVarId: existing.id } }, + signal: context.runtime.signal, }, ); if (error) { @@ -307,7 +310,7 @@ async function requireClientAndProject( context, workspace: authState.workspace, explicitProject, - listProjects: () => listRealWorkspaceProjects(client, authState.workspace!), + listProjects: () => listRealWorkspaceProjects(client, authState.workspace!, context.runtime.signal), commandName, }); diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index a8f2629..b5daad8 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -384,7 +384,7 @@ export async function runAppListDeploys( }; } - const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { + const deploymentsResult = await provider.listDeployments(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to list app deployments", error, ["prisma-cli app deploy"]); }); const currentLiveDeploymentId = await resolveCurrentLiveDeploymentId( @@ -447,7 +447,7 @@ export async function runAppShow( }; } - const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { + const deploymentsResult = await provider.listDeployments(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to inspect app", error, ["prisma-cli app list-deploys"]); }); const currentLiveDeploymentId = await resolveCurrentLiveDeploymentId( @@ -492,7 +492,7 @@ export async function runAppShowDeploy( ensurePreviewAppMode(context); const provider = await requirePreviewAppProvider(context); - const deployment = await provider.showDeployment(deploymentId).catch((error) => { + const deployment = await provider.showDeployment(deploymentId, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to show deployment", error, ["prisma-cli app list-deploys"]); }); @@ -558,7 +558,7 @@ export async function runAppOpen( ); } - const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { + const deploymentsResult = await provider.listDeployments(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to resolve app URL", error, ["prisma-cli app show"]); }); const currentLiveDeploymentId = await resolveCurrentLiveDeploymentId( @@ -632,6 +632,7 @@ export async function runAppDomainAdd( const added = await target.provider.addDomain({ appId: target.app.id, hostname: normalizedHostname, + signal: context.runtime.signal, }).catch((error) => { throw domainCommandError("add", error, normalizedHostname); }); @@ -662,8 +663,8 @@ export async function runAppDomainShow( ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); const target = await resolveAppDomainTarget(context, options, `app domain show ${normalizedHostname}`); - const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "show"); - const detail = await target.provider.showDomain(domain.id).catch((error) => { + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "show", context.runtime.signal); + const detail = await target.provider.showDomain(domain.id, { signal: context.runtime.signal }).catch((error) => { throw domainCommandError("show", error, normalizedHostname); }); @@ -689,11 +690,11 @@ export async function runAppDomainRemove( ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); const target = await resolveAppDomainTarget(context, options, `app domain remove ${normalizedHostname}`); - const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "remove"); + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "remove", context.runtime.signal); await confirmDomainRemoval(context, target.resultTarget, normalizedHostname); - await target.provider.removeDomain(domain.id).catch((error) => { + await target.provider.removeDomain(domain.id, { signal: context.runtime.signal }).catch((error) => { throw domainCommandError("remove", error, normalizedHostname); }); @@ -720,8 +721,8 @@ export async function runAppDomainRetry( ): Promise> { const normalizedHostname = normalizeDomainHostname(hostname); const target = await resolveAppDomainTarget(context, options, `app domain retry ${normalizedHostname}`); - const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "retry"); - const retried = await target.provider.retryDomain(domain.id).catch((error) => { + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "retry", context.runtime.signal); + const retried = await target.provider.retryDomain(domain.id, { signal: context.runtime.signal }).catch((error) => { throw domainCommandError("retry", error, normalizedHostname); }); @@ -749,7 +750,7 @@ export async function runAppDomainWait( const normalizedHostname = normalizeDomainHostname(hostname); const timeoutMs = parseDomainWaitTimeout(options?.timeout); const target = await resolveAppDomainTarget(context, options, `app domain wait ${normalizedHostname}`); - const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "wait"); + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "wait", context.runtime.signal); if (!context.flags.json && !context.flags.quiet) { context.output.stderr.write( @@ -814,7 +815,7 @@ export async function runAppDomainWait( } await sleep(Math.min(pollIntervalMs, Math.max(deadline - Date.now(), 0))); - current = await target.provider.showDomain(current.id).catch((error) => { + current = await target.provider.showDomain(current.id, { signal: context.runtime.signal }).catch((error) => { throw domainCommandError("wait", error, normalizedHostname); }); } @@ -882,7 +883,7 @@ async function resolveExplicitLogDeployment( ); } - const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { + const deploymentsResult = await provider.listDeployments(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to list app deployments", error, ["prisma-cli app list-deploys"]); }); const deployment = requireDeploymentForApp(deploymentsResult.deployments, deploymentId, selectedApp.name); @@ -898,7 +899,7 @@ async function resolveExplicitLogDeployment( }; } - const shown = await provider.showDeployment(deploymentId).catch((error) => { + const shown = await provider.showDeployment(deploymentId, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to show deployment", error, ["prisma-cli app list-deploys"]); }); @@ -968,7 +969,7 @@ async function resolveLiveLogDeployment( ); } - const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { + const deploymentsResult = await provider.listDeployments(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to list app deployments", error, ["prisma-cli app list-deploys"]); }); const currentLiveDeploymentId = await resolveCurrentLiveDeploymentId( @@ -1032,7 +1033,7 @@ export async function runAppPromote( }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await requireReleaseAppSelection(context, projectId, apps, appName, "promote"); - const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { + const deploymentsResult = await provider.listDeployments(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to list app deployments", error, ["prisma-cli app list-deploys"]); }); const currentLiveDeploymentId = await resolveCurrentLiveDeploymentId( @@ -1057,6 +1058,7 @@ export async function runAppPromote( await provider.promoteDeployment({ appId: selectedApp.id, deploymentId: targetDeployment.id, + signal: context.runtime.signal, progress: createPreviewPromoteProgress( context.output.stderr, !context.flags.json && !context.flags.quiet, @@ -1100,7 +1102,7 @@ export async function runAppRollback( }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await requireReleaseAppSelection(context, projectId, apps, appName, "rollback"); - const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { + const deploymentsResult = await provider.listDeployments(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw deployFailedError("Failed to list app deployments", error, ["prisma-cli app list-deploys"]); }); const currentLiveDeploymentId = await resolveCurrentLiveDeploymentId( @@ -1126,6 +1128,7 @@ export async function runAppRollback( await provider.promoteDeployment({ appId: selectedApp.id, deploymentId: targetDeployment.id, + signal: context.runtime.signal, progress: createPreviewPromoteProgress( context.output.stderr, !context.flags.json && !context.flags.quiet, @@ -1172,7 +1175,7 @@ export async function runAppRemove( await confirmAppRemoval(context, selectedApp); - const removedApp = await provider.removeApp(selectedApp.id).catch((error) => { + const removedApp = await provider.removeApp(selectedApp.id, { signal: context.runtime.signal }).catch((error) => { throw removeFailedError("Failed to remove app", error, ["prisma-cli app show", "prisma-cli app list-deploys"]); }); @@ -1306,8 +1309,9 @@ async function resolveDomainByHostname( appId: string, hostname: string, command: AppDomainCommand, + signal: AbortSignal, ): Promise { - const domains = await provider.listDomains(appId).catch((error) => { + const domains = await provider.listDomains(appId, { signal }).catch((error) => { throw domainCommandError(command, error, hostname); }); const matched = domains.find((domain) => sameDomainHostname(domain.hostname, hostname)); @@ -2246,7 +2250,7 @@ async function resolveProjectContext( workspace: authState.workspace, explicitProject, envProjectId: options?.envProjectId, - listProjects: () => listRealWorkspaceProjects(client, authState.workspace!), + listProjects: () => listRealWorkspaceProjects(client, authState.workspace!, context.runtime.signal), commandName: options?.commandName, }); const branch = options?.branch ?? await resolveDeployBranch(context, undefined); @@ -2279,7 +2283,7 @@ async function resolveDeployProjectContext( } const branch = options.branch ?? await resolveDeployBranch(context, undefined); - const projects = await listRealWorkspaceProjects(client, workspace); + const projects = await listRealWorkspaceProjects(client, workspace, context.runtime.signal); if (explicitProject) { const project = resolveProjectForSetup(explicitProject, projects, workspace); @@ -2301,7 +2305,7 @@ async function resolveDeployProjectContext( throw projectSetupNameRequiredError("app deploy --create-project"); } - const created = await createProjectForDeploySetup(provider, projectName, workspace); + const created = await createProjectForDeploySetup(provider, projectName, workspace, context.runtime.signal); return withDeployBranch({ workspace, project: toProjectSummary(created), @@ -2383,7 +2387,7 @@ async function resolveInteractiveDeployProjectSetup( const setup = await promptForProjectSetupChoice({ context, projects, - createProject: (projectName) => createProjectForDeploySetup(provider, projectName, workspace), + createProject: (projectName) => createProjectForDeploySetup(provider, projectName, workspace, context.runtime.signal), cancel: { why: "Deploy needs a Project before it can continue.", fix: "Choose an existing Project or create a new one, then rerun deploy.", @@ -2407,8 +2411,9 @@ async function createProjectForDeploySetup( provider: ReturnType, projectName: string, workspace: AuthWorkspace, + signal: AbortSignal, ): Promise { - const created = await provider.createProject({ name: projectName }).catch((error) => { + const created = await provider.createProject({ name: projectName, signal }).catch((error) => { throw projectCreateFailedError(error, projectName, workspace, { nextSteps: [ "prisma-cli project list", @@ -2966,7 +2971,7 @@ async function readCurrentWorkspaceId(context: CommandContext): Promise { if (isRealMode(context)) { - const current = await readAuthState(context.runtime.env); + const current = await readAuthState(context.runtime.env, context.runtime.signal); if (current.authenticated) { return current; } @@ -74,7 +74,7 @@ export async function requireAuthenticatedAuthState(context: CommandContext): Pr } await performLogin(context.runtime.env); - return readAuthState(context.runtime.env); + return readAuthState(context.runtime.env, context.runtime.signal); } const useCases = createAuthUseCases(createCliUseCaseGateways(context)); diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index d9fd1c3..e45542d 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -88,7 +88,7 @@ export async function runProjectList(context: CommandContext): Promise { + const created = await provider.createProject({ name, signal: context.runtime.signal }).catch((error) => { throw projectCreateFailedError(error, name, workspace, { nextSteps: ["prisma-cli project list", "prisma-cli project link "], permissionFix: "Grant the token permission to create Projects in this workspace, or link an existing Project.", @@ -231,7 +231,7 @@ export async function runProjectLink( throw authRequiredError(); } provider = createPreviewAppProvider(client); - projects = await listRealWorkspaceProjects(client, workspace); + projects = await listRealWorkspaceProjects(client, workspace, context.runtime.signal); } else { projects = listFixtureWorkspaceProjects(context, workspace); } @@ -278,7 +278,7 @@ async function resolveInteractiveProjectLinkSetup( "project", ); } - return createProjectForLinkSetup(provider, projectName, workspace); + return createProjectForLinkSetup(provider, projectName, workspace, context.runtime.signal); }, cancel: { why: "Project link needs a Project before it can continue.", @@ -294,8 +294,9 @@ async function createProjectForLinkSetup( provider: ReturnType, projectName: string, workspace: AuthWorkspace, + signal: AbortSignal, ): Promise { - const created = await provider.createProject({ name: projectName }).catch((error) => { + const created = await provider.createProject({ name: projectName, signal }).catch((error) => { throw projectCreateFailedError(error, projectName, workspace, { nextSteps: [ "prisma-cli project list", @@ -526,7 +527,7 @@ async function resolveProjectShowInRealMode( context, workspace, explicitProject, - listProjects: () => listRealWorkspaceProjects(client, workspace), + listProjects: () => listRealWorkspaceProjects(client, workspace, context.runtime.signal), commandName: "project show", }); } @@ -546,7 +547,7 @@ async function resolveRequiredProjectInRealMode( context, workspace, explicitProject, - listProjects: () => listRealWorkspaceProjects(client, workspace), + listProjects: () => listRealWorkspaceProjects(client, workspace, context.runtime.signal), commandName, }); } @@ -583,8 +584,9 @@ async function resolveRequiredProjectInFixtureMode( export async function listRealWorkspaceProjects( client: ManagementApiClient, workspace: AuthWorkspace, + signal?: AbortSignal, ): Promise { - const { data } = await client.GET("/v1/projects", {}); + const { data } = await client.GET("/v1/projects", { signal }); return sortProjects( (data?.data ?? []) .filter((project) => project.workspace.id === workspace.id) diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index 55db991..2b6de9f 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -32,28 +32,32 @@ export class PreviewBuildStrategy implements BuildStrategy { readonly #appPath: string; readonly #entrypoint?: string; readonly #buildType: PreviewBuildType; + readonly #signal?: AbortSignal; - constructor(options: { appPath: string; entrypoint?: string; buildType?: PreviewBuildType }) { + constructor(options: { appPath: string; entrypoint?: string; buildType?: PreviewBuildType; signal?: AbortSignal }) { this.#appPath = options.appPath; this.#entrypoint = options.entrypoint; this.#buildType = options.buildType ?? "auto"; + this.#signal = options.signal; } - async canBuild(): Promise { + async canBuild(signal = this.#signal): Promise { const { strategy } = await resolvePreviewBuildStrategy({ appPath: this.#appPath, entrypoint: this.#entrypoint, buildType: this.#buildType, + signal, }); - return strategy.canBuild(); + return strategy.canBuild(signal); } - async execute(): Promise { + async execute(signal = this.#signal): Promise { const { artifact } = await executePreviewBuild({ appPath: this.#appPath, entrypoint: this.#entrypoint, buildType: this.#buildType, + signal, }); return artifact; @@ -73,8 +77,9 @@ export async function executePreviewBuild(options: { appPath: options.appPath, entrypoint: options.entrypoint, buildType: options.buildType ?? "auto", + signal: options.signal, }); - const artifact = await strategy.execute(); + const artifact = await strategy.execute(options.signal); try { if (buildType === "nextjs") { @@ -124,7 +129,7 @@ export async function resolvePreviewBuildStrategy(options: { buildType, }); - if (await strategy.canBuild()) { + if (await strategy.canBuild(options.signal)) { return { buildType, strategy, diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index cb38601..5cb9590 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -104,17 +104,18 @@ export class PreviewDomainApiError extends Error { } export interface PreviewAppProvider { - createProject(options: { name: string }): Promise; + createProject(options: { name: string; signal?: AbortSignal }): Promise; listApps(projectId: string, options?: { branchName?: string; signal?: AbortSignal }): Promise; - removeApp(appId: string): Promise; - listDomains(appId: string): Promise; - addDomain(options: { appId: string; hostname: string }): Promise<{ domain: PreviewDomainRecord; existing: boolean }>; - showDomain(domainId: string): Promise; - removeDomain(domainId: string): Promise; - retryDomain(domainId: string): Promise; + removeApp(appId: string, options?: { signal?: AbortSignal }): Promise; + listDomains(appId: string, options?: { signal?: AbortSignal }): Promise; + addDomain(options: { appId: string; hostname: string; signal?: AbortSignal }): Promise<{ domain: PreviewDomainRecord; existing: boolean }>; + showDomain(domainId: string, options?: { signal?: AbortSignal }): Promise; + removeDomain(domainId: string, options?: { signal?: AbortSignal }): Promise; + retryDomain(domainId: string, options?: { signal?: AbortSignal }): Promise; promoteDeployment(options: { appId: string; deploymentId: string; + signal?: AbortSignal; progress?: unknown; }): Promise; deployApp(options: { @@ -135,18 +136,20 @@ export interface PreviewAppProvider { updateAppEnv(options: { appId: string; envVars: Record; + signal?: AbortSignal; progress?: unknown; promoteProgress?: unknown; }): Promise; listAppEnvNames(options: { appId: string; deploymentId: string; + signal?: AbortSignal; }): Promise; - listDeployments(appId: string): Promise<{ + listDeployments(appId: string, options?: { signal?: AbortSignal }): Promise<{ app: PreviewAppRecord; deployments: PreviewDeploymentRecord[]; }>; - showDeployment(deploymentId: string): Promise; + showDeployment(deploymentId: string, options?: { signal?: AbortSignal }): Promise; streamDeploymentLogs(options: { deploymentId: string; signal?: AbortSignal; @@ -165,7 +168,7 @@ export function createPreviewAppProvider( return { async createProject(options) { - const projectResult = await sdk.createProject({ name: options.name }); + const projectResult = await sdk.createProject({ name: options.name, signal: options.signal }); if (projectResult.isErr()) { throw new Error(projectResult.error.message); } @@ -178,13 +181,14 @@ export function createPreviewAppProvider( async listApps(projectId, options) { return listComputeServices(client, { - projectId, - branchGitName: options?.branchName, + projectId, + branchGitName: options?.branchName, + signal: options?.signal, }); }, - async removeApp(appId) { - const appResult = await sdk.showService({ serviceId: appId }); + async removeApp(appId, options) { + const appResult = await sdk.showService({ serviceId: appId, signal: options?.signal }); if (appResult.isErr()) { throw new Error(appResult.error.message); } @@ -194,6 +198,7 @@ export function createPreviewAppProvider( keepService: false, timeoutSeconds: 120, pollIntervalMs: 2_000, + signal: options?.signal, }); if (destroyResult.isErr()) { @@ -206,8 +211,8 @@ export function createPreviewAppProvider( }; }, - async listDomains(appId) { - return listComputeServiceDomains(client, appId); + async listDomains(appId, options) { + return listComputeServiceDomains(client, appId, options?.signal); }, async addDomain(options) { @@ -218,11 +223,12 @@ export function createPreviewAppProvider( body: { hostname: options.hostname, }, + signal: options.signal, }); if (result.error || !result.data) { if (result.response.status === 409) { - const existing = (await listComputeServiceDomains(client, options.appId)) + const existing = (await listComputeServiceDomains(client, options.appId, options.signal)) .find((domain) => sameHostname(domain.hostname, options.hostname)); if (existing) { return { @@ -241,11 +247,12 @@ export function createPreviewAppProvider( }; }, - async showDomain(domainId) { + async showDomain(domainId, options) { const result = await client.GET("/v1/domains/{domainId}", { params: { path: { domainId }, }, + signal: options?.signal, }); if (result.error || !result.data) { @@ -255,11 +262,12 @@ export function createPreviewAppProvider( return normalizeDomainRecord(result.data.data); }, - async removeDomain(domainId) { + async removeDomain(domainId, options) { const result = await client.DELETE("/v1/domains/{domainId}", { params: { path: { domainId }, }, + signal: options?.signal, }); if (result.error) { @@ -267,11 +275,12 @@ export function createPreviewAppProvider( } }, - async retryDomain(domainId) { + async retryDomain(domainId, options) { const result = await client.POST("/v1/domains/{domainId}/retry", { params: { path: { domainId }, }, + signal: options?.signal, }); if (result.error || !result.data) { @@ -287,6 +296,7 @@ export function createPreviewAppProvider( versionId: options.deploymentId, timeoutSeconds: 120, pollIntervalMs: 2000, + signal: options.signal, progress: options.progress as never, }); @@ -308,6 +318,7 @@ export function createPreviewAppProvider( branchName: options.branchName, appName: options.appName, region: options.region, + signal: options.signal, }) : { appId: undefined, @@ -320,6 +331,7 @@ export function createPreviewAppProvider( appPath: path.resolve(options.cwd), entrypoint: options.entrypoint, buildType: options.buildType, + signal: options.signal, }), projectId: options.projectId, serviceId: resolvedApp.appId, @@ -330,6 +342,7 @@ export function createPreviewAppProvider( timeoutSeconds: 120, pollIntervalMs: 2000, interaction: options.interaction as never, + signal: options.signal, progress: options.progress as never, }); @@ -362,6 +375,7 @@ export function createPreviewAppProvider( envVars: options.envVars, timeoutSeconds: 120, pollIntervalMs: 2000, + signal: options.signal, progress: options.progress as never, }); @@ -374,6 +388,7 @@ export function createPreviewAppProvider( versionId: updateResult.value.versionId, timeoutSeconds: 120, pollIntervalMs: 2000, + signal: options.signal, progress: options.promoteProgress as never, }); @@ -382,8 +397,8 @@ export function createPreviewAppProvider( } const [serviceResult, versionResult] = await Promise.all([ - sdk.showService({ serviceId: options.appId }), - sdk.showVersion({ versionId: updateResult.value.versionId }), + sdk.showService({ serviceId: options.appId, signal: options.signal }), + sdk.showVersion({ versionId: updateResult.value.versionId, signal: options.signal }), ]); if (serviceResult.isErr()) { @@ -416,8 +431,8 @@ export function createPreviewAppProvider( async listAppEnvNames(options) { const [serviceResult, versionResult] = await Promise.all([ - sdk.showService({ serviceId: options.appId }), - sdk.showVersion({ versionId: options.deploymentId }), + sdk.showService({ serviceId: options.appId, signal: options.signal }), + sdk.showVersion({ versionId: options.deploymentId, signal: options.signal }), ]); if (serviceResult.isErr()) { @@ -448,10 +463,10 @@ export function createPreviewAppProvider( }; }, - async listDeployments(appId) { + async listDeployments(appId, options) { const [appResult, versionsResult] = await Promise.all([ - sdk.showService({ serviceId: appId }), - sdk.listVersions({ serviceId: appId }), + sdk.showService({ serviceId: appId, signal: options?.signal }), + sdk.listVersions({ serviceId: appId, signal: options?.signal }), ]); if (appResult.isErr()) { @@ -486,8 +501,8 @@ export function createPreviewAppProvider( }; }, - async showDeployment(deploymentId) { - const deploymentResult = await sdk.showVersion({ versionId: deploymentId }); + async showDeployment(deploymentId, options) { + const deploymentResult = await sdk.showVersion({ versionId: deploymentId, signal: options?.signal }); if (deploymentResult.isErr()) { if (ApiError.is(deploymentResult.error) && deploymentResult.error.statusCode === 404) { return null; @@ -496,7 +511,7 @@ export function createPreviewAppProvider( throw new Error(deploymentResult.error.message); } - const app = await findAppForDeployment(sdk, deploymentId); + const app = await findAppForDeployment(sdk, deploymentId, options?.signal); return { app, @@ -591,6 +606,7 @@ async function listBranches( options: { projectId: string; gitName: string; + signal?: AbortSignal; }, ): Promise { const result = await client.GET("/v1/projects/{projectId}/branches", { @@ -598,6 +614,7 @@ async function listBranches( path: { projectId: options.projectId }, query: { gitName: options.gitName }, }, + signal: options.signal, }); if (result.error || !result.data) { throw apiCallError("Failed to list branches", result.response, result.error); @@ -611,6 +628,7 @@ async function resolveOrCreateBranch( options: { projectId: string; gitName: string; + signal?: AbortSignal; }, ): Promise { const existing = (await listBranches(client, options))[0]; @@ -626,6 +644,7 @@ async function resolveOrCreateBranch( gitName: options.gitName, isDefault: options.gitName === "main", }, + signal: options.signal, }); if (result.error || !result.data) { if (result.response.status === 409) { @@ -646,6 +665,7 @@ async function listComputeServices( options: { projectId: string; branchGitName?: string; + signal?: AbortSignal; }, ): Promise { const services: RawComputeServiceRecord[] = []; @@ -661,6 +681,7 @@ async function listComputeServices( cursor, }, }, + signal: options.signal, }); if (result.error || !result.data) { throw apiCallError("Failed to list apps", result.response, result.error); @@ -687,11 +708,13 @@ async function listComputeServices( async function listComputeServiceDomains( client: ManagementApiClient, computeServiceId: string, + signal?: AbortSignal, ): Promise { const result = await client.GET("/v1/compute-services/{computeServiceId}/domains", { params: { path: { computeServiceId }, }, + signal, }); if (result.error || !result.data) { @@ -755,11 +778,13 @@ async function createBranchApp( branchName: string; appName: string; region?: string; + signal?: AbortSignal; }, ): Promise<{ appId: string; appName: string; region: string | undefined }> { const branch = await resolveOrCreateBranch(client, { projectId: options.projectId, gitName: options.branchName, + signal: options.signal, }); const result = await client.POST("/v1/compute-services", { body: { @@ -768,12 +793,14 @@ async function createBranchApp( displayName: options.appName, ...(options.region ? { regionId: options.region } : {}), } as never, + signal: options.signal, }); if (result.error || !result.data) { if (result.response.status === 409) { const existingApps = await listComputeServices(client, { projectId: options.projectId, branchGitName: options.branchName, + signal: options.signal, }); const matched = existingApps.find((app) => app.name === options.appName); if (matched) { @@ -827,20 +854,21 @@ function domainApiCallError( async function findAppForDeployment( sdk: ComputeClient, deploymentId: string, + signal?: AbortSignal, ): Promise { - const projectsResult = await sdk.listProjects(); + const projectsResult = await sdk.listProjects({ signal }); if (projectsResult.isErr()) { throw new Error(projectsResult.error.message); } for (const project of projectsResult.value) { - const servicesResult = await sdk.listServices({ projectId: project.id }); + const servicesResult = await sdk.listServices({ projectId: project.id, signal }); if (servicesResult.isErr()) { throw new Error(servicesResult.error.message); } for (const service of servicesResult.value) { - const detailResult = await sdk.showService({ serviceId: service.id }); + const detailResult = await sdk.showService({ serviceId: service.id, signal }); if (detailResult.isErr()) { throw new Error(detailResult.error.message); } @@ -857,7 +885,7 @@ async function findAppForDeployment( return app; } - const versionsResult = await sdk.listVersions({ serviceId: service.id }); + const versionsResult = await sdk.listVersions({ serviceId: service.id, signal }); if (versionsResult.isErr()) { throw new Error(versionsResult.error.message); } diff --git a/packages/cli/src/lib/auth/auth-ops.ts b/packages/cli/src/lib/auth/auth-ops.ts index c45178d..d909cdc 100644 --- a/packages/cli/src/lib/auth/auth-ops.ts +++ b/packages/cli/src/lib/auth/auth-ops.ts @@ -36,7 +36,7 @@ export async function performLogin(env: NodeJS.ProcessEnv): Promise { await login({ tokenStorage: new FileTokenStorage(env), env }); } -export async function readAuthState(env: NodeJS.ProcessEnv): Promise { +export async function readAuthState(env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { // PRISMA_SERVICE_TOKEN is the headless / CI auth surface. When it is set, derive // auth state from the token itself and intentionally skip FileTokenStorage, // so behavior is independent of any OAuth session that happens to be stored @@ -51,7 +51,7 @@ export async function readAuthState(env: NodeJS.ProcessEnv): Promise { const client = await requireComputeAuth(env); - const currentPrincipal = await readCurrentPrincipalAuthState(client); + const currentPrincipal = await readCurrentPrincipalAuthState(client, signal); if (currentPrincipal) { return currentPrincipal; } @@ -114,6 +116,7 @@ async function readServiceTokenAuthState( claims, env, client, + signal, }); } @@ -122,11 +125,13 @@ async function buildAuthState({ claims, env, client, + signal, }: { workspaceIdFromCredential: string; claims: Record; env: NodeJS.ProcessEnv; client?: ManagementApiClient | null; + signal?: AbortSignal; }): Promise { let workspaceId = workspaceIdFromCredential; let workspaceName = workspaceIdFromCredential; @@ -137,6 +142,7 @@ async function buildAuthState({ try { const { data, response } = await client.GET("/v1/workspaces/{id}", { params: { path: { id: workspaceIdFromCredential } }, + signal, }); // A 401 from the workspace lookup means the credential the caller // presented is fundamentally invalid (revoked, wrong signing key, @@ -180,11 +186,12 @@ async function buildAuthState({ async function readCurrentPrincipalAuthState( client: ManagementApiClient | null, + signal?: AbortSignal, ): Promise { if (!client) return null; try { - const { data, response } = await client.GET("/v1/me"); + const { data, response } = await client.GET("/v1/me", { signal }); if (response?.status === 401) { return { diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index ef9d0d4..3bf9021 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -343,6 +343,7 @@ describe("app controller", () => { expect(addDomain).toHaveBeenCalledWith({ appId: "app_1", hostname: "shop.acme.com", + signal: context.runtime.signal, }); expect(retryDomain).not.toHaveBeenCalled(); expect(result.result).toMatchObject({ @@ -427,6 +428,7 @@ describe("app controller", () => { expect(addDomain).toHaveBeenCalledWith({ appId: "app_1", hostname: "shop.acme.com", + signal: context.runtime.signal, }); expect(result.result.project.id).toBe("proj_123"); }); @@ -1873,7 +1875,10 @@ describe("app controller", () => { framework: "hono", }); - expect(createProject).toHaveBeenCalledWith({ name: "interactive-project" }); + expect(createProject).toHaveBeenCalledWith({ + name: "interactive-project", + signal: context.runtime.signal, + }); expect(result.result).toMatchObject({ project: { id: "proj_new", @@ -2392,6 +2397,7 @@ describe("app controller", () => { expect(createProject).toHaveBeenCalledWith({ name: "launchpad", + signal: context.runtime.signal, }); expect(deployApp).toHaveBeenCalledWith( expect.objectContaining({ @@ -2614,7 +2620,10 @@ describe("app controller", () => { }, }, }); - expect(createProject).toHaveBeenCalledWith({ name: "next-smoke" }); + expect(createProject).toHaveBeenCalledWith({ + name: "next-smoke", + signal: context.runtime.signal, + }); await expect(readPrismaConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -4443,7 +4452,7 @@ describe("app controller", () => { const result = await runAppRemove(context, "hello-world"); - expect(removeApp).toHaveBeenCalledWith("app_1"); + expect(removeApp).toHaveBeenCalledWith("app_1", { signal: context.runtime.signal }); expect(result.result).toEqual({ projectId: "proj_123", app: { @@ -4512,7 +4521,7 @@ describe("app controller", () => { placeholder: "hello-world", }), ); - expect(removeApp).toHaveBeenCalledWith("app_1"); + expect(removeApp).toHaveBeenCalledWith("app_1", { signal: context.runtime.signal }); }); it("remove returns CONFIRMATION_REQUIRED in non-interactive mode without --yes", async () => { diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index db56169..3ec7f4d 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -53,7 +53,7 @@ describe("real auth mode", () => { const result = await runAuthLogin(context, {}); expect(performLogin).toHaveBeenCalledWith(context.runtime.env); - expect(readAuthState).toHaveBeenCalledWith(context.runtime.env); + expect(readAuthState).toHaveBeenCalledWith(context.runtime.env, context.runtime.signal); expect(result.result).toMatchObject({ authenticated: true, provider: null, diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 9679ff5..156a252 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -136,7 +136,10 @@ describe("project controller", () => { const { runProjectCreate } = await import("../src/controllers/project"); const result = await runProjectCreate(context, "New Dashboard"); - expect(createProject).toHaveBeenCalledWith({ name: "New Dashboard" }); + expect(createProject).toHaveBeenCalledWith({ + name: "New Dashboard", + signal: context.runtime.signal, + }); expect(result.result).toMatchObject({ project: { id: "proj_new", @@ -218,7 +221,10 @@ describe("project controller", () => { const { runProjectLink } = await import("../src/controllers/project"); const result = await runProjectLink(context, undefined); - expect(createProject).toHaveBeenCalledWith({ name: "Interactive Project" }); + expect(createProject).toHaveBeenCalledWith({ + name: "Interactive Project", + signal: context.runtime.signal, + }); expect(result.result).toMatchObject({ project: { id: "proj_new", diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index 60b677d..685127e 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -136,7 +136,7 @@ describe("real project mode", () => { const result = await runProjectList(context); - expect(readAuthState).toHaveBeenCalledWith(context.runtime.env); + expect(readAuthState).toHaveBeenCalledWith(context.runtime.env, context.runtime.signal); expect(requireComputeAuth).toHaveBeenCalledWith(context.runtime.env); expect(result.result).toEqual({ workspace: { From b698cdcf362c15426bd3d2807c310fc32209f709 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:01:21 -0400 Subject: [PATCH 07/20] Make waits and processes cancellable --- .../cli-cancellation-propagation-analysis.plan.md | 12 ++++++------ packages/cli/src/adapters/git.ts | 3 ++- packages/cli/src/controllers/app.ts | 15 +++++++++++---- packages/cli/src/controllers/project.ts | 2 +- packages/cli/src/lib/app/local-dev.ts | 2 ++ 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md index 37efdf0..aba026f 100644 --- a/.agents/projects/cli-cancellation-propagation-analysis.plan.md +++ b/.agents/projects/cli-cancellation-propagation-analysis.plan.md @@ -91,7 +91,7 @@ None. ### Phase 4: Polling, Sleeps, And Local Processes -**Status:** ☐ Not started +**Status:** ✓ Complete **Goal:** Make CLI-owned waiting and subprocess execution responsive to cancellation. @@ -107,11 +107,11 @@ None. **Acceptance Criteria:** -- **AC1:** Signal-aware sleeps reject immediately when already aborted and reject without waiting for the full interval when aborted during sleep. -- **AC2:** Polling loops do not perform an extra API call after cancellation is observed. -- **AC3:** Local app process cancellation does not produce `RUN_FAILED` for `SIGINT` or `SIGTERM` cancellation paths. -- **AC4:** Git adapter cancellation is test-covered at the process boundary. -- **AC5:** `pnpm --filter @prisma/cli test -- app-local-dev.test.ts git-adapter.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. +- [x] **AC1:** Signal-aware sleeps reject immediately when already aborted and reject without waiting for the full interval when aborted during sleep. +- [x] **AC2:** Polling loops do not perform an extra API call after cancellation is observed. +- [x] **AC3:** Local app process cancellation does not produce `RUN_FAILED` for `SIGINT` or `SIGTERM` cancellation paths. +- [x] **AC4:** Git adapter cancellation is test-covered at the process boundary. +- [x] **AC5:** `pnpm --filter @prisma/cli test -- app-local-dev.test.ts git-adapter.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. ### Phase 5: Filesystem And Token Storage Boundaries diff --git a/packages/cli/src/adapters/git.ts b/packages/cli/src/adapters/git.ts index 74abc02..ffe2288 100644 --- a/packages/cli/src/adapters/git.ts +++ b/packages/cli/src/adapters/git.ts @@ -11,11 +11,12 @@ export interface GitHubRepositoryReference { url: string; } -export async function readGitOriginRemote(cwd: string): Promise { +export async function readGitOriginRemote(cwd: string, signal?: AbortSignal): Promise { try { const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], { cwd, timeout: 5_000, + signal, }); const remote = stdout.trim(); return remote.length > 0 ? remote : null; diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index b5daad8..3073a14 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -188,7 +188,7 @@ export async function runAppRun( } if (runResult.signal === "SIGINT" || runResult.signal === "SIGTERM") { - process.exitCode = runResult.signal === "SIGINT" ? 130 : 143; + throw new DOMException("Command canceled", "AbortError"); } else if (runResult.exitCode !== 0) { throw runFailedError( "Local app run failed", @@ -814,7 +814,7 @@ export async function runAppDomainWait( }); } - await sleep(Math.min(pollIntervalMs, Math.max(deadline - Date.now(), 0))); + await sleep(Math.min(pollIntervalMs, Math.max(deadline - Date.now(), 0)), context.runtime.signal); current = await target.provider.showDomain(current.id, { signal: context.runtime.signal }).catch((error) => { throw domainCommandError("wait", error, normalizedHostname); }); @@ -1693,11 +1693,18 @@ function formatElapsed(milliseconds: number): string { return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`; } -async function sleep(milliseconds: number): Promise { +async function sleep(milliseconds: number, signal: AbortSignal): Promise { if (milliseconds <= 0) { return; } - await new Promise((resolve) => setTimeout(resolve, milliseconds)); + signal.throwIfAborted(); + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, milliseconds); + signal.addEventListener("abort", () => { + clearTimeout(timeout); + reject(signal.reason); + }, { once: true }); + }); } async function resolveDeployAppSelection( diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index e45542d..3838fdb 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -779,7 +779,7 @@ async function resolveRepositoryForConnect( context: CommandContext, gitUrl: string | undefined, ): Promise { - const remoteUrl = gitUrl ?? await readGitOriginRemote(context.runtime.cwd); + const remoteUrl = gitUrl ?? await readGitOriginRemote(context.runtime.cwd, context.runtime.signal); if (!remoteUrl) { throw usageError( diff --git a/packages/cli/src/lib/app/local-dev.ts b/packages/cli/src/lib/app/local-dev.ts index bb9a437..5f650ec 100644 --- a/packages/cli/src/lib/app/local-dev.ts +++ b/packages/cli/src/lib/app/local-dev.ts @@ -100,6 +100,7 @@ export async function runLocalApp(options: { ...options.env, PORT: String(options.port), }, + signal: options.signal, }, spawnImpl, "Could not find the Next.js CLI. Install it with `npm install next` or ensure npx/bunx is available.", @@ -130,6 +131,7 @@ export async function runLocalApp(options: { ...options.env, PORT: String(options.port), }, + signal: options.signal, }, spawnImpl, "Bun is required to run this app locally. Install it from https://bun.sh.", From 4473bcf2ca7f5e9bc9c26e932e70c1abde5b270e Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:16:26 -0400 Subject: [PATCH 08/20] Document log stream cancellation semantics --- packages/cli/src/lib/app/preview-provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 5cb9590..6bbfc97 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -542,6 +542,7 @@ export function createPreviewAppProvider( if (result.isErr()) { if (CancelledError.is(result.error)) { + // Stopping a log stream is an expected user action, not a failed operation. return; } From 3bcea409970f3f823b6b82d145d085146819d3b8 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:17:11 -0400 Subject: [PATCH 09/20] Prioritize runtime cancellation errors --- packages/cli/src/shell/command-runner.ts | 4 +-- .../cli/tests/command-runner-auth.test.ts | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index de61089..b75c87f 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -17,12 +17,12 @@ interface CommandPresenter { } function toCliError(error: unknown, runtime: CliRuntime): CliError | null { - if (error instanceof CliError) return error; - if (isCancellationError(error) || runtime.signal.aborted) { return commandCanceledError(); } + if (error instanceof CliError) return error; + if (error instanceof SDKAuthError) { return authRequiredError(["prisma-cli auth login"], { debug: error.message }); } diff --git a/packages/cli/tests/command-runner-auth.test.ts b/packages/cli/tests/command-runner-auth.test.ts index 8ac754f..638a560 100644 --- a/packages/cli/tests/command-runner-auth.test.ts +++ b/packages/cli/tests/command-runner-auth.test.ts @@ -3,6 +3,7 @@ import { PassThrough, Writable } from "node:stream"; import { afterEach, describe, expect, it } from "vitest"; import { runCommand, runStreamingCommand } from "../src/shell/command-runner"; +import { CliError } from "../src/shell/errors"; import type { CliRuntime } from "../src/shell/runtime"; import { createTempCwd } from "./helpers"; @@ -140,6 +141,41 @@ describe("command runner auth errors", () => { expect(stderr.buffer).not.toContain("More: Re-run with --trace"); }); + it("lets runtime cancellation override wrapped CLI errors", async () => { + const { runtime, stdout } = await createRuntime(["--json", "app", "run"]); + const controller = new AbortController(); + runtime.signal = controller.signal; + + await runCommand( + runtime, + "app.run", + {}, + async () => { + controller.abort(); + throw new CliError({ + code: "RUN_FAILED", + domain: "app", + summary: "Local app run failed", + why: "The child process was aborted.", + fix: "Retry the command.", + }); + }, + { + renderHuman: () => [], + }, + ); + + expect(process.exitCode).toBe(130); + expect(JSON.parse(stdout.buffer)).toMatchObject({ + ok: false, + command: "app.run", + error: { + code: "COMMAND_CANCELED", + domain: "cli", + }, + }); + }); + it("shows SDK auth details only with trace enabled", async () => { const { runtime, stderr } = await createRuntime(["auth", "whoami", "--trace"]); From 285b4f5474e2c1ee115dc171cfc2d5de864b2381 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:17:48 -0400 Subject: [PATCH 10/20] Make repository install polling cancellable --- packages/cli/src/controllers/project.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 3838fdb..c238bc9 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -890,6 +890,7 @@ async function waitForInstalledRepository( let inspectableInstallationCount = 0; while (Date.now() <= deadline) { + context.runtime.signal.throwIfAborted(); const installations = await listScmInstallations(api, workspaceId); const lookup = await findRepositoryInInstallations(api, installations, repository); @@ -903,7 +904,7 @@ async function waitForInstalledRepository( break; } - await sleep(Math.min(intervalMs, remainingMs)); + await sleep(Math.min(intervalMs, remainingMs), context.runtime.signal); } return { match: null, inspectableInstallationCount }; @@ -944,8 +945,15 @@ function writeInstallWaitStatus( context.output.stderr.write(`${lines.join("\n")}\n`); } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +function sleep(ms: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, ms); + signal.addEventListener("abort", () => { + clearTimeout(timeout); + reject(signal.reason); + }, { once: true }); + }); } async function listScmInstallations( From df7e40aeb6e1ab741e86f2390daea61d2a40acc8 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:18:52 -0400 Subject: [PATCH 11/20] Clean up abortable sleep listeners --- packages/cli/src/controllers/app.ts | 10 +++++++--- packages/cli/src/controllers/project.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 3073a14..f5c8bcf 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -1699,11 +1699,15 @@ async function sleep(milliseconds: number, signal: AbortSignal): Promise { } signal.throwIfAborted(); await new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, milliseconds); - signal.addEventListener("abort", () => { + const onAbort = () => { clearTimeout(timeout); reject(signal.reason); - }, { once: true }); + }; + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index c238bc9..bcbe062 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -948,11 +948,15 @@ function writeInstallWaitStatus( function sleep(ms: number, signal: AbortSignal): Promise { signal.throwIfAborted(); return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, ms); - signal.addEventListener("abort", () => { + const onAbort = () => { clearTimeout(timeout); reject(signal.reason); - }, { once: true }); + }; + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); }); } From 139f017c4d5d15c38e08a4accc0c3bcde761dfa8 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:28:58 -0400 Subject: [PATCH 12/20] Propagate cancellation through local storage --- ...-cancellation-propagation-analysis.plan.md | 12 +- packages/cli/src/adapters/local-state.ts | 9 +- packages/cli/src/adapters/mock-api.ts | 5 +- packages/cli/src/adapters/token-storage.ts | 51 +++++-- packages/cli/src/controllers/app-env.ts | 2 +- packages/cli/src/controllers/app.ts | 67 ++++++---- packages/cli/src/controllers/auth.ts | 6 +- packages/cli/src/controllers/project.ts | 24 ++-- packages/cli/src/lib/app/bun-project.ts | 14 +- packages/cli/src/lib/app/local-dev.ts | 37 ++++-- packages/cli/src/lib/app/preview-build.ts | 124 +++++++++++------- packages/cli/src/lib/auth/auth-ops.ts | 16 +-- packages/cli/src/lib/auth/guard.ts | 4 +- packages/cli/src/lib/auth/login.ts | 11 +- .../cli/src/lib/project/interactive-setup.ts | 2 +- packages/cli/src/lib/project/local-pin.ts | 21 ++- packages/cli/src/lib/project/resolution.ts | 17 ++- packages/cli/src/lib/project/setup.ts | 4 +- packages/cli/src/shell/runtime.ts | 4 +- packages/cli/tests/app-bun-compat.test.ts | 11 ++ packages/cli/tests/app-state.test.ts | 10 ++ packages/cli/tests/token-storage.test.ts | 34 +++++ 22 files changed, 333 insertions(+), 152 deletions(-) diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md index aba026f..0808761 100644 --- a/.agents/projects/cli-cancellation-propagation-analysis.plan.md +++ b/.agents/projects/cli-cancellation-propagation-analysis.plan.md @@ -115,7 +115,7 @@ None. ### Phase 5: Filesystem And Token Storage Boundaries -**Status:** ☐ Not started +**Status:** ✓ Complete **Goal:** Push cancellation through local filesystem and credential-storage helpers while documenting unsupported external boundaries. @@ -132,11 +132,11 @@ None. **Acceptance Criteria:** -- **AC1:** Supported `readFile` and `writeFile` calls receive the command signal where reachable from command execution. -- **AC2:** Unsupported filesystem and credential-store boundaries have immediate abort checks and short comments at the boundary. -- **AC3:** Token refresh-lock wait exits promptly on abort. -- **AC4:** No local race-based cancellation wrappers are introduced. -- **AC5:** `pnpm --filter @prisma/cli test -- token-storage.test.ts app-state.test.ts app-bun-compat.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. +- [x] **AC1:** Supported `readFile` and `writeFile` calls receive the command signal where reachable from command execution. +- [x] **AC2:** Unsupported filesystem and credential-store boundaries have immediate abort checks and short comments at the boundary. +- [x] **AC3:** Token refresh-lock wait exits promptly on abort. +- [x] **AC4:** No local race-based cancellation wrappers are introduced. +- [x] **AC5:** `pnpm --filter @prisma/cli test -- token-storage.test.ts app-state.test.ts app-bun-compat.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. ### Phase 6: End-To-End Verification And Cleanup diff --git a/packages/cli/src/adapters/local-state.ts b/packages/cli/src/adapters/local-state.ts index 3318d1a..98f2b0f 100644 --- a/packages/cli/src/adapters/local-state.ts +++ b/packages/cli/src/adapters/local-state.ts @@ -60,13 +60,14 @@ export function resolveLocalStateFilePath(stateDir: string): string { export class LocalStateStore { private readonly stateFilePath: string; - constructor(stateDir: string) { + constructor(stateDir: string, private readonly signal?: AbortSignal) { this.stateFilePath = resolveLocalStateFilePath(stateDir); } async read(): Promise { + this.signal?.throwIfAborted(); try { - const raw = await readFile(this.stateFilePath, "utf8"); + const raw = await readFile(this.stateFilePath, { encoding: "utf8", signal: this.signal }); const parsed = JSON.parse(raw) as Partial; return { auth: parsed.auth ?? structuredClone(DEFAULT_STATE.auth), @@ -93,8 +94,10 @@ export class LocalStateStore { } async write(state: LocalState): Promise { + this.signal?.throwIfAborted(); + // mkdir does not accept AbortSignal; check before the filesystem boundary. await mkdir(path.dirname(this.stateFilePath), { recursive: true }); - await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", signal: this.signal }); } async setAuthSession(session: NonNullable): Promise { diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 3bb5ae5..692d4f5 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -65,8 +65,9 @@ export class MockApi { this.data = data; } - static async load(fixturePath: string): Promise { - const raw = await readFile(fixturePath, "utf8"); + static async load(fixturePath: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const raw = await readFile(fixturePath, { encoding: "utf8", signal }); return new MockApi(JSON.parse(raw) as MockApiData); } diff --git a/packages/cli/src/adapters/token-storage.ts b/packages/cli/src/adapters/token-storage.ts index 8ae5f83..9eab14a 100644 --- a/packages/cli/src/adapters/token-storage.ts +++ b/packages/cli/src/adapters/token-storage.ts @@ -44,45 +44,68 @@ function tokensEqual(a: Tokens | null, b: Tokens | null): boolean { ); } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +function sleep(ms: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timeout); + reject(signal?.reason); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); } export class FileTokenStorage implements TokenStorage { private readonly credentialsStore: CredentialsStore; private readonly lockFilePath: string; - constructor(env: NodeJS.ProcessEnv = process.env) { + constructor(env: NodeJS.ProcessEnv = process.env, private readonly signal?: AbortSignal) { const authFilePath = getAuthFilePath(env); this.credentialsStore = new CredentialsStore(authFilePath); this.lockFilePath = `${authFilePath}.lock`; } async getTokens(): Promise { + this.signal?.throwIfAborted(); try { + // CredentialsStore does not accept AbortSignal; check immediately before and after the boundary. const all = await this.credentialsStore.getCredentials(); + this.signal?.throwIfAborted(); return findLatestValidTokens(all as StoredCredential[]); - } catch { + } catch (error) { + if (this.signal?.aborted) throw this.signal.reason; return null; } } async setTokens(tokens: Tokens): Promise { + this.signal?.throwIfAborted(); + // CredentialsStore does not accept AbortSignal; check immediately before and after the boundary. await this.credentialsStore.storeCredentials({ workspaceId: tokens.workspaceId, token: tokens.accessToken, refreshToken: tokens.refreshToken, }); + this.signal?.throwIfAborted(); } async clearTokens(): Promise { + this.signal?.throwIfAborted(); const all = await this.credentialsStore.getCredentials(); + this.signal?.throwIfAborted(); const tokens = findLatestValidTokens(all as StoredCredential[]); if (!tokens) return; + // CredentialsStore does not accept AbortSignal; check immediately before and after the boundary. await this.credentialsStore.deleteCredentials(tokens.workspaceId); + this.signal?.throwIfAborted(); } async clearTokensIfCurrent(tokens: Tokens): Promise { + this.signal?.throwIfAborted(); const current = await this.getTokens(); if (!tokensEqual(current, tokens)) return; await this.clearTokens(); @@ -99,13 +122,17 @@ export class FileTokenStorage implements TokenStorage { private async acquireRefreshLock(): Promise { const lockId = randomUUID(); + this.signal?.throwIfAborted(); + // mkdir does not accept AbortSignal; check before the filesystem boundary. await fs.mkdir(path.dirname(this.lockFilePath), { recursive: true }); while (true) { + this.signal?.throwIfAborted(); try { + // open does not accept AbortSignal; check before the filesystem boundary. const handle = await fs.open(this.lockFilePath, "wx"); try { - await handle.writeFile(lockId, "utf8"); + await handle.writeFile(lockId, { encoding: "utf8", signal: this.signal }); } finally { await handle.close(); } @@ -120,23 +147,31 @@ export class FileTokenStorage implements TokenStorage { continue; } - await sleep(100); + await sleep(100, this.signal); } } } private async getStaleRefreshLockId(): Promise { - const lockId = await fs.readFile(this.lockFilePath, "utf8").catch(() => null); + this.signal?.throwIfAborted(); + const lockId = await fs.readFile(this.lockFilePath, { encoding: "utf8", signal: this.signal }).catch((error) => { + if (this.signal?.aborted) throw error; + return null; + }); if (lockId === null) return null; + this.signal?.throwIfAborted(); + // stat does not accept AbortSignal; check before and after the filesystem boundary. const stats = await fs.stat(this.lockFilePath).catch(() => null); + this.signal?.throwIfAborted(); if (!stats) return null; return Date.now() - stats.mtimeMs > 30_000 ? lockId : null; } private async releaseRefreshLock(lockId: string): Promise { - const currentLockId = await fs.readFile(this.lockFilePath, "utf8").catch(() => null); + const currentLockId = await fs.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null); if (currentLockId !== lockId) return; + // unlink does not accept AbortSignal; refresh-lock cleanup must run even after cancellation. await fs.unlink(this.lockFilePath).catch(() => {}); } } diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 6de2fc1..d67d756 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -298,7 +298,7 @@ async function requireClientAndProject( commandName: string, ): Promise<{ client: ManagementApiClient; projectId: string }> { const authState = await requireAuthenticatedAuthState(context); - const client = await requireComputeAuth(context.runtime.env); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { throw authRequiredError(["prisma-cli auth login"]); } diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index f5c8bcf..72a32b0 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -236,7 +236,7 @@ export async function runAppDeploy( const skipLocalPin = Boolean(envProjectId || options?.projectRef || options?.createProjectName); const localPin = skipLocalPin ? ({ kind: "missing" } satisfies LocalResolutionPinReadResult) - : await readLocalResolutionPin(context.runtime.cwd); + : await readLocalResolutionPin(context.runtime.cwd, context.runtime.signal); if (!skipLocalPin && localPin.kind === "invalid") { throw localResolutionPinStaleError(); } @@ -283,7 +283,7 @@ export async function runAppDeploy( explicitAppName: appName, explicitAppId: envAppId, firstDeploy: Boolean(target.localPinAction), - inferName: () => inferTargetName(context.runtime.cwd), + inferName: () => inferTargetName(context.runtime.cwd, context.runtime.signal), }); await maybeRenderDeploySetupBlock(context, { @@ -308,7 +308,7 @@ export async function runAppDeploy( // derives its entrypoint from build output, so validate --entry again after it. const buildType = framework.buildType; assertSupportedEntrypoint(buildType, options?.entrypoint, "deploy"); - const entrypoint = await resolveDeployEntrypoint(context.runtime.cwd, framework, options?.entrypoint); + const entrypoint = await resolveDeployEntrypoint(context.runtime.cwd, framework, options?.entrypoint, context.runtime.signal); const portMapping = parseDeployPortMapping(String(runtime.port)); const progressState = createPreviewDeployProgressState(); @@ -598,7 +598,10 @@ export async function runAppOpen( const shouldOpen = canPrompt(context); if (shouldOpen) { + context.runtime.signal.throwIfAborted(); + // Browser launch cannot consume AbortSignal; check immediately before and after the boundary. await open(deploymentsResult.app.liveUrl); + context.runtime.signal.throwIfAborted(); } return { @@ -2149,18 +2152,18 @@ async function requirePreviewAppProvider(context: CommandContext) { async function requirePreviewAppProviderWithClient( context: CommandContext, ): Promise<{ client: ManagementApiClient; provider: ReturnType }> { - const client = await requireComputeAuth(context.runtime.env); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { throw authRequiredError(["prisma-cli auth login"]); } return { client, - provider: createPreviewAppProvider(client, createPreviewLogAuthOptions(context.runtime.env)), + provider: createPreviewAppProvider(client, createPreviewLogAuthOptions(context.runtime.env, context.runtime.signal)), }; } -function createPreviewLogAuthOptions(env: NodeJS.ProcessEnv) { +function createPreviewLogAuthOptions(env: NodeJS.ProcessEnv, signal: AbortSignal) { const rawToken = env[SERVICE_TOKEN_ENV_VAR]?.trim(); if (rawToken) { return { @@ -2169,7 +2172,7 @@ function createPreviewLogAuthOptions(env: NodeJS.ProcessEnv) { }; } - const tokenStorage = new FileTokenStorage(env); + const tokenStorage = new FileTokenStorage(env, signal); return { baseUrl: getApiBaseUrl(env), getToken: async () => { @@ -2385,7 +2388,7 @@ async function resolveDeployProjectContext( return withDeployBranch(resolved, branch); } - const suggestedName = await inferTargetName(context.runtime.cwd); + const suggestedName = await inferTargetName(context.runtime.cwd, context.runtime.signal); throw projectSetupRequiredError(projects, suggestedName); } @@ -2501,7 +2504,7 @@ async function resolveDeployBranch(context: CommandContext, explicitBranchName: }; } - const gitBranch = await readLocalGitBranch(context.runtime.cwd); + const gitBranch = await readLocalGitBranch(context.runtime.cwd, context.runtime.signal); if (gitBranch) { return { name: gitBranch, @@ -2515,42 +2518,49 @@ async function resolveDeployBranch(context: CommandContext, explicitBranchName: }; } -async function readLocalGitBranch(cwd: string): Promise { +async function readLocalGitBranch(cwd: string, signal: AbortSignal): Promise { const gitPath = path.join(cwd, ".git"); - const headPath = await resolveGitHeadPath(gitPath); + const headPath = await resolveGitHeadPath(gitPath, signal); if (!headPath) { return null; } try { - const head = (await readFile(headPath, "utf8")).trim(); + const head = (await readFile(headPath, { encoding: "utf8", signal })).trim(); const refPrefix = "ref: refs/heads/"; if (head.startsWith(refPrefix)) { return head.slice(refPrefix.length); } - } catch { + } catch (error) { + if (signal.aborted) throw error; return null; } return null; } -async function resolveGitHeadPath(gitPath: string): Promise { +async function resolveGitHeadPath(gitPath: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); try { - const raw = await readFile(gitPath, "utf8"); + const raw = await readFile(gitPath, { encoding: "utf8", signal }); const prefix = "gitdir:"; if (raw.startsWith(prefix)) { return path.join(path.resolve(path.dirname(gitPath), raw.slice(prefix.length).trim()), "HEAD"); } - } catch { + } catch (error) { + if (signal.aborted) throw error; // Fall through to try the normal .git directory shape below. // Common cases: EISDIR (normal git repo), EACCES, ENOENT. } + signal.throwIfAborted(); try { + // access does not accept AbortSignal; check before and after the filesystem boundary. await access(path.join(gitPath, "HEAD")); + signal.throwIfAborted(); return path.join(gitPath, "HEAD"); - } catch { + } catch (error) { + if (signal.aborted) throw error; return null; } } @@ -2587,7 +2597,7 @@ async function resolveDeployFramework( }; } - const detected = await detectDeployFramework(context.runtime.cwd); + const detected = await detectDeployFramework(context.runtime.cwd, context.runtime.signal); if (detected) { return detected; } @@ -2628,12 +2638,13 @@ async function resolveDeployEntrypoint( cwd: string, framework: ResolvedDeployFramework, explicitEntrypoint: string | undefined, + signal: AbortSignal, ): Promise { if (explicitEntrypoint || framework.buildType !== "bun") { return explicitEntrypoint; } - const packageJson = await readBunPackageJson(cwd); + const packageJson = await readBunPackageJson(cwd, signal); const packageEntrypoint = readBunPackageEntrypoint(packageJson); if (packageEntrypoint) { return packageEntrypoint; @@ -2644,10 +2655,14 @@ async function resolveDeployEntrypoint( } const defaultEntrypoint = "src/index.ts"; + signal.throwIfAborted(); try { + // access does not accept AbortSignal; check before and after the filesystem boundary. await access(path.join(cwd, defaultEntrypoint)); + signal.throwIfAborted(); return defaultEntrypoint; } catch (error) { + if (signal.aborted) throw error; if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } @@ -2655,9 +2670,9 @@ async function resolveDeployEntrypoint( } } -async function detectDeployFramework(cwd: string): Promise { - const packageJson = await readBunPackageJson(cwd); - const nextConfig = await detectNextConfig(cwd); +async function detectDeployFramework(cwd: string, signal: AbortSignal): Promise { + const packageJson = await readBunPackageJson(cwd, signal); + const nextConfig = await detectNextConfig(cwd, signal); if (nextConfig.exists || hasPackageDependency(packageJson, "next")) { return { @@ -2693,7 +2708,7 @@ async function detectDeployFramework(cwd: string): Promise { +async function detectNextConfig(cwd: string, signal: AbortSignal): Promise<{ exists: boolean; standalone: boolean }> { const candidates = [ "next.config.js", "next.config.mjs", @@ -2704,13 +2719,15 @@ async function detectNextConfig(cwd: string): Promise<{ exists: boolean; standal for (const candidate of candidates) { const filePath = path.join(cwd, candidate); + signal.throwIfAborted(); try { - const content = await readFile(filePath, "utf8"); + const content = await readFile(filePath, { encoding: "utf8", signal }); return { exists: true, standalone: /\boutput\s*:\s*["'`]standalone["'`]/.test(content), }; } catch (error) { + if (signal.aborted) throw error; if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } @@ -3057,7 +3074,7 @@ async function requireLocalBuildType( // Local dev server support is intentionally narrower than deploy build support. // Nuxt, Astro, and TanStack Start can deploy via SDK strategies, but app run // only starts the local dev servers currently documented for the preview. - const resolvedBuildType = await resolveLocalBuildType(context.runtime.cwd, buildType); + const resolvedBuildType = await resolveLocalBuildType(context.runtime.cwd, buildType, context.runtime.signal); if (resolvedBuildType) { return resolvedBuildType; } diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index 85f1bac..ccc60af 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -25,7 +25,7 @@ export async function runAuthLogin( let result: AuthStateResult; if (isRealMode(context)) { - await performLogin(context.runtime.env); + await performLogin(context.runtime.env, context.runtime.signal); result = await readAuthState(context.runtime.env, context.runtime.signal); } else { const useCases = createAuthUseCases(createCliUseCaseGateways(context)); @@ -39,7 +39,7 @@ export async function runAuthLogout(context: CommandContext): Promise>, ): Promise { - const pin = await readLocalResolutionPin(cwd); + const pin = await readLocalResolutionPin(cwd, context.runtime.signal); if (pin.kind === "present") { return pin.pin.workspaceId === workspace.id && projects.some((project) => project.id === pin.pin.projectId) ? { status: "linked" } @@ -84,7 +84,7 @@ export async function runProjectList(context: CommandContext): Promise | null = null; let projects: ProjectCandidate[]; if (isRealMode(context)) { - const client = await requireComputeAuth(context.runtime.env); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { throw authRequiredError(); } @@ -319,7 +319,7 @@ async function projectLinkTargetRequiredError( context: CommandContext, projects: ProjectCandidate[], ): Promise { - const suggestedName = await inferTargetName(context.runtime.cwd); + const suggestedName = await inferTargetName(context.runtime.cwd, context.runtime.signal); const createCommand = `prisma-cli project create ${formatCommandArgument(suggestedName.name)}`; const recoveryCommands = [ "prisma-cli project link ", @@ -360,7 +360,7 @@ export async function runGitConnect( } if (isRealMode(context)) { - const client = await requireComputeAuth(context.runtime.env); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { throw authRequiredError(); } @@ -457,7 +457,7 @@ export async function runGitDisconnect( } if (isRealMode(context)) { - const client = await requireComputeAuth(context.runtime.env); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { throw authRequiredError(); } @@ -518,7 +518,7 @@ async function resolveProjectShowInRealMode( workspace: AuthWorkspace, explicitProject: string | undefined, ): Promise { - const client = await requireComputeAuth(context.runtime.env); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { throw authRequiredError(); } @@ -538,7 +538,7 @@ async function resolveRequiredProjectInRealMode( explicitProject: string | undefined, commandName: string, ): Promise { - const client = await requireComputeAuth(context.runtime.env); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); if (!client) { throw authRequiredError(); } @@ -1111,9 +1111,13 @@ async function openInstallUrlIfInteractive( } try { + context.runtime.signal.throwIfAborted(); + // Browser launch cannot consume AbortSignal; check immediately before and after the boundary. await open(installUrl); + context.runtime.signal.throwIfAborted(); return true; - } catch { + } catch (error) { + if (context.runtime.signal.aborted) throw error; return false; } } diff --git a/packages/cli/src/lib/app/bun-project.ts b/packages/cli/src/lib/app/bun-project.ts index fa3ff57..3e26fe3 100644 --- a/packages/cli/src/lib/app/bun-project.ts +++ b/packages/cli/src/lib/app/bun-project.ts @@ -9,12 +9,13 @@ export interface BunPackageJsonLike { devDependencies?: unknown; } -export async function readBunPackageJson(appPath: string): Promise { +export async function readBunPackageJson(appPath: string, signal?: AbortSignal): Promise { const packageJsonPath = path.join(appPath, "package.json"); let content: string; + signal?.throwIfAborted(); try { - content = await readFile(packageJsonPath, "utf8"); + content = await readFile(packageJsonPath, { encoding: "utf8", signal }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; @@ -49,8 +50,9 @@ export function readBunPackageEntrypoint(packageJson: BunPackageJsonLike | null) export async function resolveBunEntrypoint( appPath: string, explicitEntrypoint: string | undefined, + signal?: AbortSignal, ): Promise { - const packageJson = await readBunPackageJson(appPath); + const packageJson = await readBunPackageJson(appPath, signal); const candidate = explicitEntrypoint ?? readBunPackageEntrypoint(packageJson); if (!candidate) { @@ -71,9 +73,13 @@ export async function resolveBunEntrypoint( } const entrypointPath = path.join(appPath, normalized); + signal?.throwIfAborted(); try { + // access does not accept AbortSignal; check before and after the filesystem boundary. await access(entrypointPath); - } catch { + signal?.throwIfAborted(); + } catch (error) { + if (signal?.aborted) throw error; throw new Error(`Entrypoint file does not exist: ${entrypointPath}`); } diff --git a/packages/cli/src/lib/app/local-dev.ts b/packages/cli/src/lib/app/local-dev.ts index 5f650ec..63ef6c7 100644 --- a/packages/cli/src/lib/app/local-dev.ts +++ b/packages/cli/src/lib/app/local-dev.ts @@ -34,6 +34,7 @@ interface CommandCandidate { export async function resolveLocalBuildType( appPath: string, buildType: PreviewBuildType, + signal?: AbortSignal, ): Promise { if (buildType === "bun" || buildType === "nextjs") { return buildType; @@ -43,15 +44,15 @@ export async function resolveLocalBuildType( return null; } - return detectLocalBuildType(appPath); + return detectLocalBuildType(appPath, signal); } -export async function detectLocalBuildType(appPath: string): Promise { - if (await isNextProject(appPath)) { +export async function detectLocalBuildType(appPath: string, signal?: AbortSignal): Promise { + if (await isNextProject(appPath, signal)) { return "nextjs"; } - if (await isBunProject(appPath)) { + if (await isBunProject(appPath, signal)) { return "bun"; } @@ -116,7 +117,7 @@ export async function runLocalApp(options: { }; } - const entrypoint = await resolveBunEntrypoint(options.appPath, options.entrypoint); + const entrypoint = await resolveBunEntrypoint(options.appPath, options.entrypoint, options.signal); const command = await runWithFallback( [ { @@ -147,36 +148,48 @@ export async function runLocalApp(options: { }; } -async function isNextProject(appPath: string): Promise { +async function isNextProject(appPath: string, signal?: AbortSignal): Promise { for (const fileName of NEXT_CONFIG_FILENAMES) { + signal?.throwIfAborted(); try { + // access does not accept AbortSignal; check before and after the filesystem boundary. await access(path.join(appPath, fileName)); + signal?.throwIfAborted(); return true; - } catch { + } catch (error) { + if (signal?.aborted) throw error; // ignore missing files } } - const packageJson = await readBunPackageJson(appPath); + const packageJson = await readBunPackageJson(appPath, signal); return hasDependency(packageJson, "next"); } -async function isBunProject(appPath: string): Promise { +async function isBunProject(appPath: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); try { + // access does not accept AbortSignal; check before and after the filesystem boundary. await access(path.join(appPath, "bun.lock")); + signal?.throwIfAborted(); return true; - } catch { + } catch (error) { + if (signal?.aborted) throw error; // ignore missing file } + signal?.throwIfAborted(); try { + // access does not accept AbortSignal; check before and after the filesystem boundary. await access(path.join(appPath, "bun.lockb")); + signal?.throwIfAborted(); return true; - } catch { + } catch (error) { + if (signal?.aborted) throw error; // ignore missing file } - const packageJson = await readBunPackageJson(appPath); + const packageJson = await readBunPackageJson(appPath, signal); if (!packageJson) { return false; } diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index 2b6de9f..8dccc4c 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -83,10 +83,10 @@ export async function executePreviewBuild(options: { try { if (buildType === "nextjs") { - await restageNextjsArtifact(artifact, options.appPath); + await restageNextjsArtifact(artifact, options.appPath, options.signal); } - await normalizeArtifactSymlinks(artifact.directory, options.appPath); + await normalizeArtifactSymlinks(artifact.directory, options.appPath, options.signal); return { artifact, buildType, @@ -111,6 +111,7 @@ export async function resolvePreviewBuildStrategy(options: { appPath: options.appPath, entrypoint: options.entrypoint, buildType: options.buildType, + signal: options.signal, }); return { @@ -127,6 +128,7 @@ export async function resolvePreviewBuildStrategy(options: { appPath: options.appPath, entrypoint: options.entrypoint, buildType, + signal: options.signal, }); if (await strategy.canBuild(options.signal)) { @@ -143,6 +145,7 @@ export async function resolvePreviewBuildStrategy(options: { appPath: options.appPath, entrypoint: options.entrypoint, buildType: "bun", + signal: options.signal, }), }; } @@ -151,6 +154,7 @@ async function createPreviewBuildStrategy(options: { appPath: string; entrypoint?: string; buildType: ResolvedPreviewBuildType; + signal?: AbortSignal; }): Promise { switch (options.buildType) { case "nextjs": @@ -162,7 +166,7 @@ async function createPreviewBuildStrategy(options: { case "tanstack-start": return new TanstackStartBuild({ appPath: options.appPath }); case "bun": { - const entrypoint = await resolveBunEntrypoint(options.appPath, options.entrypoint); + const entrypoint = await resolveBunEntrypoint(options.appPath, options.entrypoint, options.signal); return new BunBuild({ appPath: options.appPath, entrypoint, @@ -175,29 +179,32 @@ export async function stageNextjsStandaloneArtifact(options: { standaloneDir: string; artifactDir: string; appPath: string; + signal?: AbortSignal; }): Promise { const standaloneRoot = path.resolve(options.standaloneDir); const artifactRoot = path.resolve(options.artifactDir); const appRoot = path.resolve(options.appPath); - const sourceRoot = await resolveSourceRoot(appRoot); + const sourceRoot = await resolveSourceRoot(appRoot, options.signal); await copyPathMaterializingSymlinks(standaloneRoot, artifactRoot, { standaloneRoot, appRoot, sourceRoot, + signal: options.signal, }); - await hoistPnpmDependencies(path.join(artifactRoot, "node_modules")); + await hoistPnpmDependencies(path.join(artifactRoot, "node_modules"), options.signal); } -export async function restageNextjsArtifact(artifact: BuildArtifact, appPath: string): Promise { +export async function restageNextjsArtifact(artifact: BuildArtifact, appPath: string, signal?: AbortSignal): Promise { const artifactDir = artifact.directory; const standaloneDir = path.join(appPath, ".next", "standalone"); - await rm(artifactDir, { recursive: true, force: true }); + await unsupportedFilesystemBoundary(signal, () => rm(artifactDir, { recursive: true, force: true })); await stageNextjsStandaloneArtifact({ standaloneDir, artifactDir, appPath, + signal, }); // The SDK's Next.js strategy reports the entrypoint relative to the @@ -210,19 +217,19 @@ export async function restageNextjsArtifact(artifact: BuildArtifact, appPath: st : artifactDir; const publicDir = path.join(appPath, "public"); - if (await directoryExists(publicDir)) { - await cp(publicDir, path.join(serverDir, "public"), { + if (await directoryExists(publicDir, signal)) { + await unsupportedFilesystemBoundary(signal, () => cp(publicDir, path.join(serverDir, "public"), { recursive: true, verbatimSymlinks: true, - }); + })); } const staticDir = path.join(appPath, ".next", "static"); - if (await directoryExists(staticDir)) { - await cp(staticDir, path.join(serverDir, ".next", "static"), { + if (await directoryExists(staticDir, signal)) { + await unsupportedFilesystemBoundary(signal, () => cp(staticDir, path.join(serverDir, ".next", "static"), { recursive: true, verbatimSymlinks: true, - }); + })); } } @@ -232,25 +239,25 @@ function nextjsServerSubpath(entrypoint: string): string { return dir === "." ? "" : dir; } -async function hoistPnpmDependencies(nodeModulesDir: string): Promise { +async function hoistPnpmDependencies(nodeModulesDir: string, signal?: AbortSignal): Promise { const pnpmNodeModulesDir = path.join(nodeModulesDir, ".pnpm", "node_modules"); - if (!await directoryExists(pnpmNodeModulesDir)) { + if (!await directoryExists(pnpmNodeModulesDir, signal)) { return; } - const entries = await readdir(pnpmNodeModulesDir, { withFileTypes: true }); + const entries = await unsupportedFilesystemBoundary(signal, () => readdir(pnpmNodeModulesDir, { withFileTypes: true })); for (const entry of entries) { const sourcePath = path.join(pnpmNodeModulesDir, entry.name); if (entry.name.startsWith("@") && entry.isDirectory()) { - const scopedEntries = await readdir(sourcePath, { withFileTypes: true }); + const scopedEntries = await unsupportedFilesystemBoundary(signal, () => readdir(sourcePath, { withFileTypes: true })); for (const scopedEntry of scopedEntries) { const scopedDestination = path.join(nodeModulesDir, entry.name, scopedEntry.name); - if (await pathExists(scopedDestination)) { + if (await pathExists(scopedDestination, signal)) { continue; } - await mkdir(path.dirname(scopedDestination), { recursive: true }); + await unsupportedFilesystemBoundary(signal, () => mkdir(path.dirname(scopedDestination), { recursive: true })); await copyPathMaterializingSymlinks( path.join(sourcePath, scopedEntry.name), scopedDestination, @@ -258,6 +265,7 @@ async function hoistPnpmDependencies(nodeModulesDir: string): Promise { standaloneRoot: pnpmNodeModulesDir, appRoot: nodeModulesDir, sourceRoot: nodeModulesDir, + signal, }, ); } @@ -265,7 +273,7 @@ async function hoistPnpmDependencies(nodeModulesDir: string): Promise { } const destinationPath = path.join(nodeModulesDir, entry.name); - if (await pathExists(destinationPath)) { + if (await pathExists(destinationPath, signal)) { continue; } @@ -273,6 +281,7 @@ async function hoistPnpmDependencies(nodeModulesDir: string): Promise { standaloneRoot: pnpmNodeModulesDir, appRoot: nodeModulesDir, sourceRoot: nodeModulesDir, + signal, }); } } @@ -280,6 +289,7 @@ async function hoistPnpmDependencies(nodeModulesDir: string): Promise { export async function normalizeArtifactSymlinks( artifactDir: string, appPath: string, + signal?: AbortSignal, ): Promise { const normalizedArtifactDir = path.resolve(artifactDir); const normalizedAppPath = path.resolve(appPath); @@ -287,7 +297,7 @@ export async function normalizeArtifactSymlinks( await walkDirectory(normalizedArtifactDir); async function walkDirectory(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); + const entries = await unsupportedFilesystemBoundary(signal, () => readdir(directory, { withFileTypes: true })); for (const entry of entries) { const fullPath = path.join(directory, entry.name); @@ -301,7 +311,7 @@ export async function normalizeArtifactSymlinks( continue; } - const target = await readlink(fullPath); + const target = await unsupportedFilesystemBoundary(signal, () => readlink(fullPath)); const resolvedTarget = path.resolve(path.dirname(fullPath), target); if (isPathWithin(normalizedArtifactDir, resolvedTarget)) { @@ -312,12 +322,12 @@ export async function normalizeArtifactSymlinks( throw new Error(`Build artifact symlink escapes the app directory: ${resolvedTarget}`); } - const targetStat = await stat(resolvedTarget); - await rm(fullPath, { force: true, recursive: true }); - await cp(resolvedTarget, fullPath, { + const targetStat = await unsupportedFilesystemBoundary(signal, () => stat(resolvedTarget)); + await unsupportedFilesystemBoundary(signal, () => rm(fullPath, { force: true, recursive: true })); + await unsupportedFilesystemBoundary(signal, () => cp(resolvedTarget, fullPath, { recursive: targetStat.isDirectory(), dereference: true, - }); + })); if (targetStat.isDirectory()) { await walkDirectory(fullPath); @@ -348,9 +358,10 @@ async function copyPathMaterializingSymlinks( standaloneRoot: string; appRoot: string; sourceRoot: string; + signal?: AbortSignal; }, ): Promise { - const sourceStat = await lstat(sourcePath); + const sourceStat = await unsupportedFilesystemBoundary(options.signal, () => lstat(sourcePath)); if (sourceStat.isSymbolicLink()) { const resolvedTarget = await resolveSymlinkTarget(sourcePath, options); @@ -362,9 +373,9 @@ async function copyPathMaterializingSymlinks( } if (sourceStat.isDirectory()) { - await mkdir(destinationPath, { recursive: true }); + await unsupportedFilesystemBoundary(options.signal, () => mkdir(destinationPath, { recursive: true })); - const entries = await readdir(sourcePath, { withFileTypes: true }); + const entries = await unsupportedFilesystemBoundary(options.signal, () => readdir(sourcePath, { withFileTypes: true })); for (const entry of entries) { await copyPathMaterializingSymlinks( path.join(sourcePath, entry.name), @@ -377,9 +388,9 @@ async function copyPathMaterializingSymlinks( } if (sourceStat.isFile()) { - await mkdir(path.dirname(destinationPath), { recursive: true }); - await copyFile(sourcePath, destinationPath); - await chmod(destinationPath, sourceStat.mode); + await unsupportedFilesystemBoundary(options.signal, () => mkdir(path.dirname(destinationPath), { recursive: true })); + await unsupportedFilesystemBoundary(options.signal, () => copyFile(sourcePath, destinationPath)); + await unsupportedFilesystemBoundary(options.signal, () => chmod(destinationPath, sourceStat.mode)); } } @@ -389,12 +400,13 @@ async function resolveSymlinkTarget( standaloneRoot: string; appRoot: string; sourceRoot: string; + signal?: AbortSignal; }, ): Promise { - const linkTarget = await readlink(symlinkPath); + const linkTarget = await unsupportedFilesystemBoundary(options.signal, () => readlink(symlinkPath)); const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget); - if (await pathExists(resolvedTarget)) { + if (await pathExists(resolvedTarget, options.signal)) { if ( !isPathWithin(options.appRoot, resolvedTarget) && !isPathWithinWorkspaceDependency(options.sourceRoot, resolvedTarget) @@ -411,7 +423,7 @@ async function resolveSymlinkTarget( path.relative(options.standaloneRoot, resolvedTarget), ); - if (await pathExists(fallbackTarget)) { + if (await pathExists(fallbackTarget, options.signal)) { return fallbackTarget; } } @@ -439,34 +451,36 @@ function isPnpmHoistLink(symlinkPath: string): boolean { return false; } -async function pathExists(targetPath: string): Promise { +async function pathExists(targetPath: string, signal?: AbortSignal): Promise { try { - await stat(targetPath); + await unsupportedFilesystemBoundary(signal, () => stat(targetPath)); return true; - } catch { + } catch (error) { + if (signal?.aborted) throw error; return false; } } -async function directoryExists(targetPath: string): Promise { +async function directoryExists(targetPath: string, signal?: AbortSignal): Promise { try { - const targetStat = await stat(targetPath); + const targetStat = await unsupportedFilesystemBoundary(signal, () => stat(targetPath)); return targetStat.isDirectory(); - } catch { + } catch (error) { + if (signal?.aborted) throw error; return false; } } -async function resolveSourceRoot(appRoot: string): Promise { +async function resolveSourceRoot(appRoot: string, signal?: AbortSignal): Promise { let current = path.resolve(appRoot); while (true) { if ( - await pathExists(path.join(current, ".git")) || - await pathExists(path.join(current, "pnpm-workspace.yaml")) || - await pathExists(path.join(current, "bun.lock")) || - await pathExists(path.join(current, "bun.lockb")) || - await packageJsonDeclaresWorkspaces(current) + await pathExists(path.join(current, ".git"), signal) || + await pathExists(path.join(current, "pnpm-workspace.yaml"), signal) || + await pathExists(path.join(current, "bun.lock"), signal) || + await pathExists(path.join(current, "bun.lockb"), signal) || + await packageJsonDeclaresWorkspaces(current, signal) ) { return current; } @@ -480,12 +494,22 @@ async function resolveSourceRoot(appRoot: string): Promise { } } -async function packageJsonDeclaresWorkspaces(directory: string): Promise { +async function packageJsonDeclaresWorkspaces(directory: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); try { - const content = await readFile(path.join(directory, "package.json"), "utf8"); + const content = await readFile(path.join(directory, "package.json"), { encoding: "utf8", signal }); const parsed = JSON.parse(content) as { workspaces?: unknown }; return Boolean(parsed.workspaces); - } catch { + } catch (error) { + if (signal?.aborted) throw error; return false; } } + +async function unsupportedFilesystemBoundary(signal: AbortSignal | undefined, operation: () => Promise): Promise { + // These Node fs promise APIs do not accept AbortSignal; check immediately before and after the boundary. + signal?.throwIfAborted(); + const result = await operation(); + signal?.throwIfAborted(); + return result; +} diff --git a/packages/cli/src/lib/auth/auth-ops.ts b/packages/cli/src/lib/auth/auth-ops.ts index d909cdc..720163c 100644 --- a/packages/cli/src/lib/auth/auth-ops.ts +++ b/packages/cli/src/lib/auth/auth-ops.ts @@ -32,8 +32,8 @@ function workspaceIdFromClaims(claims: Record): string | null { return id.length > 0 ? id : null; } -export async function performLogin(env: NodeJS.ProcessEnv): Promise { - await login({ tokenStorage: new FileTokenStorage(env), env }); +export async function performLogin(env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { + await login({ tokenStorage: new FileTokenStorage(env, signal), env, signal }); } export async function readAuthState(env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { @@ -54,7 +54,7 @@ export async function readAuthState(env: NodeJS.ProcessEnv, signal?: AbortSignal return readServiceTokenAuthState(serviceToken, env, signal); } - const tokenStorage = new FileTokenStorage(env); + const tokenStorage = new FileTokenStorage(env, signal); const tokens = await tokenStorage.getTokens(); if (!tokens) { @@ -67,7 +67,7 @@ export async function readAuthState(env: NodeJS.ProcessEnv, signal?: AbortSignal }; } - const client = await requireComputeAuth(env); + const client = await requireComputeAuth(env, signal); const currentPrincipal = await readCurrentPrincipalAuthState(client, signal); if (currentPrincipal) { return currentPrincipal; @@ -88,7 +88,7 @@ async function readServiceTokenAuthState( env: NodeJS.ProcessEnv, signal?: AbortSignal, ): Promise { - const client = await requireComputeAuth(env); + const client = await requireComputeAuth(env, signal); const currentPrincipal = await readCurrentPrincipalAuthState(client, signal); if (currentPrincipal) { return currentPrincipal; @@ -136,7 +136,7 @@ async function buildAuthState({ let workspaceId = workspaceIdFromCredential; let workspaceName = workspaceIdFromCredential; - client ??= await requireComputeAuth(env); + client ??= await requireComputeAuth(env, signal); if (client) { try { @@ -225,6 +225,6 @@ async function readCurrentPrincipalAuthState( } } -export async function performLogout(env: NodeJS.ProcessEnv): Promise { - await new FileTokenStorage(env).clearTokens(); +export async function performLogout(env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { + await new FileTokenStorage(env, signal).clearTokens(); } diff --git a/packages/cli/src/lib/auth/guard.ts b/packages/cli/src/lib/auth/guard.ts index 6434bb7..4f05de3 100644 --- a/packages/cli/src/lib/auth/guard.ts +++ b/packages/cli/src/lib/auth/guard.ts @@ -18,7 +18,9 @@ import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; */ export async function requireComputeAuth( env: NodeJS.ProcessEnv = process.env, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const rawToken = env[SERVICE_TOKEN_ENV_VAR]; if (rawToken !== undefined) { @@ -34,7 +36,7 @@ export async function requireComputeAuth( }); } - const tokenStorage = new FileTokenStorage(env); + const tokenStorage = new FileTokenStorage(env, signal); const tokens = await tokenStorage.getTokens(); if (!tokens) { diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/lib/auth/login.ts index 2dbc8b4..fd1fc2f 100644 --- a/packages/cli/src/lib/auth/login.ts +++ b/packages/cli/src/lib/auth/login.ts @@ -29,6 +29,7 @@ export interface LoginOptions { port?: number; openUrl?: (url: string) => Promise | unknown; env?: NodeJS.ProcessEnv; + signal?: AbortSignal; } export async function login(options: LoginOptions = {}): Promise { @@ -51,6 +52,7 @@ export async function login(options: LoginOptions = {}): Promise { authBaseUrl: options.authBaseUrl, openUrl: options.openUrl, env: options.env, + signal: options.signal, }); const authResult = new Promise((resolve, reject) => { @@ -79,6 +81,7 @@ export async function login(options: LoginOptions = {}): Promise { }); }); + options.signal?.throwIfAborted(); await state.openLoginPage(); await authResult; } finally { @@ -105,9 +108,10 @@ class LoginState { authBaseUrl?: string; openUrl?: (url: string) => Promise | unknown; env?: NodeJS.ProcessEnv; + signal?: AbortSignal; }, ) { - this.tokenStorage = options.tokenStorage ?? new FileTokenStorage(options.env); + this.tokenStorage = options.tokenStorage ?? new FileTokenStorage(options.env, options.signal); this.sdk = createManagementApiSdk({ clientId: options.clientId ?? CLIENT_ID, redirectUri: `http://${options.hostname}:${options.port}/auth/callback`, @@ -119,6 +123,7 @@ class LoginState { } async openLoginPage(): Promise { + this.options.signal?.throwIfAborted(); const { url, state, verifier } = await this.sdk.getLoginUrl({ scope: "workspace:admin offline_access", additionalParams: { @@ -131,7 +136,10 @@ class LoginState { this.latestState = state; this.latestVerifier = verifier; + this.options.signal?.throwIfAborted(); + // Browser launch cannot consume AbortSignal; check immediately before and after the boundary. await this.openUrl(url); + this.options.signal?.throwIfAborted(); } async handleCallback(url: URL): Promise { @@ -172,6 +180,7 @@ class LoginState { const { data } = await this.sdk.client.GET("/v1/workspaces/{id}", { params: { path: { id: tokens.workspaceId } }, + signal: this.options.signal, }); const name = data?.data?.name; return typeof name === "string" && name.trim().length > 0 ? name.trim() : null; diff --git a/packages/cli/src/lib/project/interactive-setup.ts b/packages/cli/src/lib/project/interactive-setup.ts index 5aff29f..c549af9 100644 --- a/packages/cli/src/lib/project/interactive-setup.ts +++ b/packages/cli/src/lib/project/interactive-setup.ts @@ -65,7 +65,7 @@ export async function promptForProjectSetupChoice(options: { }; } - const suggestedName = await inferTargetName(options.context.runtime.cwd); + const suggestedName = await inferTargetName(options.context.runtime.cwd, options.context.runtime.signal); const rawName = await textPrompt({ input: options.context.runtime.stdin, output: options.context.runtime.stderr, diff --git a/packages/cli/src/lib/project/local-pin.ts b/packages/cli/src/lib/project/local-pin.ts index 34b5a21..95f6833 100644 --- a/packages/cli/src/lib/project/local-pin.ts +++ b/packages/cli/src/lib/project/local-pin.ts @@ -13,9 +13,10 @@ export type LocalResolutionPinReadResult = | { kind: "invalid" } | { kind: "present"; pin: LocalResolutionPin }; -export async function readLocalResolutionPin(cwd: string): Promise { +export async function readLocalResolutionPin(cwd: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); try { - const raw = await readFile(path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH), "utf8"); + const raw = await readFile(path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH), { encoding: "utf8", signal }); const parsed = JSON.parse(raw) as unknown; if (!isLocalResolutionPin(parsed)) { return { kind: "invalid" }; @@ -39,21 +40,27 @@ export async function readLocalResolutionPin(cwd: string): Promise { const prismaDir = path.join(cwd, ".prisma"); + signal?.throwIfAborted(); + // mkdir does not accept AbortSignal; check before the filesystem boundary. await mkdir(prismaDir, { recursive: true }); const pinPath = path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH); const tmpPath = path.join(prismaDir, `local.${process.pid}.${Date.now()}.tmp`); - await writeFile(tmpPath, `${JSON.stringify(pin, null, 2)}\n`, "utf8"); + await writeFile(tmpPath, `${JSON.stringify(pin, null, 2)}\n`, { encoding: "utf8", signal }); + signal?.throwIfAborted(); + // rename does not accept AbortSignal; check before the filesystem boundary. await rename(tmpPath, pinPath); } -export async function ensureLocalResolutionPinGitignore(cwd: string): Promise { +export async function ensureLocalResolutionPinGitignore(cwd: string, signal?: AbortSignal): Promise { const gitignorePath = path.join(cwd, ".gitignore"); let existing: string | null = null; + signal?.throwIfAborted(); try { - existing = await readFile(gitignorePath, "utf8"); + existing = await readFile(gitignorePath, { encoding: "utf8", signal }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; @@ -61,7 +68,7 @@ export async function ensureLocalResolutionPinGitignore(cwd: string): Promise { - const suggestedName = await inferTargetName(options.cwd); + const suggestedName = await inferTargetName(options.cwd, options.signal); const candidates = sortProjects( options.projects.filter((project) => projectMatchesSuggestedName(project, suggestedName.name)), ).map(toProjectSummary); @@ -154,6 +157,7 @@ export async function projectSetupRequiredError(options: { cwd: string; projects: ProjectCandidate[]; commandName?: string; + signal?: AbortSignal; }): Promise { const suggestion = await buildProjectSetupSuggestion(options); const commandLabel = options.commandName ? `prisma-cli ${options.commandName}` : "this command"; @@ -227,9 +231,10 @@ export function buildProjectSetupNextActions(options: { return actions; } -export async function readPackageName(cwd: string): Promise { +export async function readPackageName(cwd: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); try { - const raw = await readFile(path.join(cwd, "package.json"), "utf8"); + const raw = await readFile(path.join(cwd, "package.json"), { encoding: "utf8", signal }); const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object") { return null; @@ -247,8 +252,8 @@ export async function readPackageName(cwd: string): Promise { } } -export async function inferTargetName(cwd: string): Promise { - const packageName = await readPackageName(cwd); +export async function inferTargetName(cwd: string, signal?: AbortSignal): Promise { + const packageName = await readPackageName(cwd, signal); if (packageName && isValidInferredTargetName(packageName)) { return { name: packageName, @@ -320,7 +325,7 @@ async function resolveBoundProjectTarget( }); } - const localPin = await readLocalResolutionPin(options.context.runtime.cwd); + const localPin = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal); if (localPin.kind === "invalid") { throw localStateStaleError(); } diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index 9594aaf..a1c54b7 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -50,8 +50,8 @@ export async function bindProjectToDirectory( await writeLocalResolutionPin(context.runtime.cwd, { workspaceId: workspace.id, projectId: project.id, - }); - await ensureLocalResolutionPinGitignore(context.runtime.cwd); + }, context.runtime.signal); + await ensureLocalResolutionPinGitignore(context.runtime.cwd, context.runtime.signal); return { workspace, diff --git a/packages/cli/src/shell/runtime.ts b/packages/cli/src/shell/runtime.ts index ff07cf1..945cd39 100644 --- a/packages/cli/src/shell/runtime.ts +++ b/packages/cli/src/shell/runtime.ts @@ -62,7 +62,7 @@ export async function createCommandContext( // Load the mock API only when fixture mode is explicitly enabled. let loadedApi: MockApi | undefined; if (fixturePath) { - loadedApi = await MockApi.load(fixturePath); + loadedApi = await MockApi.load(fixturePath, runtime.signal); } return { @@ -75,7 +75,7 @@ export async function createCommandContext( return loadedApi; }, - stateStore: new LocalStateStore(stateDir), + stateStore: new LocalStateStore(stateDir, runtime.signal), output: { stdout: runtime.stdout, stderr: runtime.stderr, diff --git a/packages/cli/tests/app-bun-compat.test.ts b/packages/cli/tests/app-bun-compat.test.ts index 9047c80..faf5914 100644 --- a/packages/cli/tests/app-bun-compat.test.ts +++ b/packages/cli/tests/app-bun-compat.test.ts @@ -32,6 +32,17 @@ describe("bun compatibility", () => { await expect(resolveBunEntrypoint(cwd, undefined)).resolves.toBe("index.ts"); }); + it("rejects Bun package reads when the command signal is already aborted", async () => { + const cwd = await createTempCwd(); + const controller = new AbortController(); + const reason = new Error("cancelled"); + controller.abort(reason); + + const { readBunPackageJson } = await import("../src/lib/app/bun-project"); + + await expect(readBunPackageJson(cwd, controller.signal)).rejects.toBe(reason); + }); + it("detects a Bun project when package.json uses module instead of main", async () => { const cwd = await createTempCwd(); diff --git a/packages/cli/tests/app-state.test.ts b/packages/cli/tests/app-state.test.ts index 12ad342..dba8e6e 100644 --- a/packages/cli/tests/app-state.test.ts +++ b/packages/cli/tests/app-state.test.ts @@ -54,6 +54,16 @@ describe("app local state", () => { }); }); + it("rejects local state reads when the command signal is already aborted", async () => { + const cwd = await createTempCwd(); + const controller = new AbortController(); + const reason = new Error("cancelled"); + controller.abort(reason); + const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME), controller.signal); + + await expect(store.read()).rejects.toBe(reason); + }); + it("persists known live deployments by project and app id", async () => { const cwd = await createTempCwd(); const store = new LocalStateStore(path.join(cwd, DEFAULT_STATE_DIR_NAME)); diff --git a/packages/cli/tests/token-storage.test.ts b/packages/cli/tests/token-storage.test.ts index 65c7176..1d5e78e 100644 --- a/packages/cli/tests/token-storage.test.ts +++ b/packages/cli/tests/token-storage.test.ts @@ -91,6 +91,40 @@ describe("FileTokenStorage", () => { }); }); + it("stops waiting for the refresh lock when the command signal is aborted", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + const firstStorage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv); + const controller = new AbortController(); + const secondStorage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv, controller.signal); + const reason = new Error("cancelled"); + let releaseFirst!: () => void; + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstStarted!: () => void; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + + const first = firstStorage.withRefreshLock(async () => { + markFirstStarted(); + await firstReleased; + }); + await firstStarted; + + const second = secondStorage.withRefreshLock(async () => undefined); + controller.abort(reason); + + await expect(second).rejects.toBe(reason); + releaseFirst(); + await first; + }); + it("replaces stale refresh locks", async () => { const cwd = await createTempCwd(); const authFilePath = path.join(cwd, "auth.json"); From f94a32990e9438f91007dc8f4a7027467d50fea6 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:34:33 -0400 Subject: [PATCH 13/20] Complete cancellation propagation audit --- ...-cancellation-propagation-analysis.plan.md | 14 +++---- packages/cli/src/controllers/app-env.ts | 39 +++++++++++++------ packages/cli/src/controllers/project.ts | 36 +++++++++++------ packages/cli/tests/auth-real-mode.test.ts | 2 +- packages/cli/tests/project-real-mode.test.ts | 5 ++- 5 files changed, 64 insertions(+), 32 deletions(-) diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md index 0808761..daf5cb3 100644 --- a/.agents/projects/cli-cancellation-propagation-analysis.plan.md +++ b/.agents/projects/cli-cancellation-propagation-analysis.plan.md @@ -140,7 +140,7 @@ None. ### Phase 6: End-To-End Verification And Cleanup -**Status:** ☐ Not started +**Status:** ✓ Complete **Goal:** Prove cancellation behavior across the CLI surface and remove inconsistencies left by incremental propagation. @@ -157,11 +157,11 @@ None. **Acceptance Criteria:** -- **AC1:** No remaining CLI-owned polling loop uses a non-signal-aware sleep. -- **AC2:** No supported SDK, child-process, or filesystem boundary lacks the propagated command signal where the upstream API accepts it. -- **AC3:** Unsupported boundaries are guarded and documented locally without fake cancellation wrappers. -- **AC4:** Human and JSON cancellation output are stable for regular and streaming commands. -- **AC5:** `pnpm --filter @prisma/cli test` passes. -- **AC6:** `pnpm --filter @prisma/cli build` passes. +- [x] **AC1:** No remaining CLI-owned polling loop uses a non-signal-aware sleep. +- [x] **AC2:** No supported SDK, child-process, or filesystem boundary lacks the propagated command signal where the upstream API accepts it. +- [x] **AC3:** Unsupported boundaries are guarded and documented locally without fake cancellation wrappers. +- [x] **AC4:** Human and JSON cancellation output are stable for regular and streaming commands. +- [x] **AC5:** `pnpm --filter @prisma/cli test` passes. +- [x] **AC6:** `pnpm --filter @prisma/cli build` passes. ## Revision Log diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index d67d756..7385d8a 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -74,9 +74,10 @@ export async function runEnvAdd( const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env add"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: true, + signal: context.runtime.signal, }); - const existing = await findVariableByNaturalKey(client, projectId, key, resolved); + const existing = await findVariableByNaturalKey(client, projectId, key, resolved, context.runtime.signal); if (existing) { throw new CliError({ @@ -98,7 +99,7 @@ export async function runEnvAdd( scope: { kind: "role", role: "preview" }, descriptor: { kind: "role", role: "preview" }, apiTarget: { class: "preview", branchId: null }, - })) + }, context.runtime.signal)) ? [ `Variable "${key}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`, ] @@ -155,9 +156,10 @@ export async function runEnvUpdate( const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env update"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: false, + signal: context.runtime.signal, }); - const existing = await findVariableByNaturalKey(client, projectId, key, resolved); + const existing = await findVariableByNaturalKey(client, projectId, key, resolved, context.runtime.signal); if (!existing) { throw new CliError({ @@ -207,8 +209,9 @@ export async function runEnvList( const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env list"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: false, + signal: context.runtime.signal, }); - const variables = await listVariables(client, projectId, resolved); + const variables = await listVariables(client, projectId, resolved, context.runtime.signal); return { command: "project.env.list", @@ -253,8 +256,9 @@ export async function runEnvRemove( const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env remove"); const resolved = await resolveScopeToApi(client, projectId, scope, { createBranchIfMissing: false, + signal: context.runtime.signal, }); - const existing = await findVariableByNaturalKey(client, projectId, key, resolved); + const existing = await findVariableByNaturalKey(client, projectId, key, resolved, context.runtime.signal); if (!existing) { throw new CliError({ code: "ENV_VARIABLE_NOT_FOUND", @@ -321,7 +325,7 @@ async function resolveScopeToApi( client: ManagementApiClient, projectId: string, scope: EnvScope, - options: { createBranchIfMissing: boolean }, + options: { createBranchIfMissing: boolean; signal: AbortSignal }, ): Promise { if (scope.kind === "role") { return { @@ -332,8 +336,8 @@ async function resolveScopeToApi( } const branch = options.createBranchIfMissing - ? await resolveOrCreateBranch(client, projectId, scope.branchName) - : await resolveExistingBranch(client, projectId, scope.branchName); + ? await resolveOrCreateBranch(client, projectId, scope.branchName, options.signal) + : await resolveExistingBranch(client, projectId, scope.branchName, options.signal); if (branch.isDefault) { throw new CliError({ @@ -369,6 +373,7 @@ async function listBranchesByName( client: ManagementApiClient, projectId: string, branchName: string, + signal: AbortSignal, ): Promise { const { data, error, response } = await client.GET( "/v1/projects/{projectId}/branches", @@ -377,6 +382,7 @@ async function listBranchesByName( path: { projectId }, query: { gitName: branchName }, }, + signal, }, ); if (error || !data) { @@ -390,8 +396,9 @@ async function resolveExistingBranch( client: ManagementApiClient, projectId: string, branchName: string, + signal: AbortSignal, ): Promise { - const branch = (await listBranchesByName(client, projectId, branchName))[0]; + const branch = (await listBranchesByName(client, projectId, branchName, signal))[0]; if (!branch) { throw new CliError({ code: "ENV_BRANCH_NOT_FOUND", @@ -410,13 +417,14 @@ async function resolveOrCreateBranch( client: ManagementApiClient, projectId: string, branchName: string, + signal: AbortSignal, ): Promise { - const existing = (await listBranchesByName(client, projectId, branchName))[0]; + const existing = (await listBranchesByName(client, projectId, branchName, signal))[0]; if (existing) { return existing; } - if (!(await projectHasDefaultBranch(client, projectId))) { + if (!(await projectHasDefaultBranch(client, projectId, signal))) { throw new CliError({ code: "ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH", domain: "app", @@ -433,11 +441,12 @@ async function resolveOrCreateBranch( { params: { path: { projectId } }, body: { gitName: branchName, isDefault: false }, + signal, }, ); if (error || !data) { if (response?.status === 409) { - const raced = (await listBranchesByName(client, projectId, branchName))[0]; + const raced = (await listBranchesByName(client, projectId, branchName, signal))[0]; if (raced) { return raced; } @@ -452,6 +461,7 @@ async function resolveOrCreateBranch( async function projectHasDefaultBranch( client: ManagementApiClient, projectId: string, + signal: AbortSignal, ): Promise { let cursor: string | undefined; @@ -469,6 +479,7 @@ async function projectHasDefaultBranch( path: { projectId }, query, }, + signal, }, ); if (result.error || !result.data) { @@ -491,6 +502,7 @@ async function findVariableByNaturalKey( projectId: string, key: string, resolved: ResolvedScope, + signal: AbortSignal, ): Promise { const { data, error, response } = await client.GET("/v1/environment-variables", { params: { @@ -500,6 +512,7 @@ async function findVariableByNaturalKey( key, }, }, + signal, }); if (error || !data) { throw apiCallError(`Failed to look up ${key}`, response, error); @@ -515,6 +528,7 @@ async function listVariables( client: ManagementApiClient, projectId: string, resolved: ResolvedScope, + signal: AbortSignal, ): Promise { const collected: RawEnvironmentVariable[] = []; let cursor: string | undefined; @@ -531,6 +545,7 @@ async function listVariables( const result = await client.GET("/v1/environment-variables", { params: { query }, + signal, }); if (result.error || !result.data) { throw apiCallError( diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index bf0777c..73a7b5b 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -63,8 +63,9 @@ async function readProjectListLocalBinding( cwd: string, workspace: AuthWorkspace, projects: Array>, + signal: AbortSignal, ): Promise { - const pin = await readLocalResolutionPin(cwd, context.runtime.signal); + const pin = await readLocalResolutionPin(cwd, signal); if (pin.kind === "present") { return pin.pin.workspaceId === workspace.id && projects.some((project) => project.id === pin.pin.projectId) ? { status: "linked" } @@ -89,7 +90,7 @@ export async function runProjectList(context: CommandContext): Promise { - const installations = await listScmInstallations(api, workspaceId); - const lookup = await findRepositoryInInstallations(api, installations, repository); + const installations = await listScmInstallations(api, workspaceId, context.runtime.signal); + const lookup = await findRepositoryInInstallations(api, installations, repository, context.runtime.signal); if (lookup.match) { return lookup.match; } - const installUrl = await createGitHubInstallIntent(api, workspaceId); + const installUrl = await createGitHubInstallIntent(api, workspaceId, context.runtime.signal); const canWait = canPrompt(context); const opened = await openInstallUrlIfInteractive(context, installUrl); @@ -841,6 +844,7 @@ async function findRepositoryInInstallations( api: SourceRepositoryApiClient, installations: ScmInstallationResponse[], repository: GitHubRepositoryReference, + signal: AbortSignal, ): Promise { let inspectableInstallationCount = 0; @@ -849,7 +853,7 @@ async function findRepositoryInInstallations( continue; } - const matchedRepository = await findRepositoryInInstallationIfAvailable(api, installation.id, repository); + const matchedRepository = await findRepositoryInInstallationIfAvailable(api, installation.id, repository, signal); if (matchedRepository === "unavailable") { continue; } @@ -891,9 +895,9 @@ async function waitForInstalledRepository( while (Date.now() <= deadline) { context.runtime.signal.throwIfAborted(); - const installations = await listScmInstallations(api, workspaceId); + const installations = await listScmInstallations(api, workspaceId, context.runtime.signal); - const lookup = await findRepositoryInInstallations(api, installations, repository); + const lookup = await findRepositoryInInstallations(api, installations, repository, context.runtime.signal); inspectableInstallationCount = lookup.inspectableInstallationCount; if (lookup.match) { return { match: lookup.match, inspectableInstallationCount }; @@ -963,6 +967,7 @@ function sleep(ms: number, signal: AbortSignal): Promise { async function listScmInstallations( api: SourceRepositoryApiClient, workspaceId: string, + signal: AbortSignal, ): Promise { const installations: ScmInstallationResponse[] = []; let cursor: string | undefined; @@ -977,6 +982,7 @@ async function listScmInstallations( ...(cursor ? { cursor } : {}), }, }, + signal, }); if (error || !data) { @@ -999,6 +1005,7 @@ async function findRepositoryInInstallation( api: SourceRepositoryApiClient, installationId: string, repository: GitHubRepositoryReference, + signal: AbortSignal, ): Promise { const expectedFullName = repository.fullName.toLowerCase(); let cursor: string | undefined; @@ -1015,6 +1022,7 @@ async function findRepositoryInInstallation( ...(cursor ? { cursor } : {}), }, }, + signal, }); if (error || !data) { @@ -1064,10 +1072,12 @@ async function findRepositoryInInstallationIfAvailable( api: SourceRepositoryApiClient, installationId: string, repository: GitHubRepositoryReference, + signal: AbortSignal, ): Promise { try { - return await findRepositoryInInstallation(api, installationId, repository); + return await findRepositoryInInstallation(api, installationId, repository, signal); } catch (error) { + if (signal.aborted) throw error; if (isUnavailableScmInstallationError(error)) { return "unavailable"; } @@ -1087,12 +1097,14 @@ function isUnavailableScmInstallationError(error: unknown): boolean { async function createGitHubInstallIntent( api: SourceRepositoryApiClient, workspaceId: string, + signal: AbortSignal, ): Promise { const { data, error, response } = await api.POST("/v1/scm-installations/install-intents", { body: { provider: "github", workspaceId, }, + signal, }); if (error || !data) { @@ -1125,6 +1137,7 @@ async function openInstallUrlIfInteractive( async function readFirstSourceRepository( api: SourceRepositoryApiClient, projectId: string, + signal: AbortSignal, ): Promise { const { data, error, response } = await api.GET("/v1/source-repositories", { params: { @@ -1133,6 +1146,7 @@ async function readFirstSourceRepository( limit: 1, }, }, + signal, }); if (error || !data) { diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index 3ec7f4d..10de7f1 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -52,7 +52,7 @@ describe("real auth mode", () => { const result = await runAuthLogin(context, {}); - expect(performLogin).toHaveBeenCalledWith(context.runtime.env); + expect(performLogin).toHaveBeenCalledWith(context.runtime.env, context.runtime.signal); expect(readAuthState).toHaveBeenCalledWith(context.runtime.env, context.runtime.signal); expect(result.result).toMatchObject({ authenticated: true, diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index 685127e..e2c3205 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -137,7 +137,7 @@ describe("real project mode", () => { const result = await runProjectList(context); expect(readAuthState).toHaveBeenCalledWith(context.runtime.env, context.runtime.signal); - expect(requireComputeAuth).toHaveBeenCalledWith(context.runtime.env); + expect(requireComputeAuth).toHaveBeenCalledWith(context.runtime.env, context.runtime.signal); expect(result.result).toEqual({ workspace: { id: "ws_123", @@ -347,6 +347,7 @@ describe("real project mode", () => { cursor: "2", }, }, + signal: context.runtime.signal, }); expect(result.result.repositoryConnection).toMatchObject({ id: "srcrepo_123", @@ -751,6 +752,7 @@ describe("real project mode", () => { providerRepositoryId: 123456, installationId: "scminstall_123", }, + signal: context.runtime.signal, }); expect(stderr.buffer).toContain("Waiting for GitHub App installation or repository access approval"); expect(result.result.repositoryConnection?.repository.fullName).toBe("prisma/prisma-cli"); @@ -1062,6 +1064,7 @@ describe("real project mode", () => { id: "srcrepo_123", }, }, + signal: context.runtime.signal, }); expect(result.result.repositoryConnection?.repository.fullName).toBe("prisma/prisma-cli"); }); From 909d5e60dea4aff6da9dcd958912276a05b70082 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:40:35 -0400 Subject: [PATCH 14/20] Make OAuth callback wait cancellable --- packages/cli/src/lib/auth/login.ts | 16 +++++++++---- packages/cli/tests/auth-login.test.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/lib/auth/login.ts index fd1fc2f..589004c 100644 --- a/packages/cli/src/lib/auth/login.ts +++ b/packages/cli/src/lib/auth/login.ts @@ -56,6 +56,15 @@ export async function login(options: LoginOptions = {}): Promise { }); const authResult = new Promise((resolve, reject) => { + const onAbort = () => { + reject(options.signal?.reason); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + const settle = (callback: () => void) => { + options.signal?.removeEventListener("abort", onAbort); + callback(); + }; + server.on("request", async (req, res) => { const url = new URL(`http://${state.host}${req.url}`); if (url.pathname !== "/auth/callback") { @@ -70,20 +79,19 @@ export async function login(options: LoginOptions = {}): Promise { res.statusCode = 400; const message = error instanceof Error ? error.message : String(error); res.end(message); - reject(error); + settle(() => reject(error)); return; } const workspaceName = await state.resolveWorkspaceName(); res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end(renderSuccessPage(workspaceName)); - resolve(); + settle(resolve); }); }); options.signal?.throwIfAborted(); - await state.openLoginPage(); - await authResult; + await Promise.all([state.openLoginPage(), authResult]); } finally { if (server.listening) { await new Promise((resolve) => server.close(() => resolve())); diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index ef4877d..c17cb34 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -64,6 +64,39 @@ describe("auth login callback", () => { expect(result.body).toContain("height: 36px;"); expect(result.body).toContain("fill: currentColor !important;"); }); + + it("rejects when the command signal aborts while waiting for the callback", async () => { + vi.doMock("@prisma/management-api-sdk", () => ({ + AuthError: class SDKAuthError extends Error {}, + createManagementApiSdk: vi.fn().mockReturnValue({ + getLoginUrl: vi.fn().mockReturnValue({ + url: "https://auth.example.test/login", + state: "state_123", + verifier: "verifier_123", + }), + handleCallback: vi.fn(), + client: { GET: vi.fn() }, + }), + })); + const controller = new AbortController(); + const reason = new DOMException("Command canceled", "AbortError"); + const tokenStorage: TokenStorage = { + getTokens: vi.fn(), + setTokens: vi.fn(), + clearTokens: vi.fn(), + }; + + const { login } = await import("../src/lib/auth/login"); + + await expect(login({ + hostname: "127.0.0.1", + tokenStorage, + signal: controller.signal, + openUrl: () => { + controller.abort(reason); + }, + })).rejects.toBe(reason); + }); }); async function requestSuccessPage(options: { From 82ecd4aa9fd3a3b22d2371e7971676968ba718c2 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:42:02 -0400 Subject: [PATCH 15/20] Preserve auth cancellation during fallbacks --- packages/cli/src/lib/auth/auth-ops.ts | 2 + packages/cli/src/lib/auth/login.ts | 10 ++--- packages/cli/tests/auth-login.test.ts | 49 ++++++++++++++++++++++++ packages/cli/tests/auth-ops.test.ts | 54 +++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/auth/auth-ops.ts b/packages/cli/src/lib/auth/auth-ops.ts index 720163c..759ce31 100644 --- a/packages/cli/src/lib/auth/auth-ops.ts +++ b/packages/cli/src/lib/auth/auth-ops.ts @@ -167,6 +167,7 @@ async function buildAuthState({ workspaceName = data.data.name; } } catch { + signal?.throwIfAborted(); // fall through - use workspaceId as name } } @@ -221,6 +222,7 @@ async function readCurrentPrincipalAuthState( credential: principal.credential, }; } catch { + signal?.throwIfAborted(); return null; } } diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/lib/auth/login.ts index 589004c..fa67d51 100644 --- a/packages/cli/src/lib/auth/login.ts +++ b/packages/cli/src/lib/auth/login.ts @@ -75,6 +75,10 @@ export async function login(options: LoginOptions = {}): Promise { try { await state.handleCallback(url); + const workspaceName = await state.resolveWorkspaceName(); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderSuccessPage(workspaceName)); + settle(resolve); } catch (error) { res.statusCode = 400; const message = error instanceof Error ? error.message : String(error); @@ -82,11 +86,6 @@ export async function login(options: LoginOptions = {}): Promise { settle(() => reject(error)); return; } - - const workspaceName = await state.resolveWorkspaceName(); - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(renderSuccessPage(workspaceName)); - settle(resolve); }); }); @@ -193,6 +192,7 @@ class LoginState { const name = data?.data?.name; return typeof name === "string" && name.trim().length > 0 ? name.trim() : null; } catch { + this.options.signal?.throwIfAborted(); return null; } } diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index c17cb34..74461a5 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -97,6 +97,55 @@ describe("auth login callback", () => { }, })).rejects.toBe(reason); }); + + it("rejects when the command signal aborts during workspace lookup", async () => { + const controller = new AbortController(); + const reason = new DOMException("Command canceled", "AbortError"); + const tokenStorage: TokenStorage = { + getTokens: vi.fn().mockResolvedValue({ + workspaceId: "ws_123", + accessToken: "access-token", + refreshToken: "refresh-token", + }), + setTokens: vi.fn().mockResolvedValue(undefined), + clearTokens: vi.fn().mockResolvedValue(undefined), + }; + let redirectUri: string | undefined; + + vi.doMock("@prisma/management-api-sdk", () => ({ + AuthError: class SDKAuthError extends Error {}, + createManagementApiSdk: vi.fn().mockImplementation((sdkOptions: { redirectUri: string }) => { + redirectUri = sdkOptions.redirectUri; + + return { + getLoginUrl: vi.fn().mockReturnValue({ + url: "https://auth.example.test/login", + state: "state_123", + verifier: "verifier_123", + }), + handleCallback: vi.fn().mockResolvedValue(undefined), + client: { + GET: vi.fn().mockImplementation(() => { + controller.abort(reason); + throw reason; + }), + }, + }; + }), + })); + + const { login } = await import("../src/lib/auth/login"); + + await expect(login({ + hostname: "127.0.0.1", + tokenStorage, + signal: controller.signal, + openUrl: async () => { + expect(redirectUri).toBeDefined(); + await fetch(`${redirectUri}?code=code_123&state=state_123`); + }, + })).rejects.toBe(reason); + }); }); async function requestSuccessPage(options: { diff --git a/packages/cli/tests/auth-ops.test.ts b/packages/cli/tests/auth-ops.test.ts index 52db3a4..909f969 100644 --- a/packages/cli/tests/auth-ops.test.ts +++ b/packages/cli/tests/auth-ops.test.ts @@ -407,6 +407,60 @@ describe("readAuthState", () => { }); }); + it("rejects when cancellation aborts the current principal lookup", async () => { + const controller = new AbortController(); + const reason = new DOMException("Command canceled", "AbortError"); + const requireComputeAuth = vi.fn().mockResolvedValue({ + GET: vi.fn().mockImplementation(() => { + controller.abort(reason); + throw reason; + }), + }); + + vi.doMock("../src/adapters/token-storage", () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + getTokens: vi.fn(), + })), + })); + vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + + const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); + + await expect( + readAuthState({ PRISMA_SERVICE_TOKEN: token } as NodeJS.ProcessEnv, controller.signal), + ).rejects.toBe(reason); + }); + + it("rejects when cancellation aborts the workspace fallback lookup", async () => { + const controller = new AbortController(); + const reason = new DOMException("Command canceled", "AbortError"); + const requireComputeAuth = vi.fn().mockResolvedValue({ + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/me") { + return { data: { data: null } }; + } + + controller.abort(reason); + throw reason; + }), + }); + + vi.doMock("../src/adapters/token-storage", () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + getTokens: vi.fn(), + })), + })); + vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth })); + + const { readAuthState } = await import("../src/lib/auth/auth-ops"); + const token = encodeJwt({ sub: "workspace:clitq5hfg0000qv0gtg9nv9fy" }); + + await expect( + readAuthState({ PRISMA_SERVICE_TOKEN: token } as NodeJS.ProcessEnv, controller.signal), + ).rejects.toBe(reason); + }); + it("returns signed-out state when PRISMA_SERVICE_TOKEN does not carry a workspace subject", async () => { const getTokens = vi.fn(); vi.doMock("../src/adapters/token-storage", () => ({ From df5d7e2603716923bb4eff6c6edfa941c19be80d Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:46:33 -0400 Subject: [PATCH 16/20] Drop cancellation plan artifacts --- ...-cancellation-propagation-analysis.plan.md | 167 ------------------ ...-cancellation-propagation-analysis.spec.md | 122 ------------- 2 files changed, 289 deletions(-) delete mode 100644 .agents/projects/cli-cancellation-propagation-analysis.plan.md delete mode 100644 .agents/projects/cli-cancellation-propagation-analysis.spec.md diff --git a/.agents/projects/cli-cancellation-propagation-analysis.plan.md b/.agents/projects/cli-cancellation-propagation-analysis.plan.md deleted file mode 100644 index daf5cb3..0000000 --- a/.agents/projects/cli-cancellation-propagation-analysis.plan.md +++ /dev/null @@ -1,167 +0,0 @@ -# CLI Cancellation Propagation Plan - -## Assumptions - -- **A1 Spec source:** This plan implements `.agents/projects/cli-cancellation-propagation-analysis.spec.md` and uses `docs/architecture/cancellation-propagation-analysis.md` as implementation source material. -- **A2 Dependency baseline:** The current package versions already satisfy the SDK baseline: `@prisma/management-api-sdk@^1.35.0` and `@prisma/compute-sdk@^0.20.0`. -- **A3 Error model:** Cancellation uses `COMMAND_CANCELED`, domain `cli`, and exit code `130`. -- **A4 Runtime boundary:** `bin.ts` owns OS signal listeners and `runCli` accepts a runtime signal for tests and embedded invocation. -- **A5 Verification surface:** Vitest tests under `packages/cli/tests` are the primary regression suite, with targeted tests added near the affected shell, provider, adapter, and controller behavior. -- **A6 Implementation discipline:** The plan does not add fake `Promise.race` cancellation around non-cancelable external APIs. Unsupported boundaries get immediate `signal.throwIfAborted()` checks and a short boundary comment. - -## Open Questions - -None. - -## Phases - -### Phase 1: Runtime Root And Central Cancellation Error - -**Status:** ✓ Complete - -**Goal:** Establish a thin end-to-end cancellation path from CLI entry to command-runner error output before changing deeper I/O code. - -**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR18, FR19, NFR2, NFR3, NFR4, NFR5, NFR6 - -**Changes:** - -- **C1 Entrypoint:** Update `packages/cli/src/bin.ts` to create one `AbortController`, map `SIGINT` and `SIGTERM` to controller abort, and pass the signal into `runCli`. -- **C2 Runtime type:** Update `packages/cli/src/cli.ts` and `packages/cli/src/shell/runtime.ts` so `CliRuntime` always has a signal, while tests and embedded callers can pass their own signal. -- **C3 Context type:** Ensure `CommandContext` exposes the same signal through `runtime.signal` without creating per-command controllers. -- **C4 Error helper:** Add a `COMMAND_CANCELED` `CliError` helper in `packages/cli/src/shell/errors.ts` with domain `cli`, exit code `130`, concise human summary, and no misleading recovery instructions. -- **C5 Error conversion:** Update `packages/cli/src/shell/command-runner.ts` so both `runCommand` and `runStreamingCommand` convert DOM abort errors, aborted runtime signals, and known cancellation exceptions into the centralized cancellation error. -- **C6 Product docs:** Update `docs/product/error-conventions.md` with `COMMAND_CANCELED` and the exit-code exception for cancellation. -- **C7 Tests:** Add shell/runner tests covering human JSON output, streaming JSON error events, exit code `130`, and preservation of prompt usage-error behavior. - -**Acceptance Criteria:** - -- [x] **AC1:** A handler that aborts through `runtime.signal` returns a formatted `COMMAND_CANCELED` error for regular commands. -- [x] **AC2:** A streaming handler that aborts through `runtime.signal` emits a streaming error event instead of raw exception output. -- [x] **AC3:** Cancellation exits with code `130` and does not alter success output shapes. -- [x] **AC4:** Existing CLI shell tests pass with no command surface changes. -- [x] **AC5:** `pnpm --filter @prisma/cli test -- shell.test.ts command-runner-auth.test.ts prompt.test.ts` passes, or equivalent targeted Vitest filters if filenames change. - -### Phase 2: Command And Controller Signal Plumbing - -**Status:** ✓ Complete - -**Goal:** Thread the command signal through controller and command-handler boundaries so deeper I/O phases can consume it without broad follow-up signature churn. - -**Requirements:** FR3, FR8, FR9, FR10, FR11, FR12, FR13, FR14, FR15, FR18, FR19, NFR2, NFR4, NFR5 - -**Changes:** - -- **C1 Controller contracts:** Update affected controllers in `packages/cli/src/controllers` to read cancellation from `context.runtime.signal` and pass it into async dependencies that perform I/O. -- **C2 App command path:** Prepare `runAppDeploy`, `runAppLogs`, `runAppRun`, domain wait, app environment, and app state flows to pass `{ signal }` into provider, local-dev, state, and helper calls. -- **C3 Project command path:** Prepare project setup, repository installation polling, project listing, and local resolution calls to pass `{ signal }` into API, local state, and helper calls. -- **C4 Auth command path:** Prepare auth operations, login helpers, workspace lookup, and token storage calls to pass `{ signal }` into SDK/client and adapter calls where the boundary is under CLI control. -- **C5 Adapter and lib options:** Introduce small options objects only where existing APIs already need multiple optional controls; otherwise pass the signal directly when that keeps signatures clearer. -- **C6 Tests:** Update existing controller and command tests to construct runtimes with signals and add one representative controller propagation assertion for each command group touched. - -**Acceptance Criteria:** - -- [x] **AC1:** TypeScript requires new async I/O call sites in controllers/libs/adapters to consciously accept or ignore a signal. -- [x] **AC2:** Existing controller tests pass after runtime construction updates. -- [x] **AC3:** No command handler installs OS signal listeners or creates a command-lifetime controller. -- [x] **AC4:** `pnpm --filter @prisma/cli test -- auth.test.ts project.test.ts app.test.ts branch.test.ts` passes, or equivalent targeted filters if filenames change. - -### Phase 3: SDK And Provider Cancellation - -**Status:** ✓ Complete - -**Goal:** Propagate cancellation through Management API and Compute SDK boundaries, especially app deploy and logs. - -**Requirements:** FR8, FR9, FR10, FR17, FR19, NFR1, NFR2, NFR3, NFR6 - -**Changes:** - -- **C1 Management API calls:** Add `{ signal }` to supported `client.GET`, `client.POST`, `client.PATCH`, and `client.DELETE` calls in `packages/cli/src/controllers/app-env.ts`, `packages/cli/src/controllers/project.ts`, `packages/cli/src/lib/auth/auth-ops.ts`, `packages/cli/src/lib/auth/login.ts`, and `packages/cli/src/lib/app/preview-provider.ts`. -- **C2 Compute operations:** Add signal propagation to Compute SDK operations in `packages/cli/src/lib/app/preview-provider.ts`, including project, service, version, deploy, promote, env update, destroy, list, start/stop, and related operations. -- **C3 Deploy build strategy:** Update `packages/cli/src/lib/app/preview-build.ts` so `PreviewBuildStrategy.canBuild` and `PreviewBuildStrategy.execute` forward the signal into concrete SDK build strategies and preview build helpers. -- **C4 Log streaming:** Ensure `streamLogs` receives the command signal and `CancelledError` maps to the central `COMMAND_CANCELED` path rather than provider-local error handling. -- **C5 Provider tests:** Extend `packages/cli/tests/app-provider.test.ts`, `packages/cli/tests/app-build.test.ts`, `packages/cli/tests/auth-ops.test.ts`, and app-env/project API tests to assert signal forwarding at representative SDK/client boundaries. - -**Acceptance Criteria:** - -- [x] **AC1:** Representative Management API calls receive the same `AbortSignal` from command context. -- [x] **AC2:** Representative Compute SDK calls receive the same `AbortSignal` from command context. -- [x] **AC3:** App log stream cancellation produces `COMMAND_CANCELED` through command-runner mapping. -- [x] **AC4:** Preview build strategy methods accept and forward the signal without changing build selection behavior. -- [x] **AC5:** `pnpm --filter @prisma/cli test -- app-provider.test.ts app-build.test.ts auth-ops.test.ts app-env.test.ts project.test.ts` passes, or equivalent targeted filters if filenames change. - -### Phase 4: Polling, Sleeps, And Local Processes - -**Status:** ✓ Complete - -**Goal:** Make CLI-owned waiting and subprocess execution responsive to cancellation. - -**Requirements:** FR11, FR12, FR13, FR15, FR17, FR19, NFR1, NFR6, NFR7 - -**Changes:** - -- **C1 Shared sleep behavior:** Convert internal sleeps in `packages/cli/src/controllers/app.ts`, `packages/cli/src/controllers/project.ts`, and `packages/cli/src/adapters/token-storage.ts` to accept `AbortSignal` and reject promptly on abort. -- **C2 Polling loops:** Update app domain wait and project repository-installation polling to check cancellation before each poll and use signal-aware sleeps between polls. -- **C3 Local app process:** Update `packages/cli/src/lib/app/local-dev.ts` and `runAppRun` so spawned local app processes receive cancellation and abort-related exits normalize into `COMMAND_CANCELED` instead of `RUN_FAILED` or ad-hoc exit-code handling. -- **C4 Git process:** Update `packages/cli/src/adapters/git.ts` so `execFile` operations observe cancellation at the supported child-process boundary. -- **C5 Tests:** Add or update tests in `packages/cli/tests/app-local-dev.test.ts`, `packages/cli/tests/git-adapter.test.ts`, `packages/cli/tests/project-controller.test.ts`, and `packages/cli/tests/app-controller.test.ts` to cover cancellation during sleep, polling, and subprocess execution. - -**Acceptance Criteria:** - -- [x] **AC1:** Signal-aware sleeps reject immediately when already aborted and reject without waiting for the full interval when aborted during sleep. -- [x] **AC2:** Polling loops do not perform an extra API call after cancellation is observed. -- [x] **AC3:** Local app process cancellation does not produce `RUN_FAILED` for `SIGINT` or `SIGTERM` cancellation paths. -- [x] **AC4:** Git adapter cancellation is test-covered at the process boundary. -- [x] **AC5:** `pnpm --filter @prisma/cli test -- app-local-dev.test.ts git-adapter.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. - -### Phase 5: Filesystem And Token Storage Boundaries - -**Status:** ✓ Complete - -**Goal:** Push cancellation through local filesystem and credential-storage helpers while documenting unsupported external boundaries. - -**Requirements:** FR14, FR15, FR16, FR17, FR19, NFR1, NFR4, NFR5 - -**Changes:** - -- **C1 Native signal filesystem calls:** Convert `readFile` and `writeFile` string-encoding calls to object-form calls with `{ encoding: "utf8", signal }` in `packages/cli/src/adapters/local-state.ts`, `packages/cli/src/adapters/mock-api.ts`, `packages/cli/src/lib/project/local-pin.ts`, `packages/cli/src/lib/project/resolution.ts`, `packages/cli/src/lib/app/bun-project.ts`, `packages/cli/src/lib/app/preview-build.ts`, and `packages/cli/src/controllers/app.ts` where those helpers are in scope. -- **C2 Unsupported filesystem calls:** Add immediate cancellation checks and boundary comments around unsupported Node filesystem promise calls such as `access`, `copyFile`, `cp`, `lstat`, `mkdir`, `open`, `readdir`, `readlink`, `rm`, and `stat`. -- **C3 Read-only post-checks:** Add post-I/O cancellation checks for unsupported read-only operations before returning their result. -- **C4 Token storage:** Update `packages/cli/src/adapters/token-storage.ts` so public adapter methods accept and propagate the signal, lock acquisition uses signal-aware sleep, and `CredentialsStore` calls are guarded with boundary comments because the store cannot consume `AbortSignal`. -- **C5 OAuth/browser helpers:** Guard `open` and any OAuth SDK/browser/listener boundary that cannot consume `AbortSignal` with the unsupported boundary rule. -- **C6 Tests:** Extend `packages/cli/tests/token-storage.test.ts`, `packages/cli/tests/app-state.test.ts`, `packages/cli/tests/app-bun-compat.test.ts`, and relevant project/app tests for aborted filesystem and token-storage paths. - -**Acceptance Criteria:** - -- [x] **AC1:** Supported `readFile` and `writeFile` calls receive the command signal where reachable from command execution. -- [x] **AC2:** Unsupported filesystem and credential-store boundaries have immediate abort checks and short comments at the boundary. -- [x] **AC3:** Token refresh-lock wait exits promptly on abort. -- [x] **AC4:** No local race-based cancellation wrappers are introduced. -- [x] **AC5:** `pnpm --filter @prisma/cli test -- token-storage.test.ts app-state.test.ts app-bun-compat.test.ts project-controller.test.ts app-controller.test.ts` passes, or equivalent targeted filters if filenames change. - -### Phase 6: End-To-End Verification And Cleanup - -**Status:** ✓ Complete - -**Goal:** Prove cancellation behavior across the CLI surface and remove inconsistencies left by incremental propagation. - -**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, FR12, FR13, FR14, FR15, FR16, FR17, FR18, FR19, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6, NFR7 - -**Changes:** - -- **C1 Full audit:** Search for remaining async I/O boundaries without a propagated signal or deliberate unsupported-boundary guard. -- **C2 Error audit:** Ensure no controller maps cancellation into `RUN_FAILED`, `DEPLOY_FAILED`, auth errors, usage errors, or raw thrown errors. -- **C3 Stream audit:** Verify regular and streaming command output both use the documented cancellation envelopes. -- **C4 Type cleanup:** Remove redundant optional signal plumbing where the signal is always available from `CliRuntime`, keeping only options objects that carry real optional behavior. -- **C5 Documentation cleanup:** Ensure `docs/product/error-conventions.md` and architecture notes do not conflict with the resolved spec decisions. -- **C6 Full verification:** Run the CLI test suite and build. - -**Acceptance Criteria:** - -- [x] **AC1:** No remaining CLI-owned polling loop uses a non-signal-aware sleep. -- [x] **AC2:** No supported SDK, child-process, or filesystem boundary lacks the propagated command signal where the upstream API accepts it. -- [x] **AC3:** Unsupported boundaries are guarded and documented locally without fake cancellation wrappers. -- [x] **AC4:** Human and JSON cancellation output are stable for regular and streaming commands. -- [x] **AC5:** `pnpm --filter @prisma/cli test` passes. -- [x] **AC6:** `pnpm --filter @prisma/cli build` passes. - -## Revision Log diff --git a/.agents/projects/cli-cancellation-propagation-analysis.spec.md b/.agents/projects/cli-cancellation-propagation-analysis.spec.md deleted file mode 100644 index 41c7531..0000000 --- a/.agents/projects/cli-cancellation-propagation-analysis.spec.md +++ /dev/null @@ -1,122 +0,0 @@ -# CLI Cancellation Propagation Spec - -## Problem - -The CLI does not model cancellation as a first-class runtime concern. Long-running commands, network-heavy workflows, streaming logs, polling loops, filesystem operations, and local child processes mostly rely on process termination or library-specific behavior when users interrupt execution. - -This creates inconsistent outcomes: some operations stop promptly, some continue until their current I/O finishes, some surface raw abort exceptions, and some depend on whether the underlying SDK or Node API happens to observe cancellation. The CLI needs one cancellation model that starts at the process boundary, propagates through command execution, and reaches every supported I/O boundary that can honor it. - -Success means a keyboard interrupt or process termination signal reliably stops in-flight command work, preserves automation-friendly output contracts, and reports one stable cancellation error shape instead of leaking raw implementation errors. - -The case against this work is that normal process termination already stops most user-visible work. That is insufficient for this CLI because app deployment, log streaming, polling, credential refresh, local process execution, and future workflow expansion all depend on explicit runtime contracts rather than accidental process exit behavior. - -## Stakeholders - -- **S1 CLI users:** Need `Ctrl-C` and termination signals to stop long-running commands predictably without confusing raw errors. -- **S2 Automation and CI users:** Need stable structured cancellation output so agents and scripts can distinguish user cancellation from operational failure. -- **S3 CLI maintainers:** Need a single propagation rule that prevents each controller, adapter, or SDK wrapper from inventing cancellation behavior. -- **S4 SDK and platform integrators:** Need the CLI to pass available `AbortSignal` values into SDK calls that already support cancellation. - -## Functional Requirements - -- **FR1 Entry cancellation:** The CLI must create exactly one command-lifetime cancellation source at the application entry boundary and use it as the root cancellation source for the command invocation. - -- **FR2 Signal mapping:** Keyboard and process cancellation signals must be converted into command cancellation at the application entry boundary. Cancellation signal handling must not be reimplemented by controllers, adapters, providers, or command handlers. - -- **FR3 Runtime availability:** Every command execution context must expose the current command cancellation signal so command handlers and their dependencies can observe the same cancellation state. - -- **FR4 Central error shape:** Cancellation must map to one stable CLI error envelope at the command execution boundary for both regular commands and streaming commands. The envelope must use stable error code `COMMAND_CANCELED` and must be distinguishable from usage errors, authentication failures, operational failures, and internal bugs. - -- **FR5 Human cancellation output:** Human-readable cancellation output must clearly state that the command was canceled and avoid implying an underlying platform, build, auth, or filesystem failure. - -- **FR6 JSON cancellation output:** Commands run with `--json` must emit the documented failure envelope with `ok: false`, the command identifier, stable cancellation code, domain, severity, summary, and empty recovery fields when no user action is needed. - -- **FR7 Streaming cancellation output:** Streaming commands must report cancellation through the streaming error event shape rather than a raw exception, partial success event, or unformatted process exit. - -- **FR8 SDK cancellation:** All Management API and Compute SDK calls that support cancellation must receive the command cancellation signal for each operation started during command execution. - -- **FR9 Build and deploy cancellation:** App deployment workflows must propagate cancellation through app lookup, deployment creation, build capability checks, build execution, archive/upload work, HTTP calls, polling, and post-deploy status checks where the underlying boundary can observe cancellation. - -- **FR10 Log streaming cancellation:** App log streaming must stop promptly when canceled and normalize SDK cancellation results into the central CLI cancellation outcome. - -- **FR11 Polling cancellation:** Every internal polling loop must check cancellation before beginning another poll and must use sleeps that reject promptly when canceled. - -- **FR12 Local process cancellation:** Local app execution must propagate cancellation to child process execution and normalize abort-related process outcomes into the central CLI cancellation outcome. - -- **FR13 Git process cancellation:** Git adapter operations that spawn local processes must observe command cancellation and stop subprocess work when supported by the process boundary. - -- **FR14 Filesystem cancellation:** Internal filesystem helpers must accept and propagate the command cancellation signal. Filesystem operations with native `AbortSignal` support must use it. Unsupported filesystem operations must check cancellation immediately before invoking external I/O. - -- **FR15 Unsupported boundary rule:** Internal APIs must propagate the signal until the exact external I/O boundary. If that boundary cannot consume `AbortSignal`, the implementation must deliberately stop propagation there, check cancellation immediately before the call, and document that the external API cannot consume the signal. - -- **FR16 Read-only unsupported I/O:** Unsupported read-only I/O boundaries with no dangerous cancellation side effects must also check cancellation after the awaited operation before returning the result. - -- **FR17 No fake cancellation:** The CLI must not wrap non-cancelable upstream APIs in local `Promise.race` shims to simulate cancellation. - -- **FR18 Prompt separation:** Prompt-library cancellation and keyboard/process cancellation must remain distinct. Prompt cancellation may continue to surface as usage-oriented behavior, while runtime cancellation must take the higher-priority command cancellation path. - -- **FR19 Existing behavior preservation:** Cancellation support must not change command names, command grouping, target resolution, branch semantics, output stream ownership, or success output shapes. - -## Non-Functional Requirements - -- **NFR1 Responsiveness:** Signal-aware sleeps and supported SDK/process/filesystem boundaries must react to cancellation without waiting for the next fixed polling interval to elapse. - -- **NFR2 Consistency:** The same cancellation source and error mapping must apply across command groups, including `auth`, `project`, `branch`, and `app`. - -- **NFR3 Automation safety:** Structured cancellation output must be stable enough for agents and CI to branch on `error.code`, not prose. - -- **NFR4 Maintainability:** Cancellation propagation must be represented in public internal types where possible so new command code receives type pressure to pass the signal forward. - -- **NFR5 Minimality:** The change must not add a cancellation abstraction layer beyond the platform-standard `AbortController` and `AbortSignal` unless a concrete unsupported boundary requires a small local helper. - -- **NFR6 Operational clarity:** Canceled commands must not be reported as platform failures, build failures, run failures, or deployment failures unless cancellation exposed a separate, completed failure before the cancellation was observed. - -- **NFR7 Cleanup:** Cancellation handling must not leave local child processes intentionally running after the parent CLI command has been canceled. - -## Assumptions - -- **A1 SDK baseline:** `@prisma/management-api-sdk@1.35.0` and `@prisma/compute-sdk@0.20.0` are the baseline dependencies for implementation, and their documented per-call cancellation support is available. - -- **A2 Runtime root:** The correct root cancellation source is the CLI entry boundary rather than each command boundary, because cancellation must apply uniformly to parsing-triggered execution paths and command handlers. - -- **A3 Signal coverage:** `SIGINT` and `SIGTERM` must both map to the same formatted command cancellation envelope. - -- **A4 Exit code:** Canceled commands must use exit code `130`. This intentionally departs from the current MVP exit-code set because cancellation has established shell semantics and should be recognizable to operators and process supervisors while `COMMAND_CANCELED` remains the structured branching surface for agents and CI. - -- **A5 Error domain:** Command cancellation should use the `cli` error domain because cancellation is initiated by the CLI runtime rather than by auth, project, branch, app, or platform state. - -- **A6 Existing architecture analysis:** `docs/architecture/cancellation-propagation-analysis.md` is source material for implementation planning, not the governing spec artifact. - -- **A7 No product command changes:** This work is runtime behavior only and does not require changes to the product command model. - -## Downstream Effects - -- **DE1 Implementation surface:** Many signatures across controllers, libraries, adapters, providers, and local helper functions will need to accept cancellation options. This is deliberate because shallow cancellation creates false confidence. - -- **DE2 Test fixtures:** Tests that construct `CliRuntime` or command contexts will need to provide or inherit a command signal. This should improve testability by making cancellation behavior explicit. - -- **DE3 Error docs:** The product error conventions will need a new stable cancellation code and an explicit cancellation exit-code exception. - -- **DE4 Streaming behavior:** Consumers of JSON streaming output will see a structured error event on cancellation rather than relying on process interruption or raw abort output. - -- **DE5 Maintenance burden:** New I/O helpers must decide whether their external boundary supports `AbortSignal`; the unsupported boundary rule keeps that decision local and reviewable. - -- **DE6 Partial remote effects:** Cancellation can stop waiting, polling, streaming, or local work, but it cannot guarantee rollback of remote operations already accepted by platform APIs. User-facing output must avoid promising rollback or no-op semantics. - -## Out Of Scope - -- **OOS1 Rollback semantics:** This work does not add rollback or compensation for deployments, environment updates, project creation, app deletion, or other remote operations already accepted by an API. - -- **OOS2 New command surface:** This work does not add commands, flags, aliases, namespaces, or shortcuts. - -- **OOS3 Product workflow changes:** This work does not alter project, branch, app, repository, deployment, or domain resolution semantics. - -- **OOS4 Prompt redesign:** This work does not redesign prompt cancellation behavior beyond preserving separation from runtime signal cancellation. - -- **OOS5 Upstream SDK behavior:** This work does not patch external SDKs or Node APIs that cannot consume `AbortSignal`. - -- **OOS6 Fake cancellation:** This work does not simulate cancellation for unsupported external operations with local racing wrappers. - -## Open Questions - -None. From 7ffe4116aa6e6fdd1457fb6b2ff52280f23419d6 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 17:46:59 -0400 Subject: [PATCH 17/20] Drop cancellation analysis artifacts --- .agents/diary/cli-cancellation-propagation.md | 2 - .../cancellation-propagation-analysis.md | 178 ------------------ 2 files changed, 180 deletions(-) delete mode 100644 .agents/diary/cli-cancellation-propagation.md delete mode 100644 docs/architecture/cancellation-propagation-analysis.md diff --git a/.agents/diary/cli-cancellation-propagation.md b/.agents/diary/cli-cancellation-propagation.md deleted file mode 100644 index 479c12f..0000000 --- a/.agents/diary/cli-cancellation-propagation.md +++ /dev/null @@ -1,2 +0,0 @@ -Out-of-scope decision: `pnpm install` ran the repository `prepare` script and generated `.agents/skills` symlinks. These are local setup artifacts, so they should remain uncommitted unless the operator explicitly asks to version them. -Unrelated bug: Phase 1 targeted verification unexpectedly ran the full Vitest suite and exposed an `app-build.test.ts` failure where a standalone symlink escape was not rejected. This is outside cancellation propagation, but it blocks the requested all-verifications-green commit flow unless confirmed pre-existing or fixed separately. diff --git a/docs/architecture/cancellation-propagation-analysis.md b/docs/architecture/cancellation-propagation-analysis.md deleted file mode 100644 index f1865e5..0000000 --- a/docs/architecture/cancellation-propagation-analysis.md +++ /dev/null @@ -1,178 +0,0 @@ -# CLI Cancellation Propagation Analysis - -## Scope - -This analysis maps where to thread `AbortController`/`AbortSignal` from CLI entrypoint to underlying I/O. - -Current state: cancellation is not modeled as a first-class runtime concern. Most flows rely on natural process termination or library-specific behavior. - -SDK baseline after targeted update: - -- `@prisma/management-api-sdk@1.35.0` uses `openapi-fetch@0.14.0`; per-call options extend `RequestInit`, so `client.GET/POST/PATCH/DELETE(..., { signal })` is available. -- `@prisma/compute-sdk@0.20.0` exposes `signal?: AbortSignal` on `ComputeClient` operation options, build strategy methods, archive/build helpers, polling, and log streaming. - -## Design Target - -- Create one `AbortController` at app entry (`runCli`/`bin.ts` boundary). -- Map keyboard cancellation (`SIGINT`, optional `SIGTERM`) to `controller.abort(...)` only at that boundary. -- Propagate `AbortSignal` through runtime/context -> command runner -> controllers -> libs/adapters/providers -> network/fs/process I/O. -- Standardize cancellation handling into one CLI error shape (e.g. `command canceled`) instead of ad-hoc exits. - -## Primary Augmentation Path - -1. Runtime/context surface: - - `packages/cli/src/shell/runtime.ts` - - Add `signal: AbortSignal` to `CliRuntime` and `CommandContext`. -2. Entrypoint wiring: - - `packages/cli/src/bin.ts` - - `packages/cli/src/cli.ts` - - Create controller, attach signal listeners, pass signal into `runCli` runtime. -3. Command execution wrappers: - - `packages/cli/src/shell/command-runner.ts` - - Ensure cancellation exceptions map to a dedicated `CliError` path for both `runCommand` and `runStreamingCommand`. -4. Controller and dependency signatures: - - `packages/cli/src/controllers/*.ts` - - `packages/cli/src/lib/**/*.ts` - - `packages/cli/src/adapters/**/*.ts` - - Thread optional `{ signal?: AbortSignal }` into async boundaries. - -## Pseudocode Call Stacks To Update - -### 1) Common command execution path (all commands) - -```ts -bin.ts - -> create AbortController - -> on SIGINT/SIGTERM: controller.abort(reason) - -> runCli({ ..., signal: controller.signal }) - -cli.ts runCli(runtime) - -> createProgram(runtime) - -> program.parseAsync(...) - -> command.action(...) - -> runCommand/runStreamingCommand(runtime, ...) - -command-runner.ts - -> createCommandContext(runtime, flags) // context includes signal - -> handler(context) - -> map AbortError/canceled to CliError(CANCELED) -``` - -### 2) App deploy path (long-running + network-heavy) - -```ts -commands/app/index.ts createDeployCommand - -> runCommand(..., (ctx) => runAppDeploy(ctx, ...)) - -controllers/app.ts runAppDeploy - -> requireProviderAndDeployProjectContext(ctx, ...) - -> provider.listApps(..., { signal }) - -> provider.deployApp(..., { signal, progress }) - -> stateStore.setSelectedApp(..., { signal? optional if store supports }) - -lib/app/preview-provider.ts deployApp - -> sdk.deploy({ ..., signal }) - -> PreviewBuildStrategy.canBuild(signal) / execute(signal) - -> underlying build, archive, upload, HTTP, and polling calls honor signal -``` - -### 3) App logs path (streaming) - -```ts -commands/app/index.ts createLogsCommand - -> runStreamingCommand(..., (ctx) => runAppLogs(ctx, ...)) - -controllers/app.ts runAppLogs - -> provider.streamDeploymentLogs({ deploymentId, signal: ctx.runtime.signal, onRecord }) - -lib/app/preview-provider.ts streamDeploymentLogs - -> streamLogs({ ..., signal }) - -> CancelledError => map to standard cancellation result/error boundary -``` - -### 4) Polling loops (must become signal-aware sleeps) - -```ts -controllers/app.ts runAppDomainWait - while (...) { - await provider.showDomain(..., { signal }) - await sleep(interval, signal) // reject on abort - } - -controllers/project.ts waitForInstalledRepository - while (...) { - await listScmInstallations(..., { signal }) - await sleep(interval, signal) - } - -adapters/token-storage.ts acquireRefreshLock - while (...) { - signal.throwIfAborted() - await fs.open(...) - await sleep(100, signal) - } -``` - -### 5) Local process execution (`app run`) - -```ts -controllers/app.ts runAppRun - -> runLocalApp({ ..., signal: ctx.runtime.signal }) - -lib/app/local-dev.ts runLocalApp - -> spawnCommand(..., { signal }) // kill child on abort - -> normalize AbortError vs child exit signal behavior -``` - -## I/O Boundaries Requiring Signal Propagation - -- Management API client calls (`client.GET/POST/PATCH/DELETE`) can now receive `{ signal }` in: - - `packages/cli/src/controllers/app-env.ts` - - `packages/cli/src/controllers/project.ts` - - `packages/cli/src/lib/auth/auth-ops.ts` - - `packages/cli/src/lib/app/preview-provider.ts` -- Compute SDK operations can now receive `signal` in `packages/cli/src/lib/app/preview-provider.ts`: - - `sdk.deploy`, `sdk.promote`, `sdk.updateEnv`, `sdk.destroyService`, `sdk.createProject`, `sdk.showService`, `sdk.showVersion`, and related list/delete/start/stop operations. - - `streamLogs({ ..., signal })` already has a signal option and returns `CancelledError` on abort. - - SDK `BuildStrategy` methods now accept `canBuild(signal)` and `execute(signal)`; this repo's `PreviewBuildStrategy` and `executePreviewBuild` should forward the signal to concrete SDK build strategies. -- Child processes: - - `spawn` path in `packages/cli/src/lib/app/local-dev.ts` - - `execFile` path in `packages/cli/src/adapters/git.ts` -- Poll/sleep loops: - - `packages/cli/src/controllers/app.ts` - - `packages/cli/src/controllers/project.ts` - - `packages/cli/src/adapters/token-storage.ts` -- File system ops: - - Push `signal` through local helper signatures even when the final external operation cannot consume it. - - `readFile` and `writeFile` support `AbortSignal` through an options object. Current string-encoding calls must become object-form calls, e.g. `readFile(path, { encoding: "utf8", signal })`. - - Current usage appears in `packages/cli/src/adapters/local-state.ts`, `packages/cli/src/lib/project/local-pin.ts`, `packages/cli/src/lib/project/resolution.ts`, `packages/cli/src/lib/app/bun-project.ts`, `packages/cli/src/lib/app/preview-build.ts`, `packages/cli/src/controllers/app.ts`, `packages/cli/src/adapters/mock-api.ts`, and `packages/cli/src/adapters/token-storage.ts`. - - For external filesystem calls that do not support `signal`, call `signal.throwIfAborted()` immediately before the operation and add a short comment at that boundary explaining that the external API does not accept `AbortSignal`. - - For unsupported read-only operations with no dangerous cancellation side effects, also check `signal.throwIfAborted()` after the awaited operation before returning the result. - -## Unsupported I/O Boundary Rule - -Thread `AbortSignal` through internal APIs until the exact external I/O boundary. If that external API does not support `signal`, stop propagation there deliberately: - -```ts -async function readSomething(path: string, signal: AbortSignal) { - // External API does not accept AbortSignal; check immediately before I/O. - signal.throwIfAborted(); - const result = await unsupportedExternalRead(path); - signal.throwIfAborted(); - return result; -} -``` - -Apply this to: - -- `CredentialsStore` methods in `packages/cli/src/adapters/token-storage.ts`; the adapter should accept/propagate `signal`, check before store calls, use signal-aware local sleeps, and document that `CredentialsStore` itself cannot consume `AbortSignal`. -- Node filesystem promise calls without native `signal` support, such as `access`, `copyFile`, `cp`, `lstat`, `mkdir`, `open`, `readdir`, `readlink`, `rm`, and `stat`. -- OAuth/login helper calls if their external SDK/browser/listener boundaries cannot consume `signal`. - -## Execution Notes (Important) - -- Do not add local `Promise.race` cancellation shims to fake abort behavior around non-cancelable upstream APIs. -- Push `AbortSignal` as deep as possible; ignore it only at the external I/O function that does not support it, with a comment and `signal.throwIfAborted()` guard. -- Convert all internal `sleep` helpers to `sleep(ms, signal)` and reject immediately on abort. -- Keep cancellation mapping centralized in `command-runner.ts`; controllers should mostly propagate, not reinterpret. -- `@clack/prompts` cancellations already raise usage errors; keep keyboard signal cancellation separate and higher priority at runtime boundary. From 715d01b1e075dc9dfe49861266e1bc9e2f7743d7 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Tue, 2 Jun 2026 07:46:39 -0400 Subject: [PATCH 18/20] Avoid abortable local state writes --- packages/cli/src/adapters/local-state.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/adapters/local-state.ts b/packages/cli/src/adapters/local-state.ts index 98f2b0f..b9726a3 100644 --- a/packages/cli/src/adapters/local-state.ts +++ b/packages/cli/src/adapters/local-state.ts @@ -97,7 +97,9 @@ export class LocalStateStore { this.signal?.throwIfAborted(); // mkdir does not accept AbortSignal; check before the filesystem boundary. await mkdir(path.dirname(this.stateFilePath), { recursive: true }); - await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", signal: this.signal }); + this.signal?.throwIfAborted(); + await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8" }); + this.signal?.throwIfAborted(); } async setAuthSession(session: NonNullable): Promise { From 4a72067d25aa87e2bef61fc32853885efa3ed523 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Tue, 2 Jun 2026 07:47:06 -0400 Subject: [PATCH 19/20] Tighten OAuth callback cancellation test --- packages/cli/tests/auth-login.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index 08fe9cb..4cb60d5 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -80,7 +80,7 @@ describe("auth login callback", () => { state: "state_123", verifier: "verifier_123", }), - handleCallback: vi.fn(), + handleCallback: vi.fn().mockReturnValue(new Promise(() => {})), client: { GET: vi.fn() }, }), })); From 01640c4e512d9d4460f03e8cac83df2e0e4c5565 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Tue, 2 Jun 2026 08:01:06 -0400 Subject: [PATCH 20/20] Tighten cancellation boundary handling --- packages/cli/src/adapters/git.ts | 7 ++++++- packages/cli/src/adapters/token-storage.ts | 9 ++++++++- packages/cli/src/controllers/project.ts | 8 +++++++- packages/cli/tests/git-adapter.test.ts | 11 ++++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/adapters/git.ts b/packages/cli/src/adapters/git.ts index ffe2288..ba73cee 100644 --- a/packages/cli/src/adapters/git.ts +++ b/packages/cli/src/adapters/git.ts @@ -20,11 +20,16 @@ export async function readGitOriginRemote(cwd: string, signal?: AbortSignal): Pr }); const remote = stdout.trim(); return remote.length > 0 ? remote : null; - } catch { + } catch (error) { + if (signal?.aborted || isAbortError(error)) throw error; return null; } } +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + export function parseGitHubRepositoryUrl(value: string): GitHubRepositoryReference | null { const input = value.trim(); const shorthand = input.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/); diff --git a/packages/cli/src/adapters/token-storage.ts b/packages/cli/src/adapters/token-storage.ts index 9eab14a..5d9e9c4 100644 --- a/packages/cli/src/adapters/token-storage.ts +++ b/packages/cli/src/adapters/token-storage.ts @@ -128,16 +128,23 @@ export class FileTokenStorage implements TokenStorage { while (true) { this.signal?.throwIfAborted(); + let lockFileCreated = false; try { // open does not accept AbortSignal; check before the filesystem boundary. const handle = await fs.open(this.lockFilePath, "wx"); + lockFileCreated = true; try { - await handle.writeFile(lockId, { encoding: "utf8", signal: this.signal }); + this.signal?.throwIfAborted(); + await handle.writeFile(lockId, { encoding: "utf8" }); + this.signal?.throwIfAborted(); } finally { await handle.close(); } return lockId; } catch (error) { + if (lockFileCreated) { + await fs.unlink(this.lockFilePath).catch(() => undefined); + } const code = (error as NodeJS.ErrnoException).code; if (code !== "EEXIST") throw error; diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 73a7b5b..8c2cb0f 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -108,7 +108,7 @@ export async function runProjectList(context: CommandContext): Promise>; GET( @@ -704,6 +705,7 @@ interface SourceRepositoryApiClient { limit?: number; }; }; + signal?: AbortSignal; }, ): Promise>; } diff --git a/packages/cli/tests/git-adapter.test.ts b/packages/cli/tests/git-adapter.test.ts index 6ea2bf2..254103d 100644 --- a/packages/cli/tests/git-adapter.test.ts +++ b/packages/cli/tests/git-adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseGitHubRepositoryUrl } from "../src/adapters/git"; +import { parseGitHubRepositoryUrl, readGitOriginRemote } from "../src/adapters/git"; describe("git adapter", () => { it("parses supported GitHub repository URLs", () => { @@ -39,4 +39,13 @@ describe("git adapter", () => { expect(parseGitHubRepositoryUrl("https://github.com/prisma/prisma-cli/issues")).toBeNull(); expect(parseGitHubRepositoryUrl("not a url")).toBeNull(); }); + + it("preserves cancellation while reading the origin remote", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(readGitOriginRemote(process.cwd(), controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }); + }); });