codex - 💡(How to fix) Fix sandbox: seccomp `sendto` deny silently breaks Python `asyncio` cross-thread primitives

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

// https://github.com/openai/codex/blob/main/codex-rs/linux-sandbox/src/landlock.rs

deny_syscall(&mut rules, libc::SYS_sendto);
deny_syscall(&mut rules, libc::SYS_sendmmsg);
// ... no AF_UNIX exception, unlike SYS_socket/SYS_socketpair below.

Root Cause

Because call_soon_threadsafe is Python's only way to deliver an event to an asyncio loop from another OS thread, every asyncio primitive that crosses a thread boundary is silently broken:

Fix Action

Fix / Workaround

Related upstream issue (different symptom, same class of bug): https://github.com/openai/codex/issues/19927 (asyncio.subprocess hang inside the same sandbox; their workaround asyncio.SafeChildWatcher() is subprocess-specific and does nothing for the bug demonstrated here.)

Code Example

{
  "schemaVersion": 1,
  "generatedAt": "1779981391s since unix epoch",
  "overallStatus": "ok",
  "codexVersion": "0.134.0",
  "checks": {
    "app_server.status": {
      "id": "app_server.status",
      "category": "app-server",
      "status": "ok",
      "summary": "background server is not running",
      "details": {
        "control socket": "/home/vscode/.codex/app-server-control/app-server-control.sock",
        "daemon state dir": "/home/vscode/.codex/app-server-daemon",
        "mode": "ephemeral",
        "pid file": "/home/vscode/.codex/app-server-daemon/app-server.pid (missing)",
        "settings": "/home/vscode/.codex/app-server-daemon/settings.json (missing)",
        "status": "not running",
        "update-loop pid file": "/home/vscode/.codex/app-server-daemon/app-server-updater.pid (missing)"
      },
      "remediation": null,
      "durationMs": 86
    },
    "auth.credentials": {
      "id": "auth.credentials",
      "category": "auth",
      "status": "ok",
      "summary": "auth is configured",
      "details": {
        "auth env vars present": "OPENAI_API_KEY",
        "auth file": "/home/vscode/.codex/auth.json",
        "auth storage mode": "File",
        "stored API key": "false",
        "stored ChatGPT tokens": "true",
        "stored agent identity": "false",
        "stored auth mode": "chatgpt"
      },
      "remediation": null,
      "durationMs": 0
    },
    "config.load": {
      "id": "config.load",
      "category": "config",
      "status": "ok",
      "summary": "config loaded",
      "details": {
        "CODEX_HOME": "/home/vscode/.codex",
        "config.toml": "/home/vscode/.codex/config.toml",
        "config.toml parse": "ok",
        "cwd": "/workspaces/mono",
        "enabled feature flags": "shell_tool, unified_exec, shell_snapshot, terminal_resize_reflow, sqlite, hooks, enable_request_compression, multi_agent, apps, tool_suggest, plugins, in_app_browser, browser_use, browser_use_external, computer_use, plugin_sharing, image_generation, skill_mcp_dependency_install, steer, guardian_approval, goals, collaboration_modes, tool_call_mcp_elicitation, personality, fast_mode, tui_app_server, workspace_dependencies",
        "feature flag overrides": "none",
        "feature flags enabled": "27",
        "log dir": "/home/vscode/.codex/log",
        "mcp servers": "0",
        "model": "gpt-5.5",
        "model provider": "openai",
        "sqlite home": "/home/vscode/.codex"
      },
      "remediation": null,
      "durationMs": 0
    },
    "installation": {
      "id": "installation",
      "category": "install",
      "status": "ok",
      "summary": "installation looks consistent",
      "details": {
        "PATH codex #1": "/usr/bin/codex",
        "PATH codex #2": "/bin/codex",
        "PATH codex entries": "2",
        "current executable": "/usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
        "install context": "npm (package /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl, bin /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin, resources /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-resources, path /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-path)",
        "managed by bun": "false",
        "managed by npm": "true",
        "managed package root": "/usr/lib/node_modules/@openai/codex",
        "npm update target": "/usr/lib/node_modules/@openai/codex"
      },
      "remediation": null,
      "durationMs": 175
    },
    "mcp.config": {
      "id": "mcp.config",
      "category": "mcp",
      "status": "ok",
      "summary": "no MCP servers configured",
      "details": {},
      "remediation": null,
      "durationMs": 0
    },
    "network.env": {
      "id": "network.env",
      "category": "network",
      "status": "ok",
      "summary": "network-related environment looks readable",
      "details": {
        "proxy env vars": "none"
      },
      "remediation": null,
      "durationMs": 0
    },
    "network.provider_reachability": {
      "id": "network.provider_reachability",
      "category": "reachability",
      "status": "ok",
      "summary": "active provider endpoints are reachable over HTTP",
      "details": {
        "openai API base URL": "https://api.openai.com/v1 reachable (HTTP 404)",
        "openai API route probe": "https://api.openai.com/v1/<redacted> route exists (HTTP 401)",
        "reachability mode": "API key auth"
      },
      "remediation": null,
      "durationMs": 596
    },
    "network.websocket_reachability": {
      "id": "network.websocket_reachability",
      "category": "websocket",
      "status": "ok",
      "summary": "Responses WebSocket handshake succeeded",
      "details": {
        "DNS": "2 IPv4, 2 IPv6, first IPv4",
        "auth mode": "chatgpt",
        "connect timeout": "15000 ms",
        "endpoint": "wss://chatgpt.com/backend-api/<redacted>",
        "handshake result": "HTTP 101 Switching Protocols",
        "model provider": "openai",
        "models etag present": "true",
        "provider name": "OpenAI",
        "proxy env vars": "none",
        "reasoning header": "false",
        "server model present": "false",
        "supports websockets": "true",
        "wire API": "responses"
      },
      "remediation": null,
      "durationMs": 1929
    },
    "runtime.provenance": {
      "id": "runtime.provenance",
      "category": "runtime",
      "status": "ok",
      "summary": "running npm on linux-x86_64",
      "details": {
        "commit": "unknown",
        "current executable": "/usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
        "install method": "npm (package /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl, bin /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin, resources /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-resources, path /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-path)",
        "platform": "linux-x86_64",
        "version": "0.134.0"
      },
      "remediation": null,
      "durationMs": 0
    },
    "runtime.search": {
      "id": "runtime.search",
      "category": "search",
      "status": "ok",
      "summary": "search is OK (bundled)",
      "details": {
        "search command": "/usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-path/rg",
        "search command readiness": "file exists",
        "search provider": "bundled"
      },
      "remediation": null,
      "durationMs": 0
    },
    "sandbox.helpers": {
      "id": "sandbox.helpers",
      "category": "sandbox",
      "status": "ok",
      "summary": "sandbox configuration is readable",
      "details": {
        "approval policy": "OnRequest",
        "codex-linux-sandbox helper": "/home/vscode/.codex/tmp/arg0/codex-arg0icbf9u/codex-linux-sandbox",
        "execve wrapper helper": "/home/vscode/.codex/tmp/arg0/codex-arg0icbf9u/codex-execve-wrapper",
        "filesystem sandbox": "restricted",
        "network sandbox": "restricted"
      },
      "remediation": null,
      "durationMs": 0
    },
    "state.paths": {
      "id": "state.paths",
      "category": "state",
      "status": "ok",
      "summary": "state paths and databases are inspectable",
      "details": {
        "CODEX_HOME": "/home/vscode/.codex (dir)",
        "active rollout files": "5 files, 34188495 total bytes, 6837699 average bytes",
        "archived rollout files": "0 files, 0 total bytes, 0 average bytes",
        "goals DB": "/home/vscode/.codex/goals_1.sqlite (file)",
        "goals DB integrity": "ok",
        "log DB": "/home/vscode/.codex/logs_2.sqlite (file)",
        "log DB integrity": "ok",
        "log dir": "/home/vscode/.codex/log (missing)",
        "sqlite home": "/home/vscode/.codex (dir)",
        "state DB": "/home/vscode/.codex/state_5.sqlite (file)",
        "state DB integrity": "ok"
      },
      "remediation": null,
      "durationMs": 2785
    },
    "terminal.env": {
      "id": "terminal.env",
      "category": "terminal",
      "status": "ok",
      "summary": "terminal metadata was detected",
      "details": {
        "COLORTERM": "truecolor",
        "TERM_PROGRAM": "vscode",
        "VSCODE_IPC_HOOK_CLI": "present",
        "color output": "enabled",
        "effective locale": "en_US.UTF-8",
        "stderr is terminal": "true",
        "stdin is terminal": "true",
        "stdout is terminal": "true",
        "terminal": "VS Code",
        "terminal size": "254x58",
        "terminal version": "1.121.0"
      },
      "remediation": null,
      "durationMs": 0
    },
    "updates.status": {
      "id": "updates.status",
      "category": "updates",
      "status": "ok",
      "summary": "update configuration is locally consistent",
      "details": {
        "cached latest version": "0.134.0",
        "check for update on startup": "true",
        "last checked at": "2026-05-28T11:07:49.777753047Z",
        "latest version": "0.134.0",
        "latest version status": "current version is not older",
        "npm update target": "/usr/lib/node_modules/@openai/codex",
        "update action": "npm install -g @openai/codex",
        "version cache": "/home/vscode/.codex/version.json"
      },
      "remediation": null,
      "durationMs": 398
    }
  }
}

