diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-07-17 17:51:12 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-07-17 17:51:12 +0900 |
| commit | c6b10bd024894f857bf35a8f84c5e273be6f860d (patch) | |
| tree | 395fe0cd8daaa1d2cae62636bdf4ef4cd0c2c6cf /crates | |
| parent | 01352ed44259d1b6d677d392099850534ecf33ce (diff) | |
| download | php-shirabe-c6b10bd024894f857bf35a8f84c5e273be6f860d.tar.gz php-shirabe-c6b10bd024894f857bf35a8f84c5e273be6f860d.tar.zst php-shirabe-c6b10bd024894f857bf35a8f84c5e273be6f860d.zip | |
refactor(http-downloader): drop the job table for a &self Semaphore core
Replaces Job/Request/JobHandle/id_gen/running_jobs/max_jobs with a
tokio::sync::Semaphore permit held for the duration of each request.
get/add/copy/add_copy are now &self (add/add_copy are also genuinely
async); a shared execute()/dispatch() core replaces
add_job/run_rfs_job/start_job/settle_job, returning the Response
directly instead of deferring to wait()/count_active_jobs()/
get_response() (all removed — confirmed zero callers, same for the
now-unused STATUS_* constants).
get()/copy() stay synchronous rather than becoming async wrappers
around add()/add_copy(), bridging via the existing sync_executor
instead of the curl_runtime() introduced for CurlDownloader: their
callers (~35 files reaching HttpDownloader) are mostly plain sync fns
with no async boundary anywhere in the call chain, and forcing that
propagation now would pull forward the dedicated async-propagation
task. curl-eligible requests still route through curl_runtime()
inside dispatch(), same as before — nesting sync_executor::block_on
(no real reactor) around curl_runtime().block_on() (a real, separate
Runtime) is safe; it's only nesting curl_runtime() inside itself that
would panic.
CurlDownloader no longer needs Rc<RefCell<>> wrapping despite the
original design sketch: since item 2 made all of its methods &self,
a plain Option<CurlDownloader> field works fine under HttpDownloader's
own &self methods.
get/add/copy/add_copy becoming &self (rather than &mut self) requires
no changes at any of their ~35 calling files: RefMut/Ref both deref to
a type that can call &self methods just fine.
Verified manually against real network I/O (sandbox disabled): `shirabe
show -a` (get()'s sync_executor-bridged path) and `shirabe
create-project` (add_copy()'s genuinely async path via file_downloader.rs)
both complete correctly with no hang.
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/shirabe/src/util/http_downloader.rs | 554 | ||||
| -rw-r--r-- | crates/shirabe/tests/util/http_downloader_test.rs | 2 |
2 files changed, 130 insertions, 426 deletions
diff --git a/crates/shirabe/src/util/http_downloader.rs b/crates/shirabe/src/util/http_downloader.rs index c2c83b85..b7173c5e 100644 --- a/crates/shirabe/src/util/http_downloader.rs +++ b/crates/shirabe/src/util/http_downloader.rs @@ -14,6 +14,7 @@ use crate::util::StreamContextFactory; use crate::util::Url; use crate::util::http::CurlDownloader; use crate::util::http::Response; +use crate::util::sync_executor; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; use shirabe_php_shim::{ @@ -23,28 +24,21 @@ use shirabe_php_shim::{ }; use shirabe_semver::constraint::SimpleConstraint; -/// @phpstan-type Request array{url: non-empty-string, options: mixed[], copyTo: string|null} -/// @phpstan-type Job array{id: int, status: int, request: Request, sync: bool, origin: string, resolve?: callable, reject?: callable, curl_id?: int, response?: Response, exception?: \Throwable} #[derive(Debug)] pub struct HttpDownloader { /// @var IOInterface io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, /// @var Config config: std::rc::Rc<std::cell::RefCell<Config>>, - /// @var array<Job> - jobs: IndexMap<i64, Job>, /// @var mixed[] options: IndexMap<String, PhpMixed>, - /// @var int - running_jobs: i64, - /// @var int - max_jobs: i64, + /// Replaces PHP's `$runningJobs`/`$maxJobs` counters: a permit is held for the duration of + /// each in-flight request and released when it settles. + semaphore: std::rc::Rc<tokio::sync::Semaphore>, /// @var ?CurlDownloader curl: Option<CurlDownloader>, /// @var ?RemoteFilesystem rfs: Option<std::rc::Rc<std::cell::RefCell<RemoteFilesystem>>>, - /// @var int - id_gen: i64, /// @var bool disabled: bool, /// @var bool @@ -97,30 +91,17 @@ impl Default for HttpDownloaderMockHandler { } } -#[derive(Debug)] -struct Job { - id: i64, - status: i64, - request: Request, - sync: bool, - origin: String, - response: Option<Response>, - exception: Option<anyhow::Error>, -} - -#[derive(Debug, Clone)] -struct Request { - url: String, - options: IndexMap<String, PhpMixed>, - copy_to: Option<String>, -} - /// A single-threaded tokio Runtime used only to drive `CurlDownloader::download()` (which needs a /// real reactor now that it uses the non-blocking `reqwest::Client`, unlike `sync_executor::block_on` /// which assumes every awaited future resolves synchronously). `current_thread` is used because /// `block_on` (unlike `spawn`) has no `Send` bound, and `download()`'s future closes over /// `Rc<RefCell<...>>` handles that are not `Send`. /// +/// `get()`/`copy()` bridge into the async core via `sync_executor::block_on` instead of this +/// Runtime (nesting this same Runtime's `block_on` inside itself, on the curl path, would panic +/// with "Cannot start a runtime from within a runtime"); this Runtime is only ever entered at the +/// single point where `CurlDownloader::download()` is awaited. +/// /// TODO(phase-e): remove this once `HttpDownloader::add`/`get` are driven by `Loop::wait`'s /// `FuturesUnordered` under a single top-level Runtime (see the async re-architecture design). fn curl_runtime() -> &'static tokio::runtime::Runtime { @@ -134,12 +115,6 @@ fn curl_runtime() -> &'static tokio::runtime::Runtime { } impl HttpDownloader { - const STATUS_QUEUED: i64 = 1; - const STATUS_STARTED: i64 = 2; - const STATUS_COMPLETED: i64 = 3; - const STATUS_FAILED: i64 = 4; - const STATUS_ABORTED: i64 = 5; - /// @param IOInterface $io The IO instance /// @param Config $config The config /// @param mixed[] $options The options @@ -201,13 +176,10 @@ impl HttpDownloader { Self { io, config, - jobs: IndexMap::new(), options: self_options, - running_jobs: 0, - max_jobs, + semaphore: std::rc::Rc::new(tokio::sync::Semaphore::new(max_jobs as usize)), curl, rfs, - id_gen: 0, disabled, allow_async: false, mock: None, @@ -215,102 +187,51 @@ impl HttpDownloader { } /// Download a file synchronously - pub fn get( - &mut self, - url: &str, - options: IndexMap<String, PhpMixed>, - ) -> anyhow::Result<Response> { - if self.mock.is_some() { - return self.mock_get(url, &options); - } - if url.is_empty() { - return Err(InvalidArgumentException { - message: "$url must not be an empty string".to_string(), - code: 0, - } - .into()); - } - let job = self.add_job( - Request { - url: url.to_string(), - options, - copy_to: None, - }, - true, - )?; - self.wait_id(Some(job.id))?; - - let response = self.get_response(job.id)?; - - Ok(response) + pub fn get(&self, url: &str, options: IndexMap<String, PhpMixed>) -> anyhow::Result<Response> { + sync_executor::block_on(self.execute(url, options, None, true)) } /// Create an async download operation pub async fn add( - &mut self, + &self, url: &str, options: IndexMap<String, PhpMixed>, ) -> anyhow::Result<Response> { - if self.mock.is_some() { - return self.mock_get(url, &options); - } - if url.is_empty() { - return Err(InvalidArgumentException { - message: "$url must not be an empty string".to_string(), - code: 0, - } - .into()); - } - let job = self.add_job( - Request { - url: url.to_string(), - options, - copy_to: None, - }, - false, - )?; - self.wait_id(Some(job.id))?; - - self.get_response(job.id) + self.execute(url, options, None, false).await } /// Copy a file synchronously pub fn copy( - &mut self, + &self, url: &str, to: &str, options: IndexMap<String, PhpMixed>, ) -> anyhow::Result<Response> { - if self.mock.is_some() { - return self.mock_get(url, &options); - } - if url.is_empty() { - return Err(InvalidArgumentException { - message: "$url must not be an empty string".to_string(), - code: 0, - } - .into()); - } - let job = self.add_job( - Request { - url: url.to_string(), - options, - copy_to: Some(to.to_string()), - }, - true, - )?; - self.wait_id(Some(job.id))?; - - self.get_response(job.id) + sync_executor::block_on(self.execute(url, options, Some(to), true)) } /// Create an async copy operation pub async fn add_copy( - &mut self, + &self, url: &str, to: &str, options: IndexMap<String, PhpMixed>, ) -> anyhow::Result<Response> { + self.execute(url, options, Some(to), false).await + } + + /// @phpstan-param non-empty-string $url + /// + /// Shared core of `get`/`add`/`copy`/`add_copy`: mock short-circuit, empty-URL guard, the + /// sync/allow_async gate, and the concurrency-limiting semaphore permit. Mirrors PHP `addJob` + /// up to (but not including) the resolver, which is `dispatch`. + async fn execute( + &self, + url: &str, + options: IndexMap<String, PhpMixed>, + copy_to: Option<&str>, + sync: bool, + ) -> anyhow::Result<Response> { if self.mock.is_some() { return self.mock_get(url, &options); } @@ -321,41 +242,6 @@ impl HttpDownloader { } .into()); } - let job = self.add_job( - Request { - url: url.to_string(), - options, - copy_to: Some(to.to_string()), - }, - false, - )?; - self.wait_id(Some(job.id))?; - - self.get_response(job.id) - } - - /// Retrieve the options set in the constructor - pub fn get_options(&self) -> &IndexMap<String, PhpMixed> { - &self.options - } - - /// Merges new options - pub fn set_options(&mut self, options: IndexMap<String, PhpMixed>) { - self.options = array_replace_recursive(self.options.clone(), options); - } - - /// @phpstan-param Request $request - /// - /// Queues a job and starts it if there is capacity. Mirrors PHP `addJob`: for non-curl (rfs) - /// jobs the work runs synchronously here (PHP runs it in the Promise resolver during - /// construction); for curl jobs the work is driven later by `start_job` / `count_active_jobs`. - fn add_job(&mut self, mut request: Request, sync: bool) -> anyhow::Result<JobHandle> { - request.options = array_replace_recursive(self.options.clone(), request.options); - - let id = self.id_gen; - self.id_gen += 1; - let origin = Url::get_origin(&self.config.borrow(), &request.url); - if !sync && !self.allow_async { return Err(LogicException { message: @@ -366,13 +252,31 @@ impl HttpDownloader { .into()); } + let _permit = self + .semaphore + .acquire() + .await + .expect("semaphore is never closed"); + self.dispatch(url, options, copy_to).await + } + + /// Mirrors the resolver PHP's `addJob` builds (the curl branch queues itself for `startJob`; + /// the non-curl branch runs the blocking RemoteFilesystem download immediately) plus + /// `startJob`'s network-disabled short-circuit. That short-circuit only ever reached the curl + /// branch in PHP too: the non-curl resolver runs synchronously during Promise construction, + /// before `startJob` (and its `disabled` check) is ever invoked for that job. + async fn dispatch( + &self, + url: &str, + options: IndexMap<String, PhpMixed>, + copy_to: Option<&str>, + ) -> anyhow::Result<Response> { + let options = array_replace_recursive(self.options.clone(), options); + let origin = Url::get_origin(&self.config.borrow(), url); + // capture username/password from URL if there is one let mut m: IndexMap<CaptureKey, String> = IndexMap::new(); - if Preg::is_match3( - r"{^https?://([^:/]+):([^@/]+)@([^/]+)}i", - &request.url, - Some(&mut m), - ) { + if Preg::is_match3(r"{^https?://([^:/]+):([^@/]+)@([^/]+)}i", url, Some(&mut m)) { self.io.borrow_mut().set_authentication( origin.clone(), rawurldecode( @@ -390,208 +294,95 @@ impl HttpDownloader { ); } - let job = Job { - id, - status: Self::STATUS_QUEUED, - request: request.clone(), - sync, - origin, - response: None, - exception: None, - }; - let can_use_curl = self.can_use_curl(&job); - self.jobs.insert(id, job); - - // PHP runs the resolver synchronously while constructing the Promise. For non-curl jobs - // the resolver performs the blocking RemoteFilesystem download and resolves immediately. - if !can_use_curl { - self.run_rfs_job(id); - } - - if self.running_jobs < self.max_jobs { - self.start_job(id); - } - - Ok(JobHandle { id }) - } - - /// Mirrors the non-curl branch of PHP `addJob`'s Promise resolver plus the `.then` side - /// effects: performs the blocking RemoteFilesystem download and settles the job. - fn run_rfs_job(&mut self, id: i64) { - let (request, origin, url, options, copy_to) = { - let job = self.jobs.get(&id).unwrap(); - ( - job.request.clone(), - job.origin.clone(), - job.request.url.clone(), - job.request.options.clone(), - job.request.copy_to.clone(), - ) - }; - - if let Some(job) = self.jobs.get_mut(&id) { - job.status = Self::STATUS_STARTED; - } - - let result: anyhow::Result<Response> = { - let rfs = self.rfs.as_ref().unwrap().clone(); - (|| -> anyhow::Result<Response> { - if let Some(copy_to) = copy_to.as_deref() { - let (_, headers) = - rfs.borrow_mut() - .copy(&origin, &url, copy_to, false, options.clone())?; - - let code = RemoteFilesystem::find_status_code(&headers); - let body = Some(format!("{}~", copy_to)); - Ok(Response::new(request.url.clone(), code, headers, body)) - } else { - let (result, headers) = - rfs.borrow_mut() - .get_contents(&origin, &url, false, options.clone())?; - let body = match result { - GetResult::Content(s) => Some(s), - _ => None, - }; - let code = RemoteFilesystem::find_status_code(&headers); - Ok(Response::new(request.url.clone(), code, headers, body)) - } - })() - }; - - self.settle_job(id, result); - } - - /// Applies the effect of PHP's promise `.then` handlers: records the response/exception, - /// transitions the job status and decrements the running-job counter. - fn settle_job(&mut self, id: i64, result: anyhow::Result<Response>) { - match result { - Ok(response) => { - if let Some(job) = self.jobs.get_mut(&id) { - job.status = Self::STATUS_COMPLETED; - job.response = Some(response); - } - } - Err(e) => { - if let Some(job) = self.jobs.get_mut(&id) { - job.status = Self::STATUS_FAILED; - job.exception = Some(e); + if self.can_use_curl(url, &options) { + if self.disabled { + let has_if_modified_since = { + let http_header = options + .get("http") + .and_then(|v| match v { + PhpMixed::Array(m) => m.get("header"), + _ => None, + }) + .cloned(); + if let Some(PhpMixed::List(list)) = http_header.as_ref() { + let joined = implode( + "", + &list + .iter() + .map(|v| v.as_string().unwrap_or("").to_string()) + .collect::<Vec<_>>(), + ); + stripos(&joined, "if-modified-since").is_some() + } else if let Some(PhpMixed::Array(m)) = http_header.as_ref() { + let joined = implode( + "", + &m.values() + .map(|v| v.as_string().unwrap_or("").to_string()) + .collect::<Vec<_>>(), + ); + stripos(&joined, "if-modified-since").is_some() + } else { + false + } + }; + if has_if_modified_since { + return Ok(Response::new( + url.to_string(), + Some(304), + Vec::new(), + Some(String::new()), + )); } - } - } - self.mark_job_done(); - } - - fn start_job(&mut self, id: i64) { - let job_status = self.jobs.get(&id).map(|j| j.status); - if job_status != Some(Self::STATUS_QUEUED) { - return; - } - // start job - if let Some(job) = self.jobs.get_mut(&id) { - job.status = Self::STATUS_STARTED; - } - self.running_jobs += 1; - - let (request, origin, copy_to) = { - let job = self.jobs.get(&id).unwrap(); - ( - job.request.clone(), - job.origin.clone(), - job.request.copy_to.clone(), - ) - }; - let url = request.url.clone(); - let options = request.options.clone(); - - if self.disabled { - let has_if_modified_since = { - let http_header = options - .get("http") - .and_then(|v| match v { - PhpMixed::Array(m) => m.get("header"), - _ => None, - }) - .cloned(); - if let Some(PhpMixed::List(list)) = http_header.as_ref() { - let joined = implode( - "", - &list - .iter() - .map(|v| v.as_string().unwrap_or("").to_string()) - .collect::<Vec<_>>(), - ); - stripos(&joined, "if-modified-since").is_some() - } else if let Some(PhpMixed::Array(m)) = http_header.as_ref() { - let joined = implode( - "", - &m.values() - .map(|v| v.as_string().unwrap_or("").to_string()) - .collect::<Vec<_>>(), - ); - stripos(&joined, "if-modified-since").is_some() - } else { - false - } - }; - if has_if_modified_since { - let response = Ok(Response::new( - url.clone(), - Some(304), - Vec::new(), - Some(String::new()), - )); - self.settle_job(id, response); - } else { let mut e = TransportException::new( format!( "Network disabled, request canceled: {}", - Url::sanitize(url.clone()) + Url::sanitize(url.to_string()) ), 499, ); e.set_status_code(Some(499)); - self.settle_job(id, Err(e.into())); + return Err(e.into()); } - return; - } - - // curl branch: `CurlDownloader::download` now runs the whole redirect/retry/auth state - // machine to completion itself and returns the settled result directly, so this drives - // it through the temporary bridge runtime instead of PHP's promise resolve/reject firing - // during tick(). PHP catches any exception from download() and rejects the job. - let result: anyhow::Result<Response> = { let curl = self.curl.as_ref().unwrap(); - match curl_runtime().block_on(curl.download(&origin, &url, options, copy_to.as_deref())) - { + return match curl_runtime().block_on(curl.download(&origin, url, options, copy_to)) { Ok(Ok(response)) => Ok(response), Ok(Err(transport_exception)) => Err(transport_exception.into()), Err(e) => Err(e), - } - }; - self.settle_job(id, result); - } + }; + } - fn mark_job_done(&mut self) { - self.running_jobs -= 1; + let rfs = self.rfs.as_ref().unwrap().clone(); + if let Some(copy_to) = copy_to { + let (_, headers) = + rfs.borrow_mut() + .copy(&origin, url, copy_to, false, options.clone())?; + + let code = RemoteFilesystem::find_status_code(&headers); + let body = Some(format!("{}~", copy_to)); + Ok(Response::new(url.to_string(), code, headers, body)) + } else { + let (result, headers) = + rfs.borrow_mut() + .get_contents(&origin, url, false, options.clone())?; + let body = match result { + GetResult::Content(s) => Some(s), + _ => None, + }; + let code = RemoteFilesystem::find_status_code(&headers); + Ok(Response::new(url.to_string(), code, headers, body)) + } } - /// Wait for current async download jobs to complete - /// - /// @param int|null $index For internal use only, the job id - pub fn wait(&mut self) -> anyhow::Result<()> { - self.wait_id(None) + /// Retrieve the options set in the constructor + pub fn get_options(&self) -> &IndexMap<String, PhpMixed> { + &self.options } - fn wait_id(&mut self, index: Option<i64>) -> anyhow::Result<()> { - loop { - let job_count = self.count_active_jobs(index)?; - if job_count == 0 { - break; - } - } - Ok(()) + /// Merges new options + pub fn set_options(&mut self, options: IndexMap<String, PhpMixed>) { + self.options = array_replace_recursive(self.options.clone(), options); } /// @internal @@ -600,84 +391,6 @@ impl HttpDownloader { } /// @internal - pub fn count_active_jobs(&mut self, index: Option<i64>) -> anyhow::Result<i64> { - if self.running_jobs < self.max_jobs { - let queued_ids: Vec<i64> = self - .jobs - .values() - .filter(|j| j.status == Self::STATUS_QUEUED) - .map(|j| j.id) - .collect(); - for id in queued_ids { - if self.running_jobs >= self.max_jobs { - break; - } - self.start_job(id); - } - } - - // Unlike the old tick()-driven curl path, `start_job` now settles curl jobs synchronously - // (via the temporary bridge runtime), so no job is ever left lingering in STATUS_STARTED - // by the time we get here — nothing left to poll or collect. - - if let Some(index) = index { - return Ok( - if self.jobs.get(&index).map(|j| j.status).unwrap_or(0) < Self::STATUS_COMPLETED { - 1 - } else { - 0 - }, - ); - } - - let mut active: i64 = 0; - let ids: Vec<i64> = self.jobs.keys().copied().collect(); - for id in ids { - let (status, sync) = { - let j = self.jobs.get(&id).unwrap(); - (j.status, j.sync) - }; - if status < Self::STATUS_COMPLETED { - active += 1; - } else if !sync { - self.jobs.shift_remove(&id); - } - } - - Ok(active) - } - - /// @param int $index Job id - fn get_response(&mut self, index: i64) -> anyhow::Result<Response> { - if !self.jobs.contains_key(&index) { - return Err(LogicException { - message: "Invalid request id".to_string(), - code: 0, - } - .into()); - } - - if self.jobs.get(&index).unwrap().status == Self::STATUS_FAILED { - // PHP: assert(isset($this->jobs[$index]['exception'])) - let mut job = self.jobs.shift_remove(&index).unwrap(); - return Err(job.exception.take().unwrap()); - } - - if self.jobs.get(&index).unwrap().response.is_none() { - return Err(LogicException { - message: "Response not available yet, call wait() first".to_string(), - code: 0, - } - .into()); - } - - let mut job = self.jobs.shift_remove(&index).unwrap(); - let resp = job.response.take().unwrap(); - - Ok(resp) - } - - /// @internal /// /// @param array{warning?: string, info?: string, warning-versions?: string, info-versions?: string, warnings?: array<array{versions: string, message: string}>, infos?: array<array{versions: string, message: string}>} $data pub fn output_warnings( @@ -820,17 +533,16 @@ impl HttpDownloader { None } - /// @param Job $job - fn can_use_curl(&self, job: &Job) -> bool { + fn can_use_curl(&self, url: &str, options: &IndexMap<String, PhpMixed>) -> bool { if self.curl.is_none() { return false; } - if !Preg::is_match(r"{^https?://}i", &job.request.url) { + if !Preg::is_match(r"{^https?://}i", url) { return false; } - let allow_self_signed = job.request.options.get("ssl").and_then(|v| match v { + let allow_self_signed = options.get("ssl").and_then(|v| match v { PhpMixed::Array(m) => m.get("allow_self_signed").cloned(), _ => None, }); @@ -861,13 +573,10 @@ impl HttpDownloader { Self { io, config, - jobs: IndexMap::new(), options: IndexMap::new(), - running_jobs: 0, - max_jobs: 12, + semaphore: std::rc::Rc::new(tokio::sync::Semaphore::new(12)), curl: None, rfs: None, - id_gen: 0, disabled: false, allow_async: false, mock: None, @@ -921,7 +630,7 @@ impl HttpDownloader { /// For testing only. Mirrors HttpDownloaderMock::get: consumes the next matching expectation /// (or falls back to the default handler when not strict) and returns the canned response. fn mock_get( - &mut self, + &self, file_url: &str, options: &IndexMap<String, PhpMixed>, ) -> anyhow::Result<Response> { @@ -1019,8 +728,3 @@ impl HttpDownloader { Err(e.into()) } } - -#[derive(Debug, Clone, Copy)] -struct JobHandle { - id: i64, -} diff --git a/crates/shirabe/tests/util/http_downloader_test.rs b/crates/shirabe/tests/util/http_downloader_test.rs index 2be5eb96..0839cf30 100644 --- a/crates/shirabe/tests/util/http_downloader_test.rs +++ b/crates/shirabe/tests/util/http_downloader_test.rs @@ -43,7 +43,7 @@ fn test_capture_authentication_params_from_url() { let io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>> = io_mock.clone(); Platform::put_env("COMPOSER_DISABLE_NETWORK", "1"); - let mut fs = HttpDownloader::new(io, config, IndexMap::new(), false); + let fs = HttpDownloader::new(io, config, IndexMap::new(), false); Platform::clear_env("COMPOSER_DISABLE_NETWORK"); if let Err(e) = fs.get( |
