Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/elephc-curl/src/php_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 12 additions & 4 deletions docs/php/curl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/codegen/lower_inst/builtins/curl/easy_perform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions src/codegen/lower_inst/builtins/curl/multi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/codegen_support/curl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
99 changes: 99 additions & 0 deletions tests/codegen/curl/easy_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Guikingone marked this conversation as resolved.
/// 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#"<?php
$ch = curl_init("{url}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
ob_start();
$ok = curl_exec($ch);
$captured = ob_get_clean();
echo $ok === true ? "T" : "F";
echo ":", strlen($captured), ":", $captured;
"#
));
assert_eq!(out, "T:10:hello-curl");
}

/// The captured body reaches the surrounding buffer through `ob_get_length()` too, and
/// `ob_end_flush()` then releases it to the terminal in the right ORDER relative to ordinary
/// `echo` output — which is what proves the chunks really travel the shared funnel rather
/// than being spliced in afterwards.
#[test]
fn curl_exec_default_output_interleaves_with_echo_in_a_buffer() {
if skip_without_curl_native("curl_exec_default_output_interleaves_with_echo_in_a_buffer") {
return;
}
let server = LocalHttpServer::spawn_hello();
let url = server.url("/hello");
let out = compile_and_run(&format!(
r#"<?php
$ch = curl_init("{url}");
ob_start();
echo "[";
curl_exec($ch);
echo "]";
$len = ob_get_length();
ob_end_flush();
echo "|", $len;
"#
));
assert_eq!(out, "[hello-curl]|12");
}

/// Nested buffers behave like any other output: the inner buffer takes the body, and the
/// outer one never sees it once the inner is discarded.
#[test]
fn curl_exec_default_output_respects_nested_buffers() {
if skip_without_curl_native("curl_exec_default_output_respects_nested_buffers") {
return;
}
let server = LocalHttpServer::spawn_hello();
let url = server.url("/hello");
let out = compile_and_run(&format!(
r#"<?php
$ch = curl_init("{url}");
ob_start();
echo "outer-";
ob_start();
curl_exec($ch);
ob_end_clean();
echo "done";
echo "|", ob_get_length();
ob_end_flush();
"#
));
assert_eq!(out, "outer-done|11");
}

/// With NO buffer active the body still reaches the terminal, in order with `echo` — the
/// funnel's plain-syscall path. Guards against the sink swallowing output when nothing is
/// capturing.
#[test]
fn curl_exec_default_output_still_reaches_stdout_without_a_buffer() {
if skip_without_curl_native("curl_exec_default_output_still_reaches_stdout_without_a_buffer") {
return;
}
let server = LocalHttpServer::spawn_hello();
let url = server.url("/hello");
let out = compile_and_run(&format!(
r#"<?php
$ch = curl_init("{url}");
echo "<";
curl_exec($ch);
echo ">";
"#
));
assert_eq!(out, "<hello-curl>");
}

/// 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`.
Expand Down
25 changes: 13 additions & 12 deletions tests/codegen/curl/streams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"
);
}
Loading