---

// https://github.com/openai/codex/blob/main/codex-rs/linux-sandbox/src/landlock.rs

    deny_syscall(&mut rules, libc::SYS_sendto);
    deny_syscall(&mut rules, libc::SYS_sendmmsg);
    // ... no AF_UNIX exception, unlike SYS_socket/SYS_socketpair below.

---

#!/usr/bin/env python3
"""
Minimal repro: asyncio's cross-thread wakeup (`call_soon_threadsafe`)
is silently broken inside the OpenAI Codex CLI's Linux sandbox. As a
direct consequence, every higher-level asyncio primitive that relies
on this`run_in_executor`, threaded `Event.set`, `Future.set_result`
from a worker thread, the default child watcher's SIGCHLD path,
and `asyncio.run()`'s OWN cleanup — silently stalls until some
unrelated timer in the loop happens to fire and incidentally drain
the ready queue.

The subtle part: the wakeup is not "lost forever", it's "lost until
the next timer". So programs that already had a `wait_for` timeout
or a periodic `call_later` in flight will appear to "work" -- but
every cross-thread interaction will take as long as the next
scheduled timer, instead of completing in milliseconds. A
`wait_for(timeout=30)` masks a 30-second stall as success.

Usage:
    python3 repro.py

Outside Codex (or with `sandbox_mode = "danger-full-access"`):
    All three tests pass. Script exits 0.

Inside Codex with any sandbox mode that filters network
(`workspace-write`, `read-only`, `full-auto`):
    All three tests fail. Script exits 1.

NB: this script deliberately avoids `asyncio.run()` and uses
`os._exit()` to bypass interpreter shutdown. Inside Codex's sandbox,
`asyncio.run()`'s own cleanup hangs forever -- it calls
`loop.shutdown_default_executor()`, which calls
`call_soon_threadsafe()` to signal the executor-shutdown thread
back to the loop. That's exactly the broken primitive we are
demonstrating, with no timeout by default, so the interpreter never
exits. Worse, because `codex exec` captures stdout via a pipe,
Python block-buffers the test output -- the user sees ZERO output
because the buffer is never flushed. We work around this by setting
`flush=True` on every print and skipping the asyncio cleanup path.

The three tests target the same underlying bug at three layers:

  1. Syscall layer: `socket.send` on an AF_UNIX `socketpair` returns
     EPERM, because Codex's seccomp filter denies `SYS_sendto`
     unconditionally. (Yes — even on a local UNIX socket whose only
     correspondent is the same process. The filter does not condition
     on socket family the way it does for `SYS_socket` /
     `SYS_socketpair`.)

  2. asyncio primitive layer: `loop.call_soon_threadsafe(callback)`
     from a background thread fails to wake the selector. asyncio's
     `_write_to_self` calls `self._csock.send(b'\\0')` (which is
     literally a `sendto(2)` syscall on its internal AF_UNIX
     socketpair). seccomp returns EPERM. asyncio silently swallows
     the OSError. The callback sits in the ready queue but
     `epoll_wait` never returns to run it.

  3. User-visible layer: `loop.run_in_executor(None, fn)` queues `fn`
     in the default `ThreadPoolExecutor`, the worker runs it in
     microseconds, then signals completion via
     `call_soon_threadsafe(future.set_result, ...)` — exactly the
     silently-broken path. The future stays pending until some
     unrelated timer event wakes the selector.

Root cause, verified against the actual filter source:
    https://github.com/openai/codex/blob/main/codex-rs/linux-sandbox/src/landlock.rs

    deny_syscall(&mut rules, libc::SYS_sendto);
    deny_syscall(&mut rules, libc::SYS_sendmmsg);
    // ... no AF_UNIX exception, unlike SYS_socket/SYS_socketpair below.

Related upstream issue (different symptom, same class of bug):
    https://github.com/openai/codex/issues/19927  (asyncio.subprocess
    hang inside the same sandbox; their workaround
    `asyncio.SafeChildWatcher()` is subprocess-specific and does
    nothing for the bug demonstrated here.)

This script has no dependencies beyond the Python standard library.
"""

import asyncio
import os
import socket
import sys
import threading
import time

