authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:12:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:12:54-07:00
log58053d146b1ea7201884867d1543ccbbc4c05ffd
tree4d3cd7a36a856fa6aa6673d7a453493989e49b9f
parent50767683b396cf7065bb18072d6d14c05475b6e9
signature Signed by PGP key B5690EEEBB952194

[codex] Structure terminal PTY operation failures (#3364)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 251 insertions(+), 57 deletions(-)

apps/server/src/terminal/Manager.test.ts+99-2
......@@ -39,6 +39,8 @@ class FakePtyProcess implements PtyAdapter.PtyProcess {
3939 readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
4040 readonly killSignals: Array<string | undefined> = [];
4141 readonly pid: number;
42 writeFailure: unknown | undefined;
43 resizeFailure: unknown | undefined;
4244 private readonly dataListeners = new Set<(data: string) => void>();
4345 private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>();
4446 killed = false;
......@@ -48,10 +50,16 @@ class FakePtyProcess implements PtyAdapter.PtyProcess {
4850 }
4951
5052 write(data: string): void {
53 if (this.writeFailure !== undefined) {
54 throw this.writeFailure;
55 }
5156 this.writes.push(data);
5257 }
5358
5459 resize(cols: number, rows: number): void {
60 if (this.resizeFailure !== undefined) {
61 throw this.resizeFailure;
62 }
5563 this.resizeCalls.push({ cols, rows });
5664 }
5765
......@@ -435,6 +443,39 @@ it.layer(
435443 fs.writeFileString(filePath, contents),
436444 );
437445
446 it.effect("reports a missing cwd without an artificial cause", () =>
447 Effect.gen(function* () {
448 const path = yield* Path.Path;
449
450 const { manager, baseDir } = yield* createManager();
451 const cwd = path.join(baseDir, "missing-cwd");
452 const error = yield* Effect.flip(manager.open(openInput({ cwd })));
453
454 expect(error).toMatchObject({
455 _tag: "TerminalCwdNotFoundError",
456 cwd,
457 });
458 expect("cause" in error).toBe(false);
459 }),
460 );
461
462 it.effect("reports a cwd that is not a directory", () =>
463 Effect.gen(function* () {
464 const path = yield* Path.Path;
465
466 const { manager, baseDir } = yield* createManager();
467 const cwd = path.join(baseDir, "cwd-file");
468 yield* writeFileString(cwd, "not a directory");
469 const error = yield* Effect.flip(manager.open(openInput({ cwd })));
470
471 expect(error).toMatchObject({
472 _tag: "TerminalCwdNotDirectoryError",
473 cwd,
474 });
475 expect("cause" in error).toBe(false);
476 }),
477 );
478
438479 it.effect("preserves non-notFound cwd stat failures", () =>
439480 Effect.gen(function* () {
440481 if ((yield* HostProcessPlatform) === "win32") return;
......@@ -452,9 +493,11 @@ it.layer(
452493 );
453494
454495 expect(error).toMatchObject({
455 _tag: "TerminalCwdError",
496 _tag: "TerminalCwdStatError",
456497 cwd: blockedCwd,
457 reason: "statFailed",
498 cause: {
499 _tag: "PlatformError",
500 },
458501 });
459502 }),
460503 );
......@@ -498,6 +541,60 @@ it.layer(
498541 }),
499542 );
500543
544 it.effect("preserves structured context and causes for PTY I/O failures", () =>
545 Effect.gen(function* () {
546 const { manager, ptyAdapter } = yield* createManager();
547 yield* manager.open(openInput());
548 const process = ptyAdapter.processes[0];
549 expect(process).toBeDefined();
550 if (!process) return;
551
552 const writeCause = new Error("PTY input handle is unavailable");
553 process.writeFailure = writeCause;
554 const writeError = yield* Effect.flip(
555 manager.write({
556 threadId: "thread-1",
557 terminalId: DEFAULT_TERMINAL_ID,
558 data: "secret input that must not be attached to the error",
559 }),
560 );
561
562 expect(writeError).toMatchObject({
563 _tag: "TerminalWriteError",
564 threadId: "thread-1",
565 terminalId: DEFAULT_TERMINAL_ID,
566 terminalPid: process.pid,
567 });
568 expect(writeError.cause).toBe(writeCause);
569 expect(writeError).not.toHaveProperty("data");
570
571 const resizeCause = new Error("PTY resize handle is unavailable");
572 process.resizeFailure = resizeCause;
573 const resizeError = yield* Effect.flip(
574 manager.resize({
575 threadId: "thread-1",
576 terminalId: DEFAULT_TERMINAL_ID,
577 cols: 132,
578 rows: 40,
579 }),
580 );
581
582 expect(resizeError).toMatchObject({
583 _tag: "TerminalResizeError",
584 threadId: "thread-1",
585 terminalId: DEFAULT_TERMINAL_ID,
586 terminalPid: process.pid,
587 cols: 132,
588 rows: 40,
589 });
590 expect(resizeError.cause).toBe(resizeCause);
591
592 process.resizeFailure = undefined;
593 yield* manager.open(openInput({ cols: 132, rows: 40 }));
594 expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]);
595 }),
596 );
597
501598 it.effect("ignores delayed resize requests after a terminal closes", () =>
502599 Effect.gen(function* () {
503600 const { manager, ptyAdapter } = yield* createManager();
apps/server/src/terminal/Manager.ts+87-35
......@@ -9,10 +9,15 @@
99import {
1010 DEFAULT_TERMINAL_ID,
1111 TerminalCwdError,
12 TerminalCwdNotDirectoryError,
13 TerminalCwdNotFoundError,
14 TerminalCwdStatError,
1215 TerminalError,
1316 TerminalHistoryError,
1417 TerminalNotRunningError,
18 TerminalResizeError,
1519 TerminalSessionLookupError,
20 TerminalWriteError,
1621 type TerminalAttachInput,
1722 type TerminalAttachStreamEvent,
1823 type TerminalClearInput,
......@@ -58,10 +63,15 @@ import * as PtyAdapter from "./PtyAdapter.ts";
5863
5964export {
6065 TerminalCwdError,
66 TerminalCwdNotDirectoryError,
67 TerminalCwdNotFoundError,
68 TerminalCwdStatError,
6169 TerminalError,
6270 TerminalHistoryError,
6371 TerminalNotRunningError,
72 TerminalResizeError,
6473 TerminalSessionLookupError,
74 TerminalWriteError,
6575};
6676
6777const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
......@@ -190,6 +200,25 @@ interface TerminalSubprocessInspector {
190200 ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
191201}
192202
203const resizePtyProcess = (
204 session: TerminalSessionState,
205 process: PtyAdapter.PtyProcess,
206 cols: number,
207 rows: number,
208) =>
209 Effect.try({
210 try: () => process.resize(cols, rows),
211 catch: (cause) =>
212 new TerminalResizeError({
213 threadId: session.threadId,
214 terminalId: session.terminalId,
215 terminalPid: process.pid,
216 cols,
217 rows,
218 cause,
219 }),
220 });
221
193222export interface ShellCandidate {
194223 shell: string;
195224 args?: string[];
......@@ -1157,16 +1186,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
11571186 const legacyHistoryPath = (threadId: string) =>
11581187 path.join(logsDir, `${legacySafeThreadId(threadId)}.log`);
11591188
1160 const toTerminalHistoryError =
1161 (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) =>
1162 (cause: unknown) =>
1163 new TerminalHistoryError({
1164 operation,
1165 threadId,
1166 terminalId,
1167 cause,
1168 });
1169
11701189 const readManagerState = SynchronizedRef.get(managerStateRef);
11711190
11721191 const modifyManagerState = <A>(
......@@ -1250,7 +1269,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
12501269 threadId,
12511270 terminalId,
12521271 signal: "SIGTERM",
1253 error: error.message,
1272 cause: error,
12541273 }).pipe(Effect.as(false)),
12551274 ),
12561275 );
......@@ -1274,7 +1293,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
12741293 threadId,
12751294 terminalId,
12761295 signal: "SIGKILL",
1277 error: error.message,
1296 cause: error,
12781297 }),
12791298 ),
12801299 );
......@@ -1372,16 +1391,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
13721391 if (
13731392 yield* fileSystem
13741393 .exists(nextPath)
1375 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)))
1394 .pipe(
1395 Effect.mapError(
1396 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),
1397 ),
1398 )
13761399 ) {
13771400 const raw = yield* fileSystem
13781401 .readFileString(nextPath)
1379 .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId)));
1402 .pipe(
1403 Effect.mapError(
1404 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),
1405 ),
1406 );
13801407 const capped = capHistory(raw, historyLineLimit);
13811408 if (capped !== raw) {
13821409 yield* fileSystem
13831410 .writeFileString(nextPath, capped)
1384 .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId)));
1411 .pipe(
1412 Effect.mapError(
1413 (cause) =>
1414 new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }),
1415 ),
1416 );
13851417 }
13861418 return capped;
13871419 }
......@@ -1394,18 +1426,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
13941426 if (
13951427 !(yield* fileSystem
13961428 .exists(legacyPath)
1397 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))))
1429 .pipe(
1430 Effect.mapError(
1431 (cause) =>
1432 new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1433 ),
1434 ))
13981435 ) {
13991436 return "";
14001437 }
14011438
14021439 const raw = yield* fileSystem
14031440 .readFileString(legacyPath)
1404 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
1441 .pipe(
1442 Effect.mapError(
1443 (cause) =>
1444 new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1445 ),
1446 );
14051447 const capped = capHistory(raw, historyLineLimit);
14061448 yield* fileSystem
14071449 .writeFileString(nextPath, capped)
1408 .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)));
1450 .pipe(
1451 Effect.mapError(
1452 (cause) =>
1453 new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1454 ),
1455 );
14091456 yield* fileSystem.remove(legacyPath, { force: true }).pipe(
14101457 Effect.catch((cleanupError) =>
14111458 Effect.logWarning("failed to remove legacy terminal history", {
......@@ -1472,20 +1519,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
14721519
14731520 const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) {
14741521 const stats = yield* fileSystem.stat(cwd).pipe(
1475 Effect.mapError(
1476 (cause) =>
1477 new TerminalCwdError({
1478 cwd,
1479 reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed",
1480 cause,
1481 }),
1482 ),
1522 Effect.catchTags({
1523 PlatformError: (cause) =>
1524 cause.reason._tag === "NotFound"
1525 ? new TerminalCwdNotFoundError({ cwd })
1526 : new TerminalCwdStatError({ cwd, cause }),
1527 }),
14831528 );
14841529 if (stats.type !== "Directory") {
1485 return yield* new TerminalCwdError({
1486 cwd,
1487 reason: "notDirectory",
1488 });
1530 return yield* new TerminalCwdNotDirectoryError({ cwd });
14891531 }
14901532 });
14911533
......@@ -1881,7 +1923,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
18811923 yield* Effect.logError("failed to start terminal", {
18821924 threadId: session.threadId,
18831925 terminalId: session.terminalId,
1884 error: message,
1926 cause: error,
18851927 ...(startedShell ? { shell: startedShell } : {}),
18861928 });
18871929 }
......@@ -2168,10 +2210,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
21682210 }
21692211
21702212 if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) {
2213 yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows);
21712214 liveSession.cols = targetCols;
21722215 liveSession.rows = targetRows;
21732216 liveSession.updatedAt = yield* nowIso;
2174 liveSession.process.resize(targetCols, targetRows);
21752217 }
21762218
21772219 return snapshot(liveSession);
......@@ -2219,10 +2261,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
22192261 session.status === "running" &&
22202262 (session.cols !== targetCols || session.rows !== targetRows)
22212263 ) {
2264 const process = session.process;
2265 yield* resizePtyProcess(session, process, targetCols, targetRows);
22222266 session.cols = targetCols;
22232267 session.rows = targetRows;
22242268 session.updatedAt = yield* nowIso;
2225 yield* Effect.sync(() => session.process?.resize(targetCols, targetRows));
22262269 }
22272270
22282271 return snapshot(session);
......@@ -2409,7 +2452,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
24092452 terminalId,
24102453 });
24112454 }
2412 yield* Effect.sync(() => process.write(input.data));
2455 yield* Effect.try({
2456 try: () => process.write(input.data),
2457 catch: (cause) =>
2458 new TerminalWriteError({
2459 threadId: input.threadId,
2460 terminalId,
2461 terminalPid: process.pid,
2462 cause,
2463 }),
2464 });
24132465 });
24142466
24152467 const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) {
......@@ -2422,10 +2474,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
24222474 if (!process || session.value.status !== "running") {
24232475 return;
24242476 }
2477 yield* resizePtyProcess(session.value, process, input.cols, input.rows);
24252478 session.value.cols = input.cols;
24262479 session.value.rows = input.rows;
24272480 session.value.updatedAt = yield* nowIso;
2428 yield* Effect.sync(() => process.resize(input.cols, input.rows));
24292481 });
24302482
24312483 const resize: TerminalManager["Service"]["resize"] = (input) =>
packages/contracts/src/terminal.ts+65-20
......@@ -232,34 +232,47 @@ export const TerminalAttachStreamEvent = Schema.Union([
232232]);
233233export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type;
234234
235export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(
236 "TerminalCwdError",
235export class TerminalCwdNotFoundError extends Schema.TaggedErrorClass<TerminalCwdNotFoundError>()(
236 "TerminalCwdNotFoundError",
237 {
238 cwd: Schema.String,
239 },
240) {
241 override get message() {
242 return `Terminal cwd does not exist: ${this.cwd}`;
243 }
244}
245
246export class TerminalCwdNotDirectoryError extends Schema.TaggedErrorClass<TerminalCwdNotDirectoryError>()(
247 "TerminalCwdNotDirectoryError",
237248 {
238249 cwd: Schema.String,
239 reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]),
240 cause: Schema.optional(Schema.Defect()),
241250 },
242251) {
243252 override get message() {
244 if (this.reason === "notDirectory") {
245 return `Terminal cwd is not a directory: ${this.cwd}`;
246 }
247 if (this.reason === "notFound") {
248 return `Terminal cwd does not exist: ${this.cwd}`;
249 }
250 const causeMessage =
251 this.cause !== undefined &&
252 this.cause !== null &&
253 typeof this.cause === "object" &&
254 "message" in this.cause
255 ? this.cause.message
256 : undefined;
257 return typeof causeMessage === "string" && causeMessage.length > 0
258 ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})`
259 : `Failed to access terminal cwd: ${this.cwd}`;
253 return `Terminal cwd is not a directory: ${this.cwd}`;
260254 }
261255}
262256
257export class TerminalCwdStatError extends Schema.TaggedErrorClass<TerminalCwdStatError>()(
258 "TerminalCwdStatError",
259 {
260 cwd: Schema.String,
261 cause: Schema.Defect(),
262 },
263) {
264 override get message() {
265 return `Failed to access terminal cwd: ${this.cwd}`;
266 }
267}
268
269export const TerminalCwdError = Schema.Union([
270 TerminalCwdNotFoundError,
271 TerminalCwdNotDirectoryError,
272 TerminalCwdStatError,
273]);
274export type TerminalCwdError = typeof TerminalCwdError.Type;
275
263276export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()(
264277 "TerminalHistoryError",
265278 {
......@@ -298,10 +311,42 @@ export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNot
298311 }
299312}
300313
314export class TerminalWriteError extends Schema.TaggedErrorClass<TerminalWriteError>()(
315 "TerminalWriteError",
316 {
317 threadId: Schema.String,
318 terminalId: Schema.String,
319 terminalPid: Schema.Number,
320 cause: Schema.Defect(),
321 },
322) {
323 override get message() {
324 return `Failed to write to terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid}`;
325 }
326}
327
328export class TerminalResizeError extends Schema.TaggedErrorClass<TerminalResizeError>()(
329 "TerminalResizeError",
330 {
331 threadId: Schema.String,
332 terminalId: Schema.String,
333 terminalPid: Schema.Number,
334 cols: TerminalColsSchema,
335 rows: TerminalRowsSchema,
336 cause: Schema.Defect(),
337 },
338) {
339 override get message() {
340 return `Failed to resize terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid} to ${this.cols}x${this.rows}`;
341 }
342}
343
301344export const TerminalError = Schema.Union([
302345 TerminalCwdError,
303346 TerminalHistoryError,
304347 TerminalSessionLookupError,
305348 TerminalNotRunningError,
349 TerminalWriteError,
350 TerminalResizeError,
306351]);
307352export type TerminalError = typeof TerminalError.Type;