Tests fail on CI (GitHub Actions) because stream_select behavior differs from local environment. The REPL process outputs to stdout, but tests only capture the banner and miss command output. This is likely due to:
- GitHub Actions TTY limitations
- Stderr writes that unblock select
- Different buffering behavior in CI
Problem: Current loop exits on first timeout without a prompt, even with longer timeouts.
Solution: Continue polling on timeout instead of breaking. Use overall timeout guard.
Status: Implemented in ReplOutputReader::readOutput()
- Changed
breaktocontinueon timeout without prompt - Added overall timeout tracking
Solution: Use .env for local, .env.test for CI with configurable timeouts Status: Implemented
.envwith 1.5s timeout (local, gitignored).env.testwith 5.0s timeout (CI, tracked)- ReplOutputReader reads from environment
Problem: GitHub Actions might write to stderr, causing stream_select to unblock, but we only read stdout. Solution:
- Pass both
$pipes[1](stdout) and$pipes[2](stderr) to stream_select - Read from whichever is ready
- Only append stdout to output (discard stderr noise, or log it)
Status: IMPLEMENTED in
ReplOutputReader::readOutput() - Modified signature to accept
$stderrStreamparameter - Loop reads from both streams
- stderr output logged but not included in return value
- Updated
ReplSteps::readOutput()to pass stderr
Problem: Can't see what's happening in CI without visibility, but don't want noise on passing tests Solution: Add conditional debug logging to ReplOutputReader Status: IMPLEMENTED
- Buffers log messages instead of immediately outputting
- Only outputs logs when:
REPL_DEBUG=truein .env (always log)- Timeout occurs with no output (indicates failure)
- stream_select error occurs
- Keeps local test output clean (.env has
REPL_DEBUG=false) - CI gets full logs (.env.test has
REPL_DEBUG=true) - Added to
ReplProcessManager::sendInput()as well
Current: Using proc_open with pipes, streams set to non-blocking
Considerations:
- After writing to stdin (
$pipes[0]), explicitlyfflush($pipes[0]) - Consider
fclose($pipes[0])after all input to signal EOF (may not be appropriate for REPL) - Ensure both stdout and stderr are non-blocking
Status: REVIEWED - Already doing
fflush()after writes - Added logging to
sendInput()to verify bytes written
Alternative: Instead of guessing prompts, have REPL print a sentinel in test mode
// In test mode, REPL prints:
echo "COMMAND_DONE_MARKER\n";Then look for sentinel instead of prompt patterns. Status: NOT IMPLEMENTED - Requires REPL changes Priority: LOW - Nice to have, but invasive
Idea: Extract this into a reusable Phunkie Streams component
Stream\Process\asyncRead($stream, $predicate, $timeout)- Handles non-blocking reads, multiple streams, sentinel detection Status: NOT IMPLEMENTED Priority: LOW - After we solve the immediate problem
- ✅ Fix loop to continue polling instead of breaking on timeout
- ✅ Add .env configuration for timeouts
- ✅ Modify
ReplOutputReader::readOutput()to accept stderr stream - ✅ Update
ReplStepsto pass stderr toreadOutput() - ✅ Read from both streams in the loop, only append stdout to output
- ✅ Add debug logging (error_log) to see what's happening in CI
- ✅ Review
ReplProcessManagerfor proper fflush() usage - ⏳ NEXT: Test locally to verify changes don't break existing tests
- ⏳ NEXT: Test on CI with full test suite
- Review CI logs to see if stderr reading solves the issue
- Consider reducing timeouts if tests are passing consistently
- Consider sentinel-based approach for more reliable testing
- Extract pattern into Phunkie Streams if successful
- Don't just increase timeouts - that makes CI slow and doesn't address root cause
- Stderr reading is likely the key - GitHub Actions might be writing to stderr
- Keep local tests fast - use .env for short timeouts locally
- Baby steps - implement one thing at a time and test
- Loop logic fixed ✅
- Environment config added ✅
- Stderr reading implemented ✅
- Debug logging implemented with buffering ✅
- SENTINEL APPROACH IMPLEMENTED ✅
- LOCAL TESTS: All 418 scenarios, 2056 steps PASSING in ~18s ✅
- NEXT: Test on CI to verify sentinel approach works in GitHub Actions
- Local (PHP 8.4): 418 scenarios, 2056 steps - ALL PASSED ✅
- CI: Pending - need to push and test
GitHub Actions has different TTY/buffering behavior than local environments. The original non-blocking, short-timeout polling approach with stream_select would break too aggressively on timeouts, exiting the read loop before all data arrived. This caused tests to only capture the REPL banner, missing actual command output.
Instead of guessing when the REPL is ready by detecting prompt patterns, the REPL explicitly prints __PHUNKIE_READY__ when ready for input.
REPL side (src/Repl/ReplLoop.php):
- Added
isTestMode()to checkREPL_TEST_MODEenvironment variable - Added
printTestSentinel()that prints__PHUNKIE_READY__\nin test mode - Called before each prompt in
replLoopTrampoline()
Test side:
ReplProcessManagerloads.envand passesREPL_TEST_MODEto child processReplOutputReaderdetects sentinel instead of prompt patterns in test mode- Sentinel is stripped from output before returning to tests
ReplSteps.waitForPrompt()usesReplOutputReaderfor consistent detection
Changed from aggressive timeout-based polling to blocking reads that wait naturally for data.
Key changes in ReplOutputReader::readOutput():
- Longer
stream_selecttimeout: Up to 1 second (vs 50ms) to let data arrive naturally - No early exits on timeout: Only breaks when:
- Prompt/sentinel found ✅
- Overall timeout hit ⏱️
- EOF/error encountered ❌
- Calculated remaining timeout: Uses
min(1.0, remainingTimeout)for smart waiting - Smaller read chunks: 4096 bytes for more incremental reading
- Proper error handling: Detects EOF and read errors explicitly
Before (aggressive):
// 50ms timeout on stream_select
$result = @stream_select($read, $write, $except, 0, 50000);
if ($result === 0) {
break; // ❌ Exits too early!
}After (patient):
// Up to 1 second timeout, calculated from remaining time
$selectTimeout = min(1.0, $remainingTimeout);
$result = @stream_select($read, $write, $except, $selectTimeoutSec, $selectTimeoutUsec);
if ($result === 0) {
if (self::endsWithPrompt($output)) {
break; // ✅ Only exit if we have complete response
}
continue; // ✅ Keep waiting for data
}- ✅ Reliable in CI: Doesn't break early when I/O is slow
- ✅ Explicit ready signal: Sentinel removes guesswork
- ✅ Fast locally: ~18 seconds for 418 scenarios
- ✅ Handles slow environments: Waits patiently up to overall timeout
- ✅ Clean output: Sentinel stripped automatically
- ✅ Proper error handling: Detects EOF and errors gracefully
.env(local):REPL_TEST_MODE=true,REPL_OUTPUT_TIMEOUT=1.5.env.test(CI):REPL_TEST_MODE=true,REPL_OUTPUT_TIMEOUT=5.0
src/Repl/ReplLoop.php- Sentinel printingtests/Acceptance/Support/ReplProcessManager.php- Environment passingtests/Acceptance/Support/ReplOutputReader.php- Blocking read strategy + sentinel detectiontests/Acceptance/ReplSteps.php- Use ReplOutputReader in test mode.envand.env.test- Configuration