Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8d48729
Document cancellation propagation
rtbenfield Jun 1, 2026
1e99165
Add cancellation propagation project plan
rtbenfield Jun 1, 2026
6ea0137
Fix standalone symlink escape validation
rtbenfield Jun 1, 2026
555a670
Add CLI cancellation runtime root
rtbenfield Jun 1, 2026
37a5e2d
Thread cancellation through app controller
rtbenfield Jun 1, 2026
60a7dbf
Forward cancellation to SDK calls
rtbenfield Jun 1, 2026
b698cdc
Make waits and processes cancellable
rtbenfield Jun 1, 2026
4473bcf
Document log stream cancellation semantics
rtbenfield Jun 1, 2026
3bcea40
Prioritize runtime cancellation errors
rtbenfield Jun 1, 2026
285b4f5
Make repository install polling cancellable
rtbenfield Jun 1, 2026
df7e40a
Clean up abortable sleep listeners
rtbenfield Jun 1, 2026
139f017
Propagate cancellation through local storage
rtbenfield Jun 1, 2026
f94a329
Complete cancellation propagation audit
rtbenfield Jun 1, 2026
909d5e6
Make OAuth callback wait cancellable
rtbenfield Jun 1, 2026
82ecd4a
Preserve auth cancellation during fallbacks
rtbenfield Jun 1, 2026
df5d7e2
Drop cancellation plan artifacts
rtbenfield Jun 1, 2026
7ffe411
Drop cancellation analysis artifacts
rtbenfield Jun 1, 2026
5ee4011
Merge origin/main into feat/cancelation
rtbenfield Jun 2, 2026
715d01b
Avoid abortable local state writes
rtbenfield Jun 2, 2026
4a72067
Tighten OAuth callback cancellation test
rtbenfield Jun 2, 2026
01640c4
Tighten cancellation boundary handling
rtbenfield Jun 2, 2026
8e6dbeb
Merge origin/main into feat/cancelation
rtbenfield Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/product/error-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ These codes are the minimum stable set for the MVP:
- `RUN_FAILED`
- `DEPLOY_FAILED`
- `VERSION_UNAVAILABLE`
- `COMMAND_CANCELED`

Recommended meanings:

Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/adapters/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,25 @@ export interface GitHubRepositoryReference {
url: string;
}

export async function readGitOriginRemote(cwd: string): Promise<string | null> {
export async function readGitOriginRemote(cwd: string, signal?: AbortSignal): Promise<string | null> {
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;
} 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)?$/);
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/adapters/local-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalState> {
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<LocalState>;
return {
auth: parsed.auth ?? structuredClone(DEFAULT_STATE.auth),
Expand All @@ -93,8 +94,12 @@ export class LocalStateStore {
}

async write(state: LocalState): Promise<void> {
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");
this.signal?.throwIfAborted();
await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8" });
this.signal?.throwIfAborted();
}

async setAuthSession(session: NonNullable<LocalState["auth"]>): Promise<LocalState> {
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/adapters/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ export class MockApi {
this.data = data;
}

static async load(fixturePath: string): Promise<MockApi> {
const raw = await readFile(fixturePath, "utf8");
static async load(fixturePath: string, signal?: AbortSignal): Promise<MockApi> {
signal?.throwIfAborted();
const raw = await readFile(fixturePath, { encoding: "utf8", signal });
return new MockApi(JSON.parse(raw) as MockApiData);
}

Expand Down
58 changes: 50 additions & 8 deletions packages/cli/src/adapters/token-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,45 +44,68 @@ function tokensEqual(a: Tokens | null, b: Tokens | null): boolean {
);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
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<Tokens | null> {
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<void> {
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<void> {
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<void> {
this.signal?.throwIfAborted();
const current = await this.getTokens();
if (!tokensEqual(current, tokens)) return;
await this.clearTokens();
Expand All @@ -99,18 +122,29 @@ export class FileTokenStorage implements TokenStorage {

private async acquireRefreshLock(): Promise<string> {
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();
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, "utf8");
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;

Expand All @@ -120,23 +154,31 @@ export class FileTokenStorage implements TokenStorage {
continue;
}

await sleep(100);
await sleep(100, this.signal);
}
}
}

private async getStaleRefreshLockId(): Promise<string | null> {
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<void> {
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(() => {});
}
}
16 changes: 15 additions & 1 deletion packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,21 @@ if (process.env.PRISMA_CLI_RUN_UPDATE_CHECK_WORKER === "1") {
process.exitCode = 0;
});
} else {
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);
});
}
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,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,
Expand Down
Loading
Loading