Skip to content

BeMart を Swoole (bear/swoole) で動かす — グローバル状態をポートの裏に閉じ込める #145

Description

@koriym

動機

Ray.Csrf のレビュー中に、同じ問題が BeMart 自身にもあることが分かった。次の目標として、BeMart を BEAR.Swoole の常駐ワーカーで正しく動かせる状態にしたい。

これは性能の話だけではない。「1リクエスト = 1プロセス」という暗黙の前提に依存したコードを洗い出す作業であり、その前提は $_SESSION に状態を置いている箇所すべてに埋まっている。

何が壊れるか(重大度で分けること)

BEAR.Swoole はスーパーグローバルをリクエストごとに詰め直さない。SwooleAssistedWebContextParam が Swoole\Coroutine::getContext()(CoroutineContextFinder 経由)から読み、SwooleRequestProxy が PSR-7 ServerRequestInterface をコルーチン単位で供給する。したがって:

(A) 正しさの問題 — セッション状態の共有。これが本丸。
$_SESSION はワーカープロセスのグローバルなので、コルーチン間・リクエスト間で共有される。ログイン中の顧客ID、管理者ID、カート、CSRF トークンが別のリクエストから見える/上書きされる。単なる不具合ではなく、認証境界が壊れる。該当:

ファイル 役割
src/Auth/EccubeSharedSessionAdapter.php 顧客セッション
src/Auth/HtmlSessionAdapter.php / HtmlCustomerSessionWriter.php 顧客セッション読み書き
src/Auth/HtmlAdminSessionAdapter.php / HtmlAdminSessionWriter.php 管理者セッション
src/Auth/HtmlAdminLoginChallengeAdapter.php 2FA チャレンジ
src/Auth/HtmlCartSessionPrefix.php カートのセッション接頭辞
src/Auth/EccubeSharedCsrfTokenAdapter.php CSRF トークン(_csrf_token)
src/Module/BeMartTwigExtension.php テンプレートへのトークン供給

加えて session_start() はファイルロックを取るため、コルーチンをブロックする。PHP のセッション拡張はコルーチンセーフではない。

(B) 可用性の問題 — fail closed。
$_SERVER / $_POST が空になる経路。src/Compatibility/Eccube/EccubeClientIp.php($_SERVER からIP)、src/Provide/Transfer/DownloadResponder.php(header() 直呼び)。こちらは「正当なリクエストが弾かれる/値が取れない」であって、認証バイパスではない。

