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);
+}