Skip to content

fix(curl): route curl_exec's default output through PHP's output layer - #996

Merged
nahime0 merged 2 commits into
mainfrom
fix/875-curl-exec-output-buffer
Sep 14, 2026
Merged

nahime0 merged 2 commits into
mainfrom
fix/875-curl-exec-output-buffer

Conversation

@Guikingone

Copy link
Copy Markdown
Collaborator

Closes #875.

What was wrong

<?php
$ch = curl_init('https://example.com');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
ob_start();
curl_exec($ch);
$captured = ob_get_clean();
var_dump(strlen($captured));
Result
php the body's length
elephc int(0), body already on stdout

The bridge streamed the body with a direct write(1, …), bypassing every output layer the engine has — the ob_* stack, the print_r capture buffer, the output-handler discard, and the --web response 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 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.

That indirection is load-bearing, not ceremony: a plain extern "C" declaration of __rt_stdout_write inside elephc-curl would 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_exec and curl_multi_exec, which share the default write callback) rather than at startup, keeping the pay-for-use property the rest of codegen_support::curl 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 can observe either.

Tests

Four fixtures in tests/codegen/curl/easy_http.rs, over a real loopback socket:

  • capture through ob_start() / ob_get_clean() — the issue's own idiom
  • ordering against echo inside a buffer, checked with ob_get_length() and ob_end_flush(), which is what proves the chunks travel the shared funnel rather than being spliced in afterwards
  • nested buffers: the inner one takes the body, the outer never sees it after ob_end_clean()
  • no buffer active: the body still reaches the terminal, in order with echo

The first was verified to FAIL on the unpatched bridge before committing.

--test codegen_tests curl (202), buffers (38) and cargo test -p elephc-curl (37) pass; cargo build is warning-free.

Docs

docs/php/curl.md carried this as a documented Divergence ("that stdout write goes straight to file descriptor 1, so ob_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

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
@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Sep 13, 2026
@greptile-apps

greptile-apps Bot commented Sep 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR routes curl’s default response output through the generated PHP runtime’s output funnel instead of writing directly to stdout.

  • Publishes __rt_stdout_write to the curl bridge before easy and multi transfers.
  • Preserves direct stdout writes as a fallback when no runtime sink has been registered.
  • Adds regression coverage for ordinary, nested, discarded, and unbuffered output.
  • Updates curl documentation and the previously stale stream-test rationale.

Confidence Score: 5/5

The 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 streams.rs comments.

Important Files Changed

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
Loading

Reviews (3): Last reviewed commit: "docs(test): curl stream fixtures no long..." | Re-trigger Greptile

Comment thread tests/codegen/curl/easy_http.rs
@Guikingone
Guikingone force-pushed the fix/875-curl-exec-output-buffer branch from 7f99247 to 31e864d Compare September 13, 2026 19:39
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
@Guikingone Guikingone self-assigned this Sep 13, 2026
@Guikingone
Guikingone requested a review from nahime0 September 13, 2026 20:29

@nahime0 nahime0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine

@nahime0
nahime0 merged commit ae62925 into main Sep 14, 2026
148 checks passed
@nahime0
nahime0 deleted the fix/875-curl-exec-output-buffer branch September 14, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

curl_exec default stdout bypasses ob_start()

2 participants