(C) 影響なしと確認済み。
be/(ドメイン層)の $_SESSION ヒットは doc コメントと Reason/Fake/** の fake 実装のみ。ドメイン本体は汚染されていない。AGENTS.md の境界が効いている。可変な static プロパティも見つからなかった。

すでに正しい形がある

src/Auth/ には既に AdminSessionWriterInterface / CustomerSessionWriterInterface / CartSessionPrefixInterface があり、Html* と Noop* の実装をコンテキストモジュールで選んでいる。書き直しではなく、このパターンを最後まで適用する作業になる:

  1. 残っているセッション読み書きを全てポートの裏に入れる($_SESSION に触るクラスを列挙し、インターフェース経由にする)
  2. そのポートに Swoole 実装(コルーチンコンテキスト or 外部ストア)を追加し、コンテキストモジュールで選択する
  3. session_start() を呼ぶ実装を SAPI 専用と明示し、Swoole コンテキストでは選ばれないようにする。誤って選ばれたら設定時に落とす(リクエスト時に静かに壊れるのが最悪)

セッション本体をどこに置くかは設計判断が要る(Redis 等の外部ストア / コルーチンコンテキスト + Cookie)。EC-CUBE 互換のキー(_csrf_token、customer_id 等)を維持する制約があるため、外部ストア + 互換キーが現実的だと思われるが、要検討。

依存関係

bear/swoole 0.8.0 の要件と BeMart の現状:

要件 BeMart
php ^8.2 ^8.3 OK
bear/resource ^1.31 1.34.0 OK
bear/sunday ^1.6 1.9.1 OK
ray/di ^2.23 2.23.1 OK
bear/query-repository ^1.15 1.x-dev 要確認
ext-swoole ^6.1 開発機にインストール済み OK

解決を阻む障害は見当たらない。

スコープ / 非スコープ

  • スコープ: セッション系ポートの完成、Swoole 実装、コンテキストモジュール、設定時の fail-loud、Swoole 下での主要導線のスモーク
  • 非スコープ: 性能チューニング、本番の Swoole 運用、DB コネクションプール、既存 SAPI 経路の廃止(当面は両対応)

受け入れ条件

  1. $_SESSION に直接触るのは名前の付いた SAPI アダプタのみで、Resource/ドメインからは到達不能(静的解析かテストで担保)
  2. Swoole コンテキストでセッション系ポートに SAPI 実装が束縛されたら、リクエストを処理する前に落ちる
  3. Swoole ワーカー上で、別々のリクエストが互いのセッション状態を観測しないことを実証するテスト(並行リクエストで顧客A/顧客Bのカート・CSRF トークンが混ざらない)
  4. 既存の SAPI 経路のテストは全て green のまま

参考

  • Ray.Csrf 側の同種の問題: SessionCsrfToken が $_SESSION にワーカーグローバルな1個のトークンを置くため、Swoole 下では「トークンが被害者のセッションに紐づく」という synchronizer token の前提が崩れる。ライブラリ側の修復計画は別途検討中
  • BeMart は現在 Swoole 依存を持たず、本番影響はない。今の設計に誤った前提を固定しないための先行投資

依存の向き(重要・後から追記)

観測ログは上流のコード待ちではない。 BEAR.QueryRepository#213(2026-09-07 マージ、BeMart の pin に既に含まれる)が SessionStoreInterface / Session / ProcessSession / LogSinkInterface を導入済みで、ProcessSession が FPM 既定。QueryRepository#179 に残っているのはホスト側の契約で、並行ホストは次の2つを束縛する義務がある:

  1. 自分のリクエストコンテキスト(コルーチン id)で鍵付けした SessionStoreInterface
  2. そのホストのリクエスト終端で flush する LogSinkInterface

片方だけではログが出ない。 store だけ束縛して sink が flush しなければセッションは drain されず、sink が arm() で false を返せば記録自体が止まる(LogSinkInterface の docblock: 「nothing will ever drain the session: the caller must then stop recording」)。いずれも設計どおりの挙動で、BeMart のバグではない。

したがって 本 issue の受け入れ条件には「並行ホスト上で観測ログが実際に生成されること」の肯定的な検証を必ず含める。これが無いと「完了」と「黙って観測不能」が見分けられない。

なお実装上の制約も docblock に明記されている: SessionStoreInterface / LogSinkInterface の実装はコンパイル済みアプリと一緒に serialize されるため設定のみを保持すること(セッションが境界を越えると次のリクエストに前のリクエストの depth とログを渡してしまう)、sink は unserialize 後に再 arm すること(毎リクエスト走る唯一のフック)。

具体的に何を実装するのか(既定の sink は Swoole を明示的に拒否する)

既定の sink は BEAR\QueryRepository\Log\ShutdownFlush。arm() は ConcurrentRuntimeInterface::isConcurrent() が真なら false を返して記録を止め、理由と対処を error_log に出す(BeMart の pin 5abe573 の実物):

QueryRepository log: a shutdown flush is unsafe on a concurrent runtime - shutdown arrives once per worker, so sessions would accumulate and concurrent requests share one. Recording is off here; bind a request-scoped LogSinkInterface to record.

つまり register_shutdown_function はワーカーごとに1回しか来ないので、shutdown flush ではセッションが溜まり、並行リクエストが1つを共有してしまう。だから拒否する — 壊れているのではなく、安全側に倒している。

したがって Swoole 化で観測ログを生かすには、BeMart(または bear/swoole)が次の2つを束縛する:

  1. コルーチン id で鍵付けした SessionStoreInterface(既定の ProcessSession はプロセスに1つなので共有される)
  2. Swoole のリクエスト終端で flush する LogSinkInterface(ShutdownFlush の代替。arm() が true を返すもの)

これは上流の修正待ちではない。 ライブラリ側の受け口は #213 で既に入っており、拒否メッセージが実装すべきものを名指ししている。上流に残る作業は QueryRepository#179 の「ホスト契約」の記述と、診断スキル側の穴(EventSourcing#23 項目1: ShutdownFlush は RoadRunner(RR_MODE)と Swoole コルーチンを検出するが、FrankenPHP worker mode / ReactPHP / Amp / 長寿命 CLI は検出しない)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions