sdk: `events.return()` resolves before its direct child exits

Open 💬 0 comments Opened Aug 3, 2026 by MikeeI

What issue are you seeing?

A controlled Linux probe using the public TypeScript SDK observed await events.return() resolve while the SDK's direct child was still live. The probe configured codexPathOverride with a fixture that wrote one valid thread.started JSON line, ignored SIGTERM, and stayed alive. It awaited that event, called events.return(), and then ps showed the fixture still live with the probe as its parent. Harness process-tree cleanup then terminated the controlled fixture; a post-cleanup check found no fixture.

This is controlled lifecycle behavior: events.return() can complete after termination has been requested but before the direct child exits.

What steps can reproduce the bug?

On Linux, from a directory where @openai/codex-sdk resolves to a build containing the cited source, save this as repro.mjs and run node repro.mjs. It uses the public Codex, codexPathOverride, startThread(), runStreamed(), and async-iterator APIs, and makes no network or model request. The finally block verifies the fixture token before sending SIGKILL and waits for it to disappear.

import { strict as assert } from "node:assert";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Codex } from "@openai/codex-sdk";

if (process.platform !== "linux") throw new Error("Linux is required for /proc checks");

const directory = await mkdtemp(join(tmpdir(), "codex-sdk-return-"));
const fixture = join(directory, "fixture.mjs");
const pidFile = join(directory, "fixture.json");
const token = randomUUID();
let fixturePid = 0;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function within(promise, description) {
  let timer;
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      timer = setTimeout(() => reject(new Error(`${description} timed out`)), 3_000);
    }),
  ]).finally(() => clearTimeout(timer));
}
async function controlledFixtureIsLive(pid) {
  try {
    const environment = await readFile(`/proc/${pid}/environ`, "utf8");
    return environment.split("\0").includes(`CODEX_SDK_FIXTURE_TOKEN=${token}`);
  } catch {
    return false;
  }
}
async function readFixturePid() {
  try {
    const record = JSON.parse(await readFile(pidFile, "utf8"));
    return record.token === token && Number.isSafeInteger(record.pid) && record.pid > 0
      ? record.pid
      : 0;
  } catch {
    return 0;
  }
}
async function stopFixture(pid) {
  if (!(await controlledFixtureIsLive(pid))) return;
  process.kill(pid, "SIGKILL");
  const deadline = Date.now() + 3_000;
  while (await controlledFixtureIsLive(pid)) {
    if (Date.now() >= deadline) throw new Error("controlled fixture did not exit");
    await sleep(25);
  }
}

try {
  await writeFile(
    fixture,
    `#!${process.execPath}
import { writeFileSync } from "node:fs";
writeFileSync(process.env.CODEX_SDK_FIXTURE_PID_FILE, JSON.stringify({
  pid: process.pid,
  token: process.env.CODEX_SDK_FIXTURE_TOKEN,
}));
process.stdout.write(JSON.stringify({ type: "thread.started", thread_id: "fixture" }) + "\\n");
process.on("SIGTERM", () => {});
setInterval(() => {}, 1_000);
`,
  );
  await chmod(fixture, 0o755);

  const codex = new Codex({
    codexPathOverride: fixture,
    env: {
      CODEX_SDK_FIXTURE_PID_FILE: pidFile,
      CODEX_SDK_FIXTURE_TOKEN: token,
    },
  });
  const { events } = await codex.startThread().runStreamed("ignored");
  const first = await within(events.next(), "first streamed event");
  assert.equal(first.done, false);
  assert.equal(first.value.type, "thread.started");

  fixturePid = await readFixturePid();
  assert.ok(fixturePid > 0);
  assert.equal(await controlledFixtureIsLive(fixturePid), true);

  await within(events.return(), "events.return()");
  assert.equal(await controlledFixtureIsLive(fixturePid), true);
  const status = execFileSync(
    "ps",
    ["-o", "pid=,ppid=,stat=,args=", "-p", String(fixturePid)],
    { encoding: "utf8" },
  ).trim();
  assert.match(status, new RegExp(`^${fixturePid}\\s+${process.pid}\\s+`));
  console.log(status);
} finally {
  try {
    const pid = fixturePid || (await readFixturePid());
    if (pid > 0) await stopFixture(pid);
  } finally {
    await rm(directory, { recursive: true, force: true });
  }
}

The assertion passes and ps prints the live fixture only when events.return() resolves before that direct child exits. The explicit cleanup then terminates the token-verified fixture.

What is the expected behavior?

After events.return() resolves for a streamed turn, the directly spawned process should have exited. If the intended contract is only that termination was requested, the API should distinguish that state from completed process exit.

Additional information

Evidence

  • Observed: The controlled public-SDK probe described above ran on Linux: after one thread.started event, events.return() resolved and ps showed the SIGTERM-ignoring fixture still live under the probe. Harness cleanup terminated it, and a subsequent process check found no fixture.
  • Source-proven: codex.ts#L21-L24 passes the public override to CodexExec. exec.ts#L187-L190 spawns that child; exec.ts#L214-L220 creates its exit promise; exec.ts#L227-L239 awaits it after normal stdout completion; and exec.ts#L240-L248 closes the reader and sends termination during early generator closure without awaiting exit.
  • Uncertainty: The controlled fixture deliberately ignores SIGTERM. Production frequency, shutdown duration, resource effects, and shutdown behavior of the actual Codex CLI were not measured or established by this reproduction.

Impact

This establishes a public-SDK lifecycle distinction for a controlled direct child: events.return() can mean termination requested rather than child exit completed. It does not establish how often this occurs outside the fixture.

Question

Would it make sense for explicit events.return() closure to resolve only after the directly spawned process has exited, or to expose that termination was only requested?

I checked all relevant issues, comments, pull requests, discussions, and release notes; this report is not a duplicate.

I am reporting this finding only and am not proposing a pull request unless a maintainer invites one.

Disclosure

Investigated thoroughly with GPT-5.6 Sol (runtime-default reasoning effort), using Oh My Pi as the agent framework.

This report is not generic or unreviewed AI-generated output. Its claims were checked against the cited evidence, and it includes the relevant detail intended to help maintainers resolve the issue.

If reports like this are not useful to the project, please let me know and I will refrain from submitting similar ones. My intent is to help without wasting maintainer time or energy or discouraging their work.

Thank you for your work.

View original on GitHub ↗