# `TIMEOUT_S` is the outer asyncio.wait_for timeout. `HEALTHY_S` is the
# threshold above which we declare a wakeup pathologically slow.
#
# Calibration: outside Codex, a cross-thread wakeup resolves in ~50 ms
# (mostly the deliberate `time.sleep(0.05)` in the test). Inside
# Codex's sandbox, `call_soon_threadsafe` does NOT wake the selector
# at all -- but `asyncio.wait_for` schedules a `call_later` timer for
# `TIMEOUT_S`, and when THAT timer fires the selector wakes and
# drains the ready queue, which includes the long-since-queued
# `set_result` callback. The future resolves and `wait_for` returns
# normally, having waited ~`TIMEOUT_S`. So under the broken
# environment the elapsed time is essentially exactly `TIMEOUT_S`,
# not a `wait_for` timeout. The test must therefore distinguish
# "wakeup arrived in a sane time" from "wakeup arrived only because
# an unrelated timer happened to fire". `HEALTHY_S` = 0.5s cleanly
# separates the two regimes.
TIMEOUT_S = 2.0
HEALTHY_S = 0.5


def print_flush(*args, **kwargs) -> None:
    """`print` that always flushes. Required because `codex exec`
    captures stdout via a pipe, so Python's default block-buffering
    means a user under Codex sees ZERO output until the buffer is
    flushed -- which, inside Codex's sandbox, may be never (see
    the cleanup-hang explanation in the module docstring)."""
    kwargs.setdefault("flush", True)
    print(*args, **kwargs)


# ---------------------------------------------------------------------------
# Test 1: syscall layer.
# ---------------------------------------------------------------------------

def test_socket_send() -> tuple[bool, str]:
    """Try to do, synchronously, the exact operation asyncio's self-
    pipe wakeup depends on: send one byte through one half of an
    AF_UNIX socketpair. Returns (passed, message)."""
    try:
        a, b = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0)
    except OSError as e:
        return (
            False,
            f"socket.socketpair(AF_UNIX, SOCK_STREAM) raised {e!r} "
            f"-- Codex's seccomp filter denies SYS_socketpair in "
            f"proxy-routed mode.",
        )
    try:
        try:
            a.send(b'\0')
        except OSError as e:
            return (
                False,
                f"socket.send on an AF_UNIX socketpair raised {e!r} "
                f"-- Codex's seccomp filter denies SYS_sendto. This "
                f"is the same syscall asyncio uses internally for "
                f"cross-thread loop wakeups.",
            )
        return (True, "socket.send on an AF_UNIX socketpair succeeded.")
    finally:
        a.close()
        b.close()


# ---------------------------------------------------------------------------
# Test 2: asyncio primitive layer.
# ---------------------------------------------------------------------------

async def test_call_soon_threadsafe() -> tuple[bool, str]:
    """Schedule a callback from a background OS thread via
    `loop.call_soon_threadsafe` and measure how long it takes for the
    selector to wake up and run it."""
    loop = asyncio.get_running_loop()
    event = asyncio.Event()

    def from_thread() -> None:
        # Tiny stall so the main task is definitely parked on
        # `event.wait()` before we fire the wakeup. This delay is
        # OUTSIDE the timing window.
        time.sleep(0.05)
        loop.call_soon_threadsafe(event.set)

    threading.Thread(target=from_thread, daemon=True).start()
    start = time.monotonic()
    try:
        await asyncio.wait_for(event.wait(), timeout=TIMEOUT_S)
    except asyncio.TimeoutError:
        return (
            False,
            f"loop.call_soon_threadsafe failed to wake the selector "
            f"within {TIMEOUT_S}s, even with the `wait_for` deadline "
            f"timer firing. The callback is queued but `epoll_wait` is "
            f"not returning at all. asyncio's `_write_to_self` is "
            f"silently swallowing an OSError from "
            f"`self._csock.send(b'\\0')`.",
        )
    elapsed_ms = (time.monotonic() - start) * 1000
    if elapsed_ms > HEALTHY_S * 1000:
        # The wakeup eventually "arrived" -- but only because the
        # `wait_for` deadline timer fired and drained the ready queue
        # as a side effect. The real `call_soon_threadsafe` self-pipe
        # wakeup is still broken. Healthy systems resolve this in
        # tens of milliseconds; ~`TIMEOUT_S` means the bug is
        # present.
        return (
            False,
            f"loop.call_soon_threadsafe wakeup latency was "
            f"{elapsed_ms:.0f}ms (healthy <{HEALTHY_S * 1000:.0f}ms). "
            f"The callback was queued by the worker thread within "
            f"~50ms but the selector did not run it until the "
            f"`wait_for` deadline timer fired at {TIMEOUT_S}s -- "
            f"i.e. cross-thread wakeups are only succeeding by "
            f"accident, via unrelated timer events. asyncio's "
            f"`_write_to_self` is silently swallowing an OSError from "
            f"`self._csock.send(b'\\0')`.",
        )
    return (
        True,
        f"loop.call_soon_threadsafe woke the selector in "
        f"{elapsed_ms:.2f}ms.",
    )


# ---------------------------------------------------------------------------
# Test 3: user-visible layer.
# ---------------------------------------------------------------------------

async def test_run_in_executor() -> tuple[bool, str]:
    """Issue several `loop.run_in_executor` calls back-to-back. The
    very first call can succeed even on broken systems because lazy
    `ThreadPoolExecutor` creation synchronously yields the loop and
    incidentally drains the ready queue once. Subsequent calls have
    to use the broken self-pipe wakeup path, so they hang."""
    loop = asyncio.get_running_loop()
    timings_ms: list[float] = []
    for i in range(3):
        start = time.monotonic()
        try:
            await asyncio.wait_for(
                loop.run_in_executor(None, lambda: None),
                timeout=TIMEOUT_S,
            )
        except asyncio.TimeoutError:
            return (
                False,
                f"loop.run_in_executor call #{i + 1} did not return "
                f"within {TIMEOUT_S}s, even with the `wait_for` "
                f"deadline timer firing. Prior call timings (ms): "
                f"{[round(t, 2) for t in timings_ms]}.",
            )
        elapsed_ms = (time.monotonic() - start) * 1000
        timings_ms.append(elapsed_ms)
        if elapsed_ms > HEALTHY_S * 1000:
            # Same "passed only because a timer fired" pattern as
            # test 2. Note the first call typically slips under the
            # threshold even on broken systems, because lazy
            # ThreadPoolExecutor creation synchronously yields the
            # loop and incidentally drains the ready queue once.
            return (
                False,
                f"loop.run_in_executor call #{i + 1} took "
                f"{elapsed_ms:.0f}ms (healthy <{HEALTHY_S * 1000:.0f}ms). "
                f"The worker thread completed in microseconds and "
                f"queued the future's `set_result` callback via "
                f"`call_soon_threadsafe`, but the loop did not run it "
                f"until the next unrelated timer event. Timings of "
                f"all calls so far (ms): "
                f"{[round(t, 2) for t in timings_ms]}.",
            )
    return (
        True,
        f"loop.run_in_executor x3 timings (ms): "
        f"{[round(t, 2) for t in timings_ms]}.",
    )


# ---------------------------------------------------------------------------

async def amain() -> int:
    print_flush(
        f"python={sys.version.split()[0]}  pid={os.getpid()}  "
        f"ppid={os.getppid()}  loop={type(asyncio.get_running_loop()).__name__}"
    )
    # /proc/self/status:Seccomp tells us if a BPF filter is active.
    # 0=off, 1=strict, 2=filter. Under Codex's sandbox it is 2.
    try:
        with open('/proc/self/status', 'r') as f:
            for line in f:
                if line.startswith(('Seccomp:', 'NoNewPrivs:')):
                    print_flush('  ' + line.rstrip())
    except OSError:
        pass

    print_flush()
    print_flush("Test 1 (syscall): send 1 byte through an AF_UNIX socketpair")
    t1_passed, t1_msg = test_socket_send()
    print_flush(f"  {'PASS' if t1_passed else 'FAIL'}: {t1_msg}")

    print_flush()
    print_flush("Test 2 (asyncio primitive): loop.call_soon_threadsafe from a thread")
    t2_passed, t2_msg = await test_call_soon_threadsafe()
    print_flush(f"  {'PASS' if t2_passed else 'FAIL'}: {t2_msg}")

    print_flush()
    print_flush("Test 3 (user-visible): loop.run_in_executor x3")
    t3_passed, t3_msg = await test_run_in_executor()
    print_flush(f"  {'PASS' if t3_passed else 'FAIL'}: {t3_msg}")

    print_flush()
    if t1_passed and t2_passed and t3_passed:
        print_flush("ALL OK: asyncio cross-thread wakeup works here.")
        return 0
    print_flush(
        "REPRO REPRODUCED: asyncio cross-thread wakeup is broken here. "
        "See module docstring for root cause."
    )
    return 1


if __name__ == "__main__":
    # We deliberately do NOT use `asyncio.run(amain())`. asyncio.run's
    # teardown calls `loop.shutdown_default_executor()`, whose
    # implementation spawns a thread that runs
    # `executor.shutdown(wait=True)` and then signals back to the loop
    # via `call_soon_threadsafe(future.set_result, None)`. That is
    # exactly the broken primitive this script demonstrates, with no
    # timeout by default -- so under Codex's sandbox `asyncio.run`
    # hangs forever during cleanup, AFTER all our tests have already
    # completed. The user would see no output (block-buffered stdout)
    # and assume the script itself is stuck.
    #
    # Instead we drive the loop manually, then `os._exit()` to skip
    # interpreter shutdown entirely. `loop.close()` does call
    # `executor.shutdown(wait=False)`, which doesn't hang.
    _loop = asyncio.new_event_loop()
    try:
        _code = _loop.run_until_complete(amain())
    finally:
        _loop.close()
    sys.stdout.flush()
    sys.stderr.flush()
    os._exit(_code)

---

$ codex exec "python3 ./repro.py"
OpenAI Codex v0.134.0
--------
workdir: /workspaces/mono/repro
model: gpt-5.5
provider: openai
approval: never
sandbox: read-only
reasoning effort: medium
reasoning summaries: none
session id: 019e6f3d-feae-7f22-b062-aac01872b64e
--------
user
python3 ./repro.py
warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.
hook: SessionStart
hook: SessionStart Completed
codex
I’ll run the script and report the output.
exec
/bin/bash -lc 'python3 ./repro.py' in /workspaces/mono/repro
codex
The script is still running after printing the first failure and starting the asyncio-threadsafe test. I’m going to wait briefly so I can capture the rest rather than cutting off a hanging repro too early.
 exited 1 in 4032ms:
  FAIL: loop.call_soon_threadsafe wakeup latency was 2002ms (healthy <500ms). The callback was queued by the worker thread within ~50ms but the selector did not run it until the `wait_for` deadline timer fired at 2.0s -- i.e. cross-thread wakeups are only succeeding by accident, via unrelated timer events. asyncio's `_write_to_self` is silently swallowing an OSError from `self._csock.send(b'\0')`.

Test 3 (user-visible): loop.run_in_executor x3
  FAIL: loop.run_in_executor call #2 took 2002ms (healthy <500ms). The worker thread completed in microseconds and queued the future's `set_result` callback via `call_soon_threadsafe`, but the loop did not run it until the next unrelated timer event. Timings of all calls so far (ms): [2.09, 2002.26].

REPRO REPRODUCED: asyncio cross-thread wakeup is broken here. See module docstring for root cause.

codex
`python3 ./repro.py` exited with code `1`.

Key output:

"""text
Test 1: FAIL
socket.send on an AF_UNIX socketpair raised PermissionError(1, 'Operation not permitted')
Codex's seccomp filter denies SYS_sendto.

Test 2: FAIL
loop.call_soon_threadsafe wakeup latency was 2002ms

Test 3: FAIL
loop.run_in_executor call #2 took 2002ms

REPRO REPRODUCED: asyncio cross-thread wakeup is broken here.
"""

The repro confirms the environment’s seccomp filter is blocking the syscall asyncio relies on for cross-thread event loop wakeups.

---

$ python3 ./repro.py 
python=3.10.12  pid=15949  ppid=2079  loop=_UnixSelectorEventLoop
  NoNewPrivs:   0
  Seccomp:      0

Test 1 (syscall): send 1 byte through an AF_UNIX socketpair
  PASS: socket.send on an AF_UNIX socketpair succeeded.

Test 2 (asyncio primitive): loop.call_soon_threadsafe from a thread
  PASS: loop.call_soon_threadsafe woke the selector in 50.25ms.

Test 3 (user-visible): loop.run_in_executor x3
  PASS: loop.run_in_executor x3 timings (ms): [1.8, 0.14, 0.12].

ALL OK: asyncio cross-thread wakeup works here.
RAW_BUFFERClick to expand / collapse

What version of Codex CLI is running?

codex-cli 0.134.0

What subscription do you have?

Plus

Which model were you using?

gpt-5.5

What platform is your computer?

Linux 6.8.0-1052-azure x86_64 x86_64

What terminal emulator and version are you using (if applicable)?

VSCode, via ssh

Codex doctor report

{
  "schemaVersion": 1,
  "generatedAt": "1779981391s since unix epoch",
  "overallStatus": "ok",
  "codexVersion": "0.134.0",
  "checks": {
    "app_server.status": {
      "id": "app_server.status",
      "category": "app-server",
      "status": "ok",
      "summary": "background server is not running",
      "details": {
        "control socket": "/home/vscode/.codex/app-server-control/app-server-control.sock",
        "daemon state dir": "/home/vscode/.codex/app-server-daemon",
        "mode": "ephemeral",
        "pid file": "/home/vscode/.codex/app-server-daemon/app-server.pid (missing)",
        "settings": "/home/vscode/.codex/app-server-daemon/settings.json (missing)",
        "status": "not running",
        "update-loop pid file": "/home/vscode/.codex/app-server-daemon/app-server-updater.pid (missing)"
      },
      "remediation": null,
      "durationMs": 86
    },
    "auth.credentials": {
      "id": "auth.credentials",
      "category": "auth",
      "status": "ok",
      "summary": "auth is configured",
      "details": {
        "auth env vars present": "OPENAI_API_KEY",
        "auth file": "/home/vscode/.codex/auth.json",
        "auth storage mode": "File",
        "stored API key": "false",
        "stored ChatGPT tokens": "true",
        "stored agent identity": "false",
        "stored auth mode": "chatgpt"
      },
      "remediation": null,
      "durationMs": 0
    },
    "config.load": {
      "id": "config.load",
      "category": "config",
      "status": "ok",
      "summary": "config loaded",
      "details": {
        "CODEX_HOME": "/home/vscode/.codex",
        "config.toml": "/home/vscode/.codex/config.toml",
        "config.toml parse": "ok",
        "cwd": "/workspaces/mono",
        "enabled feature flags": "shell_tool, unified_exec, shell_snapshot, terminal_resize_reflow, sqlite, hooks, enable_request_compression, multi_agent, apps, tool_suggest, plugins, in_app_browser, browser_use, browser_use_external, computer_use, plugin_sharing, image_generation, skill_mcp_dependency_install, steer, guardian_approval, goals, collaboration_modes, tool_call_mcp_elicitation, personality, fast_mode, tui_app_server, workspace_dependencies",
        "feature flag overrides": "none",
        "feature flags enabled": "27",
        "log dir": "/home/vscode/.codex/log",
        "mcp servers": "0",
        "model": "gpt-5.5",
        "model provider": "openai",
        "sqlite home": "/home/vscode/.codex"
      },
      "remediation": null,
      "durationMs": 0
    },
    "installation": {
      "id": "installation",
      "category": "install",
      "status": "ok",
      "summary": "installation looks consistent",
      "details": {
        "PATH codex #1": "/usr/bin/codex",
        "PATH codex #2": "/bin/codex",
        "PATH codex entries": "2",
        "current executable": "/usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
        "install context": "npm (package /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl, bin /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin, resources /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-resources, path /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-path)",
        "managed by bun": "false",
        "managed by npm": "true",
        "managed package root": "/usr/lib/node_modules/@openai/codex",
        "npm update target": "/usr/lib/node_modules/@openai/codex"
      },
      "remediation": null,
      "durationMs": 175
    },
    "mcp.config": {
      "id": "mcp.config",
      "category": "mcp",
      "status": "ok",
      "summary": "no MCP servers configured",
      "details": {},
      "remediation": null,
      "durationMs": 0
    },
    "network.env": {
      "id": "network.env",
      "category": "network",
      "status": "ok",
      "summary": "network-related environment looks readable",
      "details": {
        "proxy env vars": "none"
      },
      "remediation": null,
      "durationMs": 0
    },
    "network.provider_reachability": {
      "id": "network.provider_reachability",
      "category": "reachability",
      "status": "ok",
      "summary": "active provider endpoints are reachable over HTTP",
      "details": {
        "openai API base URL": "https://api.openai.com/v1 reachable (HTTP 404)",
        "openai API route probe": "https://api.openai.com/v1/<redacted> route exists (HTTP 401)",
        "reachability mode": "API key auth"
      },
      "remediation": null,
      "durationMs": 596
    },
    "network.websocket_reachability": {
      "id": "network.websocket_reachability",
      "category": "websocket",
      "status": "ok",
      "summary": "Responses WebSocket handshake succeeded",
      "details": {
        "DNS": "2 IPv4, 2 IPv6, first IPv4",
        "auth mode": "chatgpt",
        "connect timeout": "15000 ms",
        "endpoint": "wss://chatgpt.com/backend-api/<redacted>",
        "handshake result": "HTTP 101 Switching Protocols",
        "model provider": "openai",
        "models etag present": "true",
        "provider name": "OpenAI",
        "proxy env vars": "none",
        "reasoning header": "false",
        "server model present": "false",
        "supports websockets": "true",
        "wire API": "responses"
      },
      "remediation": null,
      "durationMs": 1929
    },
    "runtime.provenance": {
      "id": "runtime.provenance",
      "category": "runtime",
      "status": "ok",
      "summary": "running npm on linux-x86_64",
      "details": {
        "commit": "unknown",
        "current executable": "/usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
        "install method": "npm (package /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl, bin /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin, resources /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-resources, path /usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-path)",
        "platform": "linux-x86_64",
        "version": "0.134.0"
      },
      "remediation": null,
      "durationMs": 0
    },
    "runtime.search": {
      "id": "runtime.search",
      "category": "search",
      "status": "ok",
      "summary": "search is OK (bundled)",
      "details": {
        "search command": "/usr/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex-path/rg",
        "search command readiness": "file exists",
        "search provider": "bundled"
      },
      "remediation": null,
      "durationMs": 0
    },
    "sandbox.helpers": {
      "id": "sandbox.helpers",
      "category": "sandbox",
      "status": "ok",
      "summary": "sandbox configuration is readable",
      "details": {
        "approval policy": "OnRequest",
        "codex-linux-sandbox helper": "/home/vscode/.codex/tmp/arg0/codex-arg0icbf9u/codex-linux-sandbox",
        "execve wrapper helper": "/home/vscode/.codex/tmp/arg0/codex-arg0icbf9u/codex-execve-wrapper",
        "filesystem sandbox": "restricted",
        "network sandbox": "restricted"
      },
      "remediation": null,
      "durationMs": 0
    },
    "state.paths": {
      "id": "state.paths",
      "category": "state",
      "status": "ok",
      "summary": "state paths and databases are inspectable",
      "details": {
        "CODEX_HOME": "/home/vscode/.codex (dir)",
        "active rollout files": "5 files, 34188495 total bytes, 6837699 average bytes",
        "archived rollout files": "0 files, 0 total bytes, 0 average bytes",
        "goals DB": "/home/vscode/.codex/goals_1.sqlite (file)",
        "goals DB integrity": "ok",
        "log DB": "/home/vscode/.codex/logs_2.sqlite (file)",
        "log DB integrity": "ok",
        "log dir": "/home/vscode/.codex/log (missing)",
        "sqlite home": "/home/vscode/.codex (dir)",
        "state DB": "/home/vscode/.codex/state_5.sqlite (file)",
        "state DB integrity": "ok"
      },
      "remediation": null,
      "durationMs": 2785
    },
    "terminal.env": {
      "id": "terminal.env",
      "category": "terminal",
      "status": "ok",
      "summary": "terminal metadata was detected",
      "details": {
        "COLORTERM": "truecolor",
        "TERM_PROGRAM": "vscode",
        "VSCODE_IPC_HOOK_CLI": "present",
        "color output": "enabled",
        "effective locale": "en_US.UTF-8",
        "stderr is terminal": "true",
        "stdin is terminal": "true",
        "stdout is terminal": "true",
        "terminal": "VS Code",
        "terminal size": "254x58",
        "terminal version": "1.121.0"
      },
      "remediation": null,
      "durationMs": 0
    },
    "updates.status": {
      "id": "updates.status",
      "category": "updates",
      "status": "ok",
      "summary": "update configuration is locally consistent",
      "details": {
        "cached latest version": "0.134.0",
        "check for update on startup": "true",
        "last checked at": "2026-05-28T11:07:49.777753047Z",
        "latest version": "0.134.0",
        "latest version status": "current version is not older",
        "npm update target": "/usr/lib/node_modules/@openai/codex",
        "update action": "npm install -g @openai/codex",
        "version cache": "/home/vscode/.codex/version.json"
      },
      "remediation": null,
      "durationMs": 398
    }
  }
}

