You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2023/03/31 07:49:52 UTC
[skywalking-php] branch master updated: Support tracing `curl_multi_*` api. (#62)
This is an automated email from the ASF dual-hosted git repository.
wusheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking-php.git
The following commit(s) were added to refs/heads/master by this push:
new 1997570 Support tracing `curl_multi_*` api. (#62)
1997570 is described below
commit 19975706472c3581132cfafa2f94f1e84c7f02fb
Author: jmjoy <jm...@apache.org>
AuthorDate: Fri Mar 31 15:49:45 2023 +0800
Support tracing `curl_multi_*` api. (#62)
---
src/plugin/plugin_curl.rs | 387 +++++++++++++++++++++++++++++--------
tests/common/mod.rs | 14 +-
tests/data/expected_context.yaml | 145 +++++++++++++-
tests/e2e.rs | 14 +-
tests/php/fpm/curl-multi.enter.php | 114 +++++++++++
5 files changed, 586 insertions(+), 88 deletions(-)
diff --git a/src/plugin/plugin_curl.rs b/src/plugin/plugin_curl.rs
index 2766b99..75577cd 100644
--- a/src/plugin/plugin_curl.rs
+++ b/src/plugin/plugin_curl.rs
@@ -27,17 +27,44 @@ use phper::{
};
use skywalking::trace::{propagation::encoder::encode_propagation, span::Span};
use std::{cell::RefCell, collections::HashMap, os::raw::c_long};
-use tracing::debug;
+use tracing::{debug, warn};
use url::Url;
-static CURLOPT_HTTPHEADER: c_long = 10023;
+const CURLM_OK: i64 = 0;
+
+const CURLOPT_HTTPHEADER: c_long = 10023;
/// Prevent calling `curl_setopt` inside this plugin sets headers, the hook of
/// `curl_setopt` is repeatedly called.
-static SKY_CURLOPT_HTTPHEADER: c_long = 9923;
+const SKY_CURLOPT_HTTPHEADER: c_long = 9923;
thread_local! {
static CURL_HEADERS: RefCell<HashMap<i64, ZVal>> = Default::default();
+ static CURL_MULTI_INFO_MAP: RefCell<HashMap<i64, CurlMultiInfo>> = Default::default();
+}
+
+struct CurlInfo {
+ cid: i64,
+ raw_url: String,
+ url: Url,
+ peer: String,
+ is_http: bool,
+}
+
+#[derive(Default)]
+struct CurlMultiInfo {
+ exec_spans: Option<Vec<(i64, Span)>>,
+ curl_handles: HashMap<i64, ZVal>,
+}
+
+impl CurlMultiInfo {
+ fn insert_curl_handle(&mut self, id: i64, handle: ZVal) {
+ self.curl_handles.insert(id, handle);
+ }
+
+ fn remove_curl_handle(&mut self, id: i64) {
+ self.curl_handles.remove(&id);
+ }
}
#[derive(Default, Clone)]
@@ -62,6 +89,12 @@ impl Plugin for CurlPlugin {
"curl_setopt_array" => Some(self.hook_curl_setopt_array()),
"curl_exec" => Some(self.hook_curl_exec()),
"curl_close" => Some(self.hook_curl_close()),
+
+ "curl_multi_add_handle" => Some(self.hook_curl_multi_add_handle()),
+ "curl_multi_remove_handle" => Some(self.hook_curl_multi_remove_handle()),
+ "curl_multi_exec" => Some(self.hook_curl_multi_exec()),
+ "curl_multi_close" => Some(self.hook_curl_multi_close()),
+
_ => None,
}
}
@@ -118,67 +151,14 @@ impl CurlPlugin {
validate_num_args(execute_data, 1)?;
let cid = Self::get_resource_id(execute_data)?;
-
let ch = execute_data.get_parameter(0);
- let result = call("curl_getinfo", &mut [ch.clone()])?;
- let result = result.as_z_arr().context("result isn't array")?;
-
- let url = result
- .get("url")
- .context("Get url from curl_get_info result failed")?;
- let raw_url = url.as_z_str().context("url isn't string")?.to_str()?;
- let mut url = raw_url.to_string();
- if !url.contains("://") {
- url.insert_str(0, "http://");
- }
-
- let url: Url = url.parse().context("parse url")?;
- if url.scheme() != "http" && url.scheme() != "https" {
- return Ok(Box::new(()));
- }
+ let info = Self::get_curl_info(cid, ch.clone())?;
- debug!("curl_getinfo get url: {}", &url);
-
- let host = match url.host_str() {
- Some(host) => host,
- None => return Ok(Box::new(())),
- };
- let port = match url.port() {
- Some(port) => port,
- None => match url.scheme() {
- "http" => 80,
- "https" => 443,
- _ => 0,
- },
- };
- let peer = &format!("{host}:{port}");
-
- let mut span = RequestContext::try_with_global_ctx(request_id, |ctx| {
- Ok(ctx.create_exit_span(url.path(), peer))
- })?;
+ let span = Self::create_exit_span(request_id, &info)?;
- let mut span_object = span.span_object_mut();
- span_object.component_id = COMPONENT_PHP_CURL_ID;
- span_object.add_tag("url", raw_url);
- drop(span_object);
-
- let sw_header = RequestContext::try_with_global_ctx(request_id, |ctx| {
- Ok(encode_propagation(ctx, url.path(), peer))
- })?;
- let mut val = CURL_HEADERS
- .with(|headers| headers.borrow_mut().remove(&cid))
- .unwrap_or_else(|| ZVal::from(ZArray::new()));
- if let Some(arr) = val.as_mut_z_arr() {
- arr.insert(
- InsertKey::NextIndex,
- ZVal::from(format!("sw8: {}", sw_header)),
- );
- let ch = execute_data.get_parameter(0);
- call(
- "curl_setopt",
- &mut [ch.clone(), ZVal::from(SKY_CURLOPT_HTTPHEADER), val],
- )?;
+ if info.is_http {
+ Self::inject_sw_header(request_id, ch.clone(), &info)?;
}
Ok(Box::new(span))
@@ -187,27 +167,7 @@ impl CurlPlugin {
let mut span = span.downcast::<Span>().unwrap();
let ch = execute_data.get_parameter(0);
- let result = call("curl_getinfo", &mut [ch.clone()])?;
- let response = result.as_z_arr().context("response in not arr")?;
- let http_code = response
- .get("http_code")
- .and_then(|code| code.as_long())
- .context("Call curl_getinfo, http_code is null")?;
- span.add_tag("status_code", &*http_code.to_string());
- if http_code == 0 {
- let result = call("curl_error", &mut [ch.clone()])?;
- let curl_error = result
- .as_z_str()
- .context("curl_error is not string")?
- .to_str()?;
- let mut span_object = span.span_object_mut();
- span_object.is_error = true;
- span_object.add_log(vec![("CURL_ERROR", curl_error)]);
- } else if http_code >= 400 {
- span.span_object_mut().is_error = true;
- } else {
- span.span_object_mut().is_error = false;
- }
+ Self::finish_exit_span(&mut span, ch)?;
Ok(())
}),
@@ -229,12 +189,275 @@ impl CurlPlugin {
)
}
+ fn hook_curl_multi_add_handle(&self) -> (Box<BeforeExecuteHook>, Box<AfterExecuteHook>) {
+ (
+ Box::new(|_, execute_data| {
+ validate_num_args(execute_data, 2)?;
+
+ let multi_id = Self::get_resource_id(execute_data)?;
+ let ch = execute_data.get_parameter(1);
+ let cid = Self::get_handle_id(ch)?;
+
+ CURL_MULTI_INFO_MAP.with(|map| {
+ map.borrow_mut()
+ .entry(multi_id)
+ .or_default()
+ .insert_curl_handle(cid, ch.clone());
+ });
+
+ Ok(Box::new(()))
+ }),
+ Noop::noop(),
+ )
+ }
+
+ fn hook_curl_multi_remove_handle(&self) -> (Box<BeforeExecuteHook>, Box<AfterExecuteHook>) {
+ (
+ Box::new(|_, execute_data| {
+ validate_num_args(execute_data, 2)?;
+
+ let multi_id = Self::get_resource_id(execute_data)?;
+ let ch = execute_data.get_parameter(1);
+ let cid = Self::get_handle_id(ch)?;
+
+ CURL_MULTI_INFO_MAP.with(|map| {
+ map.borrow_mut()
+ .entry(multi_id)
+ .or_default()
+ .remove_curl_handle(cid);
+ });
+
+ Ok(Box::new(()))
+ }),
+ Noop::noop(),
+ )
+ }
+
+ fn hook_curl_multi_exec(&self) -> (Box<BeforeExecuteHook>, Box<AfterExecuteHook>) {
+ (
+ Box::new(|request_id, execute_data| {
+ validate_num_args(execute_data, 1)?;
+
+ let multi_id = Self::get_resource_id(execute_data)?;
+
+ let is_exec = CURL_MULTI_INFO_MAP.with(|map| {
+ let mut map = map.borrow_mut();
+ let Some(multi_info) = map.get_mut(&multi_id) else {
+ debug!(multi_id, "curl multi info is missing, maybe hasn't handles");
+ return Ok(false);
+ };
+
+ debug!(multi_id, "curl multi handles count: {}", multi_info.curl_handles.len());
+ if multi_info.curl_handles.is_empty() {
+ return Ok(false);
+ }
+ if multi_info.exec_spans.is_some() {
+ return Ok(true);
+ }
+
+ let mut curl_infos = Vec::with_capacity(multi_info.curl_handles.len());
+ for (cid, ch) in &multi_info.curl_handles {
+ curl_infos.push( (*cid, ch.clone(), Self::get_curl_info(*cid, ch.clone())?));
+ }
+ curl_infos.sort_by(|(_, _, i1), (_, _, i2)| i1.raw_url.cmp(&i2.raw_url));
+
+ let mut exec_spans = Vec::with_capacity(curl_infos.len());
+ for (cid, ch, info) in curl_infos {
+ let span = Self::create_exit_span(request_id, &info)?;
+
+ if info.is_http {
+ Self::inject_sw_header(request_id, ch, &info)?;
+ }
+
+ debug!(multi_id, operation_name = ?&span.span_object().operation_name, "create exit span");
+ exec_spans.push((cid, span));
+ }
+
+ // skywalking-rust can't create same level span at one time, so modify parent
+ // span id by hand.
+ if let [(_, head_span), tail_span @ ..] = exec_spans.as_mut_slice() {
+ let parent_span_id = head_span.span_object().parent_span_id;
+ for (_, span) in tail_span {
+ span.span_object_mut().parent_span_id = parent_span_id;
+ }
+ }
+
+ multi_info.exec_spans = Some(exec_spans);
+
+ Ok::<_, crate::Error>(true)
+ })?;
+
+ Ok(Box::new(is_exec))
+ }),
+ Box::new(move |_, is_exec, execute_data, return_value| {
+ let is_exec = is_exec.downcast::<bool>().unwrap();
+ if !*is_exec {
+ return Ok(());
+ }
+
+ if return_value.as_long() != Some(CURLM_OK) {
+ return Ok(());
+ }
+
+ let still_running = execute_data.get_parameter(1);
+ if still_running
+ .as_z_ref()
+ .map(|r| r.val())
+ .and_then(|val| val.as_long())
+ != Some(0)
+ {
+ return Ok(());
+ }
+
+ let multi_id = Self::get_resource_id(execute_data)?;
+ debug!(multi_id, "curl multi exec has finished");
+
+ CURL_MULTI_INFO_MAP.with(|map| {
+ let Some(mut info) = map.borrow_mut().remove(&multi_id) else {
+ warn!(multi_id, "curl multi info is missing after finished");
+ return Ok(());
+ };
+ let Some(mut spans) = info.exec_spans else {
+ warn!(multi_id, "curl multi spans is missing after finished");
+ return Ok(());
+ };
+
+ debug!(multi_id, "curl multi spans count: {}", spans.len());
+ loop {
+ let Some((cid, mut span)) = spans.pop() else { break };
+ let Some(ch) = info.curl_handles.remove(&cid) else { continue };
+ Self::finish_exit_span(&mut span, &ch)?;
+ }
+ Ok::<_, crate::Error>(())
+ })?;
+
+ Ok(())
+ }),
+ )
+ }
+
+ fn hook_curl_multi_close(&self) -> (Box<BeforeExecuteHook>, Box<AfterExecuteHook>) {
+ (
+ Box::new(|_, execute_data| {
+ validate_num_args(execute_data, 1)?;
+
+ let multi_id = Self::get_resource_id(execute_data)?;
+
+ CURL_MULTI_INFO_MAP.with(|map| map.borrow_mut().remove(&multi_id));
+
+ Ok(Box::new(()))
+ }),
+ Noop::noop(),
+ )
+ }
+
fn get_resource_id(execute_data: &mut ExecuteData) -> anyhow::Result<i64> {
- // The `curl_init` return object since PHP8.
let ch = execute_data.get_parameter(0);
+ Self::get_handle_id(ch)
+ }
+
+ fn get_handle_id(ch: &ZVal) -> anyhow::Result<i64> {
+ // The `curl_init` return object since PHP8.
ch.as_z_res()
.map(|res| res.handle())
.or_else(|| ch.as_z_obj().map(|obj| obj.handle().into()))
.context("Get resource id failed")
}
+
+ fn get_curl_info(cid: i64, ch: ZVal) -> crate::Result<CurlInfo> {
+ let result = call("curl_getinfo", &mut [ch])?;
+ let result = result.as_z_arr().context("result isn't array")?;
+
+ let url = result
+ .get("url")
+ .context("Get url from curl_get_info result failed")?;
+ let raw_url = url.as_z_str().context("url isn't string")?.to_str()?;
+ let mut url = raw_url.to_string();
+
+ if !url.contains("://") {
+ url.insert_str(0, "http://");
+ }
+
+ let url: Url = url.parse().context("parse url")?;
+ let is_http = ["http", "https"].contains(&url.scheme());
+
+ debug!("curl_getinfo get url: {}", &url);
+
+ let host = url.host_str().unwrap_or_default();
+ let port = match url.port() {
+ Some(port) => port,
+ None => match url.scheme() {
+ "http" => 80,
+ "https" => 443,
+ _ => 0,
+ },
+ };
+ let peer = format!("{host}:{port}");
+
+ Ok(CurlInfo {
+ cid,
+ raw_url: raw_url.to_string(),
+ url,
+ peer,
+ is_http,
+ })
+ }
+
+ fn inject_sw_header(request_id: Option<i64>, ch: ZVal, info: &CurlInfo) -> crate::Result<()> {
+ let sw_header = RequestContext::try_with_global_ctx(request_id, |ctx| {
+ Ok(encode_propagation(ctx, info.url.path(), &info.peer))
+ })?;
+ let mut val = CURL_HEADERS
+ .with(|headers| headers.borrow_mut().remove(&info.cid))
+ .unwrap_or_else(|| ZVal::from(ZArray::new()));
+ if let Some(arr) = val.as_mut_z_arr() {
+ arr.insert(
+ InsertKey::NextIndex,
+ ZVal::from(format!("sw8: {}", sw_header)),
+ );
+ call(
+ "curl_setopt",
+ &mut [ch, ZVal::from(SKY_CURLOPT_HTTPHEADER), val],
+ )?;
+ }
+ Ok(())
+ }
+
+ fn create_exit_span(request_id: Option<i64>, info: &CurlInfo) -> crate::Result<Span> {
+ let mut span = RequestContext::try_with_global_ctx(request_id, |ctx| {
+ Ok(ctx.create_exit_span(info.url.path(), &info.peer))
+ })?;
+
+ let mut span_object = span.span_object_mut();
+ span_object.component_id = COMPONENT_PHP_CURL_ID;
+ span_object.add_tag("url", &info.raw_url);
+ drop(span_object);
+
+ Ok(span)
+ }
+
+ fn finish_exit_span(span: &mut Span, ch: &ZVal) -> crate::Result<()> {
+ let result = call("curl_getinfo", &mut [ch.clone()])?;
+ let response = result.as_z_arr().context("response in not arr")?;
+ let http_code = response
+ .get("http_code")
+ .and_then(|code| code.as_long())
+ .context("Call curl_getinfo, http_code is null")?;
+ span.add_tag("status_code", &*http_code.to_string());
+ if http_code == 0 {
+ let result = call("curl_error", &mut [ch.clone()])?;
+ let curl_error = result
+ .as_z_str()
+ .context("curl_error is not string")?
+ .to_str()?;
+ let mut span_object = span.span_object_mut();
+ span_object.is_error = true;
+ span_object.add_log(vec![("CURL_ERROR", curl_error)]);
+ } else if http_code >= 400 {
+ span.span_object_mut().is_error = true;
+ } else {
+ span.span_object_mut().is_error = false;
+ }
+ Ok(())
+ }
}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 4c40fd7..1cb503b 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -32,6 +32,8 @@ use std::{
net::SocketAddr,
process::{ExitStatus, Stdio},
sync::Arc,
+ thread,
+ time::Duration,
};
use tokio::{
net::TcpStream,
@@ -283,13 +285,15 @@ fn setup_php_fpm(index: usize, fpm_addr: &str) -> Child {
"skywalking_agent.worker_threads=3",
];
info!(cmd = args.join(" "), "start command");
- Command::new(&args[0])
+ let child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::null())
.stdout(File::create("/tmp/fpm-skywalking-stdout.log").unwrap())
.stderr(File::create("/tmp/fpm-skywalking-stderr.log").unwrap())
.spawn()
- .unwrap()
+ .unwrap();
+ thread::sleep(Duration::from_secs(3));
+ child
}
#[instrument]
@@ -323,13 +327,15 @@ fn setup_php_swoole(index: usize) -> Child {
&format!("tests/php/swoole/main.{}.php", index),
];
info!(cmd = args.join(" "), "start command");
- Command::new(&args[0])
+ let child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::null())
.stdout(File::create("/tmp/swoole-skywalking-stdout.log").unwrap())
.stderr(File::create("/tmp/swoole-skywalking-stderr.log").unwrap())
.spawn()
- .unwrap()
+ .unwrap();
+ thread::sleep(Duration::from_secs(3));
+ child
}
async fn kill_command(mut child: Child) -> io::Result<ExitStatus> {
diff --git a/tests/data/expected_context.yaml b/tests/data/expected_context.yaml
index e4db16c..4c4d66b 100644
--- a/tests/data/expected_context.yaml
+++ b/tests/data/expected_context.yaml
@@ -15,7 +15,7 @@
segmentItems:
- serviceName: skywalking-agent-test-1
- segmentSize: 12
+ segmentSize: 16
segments:
- segmentId: "not null"
spans:
@@ -244,6 +244,149 @@ segmentItems:
- { key: url, value: /curl.enter.php }
- { key: http.method, value: GET }
- { key: http.status_code, value: "200" }
+ - segmentId: "not null"
+ spans:
+ - operationName: GET:/not-exists.php
+ parentSpanId: -1
+ spanId: 0
+ spanLayer: Http
+ startTime: gt 0
+ endTime: gt 0
+ componentId: 8001
+ isError: true
+ spanType: Entry
+ peer: ""
+ skipAnalysis: false
+ tags:
+ - { key: url, value: /not-exists.php }
+ - { key: http.method, value: GET }
+ - { key: http.status_code, value: "404" }
+ refs:
+ - {
+ parentEndpoint: /not-exists.php,
+ networkAddress: "127.0.0.1:9011",
+ refType: CrossProcess,
+ parentSpanId: 3,
+ parentTraceSegmentId: "not null",
+ parentServiceInstance: "not null",
+ parentService: skywalking-agent-test-1,
+ traceId: "not null",
+ }
+ - segmentId: "not null"
+ spans:
+ - operationName: POST:/curl.test.php
+ parentSpanId: -1
+ spanId: 0
+ spanLayer: Http
+ startTime: gt 0
+ endTime: gt 0
+ componentId: 8001
+ isError: false
+ spanType: Entry
+ peer: ""
+ skipAnalysis: false
+ tags:
+ - { key: url, value: /curl.test.php }
+ - { key: http.method, value: POST }
+ - { key: http.status_code, value: "200" }
+ refs:
+ - {
+ parentEndpoint: /curl.test.php,
+ networkAddress: "127.0.0.1:9011",
+ refType: CrossProcess,
+ parentSpanId: 1,
+ parentTraceSegmentId: "not null",
+ parentServiceInstance: "not null",
+ parentService: skywalking-agent-test-1,
+ traceId: "not null",
+ }
+ - segmentId: "not null"
+ spans:
+ - operationName: POST:/curl.test.php
+ parentSpanId: -1
+ spanId: 0
+ spanLayer: Http
+ startTime: gt 0
+ endTime: gt 0
+ componentId: 8001
+ isError: false
+ spanType: Entry
+ peer: ""
+ skipAnalysis: false
+ tags:
+ - { key: url, value: /curl.test.php }
+ - { key: http.method, value: POST }
+ - { key: http.status_code, value: "200" }
+ refs:
+ - {
+ parentEndpoint: /curl.test.php,
+ networkAddress: "127.0.0.1:9011",
+ refType: CrossProcess,
+ parentSpanId: 2,
+ parentTraceSegmentId: "not null",
+ parentServiceInstance: "not null",
+ parentService: skywalking-agent-test-1,
+ traceId: "not null",
+ }
+ - segmentId: "not null"
+ spans:
+ - operationName: /not-exists.php
+ parentSpanId: 0
+ spanId: 3
+ spanLayer: Http
+ startTime: gt 0
+ endTime: gt 0
+ componentId: 8002
+ isError: true
+ spanType: Exit
+ peer: 127.0.0.1:9011
+ skipAnalysis: false
+ tags:
+ - { key: url, value: "http://127.0.0.1:9011/not-exists.php" }
+ - { key: status_code, value: "500" }
+ - operationName: /curl.test.php
+ parentSpanId: 0
+ spanId: 2
+ spanLayer: Http
+ startTime: gt 0
+ endTime: gt 0
+ componentId: 8002
+ isError: false
+ spanType: Exit
+ peer: 127.0.0.1:9011
+ skipAnalysis: false
+ tags:
+ - { key: url, value: "http://127.0.0.1:9011/curl.test.php" }
+ - { key: status_code, value: "200" }
+ - operationName: /curl.test.php
+ parentSpanId: 0
+ spanId: 1
+ spanLayer: Http
+ startTime: gt 0
+ endTime: gt 0
+ componentId: 8002
+ isError: false
+ spanType: Exit
+ peer: 127.0.0.1:9011
+ skipAnalysis: false
+ tags:
+ - { key: url, value: "http://127.0.0.1:9011/curl.test.php" }
+ - { key: status_code, value: "200" }
+ - operationName: GET:/curl-multi.enter.php
+ parentSpanId: -1
+ spanId: 0
+ spanLayer: Http
+ startTime: gt 0
+ endTime: gt 0
+ componentId: 8001
+ isError: false
+ spanType: Entry
+ peer: ""
+ skipAnalysis: false
+ tags:
+ - { key: url, value: /curl-multi.enter.php }
+ - { key: http.method, value: GET }
+ - { key: http.status_code, value: "200" }
- segmentId: "not null"
spans:
- operationName: PDO->exec
diff --git a/tests/e2e.rs b/tests/e2e.rs
index bc3f876..d16ae5b 100644
--- a/tests/e2e.rs
+++ b/tests/e2e.rs
@@ -31,7 +31,7 @@ async fn e2e() {
let fixture = common::setup().await;
// TODO Prefer to listen the server ready signal.
- sleep(Duration::from_secs(3)).await;
+ sleep(Duration::from_secs(5)).await;
let result = catch_unwind(|| {
task::block_in_place(|| {
@@ -48,6 +48,7 @@ async fn e2e() {
async fn run_e2e() {
request_fpm_curl().await;
+ request_fpm_curl_multi().await;
request_fpm_pdo().await;
request_fpm_predis().await;
request_fpm_mysqli().await;
@@ -66,6 +67,17 @@ async fn request_fpm_curl() {
.await;
}
+async fn request_fpm_curl_multi() {
+ request_common(
+ HTTP_CLIENT.get(format!(
+ "http://{}/curl-multi.enter.php",
+ PROXY_SERVER_1_ADDRESS
+ )),
+ "ok",
+ )
+ .await;
+}
+
async fn request_fpm_pdo() {
request_common(
HTTP_CLIENT.get(format!("http://{}/pdo.php", PROXY_SERVER_1_ADDRESS)),
diff --git a/tests/php/fpm/curl-multi.enter.php b/tests/php/fpm/curl-multi.enter.php
new file mode 100644
index 0000000..75f4fbc
--- /dev/null
+++ b/tests/php/fpm/curl-multi.enter.php
@@ -0,0 +1,114 @@
+<?php
+
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use Webmozart\Assert\Assert;
+
+require_once dirname(__DIR__) . "/vendor/autoload.php";
+
+{
+ curl_multi_request([]);
+}
+
+{
+ $curl_callbacks = [
+ [
+ 'curl' => (function () {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, "http://127.0.0.1:9011/curl.test.php");
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ curl_setopt($ch, CURLOPT_POST, 1);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, ["foo" => "bar"]);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-FOO:BAR"]);
+ return $ch;
+ })(),
+ 'callback' => function ($output) {
+ Assert::same($output, "ok");
+ },
+ ],
+ [
+ 'curl' => (function () {
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => "http://127.0.0.1:9011/curl.test.php",
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_RETURNTRANSFER => 1,
+ CURLOPT_POST => 1,
+ CURLOPT_POSTFIELDS => ["foo" => "bar"],
+ CURLOPT_HTTPHEADER => ["X-FOO: BAR"],
+ ]);
+ return $ch;
+ })(),
+ 'callback' => function ($output) {
+ Assert::same($output, "ok");
+ },
+ ],
+ [
+ 'curl' => (function () {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, "http://127.0.0.1:9011/not-exists.php");
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ curl_setopt($ch, CURLOPT_HEADER, 0);
+ return $ch;
+ })(),
+ 'callback' => function ($output) {},
+ ],
+ ];
+ curl_multi_request($curl_callbacks);
+}
+
+sleep(5);
+
+echo "ok";
+
+function curl_multi_request($curl_callbacks) {
+ $mh = curl_multi_init();
+
+ foreach ($curl_callbacks as $curl_callback) {
+ curl_multi_add_handle($mh, $curl_callback['curl']);
+ }
+
+ do {
+ $mrc = curl_multi_exec($mh, $active);
+ } while ($mrc == CURLM_CALL_MULTI_PERFORM);
+
+ while ($active && $mrc == CURLM_OK) {
+ if (curl_multi_select($mh) == -1) {
+ return;
+ }
+ do {
+ $mrc = curl_multi_exec($mh, $active);
+ } while ($mrc == CURLM_CALL_MULTI_PERFORM);
+ }
+
+ while ($info = curl_multi_info_read($mh)) {
+ $content = curl_multi_getcontent($info['handle']);
+ foreach ($curl_callbacks as $curl_callback) {
+ if ($curl_callback['curl'] == $info['handle']) {
+ call_user_func($curl_callback['callback'], $content);
+ break;
+ }
+ }
+ }
+
+ foreach ($curl_callbacks as $curl_callback) {
+ curl_multi_remove_handle($mh, $curl_callback['curl']);
+ }
+
+ curl_multi_close($mh);
+}