aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-28 18:50:24 +0900
committernsfisis <nsfisis@gmail.com>2026-06-28 18:50:24 +0900
commitda6dc375d679d302e379214564913aee7ba6f722 (patch)
treeda57c376d065c239507d0d542f1f0bb3194977af /crates/shirabe
parent1b2473fd155b88c8aa90aaa844d183af617b6e8a (diff)
downloadphp-shirabe-da6dc375d679d302e379214564913aee7ba6f722.tar.gz
php-shirabe-da6dc375d679d302e379214564913aee7ba6f722.tar.zst
php-shirabe-da6dc375d679d302e379214564913aee7ba6f722.zip
fix(http): avoid nested tokio runtime panic in download path
The CurlDownloader owned a tokio runtime and block_on'd reqwest from its sync tick(), while the repository/installer/downloader sync bridges each created another Runtime and block_on'd async fns that reach that leaf. Driving one Runtime::block_on from within another panics with "Cannot start a runtime from within a runtime", hit by `require` when fetching p2 metadata. Switch CurlDownloader to a blocking reqwest client (its own internal thread, never nested) and replace the per-call Runtime::new().block_on bridges with a no-reactor sync_executor::block_on helper. No awaited future parks on a reactor once the only async I/O is blocking, so the helper can be nested freely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe')
-rw-r--r--crates/shirabe/src/downloader/file_downloader.rs27
-rw-r--r--crates/shirabe/src/installer/installation_manager.rs29
-rw-r--r--crates/shirabe/src/package/version/version_guesser.rs3
-rw-r--r--crates/shirabe/src/repository/composer_repository.rs11
-rw-r--r--crates/shirabe/src/util.rs1
-rw-r--r--crates/shirabe/src/util/http/curl_downloader.rs149
-rw-r--r--crates/shirabe/src/util/sync_executor.rs28
-rw-r--r--crates/shirabe/src/util/sync_helper.rs4
8 files changed, 134 insertions, 118 deletions
diff --git a/crates/shirabe/src/downloader/file_downloader.rs b/crates/shirabe/src/downloader/file_downloader.rs
index d3e1f6c..a6334fb 100644
--- a/crates/shirabe/src/downloader/file_downloader.rs
+++ b/crates/shirabe/src/downloader/file_downloader.rs
@@ -23,6 +23,7 @@ use crate::util::Platform;
use crate::util::ProcessExecutor;
use crate::util::Silencer;
use crate::util::Url as UrlUtil;
+use crate::util::sync_executor;
use anyhow::Result;
use indexmap::IndexMap;
use shirabe_php_shim::{
@@ -629,21 +630,17 @@ impl ChangeReportInterface for FileDownloader {
.remove_directory(format!("{}_compare", target_dir))?;
}
- tokio::runtime::Runtime::new()
- .unwrap()
- .block_on(self.download(
- package.clone(),
- &format!("{}_compare", target_dir),
- None,
- false,
- ))?;
- tokio::runtime::Runtime::new()
- .unwrap()
- .block_on(self.install(
- package.clone(),
- &format!("{}_compare", target_dir),
- false,
- ))?;
+ sync_executor::block_on(self.download(
+ package.clone(),
+ &format!("{}_compare", target_dir),
+ None,
+ false,
+ ))?;
+ sync_executor::block_on(self.install(
+ package.clone(),
+ &format!("{}_compare", target_dir),
+ false,
+ ))?;
let mut comparer = Comparer::new();
comparer.set_source(format!("{}_compare", target_dir));
diff --git a/crates/shirabe/src/installer/installation_manager.rs b/crates/shirabe/src/installer/installation_manager.rs
index 3ce8076..a2cc228 100644
--- a/crates/shirabe/src/installer/installation_manager.rs
+++ b/crates/shirabe/src/installer/installation_manager.rs
@@ -17,6 +17,7 @@ use crate::package::PackageInterfaceHandle;
use crate::repository::InstalledRepositoryInterface;
use crate::util::Platform;
use crate::util::r#loop::Loop;
+use crate::util::sync_executor;
use anyhow::Result;
use indexmap::IndexMap;
use shirabe_external_packages::seld::signal::SignalHandler;
@@ -231,17 +232,15 @@ impl InstallationManager {
}
for batch_to_execute in batches {
- tokio::runtime::Runtime::new().unwrap().block_on(
- self.download_and_execute_batch(
- repo,
- batch_to_execute,
- &mut cleanup_promises,
- dev_mode,
- run_scripts,
- download_only,
- all_operations.clone(),
- ),
- )?;
+ sync_executor::block_on(self.download_and_execute_batch(
+ repo,
+ batch_to_execute,
+ &mut cleanup_promises,
+ dev_mode,
+ run_scripts,
+ download_only,
+ all_operations.clone(),
+ ))?;
}
Ok(())
@@ -253,9 +252,7 @@ impl InstallationManager {
match result {
Ok(()) => {}
Err(e) => {
- tokio::runtime::Runtime::new()
- .unwrap()
- .block_on(self.run_cleanup(&cleanup_promises));
+ sync_executor::block_on(self.run_cleanup(&cleanup_promises));
return Err(e);
}
}
@@ -672,7 +669,7 @@ impl InstallationManager {
PhpMixed::Array(http.into_iter().collect()),
);
- tokio::runtime::Runtime::new().unwrap().block_on(
+ sync_executor::block_on(
self.loop_
.borrow()
.get_http_downloader()
@@ -734,7 +731,7 @@ impl InstallationManager {
PhpMixed::Array(http.into_iter().collect()),
);
- tokio::runtime::Runtime::new().unwrap().block_on(
+ sync_executor::block_on(
self.loop_
.borrow()
.get_http_downloader()
diff --git a/crates/shirabe/src/package/version/version_guesser.rs b/crates/shirabe/src/package/version/version_guesser.rs
index f18f363..8e8cc32 100644
--- a/crates/shirabe/src/package/version/version_guesser.rs
+++ b/crates/shirabe/src/package/version/version_guesser.rs
@@ -10,6 +10,7 @@ use crate::util::HttpDownloader;
use crate::util::Platform;
use crate::util::ProcessExecutor;
use crate::util::Svn as SvnUtil;
+use crate::util::sync_executor;
use anyhow::Result;
use indexmap::IndexMap;
use shirabe_external_packages::composer::pcre::{CaptureKey, Preg};
@@ -543,7 +544,7 @@ impl VersionGuesser {
},
&scm_cmdline,
);
- let mut process = tokio::runtime::Runtime::new().unwrap().block_on(
+ let mut process = sync_executor::block_on(
self.process
.borrow_mut()
.execute_async(&cmd_line, Some(path)),
diff --git a/crates/shirabe/src/repository/composer_repository.rs b/crates/shirabe/src/repository/composer_repository.rs
index e3a5711..baf5768 100644
--- a/crates/shirabe/src/repository/composer_repository.rs
+++ b/crates/shirabe/src/repository/composer_repository.rs
@@ -32,6 +32,7 @@ use crate::util::HttpDownloader;
use crate::util::Url;
use crate::util::http::Response;
use crate::util::r#loop::Loop;
+use crate::util::sync_executor;
use indexmap::IndexMap;
use shirabe_external_packages::composer::pcre::{CaptureKey, Preg};
use shirabe_metadata_minifier::MetadataMinifier;
@@ -964,9 +965,8 @@ impl ComposerRepository {
continue;
}
- let spec = tokio::runtime::Runtime::new()
- .unwrap()
- .block_on(self.start_cached_async_download(&name, Some(&name)))?;
+ let spec =
+ sync_executor::block_on(self.start_cached_async_download(&name, Some(&name)))?;
// [$response] = $spec;
let response = spec
@@ -1726,9 +1726,8 @@ impl ComposerRepository {
}
let version_parser = self.version_parser.clone();
- let spec = tokio::runtime::Runtime::new()
- .unwrap()
- .block_on(self.start_cached_async_download(&name, Some(&real_name)))?;
+ let spec =
+ sync_executor::block_on(self.start_cached_async_download(&name, Some(&real_name)))?;
// [$response, $packagesSource] = $spec;
let spec_list = spec.as_list().cloned().unwrap_or_default();
diff --git a/crates/shirabe/src/util.rs b/crates/shirabe/src/util.rs
index bd51b66..35402d7 100644
--- a/crates/shirabe/src/util.rs
+++ b/crates/shirabe/src/util.rs
@@ -25,6 +25,7 @@ pub mod remote_filesystem;
pub mod silencer;
pub mod stream_context_factory;
pub mod svn;
+pub mod sync_executor;
pub mod sync_helper;
pub mod tar;
pub mod tls_helper;
diff --git a/crates/shirabe/src/util/http/curl_downloader.rs b/crates/shirabe/src/util/http/curl_downloader.rs
index 019a5ab..0d8248b 100644
--- a/crates/shirabe/src/util/http/curl_downloader.rs
+++ b/crates/shirabe/src/util/http/curl_downloader.rs
@@ -1,9 +1,10 @@
//! ref: composer/src/Composer/Util/Http/CurlDownloader.php
//!
//! reqwest-based re-implementation. The libcurl multi-handle event loop of the PHP original is
-//! replaced by reqwest + a tokio runtime: `download()` queues a job, and `tick()` drives one job
-//! to completion by `block_on`-ing the HTTP request (mirroring the existing sync bridge used
-//! elsewhere in the codebase, e.g. file_downloader.rs / installation_manager.rs).
+//! replaced by a blocking reqwest client: `download()` queues a job, and `tick()` drives one job to
+//! completion with a synchronous HTTP request. A blocking client is used (rather than the async
+//! client + a per-downloader tokio runtime) so the request never starts a tokio runtime from within
+//! another one when invoked through the codebase's sync bridges (see util/sync_executor.rs).
//!
//! The PHP control flow (insecure-URL check, redirect following, transport/status retries,
//! authenticated-retry detection, max_file_size enforcement, atomic rename of the `~` temp file)
@@ -66,9 +67,7 @@ pub struct CurlDownloader {
/// Connection pool / cookie / TLS-session sharing — reqwest::Client handles this internally,
/// replacing the PHP multiHandle + shareHandle. Redirects are disabled because we follow them
/// manually (to control auth-header re-attachment), matching CURLOPT_FOLLOWLOCATION = false.
- client: reqwest::Client,
- /// tokio runtime used to `block_on` the async reqwest calls from the sync `tick()`.
- runtime: tokio::runtime::Runtime,
+ client: reqwest::blocking::Client,
jobs: IndexMap<i64, CurlJob>,
next_id: i64,
io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
@@ -95,11 +94,11 @@ impl CurlDownloader {
// - cookie_store(true) ~ CURL_LOCK_DATA_COOKIE
// - redirect(none) ~ CURLOPT_FOLLOWLOCATION = false (we follow manually)
// The libcurl version-specific multiplexing / accept-encoding workarounds are not needed.
- // TODO: a brand-new tokio runtime + reqwest client is created per CurlDownloader; that is
- // acceptable here (one HttpDownloader owns one CurlDownloader) but not pooled across them.
+ // TODO: a brand-new reqwest client is created per CurlDownloader; that is acceptable here
+ // (one HttpDownloader owns one CurlDownloader) but not pooled across them.
// TODO: cookie sharing (CURL_LOCK_DATA_COOKIE) would need reqwest's `cookies` feature
// (.cookie_store(true)); omitted as it is not required for package downloads.
- let client = reqwest::Client::builder()
+ let client = reqwest::blocking::Client::builder()
.pool_max_idle_per_host(8)
.redirect(reqwest::redirect::Policy::none())
.build()
@@ -107,14 +106,10 @@ impl CurlDownloader {
// cannot proceed, mirroring PHP aborting when curl is missing.
.expect("failed to build reqwest client for CurlDownloader");
- let runtime = tokio::runtime::Runtime::new()
- .expect("failed to build tokio runtime for CurlDownloader");
-
let auth_helper = AuthHelper::new(io.clone(), config.clone());
Self {
client,
- runtime,
jobs: IndexMap::new(),
next_id: 1,
io,
@@ -282,7 +277,7 @@ impl CurlDownloader {
}
// Drive every queued job to completion. The PHP multi-handle progressed all easy handles
- // a little per tick(); here each tick() fully resolves one job (block_on per request),
+ // a little per tick(); here each tick() fully resolves one job (one blocking request),
// which is observationally equivalent for the sync wait_id() loop that calls tick().
let ids: Vec<i64> = self.jobs.keys().copied().collect();
for id in ids {
@@ -531,7 +526,7 @@ impl CurlDownloader {
}
}
- /// Performs one HTTP request via reqwest (`block_on`), enforcing max_file_size and streaming the
+ /// Performs one blocking HTTP request via reqwest, enforcing max_file_size and streaming the
/// body to the `~` temp file when in file mode. Replaces PHP's curl_setopt block + curl I/O.
fn send_once(
&self,
@@ -570,76 +565,74 @@ impl CurlDownloader {
let reqwest_method =
reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::GET);
- self.runtime.block_on(async {
- let mut builder = self.client.request(reqwest_method, url);
- for header in &headers {
- if let Some((name, value)) = header.split_once(':') {
- // Skip the Connection header reqwest manages itself.
- let name = name.trim();
- if name.eq_ignore_ascii_case("connection") {
- continue;
- }
- builder = builder.header(name, value.trim());
+ let mut builder = self.client.request(reqwest_method, url);
+ for header in &headers {
+ if let Some((name, value)) = header.split_once(':') {
+ // Skip the Connection header reqwest manages itself.
+ let name = name.trim();
+ if name.eq_ignore_ascii_case("connection") {
+ continue;
}
+ builder = builder.header(name, value.trim());
}
- builder = builder.timeout(std::time::Duration::from_secs(timeout_secs as u64));
- if let Some(body) = body {
- builder = builder.body(body);
- }
+ }
+ builder = builder.timeout(std::time::Duration::from_secs(timeout_secs as u64));
+ if let Some(body) = body {
+ builder = builder.body(body);
+ }
- let resp = builder.send().await.map_err(|e| TransportError {
- message: e.to_string(),
- retryable: e.is_timeout() || e.is_connect() || e.is_request(),
- is_connect: e.is_connect(),
- was_timeout: e.is_timeout(),
- is_max_file_size: false,
- })?;
+ let resp = builder.send().map_err(|e| TransportError {
+ message: e.to_string(),
+ retryable: e.is_timeout() || e.is_connect() || e.is_request(),
+ is_connect: e.is_connect(),
+ was_timeout: e.is_timeout(),
+ is_max_file_size: false,
+ })?;
- let status = resp.status().as_u16() as i64;
- let status_line = format!(
- "HTTP/{} {}",
- match resp.version() {
- reqwest::Version::HTTP_09 => "0.9",
- reqwest::Version::HTTP_10 => "1.0",
- reqwest::Version::HTTP_11 => "1.1",
- reqwest::Version::HTTP_2 => "2",
- reqwest::Version::HTTP_3 => "3",
- _ => "1.1",
- },
- resp.status()
- );
- // Header lines start with the status line so Response::get_status_message can find it.
- let mut headers_out: Vec<String> = vec![status_line];
- for (k, v) in resp.headers().iter() {
- headers_out.push(format!("{}: {}", k, v.to_str().unwrap_or("")));
- }
+ let status = resp.status().as_u16() as i64;
+ let status_line = format!(
+ "HTTP/{} {}",
+ match resp.version() {
+ reqwest::Version::HTTP_09 => "0.9",
+ reqwest::Version::HTTP_10 => "1.0",
+ reqwest::Version::HTTP_11 => "1.1",
+ reqwest::Version::HTTP_2 => "2",
+ reqwest::Version::HTTP_3 => "3",
+ _ => "1.1",
+ },
+ resp.status()
+ );
+ // Header lines start with the status line so Response::get_status_message can find it.
+ let mut headers_out: Vec<String> = vec![status_line];
+ for (k, v) in resp.headers().iter() {
+ headers_out.push(format!("{}: {}", k, v.to_str().unwrap_or("")));
+ }
- let body = Self::read_body_with_limit(resp, max_file_size, filename)
- .await
- .map_err(|(message, is_max_file_size)| TransportError {
- message,
- retryable: false,
- is_connect: false,
- was_timeout: false,
- is_max_file_size,
- })?;
+ let body = Self::read_body_with_limit(resp, max_file_size, filename).map_err(
+ |(message, is_max_file_size)| TransportError {
+ message,
+ retryable: false,
+ is_connect: false,
+ was_timeout: false,
+ is_max_file_size,
+ },
+ )?;
- Ok(RawResponse {
- status,
- headers: headers_out,
- body,
- })
+ Ok(RawResponse {
+ status,
+ headers: headers_out,
+ body,
})
}
/// Reads the body, enforcing max_file_size, writing to the `~` temp file when in file mode.
/// The `bool` in the error is `true` when the failure is a max_file_size violation.
- async fn read_body_with_limit(
- resp: reqwest::Response,
+ fn read_body_with_limit(
+ resp: reqwest::blocking::Response,
max_file_size: Option<u64>,
filename: Option<&str>,
) -> Result<Body, (String, bool)> {
- use std::io::Write;
+ use std::io::{Read, Write};
let mut stream = resp;
let mut written: u64 = 0;
@@ -654,13 +647,15 @@ impl CurlDownloader {
None => Sink::Memory(Vec::new()),
};
+ let mut buffer = [0u8; 16 * 1024];
loop {
- let chunk = match stream.chunk().await {
- Ok(Some(c)) => c,
- Ok(None) => break,
+ let n = match stream.read(&mut buffer) {
+ Ok(0) => break,
+ Ok(n) => n,
Err(e) => return Err((e.to_string(), false)),
};
- written += chunk.len() as u64;
+ let chunk = &buffer[..n];
+ written += n as u64;
if let Some(max) = max_file_size
&& written > max
{
@@ -673,8 +668,8 @@ impl CurlDownloader {
));
}
match &mut sink {
- Sink::File(f) => f.write_all(&chunk).map_err(|e| (e.to_string(), false))?,
- Sink::Memory(buf) => buf.extend_from_slice(&chunk),
+ Sink::File(f) => f.write_all(chunk).map_err(|e| (e.to_string(), false))?,
+ Sink::Memory(buf) => buf.extend_from_slice(chunk),
}
}
diff --git a/crates/shirabe/src/util/sync_executor.rs b/crates/shirabe/src/util/sync_executor.rs
new file mode 100644
index 0000000..b7512c2
--- /dev/null
+++ b/crates/shirabe/src/util/sync_executor.rs
@@ -0,0 +1,28 @@
+//! Minimal synchronous future executor used as a drop-in for the `tokio::runtime::Runtime::new()`
+//! + `block_on` sync bridges scattered across the codebase (repository / installer / downloader).
+//!
+//! Those bridges drive `async fn`s whose `.await` points all resolve synchronously: the only async
+//! I/O (reqwest in `CurlDownloader`) is performed through a blocking client, so no reactor is
+//! required. Nesting `tokio` runtimes is forbidden ("Cannot start a runtime from within a runtime"),
+//! which is why this no-reactor executor exists; it can be nested freely.
+//!
+//! TODO(phase-e): remove this module once the async bridges are either made genuinely synchronous or
+//! consolidated onto a single shared runtime driven from `main`.
+
+use std::future::Future;
+use std::task::{Context, Poll};
+
+/// Polls `fut` to completion on the current thread without any tokio runtime.
+///
+/// Relies on the invariant that no awaited future parks on a reactor (it would otherwise spin).
+pub fn block_on<F: Future>(fut: F) -> F::Output {
+ let mut fut = std::pin::pin!(fut);
+ let waker = std::task::Waker::noop();
+ let mut cx = Context::from_waker(waker);
+ loop {
+ if let Poll::Ready(value) = fut.as_mut().poll(&mut cx) {
+ return value;
+ }
+ std::hint::spin_loop();
+ }
+}
diff --git a/crates/shirabe/src/util/sync_helper.rs b/crates/shirabe/src/util/sync_helper.rs
index 8988795..1014b86 100644
--- a/crates/shirabe/src/util/sync_helper.rs
+++ b/crates/shirabe/src/util/sync_helper.rs
@@ -179,9 +179,7 @@ impl SyncHelper {
promise: Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + '_>>>,
) -> Result<()> {
if let Some(promise) = promise {
- tokio::runtime::Runtime::new()
- .unwrap()
- .block_on(r#loop.borrow_mut().wait(vec![promise], None))?;
+ crate::util::sync_executor::block_on(r#loop.borrow_mut().wait(vec![promise], None))?;
}
Ok(())
}