pdftract/docs/test-hygiene/orphaned-process-verification.md
jedarden d5a2213467 docs(bf-vqcg3g): add reference to post-test integration documentation
Added cross-reference in the References section of the orphaned process
verification guide to link to the post-test integration documentation for
CI workflow integration details.

Acceptance criteria:
- File exists at docs/test-hygiene/orphaned-process-verification.md ✓
- Contains clear introduction to orphaned process verification ✓
- Documents basic verification script usage ✓
- References the post-test integration documentation ✓

Verification: docs/test-hygiene/orphaned-process-verification.md
2026-07-07 03:17:28 -04:00

659 lines
18 KiB
Markdown

# Orphaned Process Verification Guide
## Overview
This guide describes the orphaned process verification system for pdftract tests. Per CLAUDE.md test hygiene rules, **no processes should remain after test runs**. This system provides both automated and manual verification of cleanup.
## Problem Statement
Tests that spawn subprocesses (especially MCP servers, test harness processes) can leave orphaned processes if:
- Tests panic before cleanup runs
- A process doesn't exit when stdin closes
- `wait()` blocks indefinitely on a hung child
- Test timeouts kill the test runner but not the spawned processes
Orphaned processes from previous runs can:
- Block new test runs (port already in use)
- Consume system resources
- Cause flaky test behavior
- Violate test isolation assumptions
## Verification Methods
### 1. Shell Script (Manual/CI)
The `scripts/check-orphaned-processes.sh` script provides shell-level verification:
```bash
# Basic check (exits 0 if clean, 1 if orphans found)
./scripts/check-orphaned-processes.sh
# Verbose output
./scripts/check-orphaned-processes.sh --verbose
# JSON output for parsing
./scripts/check-orphaned-processes.sh --json
# Kill any orphans found
./scripts/check-orphaned-processes.sh --kill
# Custom process pattern
./scripts/check-orphaned-processes.sh --pattern "my-custom-process"
```
#### Exit Codes
- `0` - No orphaned processes found (clean state)
- `1` - Orphaned processes found (and not killed)
- `2` - Error occurred (invalid args, command failed, etc.)
#### JSON Output Format
```json
{
"status": "clean", // or "orphaned", "cleaned", "partial_cleanup"
"orphaned_processes": [
{"pid": "12345", "command": "pdftract mcp --stdio"},
{"pid": "12346", "command": "TH-0 test_case"}
],
"count": 2
}
```
### 2. Rust Test Helpers (In-Test)
The `test_helpers::process_guard` module provides programmatic verification:
```rust
use pdftract_core::test_helpers::process_guard::{
verify_no_orphaned_processes,
OrphanedProcessGuard,
};
#[test]
fn test_mcp_server_cleanup() {
// Record initial state, verify cleanup on drop
let _guard = OrphanedProcessGuard::new();
let mut server = spawn_mcp_stdio();
// ... test code ...
drop(server);
// Verify no orphans remain
verify_no_orphaned_processes().unwrap();
}
```
### 3. Post-Test Verification (CI Integration)
Add verification step in test scripts or CI workflows:
```bash
# Run tests
cargo nextest run
# Verify no orphans immediately after
./scripts/check-orphaned-processes.sh --kill
```
## Default Process Patterns
The verification system checks for these process patterns by default:
1. `pdftract mcp` - MCP server subprocess
2. `TH-0` - Test harness process (hyphen variant)
3. `TH_0` - Test harness process (underscore variant)
Custom patterns can be specified for tests that spawn other process types.
## Process Pattern Explanations
### `pdftract mcp` Pattern (MCP Server Subprocess)
**What it is:** The Model Context Protocol (MCP) server mode of pdftract, spawned as a subprocess by tests that verify MCP integration.
**Typical spawn pattern:**
```bash
pdftract mcp --stdio
# or
pdftract mcp --bind 127.0.0.1:8080
```
**Why it orphaned:** MCP servers are long-lived processes designed to handle multiple requests. Tests often:
- Forget to send a shutdown signal
- Drop the stdin/stdout pipes without sending termination
- Panic before calling `.kill()` on the child
- Rely on implicit cleanup when the test exits (unreliable)
**Detection example:**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "pdftract mcp" --verbose
Checking for processes matching: pdftract mcp
✓ No orphaned pdftract mcp processes found
```
**If orphaned:**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "pdftract mcp" --verbose
Checking for processes matching: pdftract mcp
⚠ Found 1 orphaned process:
PID 12345: pdftract mcp --stdio
Age: 45 seconds
Parent PPID: 1 (orphaned - parent died)
```
**Manual cleanup:**
```bash
# Find and inspect
pgrep -af "pdftract mcp"
# 12345 pdftract mcp --stdio
# Kill gracefully if possible
kill 12345
# Wait 1 second and force if still running
sleep 1
kill -9 12345 2>/dev/null || true
```
### `TH-0` Pattern (Test Harness Process - Hyphen Variant)
**What it is:** A test harness process with a hyphen in the name. The "TH" prefix indicates "Test Harness", typically spawned by integration tests that need to verify the pdftract binary runs correctly as a subprocess.
**Typical spawn pattern:**
```bash
pdftract extract test.pdf --json -
# or
cargo run --bin pdftract -- extract test.pdf
```
**When it appears:** Tests that use `Command::new()` to spawn pdftract as a subprocess and name the test with a `TH-` prefix pattern, or test harness scripts that use hyphenated naming.
**Why it orphaned:**
- Test assertion fails before cleanup
- Command spawned with Stdio::piped() but pipes never drained
- Parent test killed by timeout but subprocess left running
- `wait()` call blocked indefinitely
**Detection example:**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "TH-0" --verbose
Checking for processes matching: TH-0
✓ No orphaned TH-0 processes found
```
**If orphaned:**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "TH-0" --verbose
Checking for processes matching: TH-0
⚠ Found 2 orphaned processes:
PID 12346: pdftract extract tests/fixtures/vector/test.pdf --json -
PID 12347: pdftract mcp --stdio --bind 127.0.0.1:0
Total age: 2 minutes 15 seconds
Parent PPID: 1 (tests died but children survived)
```
**Manual cleanup:**
```bash
# List all matching
pgrep -af "TH-0"
# 12346 pdftract extract tests/fixtures/vector/test.pdf --json -
# 12347 pdftract mcp --stdio --bind 127.0.0.1:0
# Kill all matching
pkill -f "TH-0"
# Verify
pgrep -af "TH-0" || echo "All cleaned up"
```
### `TH_0` Pattern (Test Harness Process - Underscore Variant)
**What it is:** A test harness process with an underscore in the name. Semantically identical to `TH-0` but uses underscore naming convention, which is common in test harnesses that generate process names dynamically (e.g., `TH_01`, `TH_02`, etc.).
**Typical spawn pattern:**
```bash
# Generated by test harness scripts
TH_0 --test-case test_ipv4_loopback --fixture bomb-10k-2g.pdf
```
**When it appears:** Integration tests that use a separate test harness binary or script, particularly in fuzz testing or property-based testing where the harness is named with underscores for readability.
**Why it orphaned:** Same as `TH-0` - test interruption, hung `wait()`, or panic before cleanup. Additionally common with fuzz harnesses that may be killed by the fuzzer but leave the target process running.
**Detection example (clean):**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "TH_0" --verbose
Checking for processes matching: TH_0
✓ No orphaned TH_0 processes found
```
**If orphaned (fuzz scenario):**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "TH_0" --verbose
Checking for processes matching: TH_0
⚠ Found 5 orphaned processes:
PID 12350: TH_0 --fuzz-target lexer --input crash-12345.bin
PID 12351: TH_0 --fuzz-target lexer --input crash-12346.bin
PID 12352: TH_0 --fuzz-target xref --input crash-12347.bin
PID 12353: TH_0 --fuzz-target lexer --input crash-12348.bin
PID 12354: TH_0 --fuzz-target streams --input crash-12349.bin
Total age: 15 minutes (stale from previous fuzz run)
Parent PPID: 1 (fuzzer processes died, targets survived)
```
**Manual cleanup (bulk):**
```bash
# Kill all TH_0 processes at once
pkill -9 -f "TH_0"
# Verify
./scripts/check-orphaned-processes.sh --pattern "TH_0"
✓ No orphaned TH_0 processes found
```
## Manual Verification Walkthrough
### Scenario 1: After a Test Run
**Step-by-step verification after running tests:**
```bash
# 1. Run your tests
cargo nextest run --test-filter mcp
# 2. Immediately check for orphans
./scripts/check-orphaned-processes.sh
# Expected output (clean):
# ✓ No orphaned processes found
# 3. If orphans exist, see details
./scripts/check-orphaned-processes.sh --verbose
# Example output (orphaned):
# ⚠ Found 2 orphaned processes:
# PID 12360: pdftract mcp --stdio
# PID 12361: TH-0 test_ipv4_loopback
#
# Total: 2 processes
# 4. Kill them and verify
./scripts/check-orphaned-processes.sh --kill
# Expected output:
# Killed 2 orphaned processes
# 5. Verify cleanup
./scripts/check-orphaned-processes.sh
# Expected output:
# ✓ No orphaned processes found
```
### Scenario 2: Before Starting a Test Run
**Pre-flight check to ensure clean state:**
```bash
# 1. Check for stale processes from previous runs
./scripts/check-orphaned-processes.sh --json
# Expected JSON output (clean):
# {
# "status": "clean",
# "orphaned_processes": [],
# "count": 0
# }
# 2. If not clean, kill first
if ! ./scripts/check-orphaned-processes.sh; then
echo "Cleaning up before test run..."
./scripts/check-orphaned-processes.sh --kill
fi
# 3. Now run tests with confidence
cargo nextest run
```
### Scenario 3: Investigating a Leaking Test
**Find which test is leaving orphans:**
```bash
# 1. Start with clean slate
./scripts/check-orphaned-processes.sh --kill
./scripts/check-orphaned-processes.sh
# ✓ No orphaned processes found
# 2. Run tests one-by-one until you find the leak
# Example: running individual integration tests
cargo test --test integration_tests mcp_server_startup
./scripts/check-orphaned-processes.sh --verbose
# ✓ No orphaned processes found
cargo test --test integration_tests mcp_server_timeout
./scripts/check-orphaned-processes.sh --verbose
# ⚠ Found 1 orphaned process:
# PID 12370: pdftract mcp --stdio
#
# FOUND IT: mcp_server_timeout test leaves orphans
# 3. Inspect the test's cleanup code
# Look for missing ProcessGuard, bare wait(), or panic before cleanup
# 4. Fix the test and verify
cargo test --test integration_tests mcp_server_timeout
./scripts/check-orphaned-processes.sh
# ✓ No orphaned processes found
```
### Scenario 4: CI Post-Test Verification
**Automated verification in CI:**
```bash
#!/usr/bin/env bash
# .ci/scripts/post-test-check.sh
set -euo pipefail
echo "=== Post-test orphaned process verification ==="
# Run verification
RESULT=$(./scripts/check-orphaned-processes.sh --json 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "✓ Clean: No orphaned processes detected"
echo "$RESULT" | jq .
exit 0
elif [ $EXIT_CODE -eq 1 ]; then
echo "✗ FAIL: Orphaned processes found!"
echo "$RESULT" | jq .
echo ""
echo "Details:"
./scripts/check-orphaned-processes.sh --verbose
echo ""
echo "Attempting cleanup..."
./scripts/check-orphaned-processes.sh --kill
exit 1
else
echo "✗ ERROR: Verification script failed"
echo "$RESULT"
exit 2
fi
```
## Common Orphan Scenarios
### Scenario 1: Test Timeout Leaves Children Alive
**Symptom:** Test suite runs with `cargo nextest run` or `timeout`, test exceeds time limit, test runner killed but spawned processes survive.
**Example:**
```bash
# Test spawns a server
let server = Command::new("pdftract")
.arg("mcp")
.arg("--stdio")
.spawn()?;
# Test takes too long, cargo nextest kills it
# Server process continues running
```
**Verification:**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "pdftract mcp" --verbose
⚠ Found 1 orphaned process:
PID 12380: pdftract mcp --stdio
Age: 5 seconds (recent - likely from timeout)
Parent PPID: 1 (parent was killed)
```
**Fix:** Use `OrphanedProcessGuard` RAII pattern to ensure cleanup on drop/panic.
### Scenario 2: Panic Before Cleanup
**Symptom:** Test code panics after spawning a process but before cleanup code runs.
**Example:**
```rust
#[test]
fn test_something() {
let child = Command::new("pdftract")
.arg("mcp")
.spawn()
.unwrap();
// Some test code that might panic
assert!(some_condition);
// Cleanup never runs if assertion fails
child.kill().unwrap();
child.wait().unwrap();
}
```
**Verification:**
```bash
$ ./scripts/check-orphaned-processes.sh --verbose
⚠ Found 1 orphaned process:
PID 12381: pdftract mcp --stdio
Age: Variable (depends on when test run)
```
**Fix:** Use RAII guard - cleanup runs even on panic.
### Scenario 3: Undrained Stdio::piped() Blocks wait()
**Symptom:** Long-running server with `Stdio::piped()` fills stdout/stderr buffer, process blocks, `wait()` never returns.
**Example:**
```rust
let child = Command::new("pdftract")
.arg("mcp")
.arg("--stdio")
.stdin(Stdio::piped()) // Server writes to stdout
.stdout(Stdio::piped()) // but nobody reads it
.stderr(Stdio::piped())
.spawn()?;
// This blocks forever if pipe fills
child.wait().unwrap();
```
**Verification:**
```bash
$ ./scripts/check-orphaned-processes.sh --verbose
⚠ Found 1 orphaned process:
PID 12382: pdftract mcp --stdio
State: D (disk sleep - waiting for I/O)
CPU: 0% (blocked on pipe buffer)
```
**Fix:** Use `Stdio::null()` for servers, or drain pipes on a background thread.
### Scenario 4: Port Already in Use from Previous Run
**Symptom:** New test fails with "Address already in use" error, previous test's MCP server still running.
**Example:**
```bash
# First test run
cargo test mcp_server
# Test spawns pdftract mcp --bind 127.0.0.1:8080
# Test panics, server not killed
# Second test run (minutes later)
cargo test mcp_server
# FAIL: AddressAlreadyIn use - port 8080 still bound
```
**Verification:**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "pdftract mcp" --verbose
⚠ Found 1 orphaned process:
PID 12383: pdftract mcp --bind 127.0.0.1:8080
Age: 5 minutes 20 seconds (stale)
Listening ports: 127.0.0.1:8080
```
**Fix:** Check for orphans at test start, use random ports (`:0`), or enforce cleanup.
### Scenario 5: Fuzz Harness Leaves Target Processes
**Symptom:** Fuzzer crashes or is killed, target pdftract processes continue running in background.
**Example:**
```bash
# Fuzzing runs
cargo fuzz run lexer -- -max_total_time=300
# Fuzzer killed (Ctrl+C or timeout)
# Target processes still running
pgrep -af "pdftract"
# 12390 pdftract /tmp/fuzz-input-12345.bin
# 12391 pdftract /tmp/fuzz-input-12346.bin
# 12392 pdftract /tmp/fuzz-input-12347.bin
```
**Verification:**
```bash
$ ./scripts/check-orphaned-processes.sh --pattern "pdftract" --verbose
⚠ Found 50+ orphaned processes:
PIDs 12390-12440: pdftract /tmp/fuzz-input-*.bin
Total age: 30+ minutes (stale fuzz run)
```
**Fix:** Fuzz harness should trap signals and kill children on exit.
## Best Practices
### 1. Use RAII Guards for Process Spawning
Always wrap spawned child processes in RAII guards:
```rust
struct ProcessGuard {
child: Option<Child>,
}
impl Drop for ProcessGuard {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
// Graceful shutdown first
let _ = child.stdin.take();
// Wait with bounded timeout
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() >= Duration::from_millis(200) => {
// Force kill if graceful shutdown fails
let _ = child.kill();
break;
}
_ => thread::sleep(Duration::from_millis(10)),
}
}
}
}
}
```
### 2. Verify at Test Boundaries
Check for orphans at these points:
- **After each test** that spawns processes
- **After test suite completion** (in CI)
- **Before starting** new test runs (detect stale orphans)
### 3. Use Timeouts on All Waits
Never use bare `child.wait()` - always use bounded waits:
```rust
// BAD - may block forever
child.wait();
// GOOD - bounded timeout
wait_with_timeout(&mut child, 1000)?;
```
### 4. Give Children Stdio::null() for Long-Running Servers
Servers that live beyond a single request should drain pipes or use null:
```rust
Command::new("pdftract")
.arg("mcp")
.arg("--stdio")
.stdin(Stdio::null()) // Prevents pipe-full blocking
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
```
## CI Integration Example
Add to `.ci/scripts/post-test-check.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "Checking for orphaned processes after test run..."
if ./scripts/check-orphaned-processes.sh --json; then
echo "✓ No orphaned processes found"
exit 0
else
echo "⚠ Orphaned processes detected!"
./scripts/check-orphaned-processes.sh --kill --verbose
exit 1
fi
```
## Troubleshooting
### "Orphaned processes found" errors
1. **Identify the processes**:
```bash
./scripts/check-orphaned-processes.sh --verbose
```
2. **Check if they're legitimate** (running from other work):
```bash
ps aux | grep -E "pdftract|TH-0"
```
3. **Kill if truly orphaned**:
```bash
./scripts/check-orphaned-processes.sh --kill
```
4. **Find the leaking test**:
- Run tests individually until one leaves orphans
- Check the test's ProcessGuard implementation
- Verify the test doesn't panic before cleanup
### "pgrep command failed" errors
The verification system requires `pgrep` to be installed:
```bash
# On Debian/Ubuntu/NixOS
pgrep --version # Should show procps or similar
# If missing, install:
# apt install procps # Debian/Ubuntu
# nix-shell -p procps # NixOS
```
## References
- CLAUDE.md Test Hygiene Rules
- Post-Test Integration Documentation: `docs/test-hygiene/post-test-orphan-verification-integration.md` (CI workflow integration details)
- Bead bf-5xh7g: Orphaned process verification implementation
- Bead bf-119ys: TH-03 process cleanup with RAII guards