fix(curl): route curl_exec's default output through PHP's output layer - #996
Merged
Merged
Conversation
Closes #875. With no `CURLOPT_RETURNTRANSFER`, `curl_exec()` streamed the body with a direct `write(1, …)` from the bridge, bypassing every output layer the engine has. The common PHP idiom ob_start(); curl_exec($ch); $html = ob_get_clean(); therefore returned an EMPTY string with the body already on stdout, where php captures it. The `print_r` capture buffer, the output-handler discard, and the `--web` response capture were bypassed for the same reason. php-src's default write handler goes through the engine's output layer, and elephc has the exact counterpart: `__rt_stdout_write`, the single indirection every `echo` travels through. The bridge's default write path now calls it. THE ADDRESS IS PUSHED, NOT PULLED. Generated code publishes `__rt_stdout_write` to the bridge through a new `elephc_curl_set_output_sink()` entry point before each transfer, and the bridge stores and calls back through an opaque pointer — the same shape `crate::callbacks` already uses for the PHP-callable adapter. The crate therefore still names no `__rt_*` symbol and stays linkable on its own, which a plain `extern` declaration would have broken for its unit tests and for the main crate's test binaries that link it as an rlib. With no sink published, the direct-write fallback is unchanged. Publishing happens at the transfer sites (`curl_exec` and `curl_multi_exec`, which share the default write callback) rather than at startup, keeping the pay-for-use property the rest of this module holds: a curl-free binary emits none of it. It is emitted BEFORE the handle is loaded, since the call clobbers the argument registers. The sink reports no failure, so a chunk handed to it counts as fully written — the same contract `echo` has, where a failing terminal write is not something the program observes either. Tests: capture through `ob_get_clean()`, ordering against `echo` inside a buffer with `ob_get_length()`/`ob_end_flush()`, nested buffers with `ob_end_clean()`, and the no-buffer path still reaching the terminal. The first was verified to FAIL on the unpatched bridge. The documented divergence in `docs/php/curl.md` is replaced by the behavior it now has. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Greptile SummaryThis PR routes curl’s default response output through the generated PHP runtime’s output funnel instead of writing directly to stdout.
Confidence Score: 5/5The PR appears safe to merge; no actionable correctness, security, or repository-rule violations remain. The curl bridge now routes default response chunks through the runtime output layer while preserving the standalone fallback, and both easy and multi transfer paths publish the sink before transfer arguments are loaded. The previous stale-documentation finding is fully fixed in the current
|
| Filename | Overview |
|---|---|
| crates/elephc-curl/src/php_layer.rs | Stores the published runtime output callback atomically and uses it for curl’s default write path, retaining the existing direct-write fallback. |
| src/codegen_support/curl.rs | Adds target-aware emission for publishing the runtime stdout sink to the curl bridge. |
| src/codegen/lower_inst/builtins/curl/easy_perform.rs | Publishes the sink before loading arguments and invoking an easy transfer. |
| src/codegen/lower_inst/builtins/curl/multi.rs | Publishes the same sink before multi-handle transfers. |
| tests/codegen/curl/easy_http.rs | Adds focused regression tests covering output capture, ordering, nested buffers, discarding, and terminal output. |
| tests/codegen/curl/streams.rs | Corrects the stale fixture documentation identified in the previous review. |
| docs/php/curl.md | Replaces the documented output-buffering divergence with the newly supported PHP-compatible behavior. |
Sequence Diagram
sequenceDiagram
participant PHP as Compiled PHP
participant CG as Generated transfer code
participant Curl as elephc-curl bridge
participant Output as __rt_stdout_write
participant Buffer as PHP output layer
PHP->>CG: curl_exec() / curl_multi_exec()
CG->>Curl: publish output sink address
CG->>Curl: perform transfer
Curl->>Output: response chunk
Output->>Buffer: route through active output layer
Buffer-->>PHP: capture, discard, or flush output
Reviews (3): Last reviewed commit: "docs(test): curl stream fixtures no long..." | Re-trigger Greptile
Guikingone
force-pushed
the
fix/875-curl-exec-output-buffer
branch
from
September 13, 2026 19:39
7f99247 to
31e864d
Compare
Review follow-up on #996. `tests/codegen/curl/streams.rs` explained its sink-by-elimination probes with a divergence this PR removes: that the bridge writes the default sink straight to fd 1, so `ob_start()` cannot capture it. That rationale is now false and would mislead the next maintainer. The detection method itself is unchanged and deliberately so — keeping these fixtures about the last-set-wins MODE rather than about where the bytes land is what makes them independent of the output path. The comments now say that, pointing at the fixture in `easy_http.rs` that does cover the buffer behavior, and three smaller "reaching fd 1" asides become "reaching the terminal". Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #875.
What was wrong
int(0), body already on stdoutThe bridge streamed the body with a direct
write(1, …), bypassing every output layer the engine has — theob_*stack, theprint_rcapture buffer, the output-handler discard, and the--webresponse capture.Fix
php-src's default write handler goes through the engine's output layer, and elephc has the exact counterpart:
__rt_stdout_write, the single indirection everyechotravels through. The bridge's default write path now calls it.The address is pushed, not pulled. Generated code publishes
__rt_stdout_writeto the bridge through a newelephc_curl_set_output_sink()entry point before each transfer, and the bridge stores and calls back through an opaque pointer — the same shapecrate::callbacksalready uses for the PHP-callable adapter.That indirection is load-bearing, not ceremony: a plain
extern "C"declaration of__rt_stdout_writeinsideelephc-curlwould leave an undefined symbol in every binary that links the crate without a generated runtime — its own unit tests, and the main crate's test binaries, which take it as a dev-dependency rlib. With no sink published, the direct-write fallback is unchanged.Publishing happens at the transfer sites (
curl_execandcurl_multi_exec, which share the default write callback) rather than at startup, keeping the pay-for-use property the rest ofcodegen_support::curlholds: a curl-free binary emits none of it. It is emitted before the handle is loaded, since the call clobbers the argument registers.The sink reports no failure, so a chunk handed to it counts as fully written — the same contract
echohas, where a failing terminal write is not something the program can observe either.Tests
Four fixtures in
tests/codegen/curl/easy_http.rs, over a real loopback socket:ob_start()/ob_get_clean()— the issue's own idiomechoinside a buffer, checked withob_get_length()andob_end_flush(), which is what proves the chunks travel the shared funnel rather than being spliced in afterwardsob_end_clean()echoThe first was verified to FAIL on the unpatched bridge before committing.
--test codegen_tests curl(202),buffers(38) andcargo test -p elephc-curl(37) pass;cargo buildis warning-free.Docs
docs/php/curl.mdcarried this as a documented Divergence ("that stdout write goes straight to file descriptor 1, soob_start()does not capture it"). That note is replaced by the behavior it now has, with the working idiom shown.🤖 Generated with Claude Code
https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr