timeout docker compose exec から学ぶCS - Codex では動くのにTerminalからでは止まる -

timeout docker compose exec から学ぶCS - Codex では動くのにTerminalからでは止まる -

この記事で分かること

Codexの Terminal で timeout docker compose exec が正常に動作するのに、ローカルのTerminalでは同じコマンドが途中で停止してしまう。この現象の背後には、プロセスグループ、TTY制御、そして実行ハーネスという、コンピュータサイエンスが隠れています。

環境情報

  • Docker Compose: version 3.8
  • Go: 1.23
  • timeout コマンド: GNU coreutils

結論:3つのオプションで解決できる

先に結論を示します。ローカル環境で Codexと同じ安定性を得るには、次の3つのオプションを組み合わせます。

timeout --foreground 5 \
  docker compose exec -T \
  go-app bash -lc "go run main.go" \
  < /dev/null

各オプションの役割は以下の通りです。

オプション 効果 解決する問題
--foreground 同一プロセスグループで実行 SIGTTIN による停止を防止
-T TTY を無効化(非対話モード) 標準入力の読み込み試行を防止
< /dev/null 標準入力を閉鎖 入力待ちによるブロックを防止


現象:Codex では動くのにローカルでは止まる

まず、問題の現象を確認しましょう。以下のコマンドを実行したとします。

timeout 5 docker compose exec app bash -c "go run main.go"

Codex の Terminal では、このコマンドは正常に動作します。しかし、ローカルのTerminalで同じコマンドを実行すると、プロセスが途中で停止してしまいます。別のTerminalから ps コマンドで確認すると、次のように表示されます。

bash   T

この "T" は、プロセスが SIGTTIN シグナルを受け取って停止していることを意味します。


timeout が作るプロセス構造

この問題を理解するには、timeout コマンドがどのようにプロセスを管理しているかを知る必要があります。timeout は、指定した時間を超えた場合に子プロセスを確実に終了させるため、子プロセスを別のプロセスグループで起動します。

プロセス構造を図示すると、以下のようになります。

[Terminal / Shell]  ← 前景プロセスグループ (PGID=1000)
    │
    └── timeout 5 ...          ← 新しいプロセスグループ (PGID=2000)
          │
          └── docker compose exec ...
                │
                └── bash -lc "go run main.go"
                      └── go (ユーザープログラム)

この構造により、timeoutSIGTERM をプロセスグループ全体に送信できるようになります。しかし、同時に新しい問題が発生します。それが SIGTTIN です。


SIGTTIN とは何か?

SIGTTIN は、UNIX 系 OS におけるジョブ制御の仕組みの一部です。TTY(端末)には、「前景プロセスグループのみが標準入力を読み取れる」というルールがあります。バックグラウンドプロセスが read() システムコールを呼び出すと、カーネルがそれを検知し、SIGTTIN シグナルを送信してプロセスを停止させます。

timeout によって作られた新しいプロセスグループは、シェルから見るとバックグラウンド扱いです。このため、docker compose exec が(たとえ意図せずとも)TTY から標準入力を読み込もうとすると、カーネルが介入してプロセスを停止させてしまうのです。

SIGTTIN 発生の流れを図示すると、以下のようになります。

┌──────────────────────────────┐
│ timeout が子を別PGで起動    │
└──────────────────────────────┘
                │
                ▼
 docker exec が TTYを読もうとする
                │
                ▼
 カーネル「前景じゃない!」 → SIGTTIN送信
                │
                ▼
 子プロセスが停止 (T状態)

A Deep Dive into the SIGTTIN / SIGTTOU Terminal Access Control Mechanism in Linux • A Curious Thing
From an end-user perspective, the TTY system in Linux (or any POSIX-like OS) is both functional and intuitive. For example, the input you type in usually goes to the process you expect it to go to, CTRL+Z usually suspends the process you expect to... | A Curious Thing | Avid learner. Enthusiast Prog…


--foreground オプションの効果

この問題を解決する最初の鍵が、timeout --foreground オプションです。このオプションを使うと、timeout は子プロセスを同じプロセスグループで実行します。

プロセス構造は以下のように変化します。

[Terminal / Shell] (PGID=1000)
    │
    └── timeout --foreground 5 ... (同一PGID)
          │
          └── docker compose exec ...
                └── bash -lc "go run main.go"

同じ前景プロセスグループ内で実行されるため、SIGTTIN が発生せず、プロセスは停止しません。

timeout(1) - Linux manual page

Codex の実行ハーネスとは?

ここまでの説明で、ローカル環境での問題の原因は理解できました。では、なぜ Codex の環境では同じ問題が発生しないのでしょう?

