Skip to content
Source
Sign inSign up
Open History to see the last commit

stdio.test.ts

239 lines9.3 KB

1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Deferred from "effect/Deferred";
4import * as Fiber from "effect/Fiber";
5import * as Queue from "effect/Queue";
6import * as Stream from "effect/Stream";
7import * as TestClock from "effect/testing/TestClock";
8import * as PlatformError from "effect/PlatformError";
9import { ChildProcessSpawner } from "effect/process";
10​
11import * as CodexError from "../errors.ts";
12import * as CodexProtocol from "../protocol.ts";
13import {
14 appendStderrTail,
15 makeTerminationError,
16 stderrExcerpt,
17 makeInMemoryStdio,
18 makeStderrTail,
19 makeChildStdio,
20} from "./stdio.ts";
21​
22describe("Codex App Server child process termination", () => {
23 it.effect("retains exit diagnostics when stderr stays open after stdout ends", () =>
24 Effect.gen(function* () {
25 const { stdio, input, output } = yield* makeInMemoryStdio();
26 const captured = yield* Deferred.make<void>();
27 const stderr = Stream.make(new TextEncoder().encode("Error: invalid config\n")).pipe(
28 Stream.concat(Stream.fromEffect(Deferred.succeed(captured, undefined)).pipe(Stream.drain)),
29 Stream.concat(Stream.never),
30 );
31 const tail = yield* makeStderrTail(stderr);
32 const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
33 const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({
34 stdio: makeChildStdio({ stdout: stdio.stdin, stdin: stdio.stdout() }, tail.awaitDrained),
35 terminationError: makeTerminationError(
36 {
37 pid: ChildProcessSpawner.ProcessId(55),
38 exitCode: Deferred.await(exited),
39 },
40 tail.read,
41 ),
42 });
43 const pending = yield* transport.request("thread/read", {}).pipe(
44 Effect.match({
45 onFailure: (error) => error,
46 onSuccess: () => assert.fail("Expected the pending request to fail"),
47 }),
48 Effect.forkScoped,
49 );
50 yield* Queue.take(output);
51 yield* Deferred.await(captured);
52 yield* Queue.end(input);
53 yield* TestClock.adjust("100 millis");
54 yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(1));
55 yield* TestClock.adjust("250 millis");
56 const error = yield* Fiber.join(pending);
57 assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError);
58 assert.equal(
59 error.message,
60 "Codex App Server process exited with code 1: Error: invalid config",
61 );
62 }),
63 );
64​
65 it.effect("retains the process identifier and exact exit-status cause", () =>
66 Effect.gen(function* () {
67 const rootCause = new Error("private process diagnostics");
68 const cause = PlatformError.systemError({
69 _tag: "Unknown",
70 module: "ChildProcess",
71 method: "exitCode",
72 cause: rootCause,
73 });
74 const error = yield* makeTerminationError(
75 {
76 pid: ChildProcessSpawner.ProcessId(52),
77 exitCode: Effect.fail(cause),
78 },
79 Effect.succeed(""),
80 );
81​
82 assert.instanceOf(error, CodexError.CodexAppServerTransportError);
83 assert.equal(error.pid, 52);
84 assert.strictEqual(error.cause, cause);
85 assert.equal(
86 error.message,
87 "Codex App Server transport operation 'read-process-exit-status' failed.",
88 );
89 assert.notInclude(error.message, rootCause.message);
90 }),
91 );
92​
93 it.effect("names the stderr error line when the process exits", () =>
94 Effect.gen(function* () {
95 const error = yield* makeTerminationError(
96 {
97 pid: ChildProcessSpawner.ProcessId(53),
98 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
99 },
100 Effect.succeed("Error: Missing optional dependency @openai/codex-darwin-arm64.\n"),
101 );
102​
103 assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError);
104 assert.equal(error.stderr, "Error: Missing optional dependency @openai/codex-darwin-arm64.");
105 assert.equal(
106 error.message,
107 "Codex App Server process exited with code 1: Error: Missing optional dependency @openai/codex-darwin-arm64.",
108 );
109 }),
110 );
111​
112 it.effect("keeps the bare exit message when stderr is empty", () =>
113 Effect.gen(function* () {
114 const error = yield* makeTerminationError(
115 {
116 pid: ChildProcessSpawner.ProcessId(54),
117 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
118 },
119 Effect.succeed(" \n\n"),
120 );
121​
122 assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError);
123 assert.isUndefined(error.stderr);
124 assert.equal(error.message, "Codex App Server process exited with code 1");
125 }),
126 );
127});
128​
129describe("Codex App Server stderr excerpt", () => {
130 it.each([
131 {
132 name: "Node launcher missing its platform package",
133 stderr: [
134 "file:///usr/local/lib/node_modules/@openai/codex/bin/codex.js:107",
135 " throw new Error(",
136 " ^",
137 "",
138 "Error: Missing optional dependency @openai/codex-darwin-arm64. Reinstall Codex: npm install -g @openai/codex@latest",
139 " at findCodexExecutable (file:///usr/local/lib/node_modules/@openai/codex/bin/codex.js:107:9)",
140 " at file:///usr/local/lib/node_modules/@openai/codex/bin/codex.js:112:20",
141 "",
142 "Node.js v26.8.2",
143 "",
144 ].join("\n"),
145 excerpt:
146 "Error: Missing optional dependency @openai/codex-darwin-arm64. Reinstall Codex: npm install -g @openai/codex@latest",
147 },
148 {
149 name: "Node error with a code",
150 stderr:
151 "node:internal/modules/esm/resolve:873\n throw new ERR_MODULE_NOT_FOUND(\n\nError [ERR_MODULE_NOT_FOUND]: Cannot find package 'x'\n at packageResolve (node:internal/modules/esm/resolve:873:9)\n",
152 excerpt: "Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'x'",
153 },
154 {
155 name: "Rust CLI error",
156 stderr: "error: unexpected argument 'app-server' found\n\nUsage: codex [OPTIONS]\n",
157 excerpt: "error: unexpected argument 'app-server' found",
158 },
159 {
160 name: "uppercase error label",
161 stderr: "ERROR: Missing dependency\nUsage: codex [OPTIONS]\n",
162 excerpt: "ERROR: Missing dependency",
163 },
164 {
165 name: "unlabelled message",
166 stderr: "WARNING: proceeding without PATH aliases\nMissing OpenAI API key.\n",
167 excerpt: "Missing OpenAI API key.",
168 },
169 {
170 name: "launcher exit summary after a DNS failure",
171 stderr: [
172 "Starting provider",
173 'проверить токен LLM: Get "https://llm-gateway.example/api/models": dial tcp: lookup llm-gateway.example: no such host',
174 "не удалось отправить метрики на сервер: no such host",
175 "Ошибка: ошибка запуска плагина: exit status 1",
176 ].join("\n"),
177 excerpt:
178 'проверить токен LLM: Get "https://llm-gateway.example/api/models": dial tcp: lookup llm-gateway.example: no such host | не удалось отправить метрики на сервер: no such host | Ошибка: ошибка запуска плагина: exit status 1',
179 },
180 {
181 name: "labelled launcher exit summary after an authentication failure",
182 stderr: [
183 'oauth2: "invalid_request" "Errors.OIDCSession.RefreshTokenInvalid"',
184 "Error: provider launcher exited with code 1.",
185 ].join("\n"),
186 excerpt:
187 'oauth2: "invalid_request" "Errors.OIDCSession.RefreshTokenInvalid" | Error: provider launcher exited with code 1.',
188 },
189 {
190 name: "final launcher summary takes precedence over an earlier error label",
191 stderr: [
192 "Error: starting provider",
193 "lookup failed: no such host",
194 "Ошибка: exit status 1",
195 ].join("\n"),
196 excerpt: "Error: starting provider | lookup failed: no such host | Ошибка: exit status 1",
197 },
198 {
199 name: "launcher summary alone",
200 stderr: "Error: command failed with exit code 1\n",
201 excerpt: "Error: command failed with exit code 1",
202 },
203 {
204 name: "launcher diagnostics remain bounded",
205 stderr: `${"x".repeat(1_000)}\noauth2: invalid_request\nError: launcher exited with code 1\n`,
206 excerpt: `…${`${"x".repeat(1_000)} | oauth2: invalid_request | Error: launcher exited with code 1`.slice(-511)}`,
207 },
208 {
209 name: "ANSI color, carriage returns and repeated whitespace",
210 stderr: "\u001b[31m Error:\t config.toml is invalid \u001b[0m\r\n",
211 excerpt: "Error: config.toml is invalid",
212 },
213 {
214 name: "C1 terminal controls",
215 stderr: "\u009b31mError: invalid config\u009b0m\nUsage: codex [OPTIONS]\n",
216 excerpt: "Error: invalid config",
217 },
218 {
219 name: "oversized line",
220 stderr: `Error: ${"x".repeat(1_000)}`,
221 excerpt: `Error: ${"x".repeat(504)}…`,
222 },
223 ])("$name", ({ stderr, excerpt }) => {
224 assert.equal(stderrExcerpt(stderr), excerpt);
225 assert.isAtMost(excerpt.length, 512);
226 });
227​
228 it("returns nothing for blank stderr", () => {
229 assert.isUndefined(stderrExcerpt("\n \t\r\n\u001b[0m\n"));
230 });
231​
232 it("keeps only the newest stderr", () => {
233 const tail = appendStderrTail("a".repeat(8_000), `${"b".repeat(400)}\nError: last`);
234​
235 assert.equal(tail.length, 8_192);
236 assert.isTrue(tail.endsWith("Error: last"));
237 assert.equal(stderrExcerpt(tail), "Error: last");
238 });
239});