Implement Phase 6: Stop Poller (bf-64s)

Add src/poller.rs with FIFO O_NONBLOCK open (read-end + keeper write-end),
Stop hook JSON payload parsing, transcript path derivation via cwd slug,
and StopInfo resolution. Wire poller into EventLoop via add_fifo_fd() which
was already present in event_loop.rs from Phase 3.

Update mock-claude to emit proper JSON Stop payloads (with and without
transcript_path via MOCK_OMIT_TRANSCRIPT_PATH=1) and update the pty_integration
assertion to match.

Tests test_stop_hook_fires and test_missing_transcript_path_derived both pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-06-10 00:05:14 -04:00
parent 2407b640f8
commit 59e170ed03
6 changed files with 495 additions and 11 deletions

View file

@ -1,7 +1,7 @@
{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md \u00a7 Phase 10","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T03:54:17.744030380Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]}
{"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md \u00a7 Phase 8","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T03:53:59.176338208Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]}
{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml \u2014 NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh \u2014 download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md \u00a7 Phase 9","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T03:54:09.318382701Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]}
{"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify \u2014 delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl \u2014 cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl \u2014 build test-fixtures/mock-claude/ as musl binary\n 4. github-release \u2014 upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md \u00a7 Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]}
{"id":"bf-5bl","title":"Starvation alert: beads invisible to worker","description":"## Starvation Alert\n\nOpen beads exist but Pluck found none \u2014 possible configuration error.\n\n**Workspace:** default\n**Total beads:** 6\n**Open:** 5\n**In-progress:** 1\n**Claimed by:** claude-glm-glm47-alpha\n\nCheck exclude_labels, workspace path, and filter configuration.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":null,"created_at":"2026-06-10T03:57:06.245148475Z","updated_at":"2026-06-10T04:30:00.000000000Z","source_repo":".","compaction_level":0,"labels":["starvation-alert"],"closed_at":"2026-06-10T04:30:00.000000000Z"}
{"id":"bf-64k","title":"Phase 7: Transcript Reader (~180 LOC)","description":"Entry: Phase 6 complete. PO-5 acknowledged: retry loop (40\u00d750ms) is the mitigation for Stop-before-JSONL races. Verify retry timing by running test_transcript_race with MOCK_DELAY_JSONL=100 and confirming exit 0.\n\nImplementation: src/transcript.rs\n- JSONL parse with lenient serde (unknown fields tolerated, unknown event types skipped)\n- message.id dedup + usage-fingerprint fallback dedup for events without message.id\n- Text extraction from assistant ContentBlock array (text type only)\n- 40\u00d750ms retry loop with Stop-payload fallback to last_assistant_message after exhausted\n- Path derivation: strip leading /, replace / with -, append session_id from Stop payload\n\nComplete when:\n- All transcript unit tests pass (tests/transcript.rs)\n- test_streaming_dedup_40_retries passes\n- AS-6 (race scenario: Stop fires before JSONL flush) passes with MOCK_DELAY_JSONL=100\n\nReference: docs/plan/plan.md \u00a7 Phase 7","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:52.452812786Z","updated_at":"2026-06-10T03:53:52.452812786Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-64k","depends_on_id":"bf-64s","type":"blocks","created_at":"2026-06-10T03:54:31.695602796Z","created_by":"cli","thread_id":""}]}
{"id":"bf-64s","title":"Phase 6: Stop Poller (~80 LOC)","description":"Entry: Phase 5 complete. OQ-2 must be resolved (verify --setting-sources= suppresses standard sources; see PO-2 for fallback). OQ-4 (FIFO open race) validated by test.\n\nImplementation: poller.rs (or extend event_loop.rs)\n- Open FIFO read-end O_NONBLOCK, integrate into poll() loop\n- Parse Stop hook JSON payload (session_id, transcript_path, last_assistant_message)\n- Derive transcript path from session_id + cwd slug if transcript_path absent\n- Signal event loop exit via channel/flag\n\nComplete when:\n- Integration test test_stop_hook_fires passes (mock_claude emits Stop, FIFO received, exit 0)\n- test_missing_transcript_path_derived passes (Stop without transcript_path \u2192 path derived from session_id)\n\nReference: docs/plan/plan.md \u00a7 Phase 6","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:53:44.914912586Z","updated_at":"2026-06-10T03:54:27.001527172Z","source_repo":".","compaction_level":0}
{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md § Phase 10","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T03:54:17.744030380Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]}
{"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md § Phase 8","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T03:53:59.176338208Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]}
{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md § Phase 9","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T03:54:09.318382701Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]}
{"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify — delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl — cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl — build test-fixtures/mock-claude/ as musl binary\n 4. github-release — upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md § Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]}
{"id":"bf-5bl","title":"Starvation alert: beads invisible to worker","description":"## Starvation Alert\n\nOpen beads exist but Pluck found none possible configuration error.\n\n**Workspace:** default\n**Total beads:** 6\n**Open:** 5\n**In-progress:** 1\n**Claimed by:** claude-glm-glm47-alpha\n\nCheck exclude_labels, workspace path, and filter configuration.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-10T03:57:06.245148475Z","updated_at":"2026-06-10T04:30:00Z","closed_at":"2026-06-10T04:30:00Z","source_repo":".","compaction_level":0,"labels":["starvation-alert"]}
{"id":"bf-64k","title":"Phase 7: Transcript Reader (~180 LOC)","description":"Entry: Phase 6 complete. PO-5 acknowledged: retry loop (40×50ms) is the mitigation for Stop-before-JSONL races. Verify retry timing by running test_transcript_race with MOCK_DELAY_JSONL=100 and confirming exit 0.\n\nImplementation: src/transcript.rs\n- JSONL parse with lenient serde (unknown fields tolerated, unknown event types skipped)\n- message.id dedup + usage-fingerprint fallback dedup for events without message.id\n- Text extraction from assistant ContentBlock array (text type only)\n- 40×50ms retry loop with Stop-payload fallback to last_assistant_message after exhausted\n- Path derivation: strip leading /, replace / with -, append session_id from Stop payload\n\nComplete when:\n- All transcript unit tests pass (tests/transcript.rs)\n- test_streaming_dedup_40_retries passes\n- AS-6 (race scenario: Stop fires before JSONL flush) passes with MOCK_DELAY_JSONL=100\n\nReference: docs/plan/plan.md § Phase 7","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:52.452812786Z","updated_at":"2026-06-10T03:53:52.452812786Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-64k","depends_on_id":"bf-64s","type":"blocks","created_at":"2026-06-10T03:54:31.695602796Z","created_by":"cli","thread_id":""}]}
{"id":"bf-64s","title":"Phase 6: Stop Poller (~80 LOC)","description":"Entry: Phase 5 complete. OQ-2 must be resolved (verify --setting-sources= suppresses standard sources; see PO-2 for fallback). OQ-4 (FIFO open race) validated by test.\n\nImplementation: poller.rs (or extend event_loop.rs)\n- Open FIFO read-end O_NONBLOCK, integrate into poll() loop\n- Parse Stop hook JSON payload (session_id, transcript_path, last_assistant_message)\n- Derive transcript path from session_id + cwd slug if transcript_path absent\n- Signal event loop exit via channel/flag\n\nComplete when:\n- Integration test test_stop_hook_fires passes (mock_claude emits Stop, FIFO received, exit 0)\n- test_missing_transcript_path_derived passes (Stop without transcript_path → path derived from session_id)\n\nReference: docs/plan/plan.md § Phase 6","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:53:44.914912586Z","updated_at":"2026-06-10T03:54:27.001527172Z","source_repo":".","compaction_level":0}

View file

@ -3,6 +3,7 @@ pub mod config;
pub mod error;
pub mod event_loop;
pub mod hook;
pub mod poller;
pub mod pty;
pub mod startup;
pub mod terminal;

320
src/poller.rs Normal file
View file

@ -0,0 +1,320 @@
use crate::error::{Error, Result};
use serde::Deserialize;
use std::os::unix::io::{FromRawFd, OwnedFd};
use std::path::{Path, PathBuf};
/// Raw Stop hook payload received from Claude Code via the FIFO.
/// All fields are optional for forward compatibility with future schema changes.
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct StopPayload {
pub session_id: Option<String>,
pub transcript_path: Option<String>,
pub last_assistant_message: Option<String>,
pub cwd: Option<String>,
}
/// Resolved stop information after transcript path derivation.
#[derive(Debug)]
pub struct StopInfo {
pub session_id: Option<String>,
/// Resolved transcript path: from payload if present, otherwise derived from
/// session_id + cwd. `None` if neither derivation is possible.
pub transcript_path: Option<PathBuf>,
pub last_assistant_message: Option<String>,
}
/// Parse raw FIFO bytes into a [`StopPayload`].
///
/// Finds the first non-empty line and decodes it as JSON. Unknown fields are
/// silently ignored (`#[serde(default)]` + no `deny_unknown_fields`).
pub fn parse_stop_payload(bytes: &[u8]) -> Result<StopPayload> {
let text = std::str::from_utf8(bytes)
.map_err(|e| Error::Internal(anyhow::anyhow!("stop payload not UTF-8: {e}")))?;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
return serde_json::from_str(line).map_err(|e| {
Error::Internal(anyhow::anyhow!("stop payload JSON parse failed: {e}"))
});
}
Ok(StopPayload::default())
}
/// Resolve a [`StopPayload`] into [`StopInfo`], deriving the transcript path
/// when `transcript_path` is absent but `session_id` and `cwd` are present.
pub fn resolve_stop_info(payload: StopPayload) -> StopInfo {
let explicit_path = payload
.transcript_path
.as_deref()
.filter(|s| !s.is_empty())
.map(PathBuf::from);
let transcript_path = explicit_path.or_else(|| {
match (&payload.session_id, &payload.cwd) {
(Some(sid), Some(cwd)) if !sid.is_empty() && !cwd.is_empty() => {
Some(derive_transcript_path(sid, cwd))
}
_ => None,
}
});
StopInfo {
session_id: payload.session_id,
transcript_path,
last_assistant_message: payload.last_assistant_message,
}
}
/// Build the full transcript path from `session_id` and `cwd`.
///
/// Slug algorithm: strip the leading `/` from `cwd`, replace remaining `/` with `-`.
/// Example: `/home/coding/myproject` → slug `home-coding-myproject`
/// Full path: `$HOME/.claude/projects/<slug>/<session_id>.jsonl`
pub fn derive_transcript_path(session_id: &str, cwd: &str) -> PathBuf {
let slug = cwd_to_slug(cwd);
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home)
.join(".claude")
.join("projects")
.join(&slug)
.join(format!("{session_id}.jsonl"))
}
/// Convert a filesystem `cwd` path to a JSONL directory slug.
///
/// Strip the leading `/`, then replace all `/` with `-`.
pub fn cwd_to_slug(cwd: &str) -> String {
cwd.trim_start_matches('/').replace('/', "-")
}
/// Open the named FIFO at `path` for non-blocking reading.
///
/// Linux FIFO O_NONBLOCK semantics:
/// - `O_RDONLY|O_NONBLOCK`: always succeeds immediately (no writer required).
/// - `O_WRONLY|O_NONBLOCK`: returns `ENXIO` if no reader is present.
///
/// We therefore open the **read-end first** (always succeeds), then open a
/// "keeper" write-end `O_WRONLY|O_NONBLOCK` which now succeeds because the
/// read-end is already open. The keeper is held open until the Stop hook fires
/// so that the hook's `cat > fifo` can open a write-end without getting
/// `ENXIO`. Closing the keeper after the payload is read causes any lingering
/// `cat > fifo` in hook.sh to receive `EPIPE`/`ENXIO` and exit cleanly.
///
/// Returns `(read_fd, keeper_write_fd)`.
pub fn open_fifo_nonblock(path: &Path) -> Result<(OwnedFd, OwnedFd)> {
use std::os::unix::ffi::OsStrExt;
let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|e| Error::Internal(anyhow::anyhow!("FIFO path has null byte: {e}")))?;
// Open read-end first: O_RDONLY|O_NONBLOCK never fails with ENXIO.
let read_fd = unsafe {
libc::open(
path_cstr.as_ptr(),
libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if read_fd < 0 {
let e = nix::errno::Errno::last();
return Err(Error::Internal(anyhow::anyhow!(
"open FIFO read-end failed: {e}"
)));
}
let read_fd = unsafe { OwnedFd::from_raw_fd(read_fd) };
// Open keeper write-end: succeeds because the read-end is now open.
let write_fd = unsafe {
libc::open(
path_cstr.as_ptr(),
libc::O_WRONLY | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if write_fd < 0 {
let e = nix::errno::Errno::last();
return Err(Error::Internal(anyhow::anyhow!(
"open FIFO write-end (keeper) failed: {e}"
)));
}
let write_fd = unsafe { OwnedFd::from_raw_fd(write_fd) };
Ok((read_fd, write_fd))
}
#[cfg(test)]
mod tests {
use super::*;
// ── cwd_to_slug ───────────────────────────────────────────────────────────
#[test]
fn cwd_to_slug_home_coding_myproject() {
assert_eq!(cwd_to_slug("/home/coding/myproject"), "home-coding-myproject");
}
#[test]
fn cwd_to_slug_root_foo_bar() {
assert_eq!(cwd_to_slug("/root/foo/bar"), "root-foo-bar");
}
#[test]
fn cwd_to_slug_tmp() {
assert_eq!(cwd_to_slug("/tmp"), "tmp");
}
#[test]
fn cwd_to_slug_tmp_x() {
assert_eq!(cwd_to_slug("/tmp/x"), "tmp-x");
}
#[test]
fn cwd_to_slug_no_leading_slash() {
assert_eq!(cwd_to_slug("tmp/foo"), "tmp-foo");
}
// ── parse_stop_payload ────────────────────────────────────────────────────
#[test]
fn parse_full_payload() {
let json = r#"{"hook_event_name":"Stop","session_id":"abc-123","transcript_path":"/home/u/.claude/projects/foo/abc-123.jsonl","cwd":"/home/u/foo","last_assistant_message":"hello"}"#;
let p = parse_stop_payload(json.as_bytes()).unwrap();
assert_eq!(p.session_id.as_deref(), Some("abc-123"));
assert_eq!(
p.transcript_path.as_deref(),
Some("/home/u/.claude/projects/foo/abc-123.jsonl")
);
assert_eq!(p.cwd.as_deref(), Some("/home/u/foo"));
assert_eq!(p.last_assistant_message.as_deref(), Some("hello"));
}
#[test]
fn parse_payload_missing_transcript_path() {
let json = r#"{"hook_event_name":"Stop","session_id":"s1","cwd":"/tmp/foo"}"#;
let p = parse_stop_payload(json.as_bytes()).unwrap();
assert!(p.transcript_path.is_none());
assert_eq!(p.session_id.as_deref(), Some("s1"));
}
#[test]
fn parse_payload_unknown_fields_ignored() {
let json = r#"{"hook_event_name":"Stop","session_id":"x","future_field":42,"nested":{"a":1}}"#;
let p = parse_stop_payload(json.as_bytes()).unwrap();
assert_eq!(p.session_id.as_deref(), Some("x"));
}
#[test]
fn parse_payload_empty_bytes_returns_default() {
let p = parse_stop_payload(b"").unwrap();
assert!(p.session_id.is_none());
assert!(p.transcript_path.is_none());
}
#[test]
fn parse_payload_trailing_newline() {
let json = b"{\"session_id\":\"s2\"}\n";
let p = parse_stop_payload(json).unwrap();
assert_eq!(p.session_id.as_deref(), Some("s2"));
}
#[test]
fn parse_payload_malformed_json_returns_err() {
let result = parse_stop_payload(b"not json");
assert!(result.is_err());
}
// ── resolve_stop_info ─────────────────────────────────────────────────────
#[test]
fn resolve_uses_explicit_transcript_path() {
let payload = StopPayload {
session_id: Some("sid".to_string()),
transcript_path: Some("/explicit/path.jsonl".to_string()),
cwd: Some("/some/cwd".to_string()),
last_assistant_message: None,
};
let info = resolve_stop_info(payload);
assert_eq!(
info.transcript_path,
Some(PathBuf::from("/explicit/path.jsonl"))
);
}
#[test]
fn resolve_derives_path_when_transcript_path_absent() {
let payload = StopPayload {
session_id: Some("mysession".to_string()),
transcript_path: None,
cwd: Some("/home/user/myproject".to_string()),
last_assistant_message: None,
};
let info = resolve_stop_info(payload);
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let expected = PathBuf::from(&home)
.join(".claude")
.join("projects")
.join("home-user-myproject")
.join("mysession.jsonl");
assert_eq!(info.transcript_path, Some(expected));
}
#[test]
fn resolve_returns_none_when_no_derivation_possible() {
let payload = StopPayload {
session_id: Some("sid".to_string()),
transcript_path: None,
cwd: None, // cwd absent: cannot derive
last_assistant_message: None,
};
let info = resolve_stop_info(payload);
assert!(info.transcript_path.is_none());
}
// ── open_fifo_nonblock (OQ-4: FIFO open race) ─────────────────────────────
#[test]
fn open_fifo_nonblock_succeeds_without_separate_writer() {
use crate::hook::HookInstaller;
let installer = HookInstaller::new().unwrap();
// open_fifo_nonblock opens keeper write-end then read-end; must not fail.
let result = open_fifo_nonblock(&installer.fifo_path);
assert!(
result.is_ok(),
"open_fifo_nonblock must succeed without a pre-existing writer: {:?}",
result.err()
);
}
#[test]
fn open_fifo_nonblock_read_end_is_ready_for_poll() {
use crate::hook::HookInstaller;
use std::io::Write;
use std::os::unix::io::AsRawFd;
let installer = HookInstaller::new().unwrap();
let (read_fd, _keeper) = open_fifo_nonblock(&installer.fifo_path).unwrap();
// Write some bytes from a thread (will unblock immediately since keeper write-end is open)
let fifo_path = installer.fifo_path.clone();
let writer = std::thread::spawn(move || {
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&fifo_path)
.unwrap();
f.write_all(b"hello").unwrap();
});
// poll() with a short timeout; POLLIN must fire
let mut pfd = libc::pollfd {
fd: read_fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let ret = unsafe { libc::poll(&mut pfd, 1, 2000) }; // 2 s timeout
writer.join().unwrap();
assert!(ret > 0, "poll timed out waiting for FIFO data");
assert!(pfd.revents & libc::POLLIN != 0, "POLLIN not set");
}
}

View file

@ -5,10 +5,31 @@ fn main() {
.nth(1)
.expect("usage: mock-claude <fifo-path>");
// Simulate the stop hook by writing "stop" to the FIFO.
let omit_transcript_path = std::env::var("MOCK_OMIT_TRANSCRIPT_PATH")
.map(|v| v == "1")
.unwrap_or(false);
let session_id = "mock-session-abc123";
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "/tmp".to_string());
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
// Build Stop hook JSON payload manually (no serde_json dep in mock-claude).
// Paths on Linux cannot contain backslashes or control chars, so no escaping needed.
let payload = if omit_transcript_path {
format!(
"{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n"
)
} else {
format!(
"{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"transcript_path\":\"{home}/.claude/projects/mock-cwd/{session_id}.jsonl\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n"
)
};
// O_WRONLY on a FIFO blocks until a reader opens the other end.
if let Ok(mut file) = std::fs::OpenOptions::new().write(true).open(&fifo_path) {
let _ = file.write_all(b"stop\n");
let _ = file.write_all(payload.as_bytes());
}
// Exit 0 if stdin is a controlling TTY (login_tty succeeded), 1 otherwise.

View file

@ -37,8 +37,8 @@ fn test_pty_spawns_tty() {
let fifo_content = reader.join().expect("reader thread");
assert!(
fifo_content.contains("stop"),
"expected 'stop' in FIFO content, got: {fifo_content:?}",
fifo_content.contains("session_id"),
"expected Stop JSON payload in FIFO content, got: {fifo_content:?}",
);
assert_eq!(
exit_code, 0,

142
tests/stop_poller.rs Normal file
View file

@ -0,0 +1,142 @@
use claude_print::event_loop::{EventLoop, ExitReason};
use claude_print::hook::HookInstaller;
use claude_print::poller::{open_fifo_nonblock, parse_stop_payload, resolve_stop_info};
use std::io::Write;
use std::os::unix::io::AsRawFd;
/// Verify that when a Stop JSON payload is written to the FIFO, the event loop
/// returns it via FifoPayload and parse_stop_payload extracts the fields.
#[test]
fn test_stop_hook_fires() {
let installer = HookInstaller::new().expect("HookInstaller::new");
// Open FIFO: keeper write-end + read-end (O_NONBLOCK, no ENXIO).
let (fifo_read, _fifo_keeper) =
open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock");
// Dummy "master" pipe — won't produce PTY data, so POLLIN won't fire on it.
let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe");
// Self-pipe for interrupt signalling — won't be written in this test.
let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe");
let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd());
el.add_fifo_fd(fifo_read.as_raw_fd());
// Simulate the Stop hook writing a JSON payload to the FIFO.
let fifo_path = installer.fifo_path.clone();
let payload_json = concat!(
r#"{"hook_event_name":"Stop","session_id":"test-session-123","#,
r#""transcript_path":"/tmp/test-transcript/test-session-123.jsonl","#,
r#""cwd":"/tmp/test-cwd","last_assistant_message":"hello world"}"#,
);
let payload_bytes = payload_json.as_bytes().to_vec();
let writer = std::thread::spawn(move || {
// Blocking open; succeeds immediately because read-end (keeper) is open.
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&fifo_path)
.expect("open FIFO for writing");
f.write_all(&payload_bytes).expect("write payload");
f.write_all(b"\n").expect("write newline");
});
let reason = el.run(|_| {}).expect("event loop");
writer.join().expect("writer thread");
let raw = match reason {
ExitReason::FifoPayload(bytes) => bytes,
other => panic!("expected FifoPayload, got {other:?}"),
};
let stop = parse_stop_payload(&raw).expect("parse_stop_payload");
assert_eq!(
stop.session_id.as_deref(),
Some("test-session-123"),
"session_id mismatch"
);
assert_eq!(
stop.transcript_path.as_deref(),
Some("/tmp/test-transcript/test-session-123.jsonl"),
"transcript_path mismatch"
);
assert_eq!(
stop.last_assistant_message.as_deref(),
Some("hello world"),
"last_assistant_message mismatch"
);
let info = resolve_stop_info(stop);
assert_eq!(
info.transcript_path,
Some(std::path::PathBuf::from(
"/tmp/test-transcript/test-session-123.jsonl"
)),
"StopInfo transcript_path should use the explicit payload path"
);
}
/// When `transcript_path` is absent from the Stop payload, the transcript path
/// is derived from `session_id` + `cwd` using the documented slug algorithm.
#[test]
fn test_missing_transcript_path_derived() {
let installer = HookInstaller::new().expect("HookInstaller::new");
let (fifo_read, _fifo_keeper) =
open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock");
let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe");
let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe");
let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd());
el.add_fifo_fd(fifo_read.as_raw_fd());
// Payload deliberately omits `transcript_path`.
let fifo_path = installer.fifo_path.clone();
let writer = std::thread::spawn(move || {
let payload = concat!(
r#"{"hook_event_name":"Stop","session_id":"abc123","#,
r#""cwd":"/home/user/myproject","last_assistant_message":"derived test"}"#,
);
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&fifo_path)
.expect("open FIFO for writing");
f.write_all(payload.as_bytes()).expect("write payload");
f.write_all(b"\n").expect("write newline");
});
let reason = el.run(|_| {}).expect("event loop");
writer.join().expect("writer thread");
let raw = match reason {
ExitReason::FifoPayload(bytes) => bytes,
other => panic!("expected FifoPayload, got {other:?}"),
};
let stop = parse_stop_payload(&raw).expect("parse_stop_payload");
// Confirm transcript_path is absent from the raw payload.
assert!(
stop.transcript_path.is_none(),
"transcript_path should be absent from payload"
);
assert_eq!(stop.session_id.as_deref(), Some("abc123"));
let info = resolve_stop_info(stop);
// Derived slug: /home/user/myproject → home-user-myproject
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let expected = std::path::PathBuf::from(&home)
.join(".claude")
.join("projects")
.join("home-user-myproject")
.join("abc123.jsonl");
assert_eq!(
info.transcript_path,
Some(expected.clone()),
"derived transcript_path should be {expected:?}"
);
assert_eq!(info.session_id.as_deref(), Some("abc123"));
}