Codex は、見た目は普通の bash ですが、実際には安全な AI 実行ハーネス(sandbox harness)の上で動作しています。実行ハーネスとは、プログラムを安全かつ再現性高く実行するための外側の制御構造のことです。

Codexの実行ハーネスの構造を図示すると、以下のようになります。

Claude Code 実行ハーネス:

  LLM Agent (OpenAI Responses API)
       ↓
  ToolOrchestrator (承認・サンドボックス選択)
       ↓
  UnifiedExecRuntime
       ↓
  UnifiedExecSessionManager
       ├─ 承認チェック (approval/bypass/cache)
       ├─ サンドボックス変換 (ExecEnv)
       └─ セッション再利用
       ↓
  PTY (Pseudo-TTY) System
     ├─ portable_pty (クロスプラットフォーム)
     ├─ Master/Slave ペア
     ├─ Reader Thread (stdout/stderr)
     ├─ Writer Thread (stdin)
     └─ Wait Thread (exit code)
       ↓
  Sandboxed Process
     ├─ Linux: Landlock + Seccomp
     ├─ macOS: Seatbelt
     └─ 環境変数: CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
       ↓
  bash -lc "docker compose exec ... go run main.go"
     ├─ 対話的シェル (bash -i) または
     └─ ワンショットコマンド

Codex では、以下の理由で SIGTTIN の発生条件がそもそも満たされません。

  • stdin が閉じている: すべてのコマンドの標準入力は、デフォルトで /dev/null に接続されています。これにより、プロセスが入力待ちでブロックされることがありません。

これが、「Codex では動くのにローカルで止まる」現象の真相です。

codex/codex-rs/core/src/spawn.rs at main · openai/codex
Lightweight coding agent that runs in your terminal - openai/codex

実験環境:Docker Compose で再現する

ここからは、実際に問題の挙動を再現し、対策の効果を確認できる最小構成の Docker Compose 環境を紹介します。

docker-compose.yml

以下の設定ファイルを作成します。

version: "3.8"

services:
  go-app:
    image: golang:1.23
    working_dir: /work
    volumes:
      - .:/work:cached
    tty: false
    stdin_open: false
    environment:
      - CGO_ENABLED=0

ポイント: tty: falsestdin_open: false で非対話化しています。

実行用スクリプト

次に、実行用のスクリプト bin/go-run.sh を作成します。

#!/usr/bin/env bash
set -euo pipefail

TARGET="${1:-.}"
TIMEOUT="${TIMEOUT:-120}"

timeout --foreground "${TIMEOUT}" \
  docker compose exec -T \
  go-app bash -lc "go run ${TARGET}" \
  < /dev/null

このスクリプトは、前述の3つの対策をすべて盛り込んだ「安定版」です。

環境の起動

以下のコマンドで、実行権限を付与し、Docker コンテナを起動します。

chmod +x bin/go-run.sh
docker compose up -d

実験1:正常動作を確認する

まず、対策を施したスクリプトが正常に動作することを確認します。簡単な Go プログラム(例: cmd/foo/main.go)を作成し、以下のように実行します。

bin/go-run.sh ./cmd/foo

期待される結果:

hello from go run

このように、プログラムが正常に実行され、出力が表示されます。


実験2:SIGTTIN をわざと発生させる

次に、対策を施さないコマンドを実行して、SIGTTIN を意図的に発生させます。

timeout 5 docker compose exec -it go-app bash -lc 'read -p "input: " X; echo $X'

別のTerminalから以下のコマンドでプロセスの状態を確認します。

ps aux | grep bash
# → 状態が「T」になっている(SIGTTINで停止中)

プロセスが停止状態(T)になっていることが確認できます。これが、SIGTTIN による停止です。


実験3:対策の効果を確認する

最後に、3つの対策を適用したコマンドを実行します。

timeout --foreground 5 \
  docker compose exec -T \
  go-app bash -lc 'read X || echo "no stdin"; echo ok' \
  < /dev/null

期待される結果:

no stdin
ok

TTY を無効化し、stdin を閉じることで、プロセスが停止せずに完全に安定して実行できることが確認できます。

docker compose exec
″”

この環境は、自動化スクリプトや CI/CD パイプラインの設計において、使える考えとなります。


まとめ

Codex とローカル環境の挙動の違いは、OS の基本的なプロセス管理と I/O 制御の仕組みに起因します。

Codexは、 stdin を閉じた実行ハーネス上で動いているため、SIGTTIN が発生しません。ローカルでは TTY と stdin が開いており、timeout が子を別グループにすると停止します。--foreground -T < /dev/null の3点セットで、どちらの環境でも安定したハーネス実行が可能になります。

この知識は、自動化スクリプトや CI/CD パイプラインを構築する上で、非常に役立つはずです。