diff --git a/crates/elephc-curl/src/php_layer.rs b/crates/elephc-curl/src/php_layer.rs index 13faaa03f7..b08626b848 100644 --- a/crates/elephc-curl/src/php_layer.rs +++ b/crates/elephc-curl/src/php_layer.rs @@ -31,11 +31,39 @@ //! function-pointer callback). use std::ffi::{c_char, c_int, c_long, c_void}; +use std::sync::atomic::{AtomicUsize, Ordering}; use crate::callbacks; use crate::easy::{self, CURL}; use crate::handles::{self, EasyEntry}; +/// The compiled program's own terminal-output funnel, or `0` when none was published. +/// +/// Set by generated code through [`elephc_curl_set_output_sink`] before every transfer. +/// The crate NEVER names the runtime symbol itself: it only stores and calls back through +/// an opaque address, exactly as `crate::callbacks` does for the PHP-callable adapter, so +/// linking this crate still requires no runtime object (its own unit tests and any +/// non-compiled consumer simply leave the sink unset and take the direct-write fallback). +static OUTPUT_SINK: AtomicUsize = AtomicUsize::new(0); + +/// The ABI of the published sink: `(ptr, len)`, no result — `__rt_stdout_write`'s own. +type OutputSink = unsafe extern "C" fn(*const u8, usize); + +/// Publishes the compiled program's terminal-output funnel for the default write path. +/// +/// PHP's default `curl_exec()` write handler goes through the ENGINE'S OUTPUT LAYER, so +/// `ob_start(); curl_exec($ch); $html = ob_get_clean();` captures the body. Writing to fd 1 +/// directly bypassed every one of those layers — output buffering, the `print_r` capture +/// buffer, the `--web` response capture — so the idiom above returned an empty string and +/// the body appeared on stdout instead (issue #875). +/// +/// Publishing an ADDRESS rather than reading a runtime symbol is what keeps the bridge +/// linkable on its own; see [`OUTPUT_SINK`]. +#[no_mangle] +pub extern "C" fn elephc_curl_set_output_sink(sink: usize) { + OUTPUT_SINK.store(sink, Ordering::Release); +} + /// `CURLOPT_RETURNTRANSFER` (19913): a PHP-only pseudo-option, never a real /// libcurl `CURLOPT_*` value. Frozen from `scripts/docs/curl_surface.json`'s /// PHP surface extraction, not recomputed by hand. PHP's own @@ -164,6 +192,23 @@ unsafe extern "C" fn write_callback( /// POSIX that is not an interruption but a write that made no progress, and /// retrying it would spin forever. fn write_all_stdout(bytes: &[u8]) -> usize { + // THE PUBLISHED SINK WINS when the compiled program registered one. It is + // `__rt_stdout_write`, the single indirection every `echo` travels through, so the body + // meets output buffering, the `print_r` capture buffer and the `--web` response capture + // exactly as PHP's own default write handler does (issue #875). It reports no failure, so + // the whole chunk counts as written — the same contract `echo` has, where a failing + // terminal write is not an error the program can observe either. + let sink = OUTPUT_SINK.load(Ordering::Acquire); + if sink != 0 { + // SAFETY: the address was published by generated code as `__rt_stdout_write`, whose + // signature is `(ptr, len)` with no result; `bytes` is valid for its own length and + // the callee only reads from it. + unsafe { + let sink: OutputSink = std::mem::transmute(sink); + sink(bytes.as_ptr(), bytes.len()); + } + return bytes.len(); + } let mut written = 0usize; while written < bytes.len() { // SAFETY: `bytes[written..]` is a valid slice for its own length; diff --git a/docs/php/curl.md b/docs/php/curl.md index 30f59cc16a..8d1cb71711 100644 --- a/docs/php/curl.md +++ b/docs/php/curl.md @@ -507,10 +507,18 @@ despite the name, php-src forwards it straight to libcurl unchanged, and elephc the same: it is an ordinary `long` option, and real libcurl implements the header-in-body behavior on its own. -> **Divergence:** that stdout write goes straight to file descriptor 1, so -> `ob_start()` does **not** capture it the way php's does. Wrap the transfer in -> `CURLOPT_RETURNTRANSFER` (or a `CURLOPT_WRITEFUNCTION`) if you need the body as a -> string. +That write travels the same output funnel as `echo`, so `ob_start()` captures it +exactly as php's does: + +```php +$ch = curl_init('https://example.com'); +ob_start(); +curl_exec($ch); +$html = ob_get_clean(); // the body, as in php +``` + +Nested buffers, `ob_get_length()`, `ob_end_clean()` and the `--web` response capture +all see the body the same way. ### Stream options diff --git a/src/codegen/lower_inst/builtins/curl/easy_perform.rs b/src/codegen/lower_inst/builtins/curl/easy_perform.rs index 4c956aeef9..d8b4d0cc9b 100644 --- a/src/codegen/lower_inst/builtins/curl/easy_perform.rs +++ b/src/codegen/lower_inst/builtins/curl/easy_perform.rs @@ -24,6 +24,9 @@ pub(crate) fn lower_curl_easy_perform( inst: &Instruction, ) -> Result<()> { ensure_curl_arg_count(inst, "__elephc_curl_easy_perform", 1)?; + // BEFORE the handle is loaded: publishing clobbers the argument registers, and the + // default write path needs the sink in place for the duration of the transfer. + crate::codegen::curl::publish_elephc_curl_output_sink(ctx.emitter); load_handle_to_first_arg(ctx, inst, 0, "curl_exec")?; crate::codegen::curl::publish_elephc_curl_function_pointers(ctx.emitter); abi::emit_call_label(ctx.emitter, "__rt_curl_easy_perform"); diff --git a/src/codegen/lower_inst/builtins/curl/multi.rs b/src/codegen/lower_inst/builtins/curl/multi.rs index 79f207624c..fac256ed5d 100644 --- a/src/codegen/lower_inst/builtins/curl/multi.rs +++ b/src/codegen/lower_inst/builtins/curl/multi.rs @@ -85,6 +85,10 @@ pub(crate) fn lower_curl_multi_exec( ctx: &mut FunctionContext<'_>, inst: &Instruction, ) -> Result<()> { + // The multi path streams bodies through the SAME default write callback as + // `curl_exec()`, so it needs the output sink published too (issue #875). Before the + // handle is loaded: publishing clobbers the argument registers. + crate::codegen::curl::publish_elephc_curl_output_sink(ctx.emitter); lower_multi_only( ctx, inst, diff --git a/src/codegen_support/curl.rs b/src/codegen_support/curl.rs index 5a9ba0522d..fa23b22035 100644 --- a/src/codegen_support/curl.rs +++ b/src/codegen_support/curl.rs @@ -26,6 +26,31 @@ use crate::codegen_support::emit::Emitter; use crate::codegen_support::platform::Arch; use crate::codegen_support::runtime::curl_abi_slots; +/// Publishes the compiled program's terminal-output funnel to the curl bridge. +/// +/// `__rt_stdout_write` is the single indirection every `echo` travels through: the +/// `print_r` capture buffer, the output-handler discard, the `ob_*` stack, the `--web` +/// response capture, and only then the `write(1, …)` syscall. PHP's default `curl_exec()` +/// write handler goes through the engine's output layer for the same reason, so +/// `ob_start(); curl_exec($ch); $html = ob_get_clean();` captures the body — where the +/// bridge's own `write(1, …)` bypassed all of it and left the buffer empty (issue #875). +/// +/// EMITTED AS A CALL, not as one of the published function-pointer SLOTS above, because the +/// direction is reversed: those let the runtime call INTO the bridge, while this hands the +/// bridge an address to call BACK with. The bridge stores it opaquely and never names a +/// `__rt_*` symbol, which is what keeps `elephc-curl` linkable on its own. +/// +/// It clobbers the argument registers, so it must be emitted BEFORE the call's own operands +/// are loaded. Publishing it at every transfer site rather than once at startup keeps the +/// pay-for-use property this module holds: a curl-free binary emits none of this. +pub(crate) fn publish_elephc_curl_output_sink(emitter: &mut Emitter) { + match emitter.target.arch { + Arch::AArch64 => abi::emit_symbol_address(emitter, "x0", "__rt_stdout_write"), + Arch::X86_64 => abi::emit_symbol_address(emitter, "rdi", "__rt_stdout_write"), + } + emitter.bl_c("elephc_curl_set_output_sink"); // hand the bridge the funnel every echo uses +} + /// Publishes every `elephc_curl` entry point into its runtime slot. pub(crate) fn publish_elephc_curl_function_pointers(emitter: &mut Emitter) { match emitter.target.arch { diff --git a/tests/codegen/curl/easy_http.rs b/tests/codegen/curl/easy_http.rs index 5d61550e89..34f40c8924 100644 --- a/tests/codegen/curl/easy_http.rs +++ b/tests/codegen/curl/easy_http.rs @@ -62,6 +62,105 @@ fn curl_exec_writes_stdout_without_returntransfer() { assert_eq!(out, "hello-curlT"); } +/// Issue #875: the DEFAULT `curl_exec()` write path goes through PHP's output layer, so +/// `ob_start(); curl_exec($ch); $html = ob_get_clean();` captures the body. +/// +/// The bridge used to `write(1, …)` the chunks directly, bypassing the `ob_*` stack +/// entirely: the buffer came back empty and the body appeared on stdout instead. Measured +/// against the real interpreter for the same fixture, which captures it. +#[test] +fn curl_exec_default_output_is_captured_by_ob_start() { + if skip_without_curl_native("curl_exec_default_output_is_captured_by_ob_start") { + return; + } + let server = LocalHttpServer::spawn_hello(); + let url = server.url("/hello"); + let out = compile_and_run(&format!( + r#""; + "# + )); + assert_eq!(out, ""); +} + /// A connection refused on a closed loopback port reports PHP's honest failure shape /// (`false` + non-zero `curl_errno()` + non-empty `curl_error()`) for a REAL `http://` /// transfer, complementing `easy_handle.rs::failed_transfer_reports_curl_error`. diff --git a/tests/codegen/curl/streams.rs b/tests/codegen/curl/streams.rs index 975b5d61cb..b7ec89d995 100644 --- a/tests/codegen/curl/streams.rs +++ b/tests/codegen/curl/streams.rs @@ -66,13 +66,14 @@ fn curl_file_writes_the_body_to_a_stream() { /// fixture means PHP_CURL_STDOUT, and shows up as a bare `hello-curl` in the program's /// own output just before the `-`. /// -/// STDOUT IS DETECTED BY ELIMINATION RATHER THAN BY `ob_start()`, because elephc's curl -/// bridge writes the default sink straight to fd 1 (`write_all_stdout` in -/// `crates/elephc-curl/src/php_layer.rs`) instead of through PHP's output layer, so an -/// output buffer does not capture it the way php's does. That divergence predates the -/// stream options — it is how `CURLOPT_RETURNTRANSFER => false` has always behaved — and -/// is recorded in `docs/php/curl.md`; this fixture is written not to depend on it either -/// way, and the interleaved `hello-curl` in the expectation is that raw fd-1 write. +/// STDOUT IS DETECTED BY ELIMINATION RATHER THAN BY `ob_start()`, and deliberately stays +/// that way. The default sink now travels PHP's own output funnel — `write_all_stdout` in +/// `crates/elephc-curl/src/php_layer.rs` calls the published `__rt_stdout_write`, so an +/// output buffer captures the body exactly as php's does (issue #875, covered by +/// `easy_http.rs::curl_exec_default_output_is_captured_by_ob_start`). Detecting the sink by +/// elimination keeps THIS fixture about the last-set-wins MODE rather than about where the +/// bytes land, so it cannot break when the output path changes again; the interleaved +/// `hello-curl` in the expectation is that unbuffered terminal write. #[test] fn curl_file_returntransfer_and_writefunction_share_one_last_set_wins_mode() { if skip_without_curl_native("curl_file_returntransfer_and_writefunction_share_one_last_set_wins_mode") @@ -150,7 +151,7 @@ fn curl_file_returntransfer_and_writefunction_share_one_last_set_wins_mode() { out, // FILE=f / FILE+RT=r / RT+FILE=f / FILE+CB=c / CB+FILE=f / // FILE+RT=false, FILE+CB=null and FILE+FILE=null all fall back to STDOUT, each - // leaking its body to fd 1 just before the `-` / FILE+RT+FILE=f. + // writing its body to the terminal just before the `-` / FILE+RT+FILE=f. "f\nr\nf\nc\nf\nhello-curl-\nhello-curl-\nhello-curl-\nf\n" ); } @@ -558,9 +559,9 @@ fn curl_reset_clears_stream_options_and_copy_carries_them() { curl_setopt($ch, CURLOPT_FILE, $sink); curl_reset($ch); curl_setopt($ch, CURLOPT_URL, "{url}"); - // The body now goes to the default stdout sink, which the bridge writes straight - // to fd 1 — so it appears in this program's output right here, before the two - // markers below. That raw write is why this fixture does not use ob_start(). + // The body now goes to the default stdout sink, so it appears in this program's + // output right here, before the two markers below. No buffer is active, so it + // reaches the terminal; this fixture asserts the ORDER rather than capturing it. curl_exec($ch); curl_close($ch); fclose($sink); @@ -615,7 +616,7 @@ fn curl_reset_clears_stream_options_and_copy_carries_them() { )); assert_eq!( out, - // `hello-curl` first: the reset handle's body reaching fd 1 directly. + // `hello-curl` first: the reset handle's body reaching the terminal. "hello-curl\nreset-file-empty\nbool(true)\nhello-curl\ncopy-headers\ncopy-upload\n" ); }