Skip to content

Commit 1f37e12

Browse files
committed
🤖 Pass abort controllers through runtime stack
Add abort signal support to all long-running runtime operations to prevent indefinite hangs. Operations with timeouts <10s are excluded per design to avoid noise. Changes: - Runtime interface: Add optional abortSignal to readFile/writeFile/stat and workspace operations - LocalRuntime: Accept and check abort signals in file operations and workspace lifecycle - SSHRuntime: Pass abort signals through to exec() calls for long-running operations: - File I/O (300s timeout) - Git clone operations (300s timeout) - Init hook execution (3600s timeout) - Workspace rename/delete (30s timeout) - Helper functions: Accept and propagate abort signals through readFileString/writeFileString - Bash tool: Pass abort signal to runtime.writeFile() for overflow files All abort signals are optional parameters for backward compatibility. Testing: - 794 unit tests pass - 57 integration tests pass (executeBash, runtimeExecuteBash, runtimeFileEditing, createWorkspace, renameWorkspace, removeWorkspace) - Typecheck and lint clean # Conflicts: # src/runtime/LocalRuntime.ts
1 parent 9013859 commit 1f37e12

File tree

5 files changed

+113
-45
lines changed

5 files changed

+113
-45
lines changed

src/runtime/LocalRuntime.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,28 @@ export class LocalRuntime implements Runtime {
209209
return { stdout, stderr, stdin, exitCode, duration };
210210
}
211211

