Fix session init hang when start runs in an uninterruptible region (0.4.1) #50

Merged
mprihoda merged 1 commit from fix/session-init-interruptible into main 2026-06-25 06:02:22 +00:00
mprihoda commented 2026-06-25 05:56:36 +00:00 (Migrated from github.com)

Problem

Downstream (the procedures dashboard, PROC-589), opening a worker session hung forever: the bootstrap turn spawned the CLI but never produced an init message or any terminal frame.

Root cause

SessionProcess.readInitMessage waits for the CLI's init message with:

messageQueue.take.timeout(InitReadTimeout)   // 1.second

ZIO.timeout completes by interrupting the losing take when the sleep wins — so it can only fire if the take is interruptible. If a caller runs session/start inside an uninterruptible region, the take is uninterruptible, the interruption blocks, and the timeout never fires.

That is easy to hit in practice: a forked fiber inherits its parent's interrupt status, and request handlers (e.g. tapir-netty) run uninterruptibly, so …forkDaemon background work is uninterruptible. The worker CLI's init is legitimately slow when it does an MCP handshake before emitting its first stream-json line — exactly the case the 1s timeout exists to tolerate (proceed with a pending session id). With the timeout dead, "slow init" becomes an infinite hang.

The bare-CLI / mock suites never caught this because they run the wait in an interruptible context (ZIO.scoped in tests, fast init with no MCP).

Fix

Force the bounded init-wait interruptible so the timeout always fires regardless of the caller's ambient interrupt status:

messageQueue.take.timeout(InitReadTimeout).interruptible

A self-contained startup timeout must not silently become unbounded based on how the caller scopes interruption.

Tests

  • Regression (SessionTest): drives readInitMessage under ZIO.uninterruptible and observes completion from an interruptible fiber — fails by observer-timeout without the fix, passes (≈1s) with it. readInitMessage is now private[claude] so the unit test can drive it directly.
  • New real-CLI e2e (zio/itest/SessionE2ETest): exercises a long-lived ZIO Session against the real CLI (single turn, multi-turn context, session-id stability) — the stdin-streaming path the existing ZIO suites only drove via a bash mock.

./mill zio.test (10/10) and ./mill zio.itest.compile green.

Release

Bumps publishVersion to 0.4.1 (patch). See the release steps in the PR discussion / publish.yml (tag v0.4.1 → Sonatype Central).

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

🤖 Generated with Claude Code

## Problem Downstream (the `procedures` dashboard, PROC-589), opening a worker session hung forever: the bootstrap turn spawned the CLI but never produced an init message or any terminal frame. ## Root cause `SessionProcess.readInitMessage` waits for the CLI's init message with: ```scala messageQueue.take.timeout(InitReadTimeout) // 1.second ``` `ZIO.timeout` completes by **interrupting the losing `take`** when the sleep wins — so it can only fire if the `take` is **interruptible**. If a caller runs `session`/`start` inside an **uninterruptible** region, the `take` is uninterruptible, the interruption blocks, and the timeout *never fires*. That is easy to hit in practice: a forked fiber inherits its parent's interrupt status, and request handlers (e.g. tapir-netty) run uninterruptibly, so `…forkDaemon` background work is uninterruptible. The worker CLI's init is legitimately slow when it does an MCP handshake before emitting its first `stream-json` line — exactly the case the 1s timeout exists to tolerate (proceed with a `pending` session id). With the timeout dead, "slow init" becomes an infinite hang. The bare-CLI / mock suites never caught this because they run the wait in an interruptible context (`ZIO.scoped` in tests, fast init with no MCP). ## Fix Force the bounded init-wait `interruptible` so the timeout always fires regardless of the caller's ambient interrupt status: ```scala messageQueue.take.timeout(InitReadTimeout).interruptible ``` A self-contained startup timeout must not silently become unbounded based on how the caller scopes interruption. ## Tests - **Regression** (`SessionTest`): drives `readInitMessage` under `ZIO.uninterruptible` and observes completion from an interruptible fiber — fails by observer-timeout without the fix, passes (≈1s) with it. `readInitMessage` is now `private[claude]` so the unit test can drive it directly. - **New real-CLI e2e** (`zio/itest/SessionE2ETest`): exercises a long-lived ZIO `Session` against the real CLI (single turn, multi-turn context, session-id stability) — the stdin-streaming path the existing ZIO suites only drove via a bash mock. `./mill zio.test` (10/10) and `./mill zio.itest.compile` green. ## Release Bumps `publishVersion` to **0.4.1** (patch). See the release steps in the PR discussion / `publish.yml` (tag `v0.4.1` → Sonatype Central). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/claude-code-query!50
No description provided.