What issue are you seeing?

Inside Codex's Linux sandbox (any sandbox_mode other than danger-full-access), Python's asyncio.AbstractEventLoop.call_soon_threadsafe(callback) from a background thread silently fails to wake the asyncio selector. The callback is queued in the loop's ready queue but epoll_wait does not return to run it. The callback only ever runs as a side effect of some other event waking the selector — most commonly, the next scheduled timer firing (any call_later, asyncio.sleep, or wait_for(timeout=…)).

The other events makes the bug non-obvious. It is not a clean "call_soon_threadsafe hangs forever"; it is "call_soon_threadsafe takes as long as the next unrelated timer in the loop". A program that has, say, a wait_for(coro, timeout=30) in flight will see every cross-thread interaction silently stretched to up to 30 s instead of completing in milliseconds — and wait_for will still report success when its inner coroutine eventually completes inside the deadline, so nothing throws and nothing logs.

Because call_soon_threadsafe is Python's only way to deliver an event to an asyncio loop from another OS thread, every asyncio primitive that crosses a thread boundary is silently broken:

  • loop.run_in_executor(executor, fn) resolves its Future via call_soon_threadsafe(future.set_result, …) from the worker thread. After the first call (which can succeed by accident — see "Why the first call works" below), every subsequent call hangs.
  • asyncio.Event.set() called from a background Python thread.
  • asyncio.Future.set_result() called from a background Python thread.
  • asyncio.to_thread(fn) (Python 3.9+, the modern wrapper around run_in_executor).
  • Any Python library that bridges blocking I/O to asyncio via a thread pool (httpx's sync transports under async, asyncpg's blocking fallbacks, sync database drivers wrapped by anyio, filesystem access via aiofiles, …).

The bug is silent — no log, no exception, no warning. To the Python program it looks like the kernel just isn't delivering the wakeup.

My agent claims the root cause is the blocking of the sendto syscall by the Codex sandbox:

    // https://github.com/openai/codex/blob/main/codex-rs/linux-sandbox/src/landlock.rs

    deny_syscall(&mut rules, libc::SYS_sendto);
    deny_syscall(&mut rules, libc::SYS_sendmmsg);
    // ... no AF_UNIX exception, unlike SYS_socket/SYS_socketpair below.

What steps can reproduce the bug?

Run the following Python program first outside the codex CLI, then inside it:

#!/usr/bin/env python3
"""
Minimal repro: asyncio's cross-thread wakeup (`call_soon_threadsafe`)
is silently broken inside the OpenAI Codex CLI's Linux sandbox. As a
direct consequence, every higher-level asyncio primitive that relies
on this — `run_in_executor`, threaded `Event.set`, `Future.set_result`
from a worker thread, the default child watcher's SIGCHLD path,
and `asyncio.run()`'s OWN cleanup — silently stalls until some
unrelated timer in the loop happens to fire and incidentally drain
the ready queue.

The subtle part: the wakeup is not "lost forever", it's "lost until
the next timer". So programs that already had a `wait_for` timeout
or a periodic `call_later` in flight will appear to "work" -- but
every cross-thread interaction will take as long as the next
scheduled timer, instead of completing in milliseconds. A
`wait_for(timeout=30)` masks a 30-second stall as success.

Usage:
    python3 repro.py

Outside Codex (or with `sandbox_mode = "danger-full-access"`):
    All three tests pass. Script exits 0.

Inside Codex with any sandbox mode that filters network
(`workspace-write`, `read-only`, `full-auto`):
    All three tests fail. Script exits 1.

NB: this script deliberately avoids `asyncio.run()` and uses
`os._exit()` to bypass interpreter shutdown. Inside Codex's sandbox,
`asyncio.run()`'s own cleanup hangs forever -- it calls
`loop.shutdown_default_executor()`, which calls
`call_soon_threadsafe()` to signal the executor-shutdown thread
back to the loop. That's exactly the broken primitive we are
demonstrating, with no timeout by default, so the interpreter never
exits. Worse, because `codex exec` captures stdout via a pipe,
Python block-buffers the test output -- the user sees ZERO output
because the buffer is never flushed. We work around this by setting
`flush=True` on every print and skipping the asyncio cleanup path.

The three tests target the same underlying bug at three layers:

  1. Syscall layer: `socket.send` on an AF_UNIX `socketpair` returns
     EPERM, because Codex's seccomp filter denies `SYS_sendto`
     unconditionally. (Yes — even on a local UNIX socket whose only
     correspondent is the same process. The filter does not condition
     on socket family the way it does for `SYS_socket` /
     `SYS_socketpair`.)

  2. asyncio primitive layer: `loop.call_soon_threadsafe(callback)`
     from a background thread fails to wake the selector. asyncio's
     `_write_to_self` calls `self._csock.send(b'\\0')` (which is
     literally a `sendto(2)` syscall on its internal AF_UNIX
     socketpair). seccomp returns EPERM. asyncio silently swallows
     the OSError. The callback sits in the ready queue but
     `epoll_wait` never returns to run it.

  3. User-visible layer: `loop.run_in_executor(None, fn)` queues `fn`
     in the default `ThreadPoolExecutor`, the worker runs it in
     microseconds, then signals completion via
     `call_soon_threadsafe(future.set_result, ...)` — exactly the
     silently-broken path. The future stays pending until some
     unrelated timer event wakes the selector.

Root cause, verified against the actual filter source:
    https://github.com/openai/codex/blob/main/codex-rs/linux-sandbox/src/landlock.rs

    deny_syscall(&mut rules, libc::SYS_sendto);
    deny_syscall(&mut rules, libc::SYS_sendmmsg);
    // ... no AF_UNIX exception, unlike SYS_socket/SYS_socketpair below.

Related upstream issue (different symptom, same class of bug):
    https://github.com/openai/codex/issues/19927  (asyncio.subprocess
    hang inside the same sandbox; their workaround
    `asyncio.SafeChildWatcher()` is subprocess-specific and does
    nothing for the bug demonstrated here.)

This script has no dependencies beyond the Python standard library.
"""

import asyncio
import os
import socket
import sys
import threading
import time

# `TIMEOUT_S` is the outer asyncio.wait_for timeout. `HEALTHY_S` is the
# threshold above which we declare a wakeup pathologically slow.
#
# Calibration: outside Codex, a cross-thread wakeup resolves in ~50 ms
# (mostly the deliberate `time.sleep(0.05)` in the test). Inside
# Codex's sandbox, `call_soon_threadsafe` does NOT wake the selector
# at all -- but `asyncio.wait_for` schedules a `call_later` timer for
# `TIMEOUT_S`, and when THAT timer fires the selector wakes and
# drains the ready queue, which includes the long-since-queued
# `set_result` callback. The future resolves and `wait_for` returns
# normally, having waited ~`TIMEOUT_S`. So under the broken
# environment the elapsed time is essentially exactly `TIMEOUT_S`,
# not a `wait_for` timeout. The test must therefore distinguish
# "wakeup arrived in a sane time" from "wakeup arrived only because
# an unrelated timer happened to fire". `HEALTHY_S` = 0.5s cleanly
# separates the two regimes.
TIMEOUT_S = 2.0
HEALTHY_S = 0.5


def print_flush(*args, **kwargs) -> None:
    """`print` that always flushes. Required because `codex exec`
    captures stdout via a pipe, so Python's default block-buffering
    means a user under Codex sees ZERO output until the buffer is
    flushed -- which, inside Codex's sandbox, may be never (see
    the cleanup-hang explanation in the module docstring)."""
    kwargs.setdefault("flush", True)
    print(*args, **kwargs)


# ---------------------------------------------------------------------------
# Test 1: syscall layer.
# ---------------------------------------------------------------------------

def test_socket_send() -> tuple[bool, str]:
    """Try to do, synchronously, the exact operation asyncio's self-
    pipe wakeup depends on: send one byte through one half of an
    AF_UNIX socketpair. Returns (passed, message)."""
    try:
        a, b = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0)
    except OSError as e:
        return (
            False,
            f"socket.socketpair(AF_UNIX, SOCK_STREAM) raised {e!r} "
            f"-- Codex's seccomp filter denies SYS_socketpair in "
            f"proxy-routed mode.",
        )
    try:
        try:
            a.send(b'\0')
        except OSError as e:
            return (
                False,
                f"socket.send on an AF_UNIX socketpair raised {e!r} "
                f"-- Codex's seccomp filter denies SYS_sendto. This "
                f"is the same syscall asyncio uses internally for "
                f"cross-thread loop wakeups.",
            )
        return (True, "socket.send on an AF_UNIX socketpair succeeded.")
    finally:
        a.close()
        b.close()


# ---------------------------------------------------------------------------
# Test 2: asyncio primitive layer.
# ---------------------------------------------------------------------------

async def test_call_soon_threadsafe() -> tuple[bool, str]:
    """Schedule a callback from a background OS thread via
    `loop.call_soon_threadsafe` and measure how long it takes for the
    selector to wake up and run it."""
    loop = asyncio.get_running_loop()
    event = asyncio.Event()

    def from_thread() -> None:
        # Tiny stall so the main task is definitely parked on
        # `event.wait()` before we fire the wakeup. This delay is
        # OUTSIDE the timing window.
        time.sleep(0.05)
        loop.call_soon_threadsafe(event.set)

    threading.Thread(target=from_thread, daemon=True).start()
    start = time.monotonic()
    try:
        await asyncio.wait_for(event.wait(), timeout=TIMEOUT_S)
    except asyncio.TimeoutError:
        return (
            False,
            f"loop.call_soon_threadsafe failed to wake the selector "
            f"within {TIMEOUT_S}s, even with the `wait_for` deadline "
            f"timer firing. The callback is queued but `epoll_wait` is "
            f"not returning at all. asyncio's `_write_to_self` is "
            f"silently swallowing an OSError from "
            f"`self._csock.send(b'\\0')`.",
        )
    elapsed_ms = (time.monotonic() - start) * 1000
    if elapsed_ms > HEALTHY_S * 1000:
        # The wakeup eventually "arrived" -- but only because the
        # `wait_for` deadline timer fired and drained the ready queue
        # as a side effect. The real `call_soon_threadsafe` self-pipe
        # wakeup is still broken. Healthy systems resolve this in
        # tens of milliseconds; ~`TIMEOUT_S` means the bug is
        # present.
        return (
            False,
            f"loop.call_soon_threadsafe wakeup latency was "
            f"{elapsed_ms:.0f}ms (healthy <{HEALTHY_S * 1000:.0f}ms). "
            f"The callback was queued by the worker thread within "
            f"~50ms but the selector did not run it until the "
            f"`wait_for` deadline timer fired at {TIMEOUT_S}s -- "
            f"i.e. cross-thread wakeups are only succeeding by "
            f"accident, via unrelated timer events. asyncio's "
            f"`_write_to_self` is silently swallowing an OSError from "
            f"`self._csock.send(b'\\0')`.",
        )
    return (
        True,
        f"loop.call_soon_threadsafe woke the selector in "
        f"{elapsed_ms:.2f}ms.",
    )


# ---------------------------------------------------------------------------
# Test 3: user-visible layer.
# ---------------------------------------------------------------------------

async def test_run_in_executor() -> tuple[bool, str]:
    """Issue several `loop.run_in_executor` calls back-to-back. The
    very first call can succeed even on broken systems because lazy
    `ThreadPoolExecutor` creation synchronously yields the loop and
    incidentally drains the ready queue once. Subsequent calls have
    to use the broken self-pipe wakeup path, so they hang."""
    loop = asyncio.get_running_loop()
    timings_ms: list[float] = []
    for i in range(3):
        start = time.monotonic()
        try:
            await asyncio.wait_for(
                loop.run_in_executor(None, lambda: None),
                timeout=TIMEOUT_S,
            )
        except asyncio.TimeoutError:
            return (
                False,
                f"loop.run_in_executor call #{i + 1} did not return "
                f"within {TIMEOUT_S}s, even with the `wait_for` "
                f"deadline timer firing. Prior call timings (ms): "
                f"{[round(t, 2) for t in timings_ms]}.",
            )
        elapsed_ms = (time.monotonic() - start) * 1000
        timings_ms.append(elapsed_ms)
        if elapsed_ms > HEALTHY_S * 1000:
            # Same "passed only because a timer fired" pattern as
            # test 2. Note the first call typically slips under the
            # threshold even on broken systems, because lazy
            # ThreadPoolExecutor creation synchronously yields the
            # loop and incidentally drains the ready queue once.
            return (
                False,
                f"loop.run_in_executor call #{i + 1} took "
                f"{elapsed_ms:.0f}ms (healthy <{HEALTHY_S * 1000:.0f}ms). "
                f"The worker thread completed in microseconds and "
                f"queued the future's `set_result` callback via "
                f"`call_soon_threadsafe`, but the loop did not run it "
                f"until the next unrelated timer event. Timings of "
                f"all calls so far (ms): "
                f"{[round(t, 2) for t in timings_ms]}.",
            )
    return (
        True,
        f"loop.run_in_executor x3 timings (ms): "
        f"{[round(t, 2) for t in timings_ms]}.",
    )


