Skip to content

Commit b9a0aab

Browse files
authored
Add CLI cancellation propagation (#59)
* Document cancellation propagation * Add cancellation propagation project plan * Fix standalone symlink escape validation * Add CLI cancellation runtime root * Thread cancellation through app controller * Forward cancellation to SDK calls * Make waits and processes cancellable * Document log stream cancellation semantics * Prioritize runtime cancellation errors * Make repository install polling cancellable * Clean up abortable sleep listeners * Propagate cancellation through local storage * Complete cancellation propagation audit * Make OAuth callback wait cancellable * Preserve auth cancellation during fallbacks * Drop cancellation plan artifacts * Drop cancellation analysis artifacts * Avoid abortable local state writes * Tighten OAuth callback cancellation test * Tighten cancellation boundary handling
1 parent 1029ae9 commit b9a0aab

38 files changed

Lines changed: 956 additions & 298 deletions

docs/product/error-conventions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ These codes are the minimum stable set for the MVP:
195195
- `RUN_FAILED`
196196
- `DEPLOY_FAILED`
197197
- `VERSION_UNAVAILABLE`
198+
- `COMMAND_CANCELED`
198199

199200
Recommended meanings:
200201

@@ -235,6 +236,7 @@ Recommended meanings:
235236
- `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully
236237
- `DEPLOY_FAILED`: deployment or post-build health failed
237238
- `VERSION_UNAVAILABLE`: CLI could not read its own bundled package metadata to report a version (defensive; not expected in normal installs)
239+
- `COMMAND_CANCELED`: command execution was canceled by a runtime cancellation signal such as `SIGINT` or `SIGTERM`
238240

239241
## Exit Codes
240242

@@ -243,9 +245,12 @@ The MVP should use these process exit codes:
243245
- `0`: success
244246
- `1`: runtime or command failure
245247
- `2`: usage or configuration error
248+
- `130`: command cancellation
246249

247250
Stable structured error codes, not exit code granularity, are the main branching surface for agents and CI.
248251

252+
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.
253+
249254
## Production Safety
250255

251256
Production-related failures should fail closed.

packages/cli/src/adapters/git.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,25 @@ export interface GitHubRepositoryReference {
1111
url: string;
1212
}
1313

14-
export async function readGitOriginRemote(cwd: string): Promise<string | null> {
14+
export async function readGitOriginRemote(cwd: string, signal?: AbortSignal): Promise<string | null> {
1515
try {
1616
const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], {
1717
cwd,
1818
timeout: 5_000,
19+
signal,
1920
});
2021
const remote = stdout.trim();
2122
return remote.length > 0 ? remote : null;
22-
} catch {
23+
} catch (error) {
24+
if (signal?.aborted || isAbortError(error)) throw error;
2325
return null;
2426
}
2527
}
2628

29+
function isAbortError(error: unknown): boolean {
30+
return error instanceof Error && error.name === "AbortError";
31+
}
32+
2733
export function parseGitHubRepositoryUrl(value: string): GitHubRepositoryReference | null {
2834
const input = value.trim();
2935
const shorthand = input.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);

packages/cli/src/adapters/local-state.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,14 @@ export function resolveLocalStateFilePath(stateDir: string): string {
6060
export class LocalStateStore {
6161
private readonly stateFilePath: string;
6262

63-
constructor(stateDir: string) {
63+
constructor(stateDir: string, private readonly signal?: AbortSignal) {
6464
this.stateFilePath = resolveLocalStateFilePath(stateDir);
6565
}
6666

6767
async read(): Promise<LocalState> {
68+
this.signal?.throwIfAborted();
6869
try {
69-
const raw = await readFile(this.stateFilePath, "utf8");
70+
const raw = await readFile(this.stateFilePath, { encoding: "utf8", signal: this.signal });
7071
const parsed = JSON.parse(raw) as Partial<LocalState>;
7172
return {
7273
auth: parsed.auth ?? structuredClone(DEFAULT_STATE.auth),
@@ -93,8 +94,12 @@ export class LocalStateStore {
9394
}
9495

9596
async write(state: LocalState): Promise<void> {
97+
this.signal?.throwIfAborted();
98+
// mkdir does not accept AbortSignal; check before the filesystem boundary.
9699
await mkdir(path.dirname(this.stateFilePath), { recursive: true });
97-
await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
100+
this.signal?.throwIfAborted();
101+
await writeFile(this.stateFilePath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8" });
102+
this.signal?.throwIfAborted();
98103
}
99104

100105
async setAuthSession(session: NonNullable<LocalState["auth"]>): Promise<LocalState> {

packages/cli/src/adapters/mock-api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ export class MockApi {
6565
this.data = data;
6666
}
6767

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

packages/cli/src/adapters/token-storage.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,45 +44,68 @@ function tokensEqual(a: Tokens | null, b: Tokens | null): boolean {
4444
);
4545
}
4646

47-
function sleep(ms: number): Promise<void> {
48-
return new Promise((resolve) => setTimeout(resolve, ms));
47+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
48+
signal?.throwIfAborted();
49+
return new Promise((resolve, reject) => {
50+
const onAbort = () => {
51+
clearTimeout(timeout);
52+
reject(signal?.reason);
53+
};
54+
const timeout = setTimeout(() => {
55+
signal?.removeEventListener("abort", onAbort);
56+
resolve();
57+
}, ms);
58+
signal?.addEventListener("abort", onAbort, { once: true });
59+
});
4960
}
5061

5162
export class FileTokenStorage implements TokenStorage {
5263
private readonly credentialsStore: CredentialsStore;
5364
private readonly lockFilePath: string;
5465

55-
constructor(env: NodeJS.ProcessEnv = process.env) {
66+
constructor(env: NodeJS.ProcessEnv = process.env, private readonly signal?: AbortSignal) {
5667
const authFilePath = getAuthFilePath(env);
5768
this.credentialsStore = new CredentialsStore(authFilePath);
5869
this.lockFilePath = `${authFilePath}.lock`;
5970
}
6071

6172
async getTokens(): Promise<Tokens | null> {
73+
this.signal?.throwIfAborted();
6274
try {
75+
// CredentialsStore does not accept AbortSignal; check immediately before and after the boundary.
6376
const all = await this.credentialsStore.getCredentials();
77+
this.signal?.throwIfAborted();
6478
return findLatestValidTokens(all as StoredCredential[]);
65-
} catch {
79+
} catch (error) {
80+
if (this.signal?.aborted) throw this.signal.reason;
6681
return null;
6782
}
6883
}
6984

7085
async setTokens(tokens: Tokens): Promise<void> {
86+
this.signal?.throwIfAborted();
87+
// CredentialsStore does not accept AbortSignal; check immediately before and after the boundary.
7188
await this.credentialsStore.storeCredentials({
7289
workspaceId: tokens.workspaceId,
7390
token: tokens.accessToken,
7491
refreshToken: tokens.refreshToken,
7592
});
93+
this.signal?.throwIfAborted();
7694
}
7795

7896
async clearTokens(): Promise<void> {
97+
this.signal?.throwIfAborted();
7998
const all = await this.credentialsStore.getCredentials();
99+
this.signal?.throwIfAborted();
80100
const tokens = findLatestValidTokens(all as StoredCredential[]);
81101
if (!tokens) return;
102+
// CredentialsStore does not accept AbortSignal; check immediately before and after the boundary.
82103
await this.credentialsStore.deleteCredentials(tokens.workspaceId);
104+
this.signal?.throwIfAborted();
83105
}
84106

85107
async clearTokensIfCurrent(tokens: Tokens): Promise<void> {
108+
this.signal?.throwIfAborted();
86109
const current = await this.getTokens();
87110
if (!tokensEqual(current, tokens)) return;
88111
await this.clearTokens();
@@ -99,18 +122,29 @@ export class FileTokenStorage implements TokenStorage {
99122

100123
private async acquireRefreshLock(): Promise<string> {
101124
const lockId = randomUUID();
125+
this.signal?.throwIfAborted();
126+
// mkdir does not accept AbortSignal; check before the filesystem boundary.
102127
await fs.mkdir(path.dirname(this.lockFilePath), { recursive: true });
103128

104129
while (true) {
130+
this.signal?.throwIfAborted();
131+
let lockFileCreated = false;
105132
try {
133+
// open does not accept AbortSignal; check before the filesystem boundary.
106134
const handle = await fs.open(this.lockFilePath, "wx");
135+
lockFileCreated = true;
107136
try {
108-
await handle.writeFile(lockId, "utf8");
137+
this.signal?.throwIfAborted();
138+
await handle.writeFile(lockId, { encoding: "utf8" });
139+
this.signal?.throwIfAborted();
109140
} finally {
110141
await handle.close();
111142
}
112143
return lockId;
113144
} catch (error) {
145+
if (lockFileCreated) {
146+
await fs.unlink(this.lockFilePath).catch(() => undefined);
147+
}
114148
const code = (error as NodeJS.ErrnoException).code;
115149
if (code !== "EEXIST") throw error;
116150

@@ -120,23 +154,31 @@ export class FileTokenStorage implements TokenStorage {
120154
continue;
121155
}
122156

123-
await sleep(100);
157+
await sleep(100, this.signal);
124158
}
125159
}
126160
}
127161

128162
private async getStaleRefreshLockId(): Promise<string | null> {
129-
const lockId = await fs.readFile(this.lockFilePath, "utf8").catch(() => null);
163+
this.signal?.throwIfAborted();
164+
const lockId = await fs.readFile(this.lockFilePath, { encoding: "utf8", signal: this.signal }).catch((error) => {
165+
if (this.signal?.aborted) throw error;
166+
return null;
167+
});
130168
if (lockId === null) return null;
131169

170+
this.signal?.throwIfAborted();
171+
// stat does not accept AbortSignal; check before and after the filesystem boundary.
132172
const stats = await fs.stat(this.lockFilePath).catch(() => null);
173+
this.signal?.throwIfAborted();
133174
if (!stats) return null;
134175
return Date.now() - stats.mtimeMs > 30_000 ? lockId : null;
135176
}
136177

137178
private async releaseRefreshLock(lockId: string): Promise<void> {
138-
const currentLockId = await fs.readFile(this.lockFilePath, "utf8").catch(() => null);
179+
const currentLockId = await fs.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null);
139180
if (currentLockId !== lockId) return;
181+
// unlink does not accept AbortSignal; refresh-lock cleanup must run even after cancellation.
140182
await fs.unlink(this.lockFilePath).catch(() => {});
141183
}
142184
}

packages/cli/src/bin.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,21 @@ if (process.env.PRISMA_CLI_RUN_UPDATE_CHECK_WORKER === "1") {
99
process.exitCode = 0;
1010
});
1111
} else {
12-
runCli().then((exitCode) => {
12+
const controller = new AbortController();
13+
14+
const abortCli = () => {
15+
if (!controller.signal.aborted) {
16+
controller.abort(new DOMException("Command canceled", "AbortError"));
17+
}
18+
};
19+
20+
process.once("SIGINT", abortCli);
21+
process.once("SIGTERM", abortCli);
22+
23+
runCli({ signal: controller.signal }).then((exitCode) => {
1324
process.exitCode = exitCode;
25+
}).finally(() => {
26+
process.off("SIGINT", abortCli);
27+
process.off("SIGTERM", abortCli);
1428
});
1529
}

packages/cli/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ function resolveRuntime(options: RunCliOptions): CliRuntime {
155155
argv: options.argv ?? process.argv.slice(2),
156156
cwd: options.cwd ?? process.env.INIT_CWD ?? process.cwd(),
157157
env: options.env ?? process.env,
158+
signal: options.signal ?? new AbortController().signal,
158159
stdin: options.stdin ?? process.stdin,
159160
stdout: options.stdout ?? process.stdout,
160161
stderr: options.stderr ?? process.stderr,

0 commit comments

Comments
 (0)