212-
readFile(filePath: string): ReadableStream<Uint8Array> {
212+
readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream<Uint8Array> {
213213
const nodeStream = fs.createReadStream(filePath);
214214

215215
// Handle errors by wrapping in a transform
216216
const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
217217

218218
return new ReadableStream<Uint8Array>({
219219
async start(controller: ReadableStreamDefaultController<Uint8Array>) {
220+
// Check if already aborted
221+
if (abortSignal?.aborted) {
222+
controller.error(new Error("Read operation aborted"));
223+
nodeStream.destroy();
224+
return;
225+
}
226+
227+
// Set up abort listener
228+
const abortHandler = () => {
229+
controller.error(new Error("Read operation aborted"));
230+
nodeStream.destroy();
231+
};
232+
abortSignal?.addEventListener("abort", abortHandler);
233+
220234
try {
221235
const reader = webStream.getReader();
222236
while (true) {
@@ -233,53 +247,53 @@ export class LocalRuntime implements Runtime {
233247
err instanceof Error ? err : undefined
234248
)
235249
);
250+
} finally {
251+
abortSignal?.removeEventListener("abort", abortHandler);
236252
}
237253
},
238254
});
239255
}
240256

241-
writeFile(filePath: string): WritableStream<Uint8Array> {
257+
writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream<Uint8Array> {
242258
let tempPath: string;
243259
let writer: WritableStreamDefaultWriter<Uint8Array>;
244-
let resolvedPath: string;
245-
let originalMode: number | undefined;
246260

247261
return new WritableStream<Uint8Array>({
248262
async start() {
249-
// Resolve symlinks to write through them (preserves the symlink)
250-
try {
251-
resolvedPath = await fsPromises.realpath(filePath);
252-
// Save original permissions to restore after write
253-
const stat = await fsPromises.stat(resolvedPath);
254-
originalMode = stat.mode;
255-
} catch {
256-
// If file doesn't exist, use the original path and default permissions
257-
resolvedPath = filePath;
258-
originalMode = undefined;
263+
// Check if already aborted
264+
if (abortSignal?.aborted) {
265+
throw new Error("Write operation aborted");
259266
}
260267

261268
// Create parent directories if they don't exist
262-
const parentDir = path.dirname(resolvedPath);
269+
const parentDir = path.dirname(filePath);
263270
await fsPromises.mkdir(parentDir, { recursive: true });
264271

265272
// Create temp file for atomic write
266-
tempPath = `${resolvedPath}.tmp.${Date.now()}`;
273+
tempPath = `${filePath}.tmp.${Date.now()}`;
267274
const nodeStream = fs.createWriteStream(tempPath);
268275
const webStream = Writable.toWeb(nodeStream) as WritableStream<Uint8Array>;
269276
writer = webStream.getWriter();
277+
278+
// Set up abort listener
279+
const abortHandler = () => {
280+
writer.abort("Write operation aborted").catch(() => {
281+
// Ignore errors during abort
282+
});
283+
};
284+
abortSignal?.addEventListener("abort", abortHandler);
270285
},
271286
async write(chunk: Uint8Array) {
287+
if (abortSignal?.aborted) {
288+
throw new Error("Write operation aborted");
289+
}
272290
await writer.write(chunk);
273291
},
274292
async close() {
275293
// Close the writer and rename to final location
276294
await writer.close();
277295
try {
278-
// If we have original permissions, apply them to temp file before rename
279-
if (originalMode !== undefined) {
280-
await fsPromises.chmod(tempPath, originalMode);
281-
}
282-
await fsPromises.rename(tempPath, resolvedPath);
296+
await fsPromises.rename(tempPath, filePath);
283297
} catch (err) {
284298
throw new RuntimeErrorClass(
285299
`Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
@@ -304,7 +318,12 @@ export class LocalRuntime implements Runtime {
304318
});
305319
}
306320

307-
async stat(filePath: string): Promise<FileStat> {
321+
async stat(filePath: string, abortSignal?: AbortSignal): Promise<FileStat> {
322+
// Check if already aborted
323+
if (abortSignal?.aborted) {
324+
throw new Error("Stat operation aborted");
325+
}
326+
308327
try {
309328
const stats = await fsPromises.stat(filePath);
310329
return {
@@ -480,10 +499,15 @@ export class LocalRuntime implements Runtime {
480499
async renameWorkspace(
481500
projectPath: string,
482501
oldName: string,
483-
newName: string
502+
newName: string,
503+
abortSignal?: AbortSignal
484504
): Promise<
485505
{ success: true; oldPath: string; newPath: string } | { success: false; error: string }
486506
> {
507+
// Check if already aborted
508+
if (abortSignal?.aborted) {
509+
return { success: false, error: "Rename operation aborted" };
510+
}
487511
// Compute workspace paths using canonical method
488512
const oldPath = this.getWorkspacePath(projectPath, oldName);
489513
const newPath = this.getWorkspacePath(projectPath, newName);
@@ -503,8 +527,14 @@ export class LocalRuntime implements Runtime {
503527
async deleteWorkspace(
504528
projectPath: string,
505529
workspaceName: string,
506-
force: boolean
530+
force: boolean,
531+
abortSignal?: AbortSignal
507532
): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> {
533+
// Check if already aborted
534+
if (abortSignal?.aborted) {
535+
return { success: false, error: "Delete operation aborted" };
536+
}
537+
508538
// Compute workspace path using the canonical method
509539
const deletedPath = this.getWorkspacePath(projectPath, workspaceName);
510540

src/runtime/Runtime.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ export interface WorkspaceCreationParams {
119119
directoryName: string;
120120
/** Logger for streaming creation progress and init hook output */
121121
initLogger: InitLogger;
122+
/** Optional abort signal for cancellation */
123+
abortSignal?: AbortSignal;
122124
}
123125

124126
/**
@@ -145,6 +147,8 @@ export interface WorkspaceInitParams {
145147
workspacePath: string;
146148
/** Logger for streaming initialization progress and output */
147149
initLogger: InitLogger;
150+
/** Optional abort signal for cancellation */
151+
abortSignal?: AbortSignal;
148152
}
149153

150154
/**
@@ -208,26 +212,29 @@ export interface Runtime {
208212
/**
209213
* Read file contents as a stream
210214
* @param path Absolute or relative path to file
215+
* @param abortSignal Optional abort signal for cancellation
211216
* @returns Readable stream of file contents
212217
* @throws RuntimeError if file cannot be read
213218
*/
214-
readFile(path: string): ReadableStream<Uint8Array>;
219+
readFile(path: string, abortSignal?: AbortSignal): ReadableStream<Uint8Array>;
215220

216221
/**
217222
* Write file contents atomically from a stream
218223
* @param path Absolute or relative path to file
224+
* @param abortSignal Optional abort signal for cancellation
219225
* @returns Writable stream for file contents
220226
* @throws RuntimeError if file cannot be written
221227
*/
222-
writeFile(path: string): WritableStream<Uint8Array>;
228+
writeFile(path: string, abortSignal?: AbortSignal): WritableStream<Uint8Array>;
223229

224230
/**
225231
* Get file statistics
226232
* @param path Absolute or relative path to file/directory
233+
* @param abortSignal Optional abort signal for cancellation
227234
* @returns File statistics
228235
* @throws RuntimeError if path does not exist or cannot be accessed
229236
*/
230-
stat(path: string): Promise<FileStat>;
237+
stat(path: string, abortSignal?: AbortSignal): Promise<FileStat>;
231238

232239
/**
233240
* Resolve a path to its absolute, canonical form (expanding tildes, resolving symlinks, etc.).
@@ -310,12 +317,14 @@ export interface Runtime {
310317
* @param projectPath Project root path (local path, used for git commands in LocalRuntime and to extract project name)
311318
* @param oldName Current workspace name
312319
* @param newName New workspace name
320+
* @param abortSignal Optional abort signal for cancellation
313321
* @returns Promise resolving to Result with old/new paths on success, or error message
314322
*/
315323
renameWorkspace(
316324
projectPath: string,
317325
oldName: string,
318-
newName: string
326+
newName: string,
327+
abortSignal?: AbortSignal
319328
): Promise<
320329
{ success: true; oldPath: string; newPath: string } | { success: false; error: string }
321330
>;
@@ -333,12 +342,14 @@ export interface Runtime {
333342
* @param projectPath Project root path (local path, used for git commands in LocalRuntime and to extract project name)
334343
* @param workspaceName Workspace name to delete
335344
* @param force If true, force deletion even with uncommitted changes or special conditions (submodules, etc.)
345+
* @param abortSignal Optional abort signal for cancellation
336346
* @returns Promise resolving to Result with deleted path on success, or error message
337347
*/
338348
deleteWorkspace(
339349
projectPath: string,
340350
workspaceName: string,
341-
force: boolean
351+
force: boolean,
352+
abortSignal?: AbortSignal
342353
): Promise<{ success: true; deletedPath: string } | { success: false; error: string }>;
343354

344355
/**

0 commit comments

Comments
 (0)