# ---------------------------------------------------------------------------

async def amain() -> int:
    print_flush(
        f"python={sys.version.split()[0]}  pid={os.getpid()}  "
        f"ppid={os.getppid()}  loop={type(asyncio.get_running_loop()).__name__}"
    )
    # /proc/self/status:Seccomp tells us if a BPF filter is active.
    # 0=off, 1=strict, 2=filter. Under Codex's sandbox it is 2.
    try:
        with open('/proc/self/status', 'r') as f:
            for line in f:
                if line.startswith(('Seccomp:', 'NoNewPrivs:')):
                    print_flush('  ' + line.rstrip())
    except OSError:
        pass

    print_flush()
    print_flush("Test 1 (syscall): send 1 byte through an AF_UNIX socketpair")
    t1_passed, t1_msg = test_socket_send()
    print_flush(f"  {'PASS' if t1_passed else 'FAIL'}: {t1_msg}")

    print_flush()
    print_flush("Test 2 (asyncio primitive): loop.call_soon_threadsafe from a thread")
    t2_passed, t2_msg = await test_call_soon_threadsafe()
    print_flush(f"  {'PASS' if t2_passed else 'FAIL'}: {t2_msg}")

    print_flush()
    print_flush("Test 3 (user-visible): loop.run_in_executor x3")
    t3_passed, t3_msg = await test_run_in_executor()
    print_flush(f"  {'PASS' if t3_passed else 'FAIL'}: {t3_msg}")

    print_flush()
    if t1_passed and t2_passed and t3_passed:
        print_flush("ALL OK: asyncio cross-thread wakeup works here.")
        return 0
    print_flush(
        "REPRO REPRODUCED: asyncio cross-thread wakeup is broken here. "
        "See module docstring for root cause."
    )
    return 1


if __name__ == "__main__":
    # We deliberately do NOT use `asyncio.run(amain())`. asyncio.run's
    # teardown calls `loop.shutdown_default_executor()`, whose
    # implementation spawns a thread that runs
    # `executor.shutdown(wait=True)` and then signals back to the loop
    # via `call_soon_threadsafe(future.set_result, None)`. That is
    # exactly the broken primitive this script demonstrates, with no
    # timeout by default -- so under Codex's sandbox `asyncio.run`
    # hangs forever during cleanup, AFTER all our tests have already
    # completed. The user would see no output (block-buffered stdout)
    # and assume the script itself is stuck.
    #
    # Instead we drive the loop manually, then `os._exit()` to skip
    # interpreter shutdown entirely. `loop.close()` does call
    # `executor.shutdown(wait=False)`, which doesn't hang.
    _loop = asyncio.new_event_loop()
    try:
        _code = _loop.run_until_complete(amain())
    finally:
        _loop.close()
    sys.stdout.flush()
    sys.stderr.flush()
    os._exit(_code)

The output I observe is:

$ codex exec "python3 ./repro.py"
OpenAI Codex v0.134.0
--------
workdir: /workspaces/mono/repro
model: gpt-5.5
provider: openai
approval: never
sandbox: read-only
reasoning effort: medium
reasoning summaries: none
session id: 019e6f3d-feae-7f22-b062-aac01872b64e
--------
user
python3 ./repro.py
warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.
hook: SessionStart
hook: SessionStart Completed
codex
I’ll run the script and report the output.
exec
/bin/bash -lc 'python3 ./repro.py' in /workspaces/mono/repro
codex
The script is still running after printing the first failure and starting the asyncio-threadsafe test. I’m going to wait briefly so I can capture the rest rather than cutting off a hanging repro too early.
 exited 1 in 4032ms:
  FAIL: loop.call_soon_threadsafe wakeup latency was 2002ms (healthy <500ms). The callback was queued by the worker thread within ~50ms but the selector did not run it until the `wait_for` deadline timer fired at 2.0s -- i.e. cross-thread wakeups are only succeeding by accident, via unrelated timer events. asyncio's `_write_to_self` is silently swallowing an OSError from `self._csock.send(b'\0')`.

Test 3 (user-visible): loop.run_in_executor x3
  FAIL: loop.run_in_executor call #2 took 2002ms (healthy <500ms). The worker thread completed in microseconds and queued the future's `set_result` callback via `call_soon_threadsafe`, but the loop did not run it until the next unrelated timer event. Timings of all calls so far (ms): [2.09, 2002.26].

REPRO REPRODUCED: asyncio cross-thread wakeup is broken here. See module docstring for root cause.

codex
`python3 ./repro.py` exited with code `1`.

Key output:

"""text
Test 1: FAIL
socket.send on an AF_UNIX socketpair raised PermissionError(1, 'Operation not permitted')
Codex's seccomp filter denies SYS_sendto.

Test 2: FAIL
loop.call_soon_threadsafe wakeup latency was 2002ms

Test 3: FAIL
loop.run_in_executor call #2 took 2002ms

REPRO REPRODUCED: asyncio cross-thread wakeup is broken here.
"""

The repro confirms the environment’s seccomp filter is blocking the syscall asyncio relies on for cross-thread event loop wakeups.

What is the expected behavior?

As run outside a Codex sandbox:

$ python3 ./repro.py 
python=3.10.12  pid=15949  ppid=2079  loop=_UnixSelectorEventLoop
  NoNewPrivs:   0
  Seccomp:      0

Test 1 (syscall): send 1 byte through an AF_UNIX socketpair
  PASS: socket.send on an AF_UNIX socketpair succeeded.

Test 2 (asyncio primitive): loop.call_soon_threadsafe from a thread
  PASS: loop.call_soon_threadsafe woke the selector in 50.25ms.

Test 3 (user-visible): loop.run_in_executor x3
  PASS: loop.run_in_executor x3 timings (ms): [1.8, 0.14, 0.12].

ALL OK: asyncio cross-thread wakeup works here.

Additional information

No response

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING