rusty_cat/meow_client.rs
1use std::panic::AssertUnwindSafe;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock};
4
5use tokio::sync::oneshot;
6
7use crate::binary::{BinaryCompleteCb, BinaryDownloadOutput, BinaryExecutor, BinaryTask};
8use crate::dflt::default_http_transfer::{
9 build_internal_client, default_breakpoint_arcs, DefaultHttpTransfer,
10};
11use crate::error::{InnerErrorCode, MeowError};
12use crate::file_transfer_record::FileTransferRecord;
13use crate::ids::{GlobalProgressListenerId, TaskId};
14use crate::inner::executor::Executor;
15use crate::inner::inner_task::InnerTask;
16use crate::inner::task_callbacks::{CompleteCb, ProgressCb, TaskCallbacks};
17use crate::log::{set_debug_log_listener, DebugLogListener, DebugLogListenerError};
18use crate::meow_config::MeowConfig;
19use crate::pounce_task::PounceTask;
20use crate::transfer_snapshot::TransferSnapshot;
21use crate::transfer_status::TransferStatus;
22
23/// Callback type for globally observing task progress events.
24///
25/// The callback is invoked from runtime worker context. Keep callback logic
26/// fast and non-blocking to avoid delaying event processing.
27pub type GlobalProgressListener = ProgressCb;
28
29/// Outcome of a task that reached [`TransferStatus::Complete`].
30///
31/// Returned by [`MeowClient::enqueue_and_wait`].
32#[derive(Debug, Clone)]
33pub struct TaskOutcome {
34 /// Task identifier returned by the underlying scheduler.
35 pub task_id: TaskId,
36 /// Provider-defined payload returned by upload protocol's `complete_upload`.
37 /// Download tasks usually receive `None`.
38 pub payload: Option<String>,
39}
40
41type TerminalMsg = Result<(TaskId, Option<String>), MeowError>;
42
43/// Main entry point of the `rusty-cat` SDK.
44///
45/// `MeowClient` owns runtime state and provides high-level operations:
46/// enqueue, bounded binary GET, pause, resume, cancel, snapshot, and close.
47///
48/// # Usage pattern
49///
50/// 1. Create [`MeowConfig`].
51/// 2. Construct `MeowClient::new(config)`.
52/// 3. Build tasks with upload/download builders.
53/// 4. Call [`Self::try_enqueue`] or [`Self::enqueue_and_wait`].
54/// 5. Control task lifecycle with pause/resume/cancel.
55/// 6. Call [`Self::close`] during shutdown.
56///
57/// # Lifecycle contract: you **must** call [`Self::close`]
58///
59/// Transfer and binary schedulers use separate threads, runtimes, HTTP clients,
60/// command queues and callback dispatchers. The clean shutdown protocol is an explicit
61/// `close().await` command which:
62///
63/// - cancels in-flight transfers,
64/// - flushes `Paused` status events to user callbacks for every known group,
65/// - drains already submitted callback jobs,
66/// - joins the scheduler thread and lets the runtime drop.
67///
68/// Forgetting to call `close` leaves the scheduler thread alive until all
69/// command senders are dropped (which does happen when `MeowClient` is
70/// dropped, but only as a fallback). When that fallback path runs, the
71/// guarantees above do **not** hold: callers may miss terminal status
72/// events, in-flight HTTP transfers are aborted abruptly, and for long-lived
73/// SDK hosts (servers, mobile runtimes, etc.) the misuse is nearly
74/// impossible to debug from the outside.
75///
76/// To help surface this misuse the internal executor implements a
77/// **best-effort [`Drop`]** that, when `close` was never called:
78///
79/// - emits a `Warn`-level log via the debug log listener (tag
80/// `"executor_drop"`),
81/// - performs a non-blocking `try_send` of a final `Close` command so the
82/// worker still has a chance to drain its state,
83/// - then drops the command sender, causing the worker loop to exit on its
84/// own.
85///
86/// This is a safety net, **not** a substitute for calling `close`. Treat
87/// `close().await` as a mandatory step in your shutdown sequence.
88///
89/// # Sharing across tasks / threads
90///
91/// `MeowClient` **intentionally does not implement [`Clone`]**.
92///
93/// The client owns a lazily-initialized internal `Executor` (a single background
94/// worker loop plus its task table, scheduler state and shutdown flag). A
95/// naive field-by-field `Clone` would copy the `OnceLock<Executor>` *before*
96/// it was initialized, letting different clones each spin up their **own**
97/// executor on first use. The result would be:
98///
99/// - multiple independent task tables (tasks enqueued via one clone are
100/// invisible to `pause` / `resume` / `cancel` / `snapshot` on another);
101/// - concurrency limits ([`MeowConfig::max_upload_concurrency`] /
102/// [`MeowConfig::max_download_concurrency`]) silently multiplied by the
103/// number of clones;
104/// - [`Self::close`] only shutting down one of the worker loops, leaking the
105/// rest.
106///
107/// To share a client across tasks or threads, wrap it in [`std::sync::Arc`]
108/// and clone the `Arc` instead:
109///
110/// ```no_run
111/// use std::sync::Arc;
112/// use rusty_cat::api::{MeowClient, MeowConfig};
113///
114/// let client = Arc::new(MeowClient::new(MeowConfig::default()));
115/// let client_for_task = Arc::clone(&client);
116/// tokio::spawn(async move {
117/// let _ = client_for_task; // use the shared client here
118/// });
119/// ```
120pub struct MeowClient {
121 /// Lazily initialized task executor.
122 ///
123 /// The `OnceLock` itself has one owner because `MeowClient` is not `Clone`.
124 /// Its executor is held by `Arc` only so an already-started mixed close can
125 /// finish safely if the caller drops the close Future. Share the client
126 /// itself via `Arc<MeowClient>` when multi-owner access is needed.
127 executor: OnceLock<Arc<Executor>>,
128 executor_init: StdMutex<()>,
129 /// Lazily initialized executor dedicated to bounded in-memory GETs.
130 binary_executor: OnceLock<Arc<BinaryExecutor>>,
131 /// Serializes BinaryExecutor publication/task admission with close.
132 binary_lifecycle: Arc<StdMutex<BinaryLifecycle>>,
133 close_notify: Arc<tokio::sync::Notify>,
134 /// Immutable runtime configuration.
135 config: MeowConfig,
136 /// Global listeners receiving progress records for all tasks.
137 global_progress_listener: crate::inner::scheduler_state::GlobalProgressStore,
138 /// Stable identity shared with this client's transfer callback dispatcher.
139 callback_dispatcher_owner: crate::inner::cb_dispatcher::CallbackDispatcherOwner,
140 /// Global closed flag. Once set to `true`, task control APIs reject calls.
141 closed: Arc<AtomicBool>,
142}
143
144#[derive(Debug, Clone, Copy)]
145enum BinaryLifecycle {
146 Open,
147 Closing,
148 Closed,
149 CloseFailed {
150 pounce_closed: bool,
151 binary_closed: bool,
152 },
153}
154
155impl std::fmt::Debug for MeowClient {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("MeowClient")
158 .field("config", &self.config)
159 .field("global_progress_listener", &"..")
160 .finish()
161 }
162}
163
164impl MeowClient {
165 /// Creates a new client with the provided configuration.
166 ///
167 /// The internal executor is initialized lazily on first task operation.
168 ///
169 /// # Examples
170 ///
171 /// ```no_run
172 /// use rusty_cat::api::{MeowClient, MeowConfig};
173 ///
174 /// let config = MeowConfig::default();
175 /// let client = MeowClient::new(config);
176 /// let _ = client;
177 /// ```
178 pub fn new(config: MeowConfig) -> Self {
179 MeowClient {
180 executor: Default::default(),
181 executor_init: StdMutex::new(()),
182 binary_executor: Default::default(),
183 binary_lifecycle: Arc::new(StdMutex::new(BinaryLifecycle::Open)),
184 close_notify: Arc::new(tokio::sync::Notify::new()),
185 config,
186 global_progress_listener: Arc::new(RwLock::new(Arc::from([]))),
187 callback_dispatcher_owner: crate::inner::cb_dispatcher::CallbackDispatcherOwner::new(),
188 closed: Arc::new(AtomicBool::new(false)),
189 }
190 }
191
192 /// Returns a `reqwest::Client` aligned with this client's configuration.
193 ///
194 /// - If [`MeowConfigBuilder::http_client`](crate::api::MeowConfigBuilder::http_client)
195 /// injected a custom client, this
196 /// returns its clone.
197 /// - Otherwise, this builds a new client from `http_timeout` and
198 /// `tcp_keepalive`.
199 ///
200 /// # Errors
201 ///
202 /// Returns [`MeowError`] with `HttpClientBuildFailed` when client creation
203 /// fails.
204 ///
205 /// # Examples
206 ///
207 /// ```no_run
208 /// use rusty_cat::api::{MeowClient, MeowConfig};
209 ///
210 /// let client = MeowClient::new(MeowConfig::default());
211 /// let http = client.http_client()?;
212 /// let _ = http;
213 /// # Ok::<(), rusty_cat::api::MeowError>(())
214 /// ```
215 pub fn http_client(&self) -> Result<reqwest::Client, MeowError> {
216 if let Some(c) = self.config.http_client_ref() {
217 return Ok(c.clone());
218 }
219 // Build through the shared helper so this client carries the exact same
220 // transport policy (connect timeout + idle connection pool) as the
221 // transfer backend, rather than reqwest's bare defaults.
222 build_internal_client(self.config.http_timeout(), self.config.tcp_keepalive()).map_err(
223 |e| {
224 MeowError::from_source(
225 InnerErrorCode::HttpClientBuildFailed,
226 format!(
227 "build reqwest client failed (timeout={:?}, keepalive={:?})",
228 self.config.http_timeout(),
229 self.config.tcp_keepalive()
230 ),
231 e,
232 )
233 },
234 )
235 }
236
237 fn get_exec(&self) -> Result<&Executor, MeowError> {
238 if let Some(exec) = self.executor.get() {
239 crate::meow_flow_log!("executor", "reuse existing executor");
240 return Ok(exec.as_ref());
241 }
242
243 let _init_guard = self.executor_init.lock().map_err(|e| {
244 MeowError::from_code(
245 InnerErrorCode::LockPoisoned,
246 format!("executor init lock poisoned: {}", e),
247 )
248 })?;
249 if let Some(exec) = self.executor.get() {
250 crate::meow_flow_log!(
251 "executor",
252 "reuse executor initialized by concurrent caller"
253 );
254 return Ok(exec.as_ref());
255 }
256
257 let default_http_transfer = DefaultHttpTransfer::try_with_http_timeouts(
258 self.config.http_timeout(),
259 self.config.tcp_keepalive(),
260 )?;
261 crate::meow_key_log!(
262 "executor",
263 "initializing DefaultHttpTransfer (timeout={:?}, tcp_keepalive={:?})",
264 self.config.http_timeout(),
265 self.config.tcp_keepalive()
266 );
267 let exec = Arc::new(Executor::new(
268 self.config.clone(),
269 Arc::new(default_http_transfer),
270 self.global_progress_listener.clone(),
271 self.callback_dispatcher_owner.clone(),
272 )?);
273 self.executor.set(exec).map_err(|_| {
274 crate::meow_error_log!(
275 "executor",
276 "executor init race failed while holding init lock"
277 );
278 MeowError::from_code_str(
279 InnerErrorCode::RuntimeCreationFailedError,
280 "executor init race failed",
281 )
282 })?;
283 self.executor.get().map(Arc::as_ref).ok_or_else(|| {
284 crate::meow_error_log!(
285 "executor",
286 "executor init race failed after set; returning RuntimeCreationFailedError"
287 );
288 MeowError::from_code_str(
289 InnerErrorCode::RuntimeCreationFailedError,
290 "executor init race failed",
291 )
292 })
293 }
294
295 /// Returns the isolated binary executor. The caller must hold
296 /// `binary_lifecycle` so publication cannot race with `close()`.
297 fn get_binary_exec_locked(&self) -> Result<&BinaryExecutor, MeowError> {
298 if let Some(exec) = self.binary_executor.get() {
299 return Ok(exec.as_ref());
300 }
301 let exec = Arc::new(BinaryExecutor::new(&self.config)?);
302 self.binary_executor.set(exec).map_err(|_| {
303 MeowError::from_code_str(
304 InnerErrorCode::RuntimeCreationFailedError,
305 "binary executor publication raced unexpectedly",
306 )
307 })?;
308 self.binary_executor.get().map(Arc::as_ref).ok_or_else(|| {
309 MeowError::from_code_str(
310 InnerErrorCode::RuntimeCreationFailedError,
311 "binary executor was not available after publication",
312 )
313 })
314 }
315
316 /// Ensures the client is still open.
317 ///
318 /// Returns `ClientClosed` if [`Self::close`] was called successfully.
319 fn ensure_open(&self) -> Result<(), MeowError> {
320 if self.closed.load(Ordering::SeqCst) {
321 crate::meow_flow_log!("client", "ensure_open failed: client already closed");
322 Err(MeowError::from_code_str(
323 InnerErrorCode::ClientClosed,
324 "meow client is already closed",
325 ))
326 } else {
327 Ok(())
328 }
329 }
330
331 /// Registers a global progress listener for all tasks.
332 ///
333 /// # Parameters
334 ///
335 /// - `listener`: Callback receiving [`FileTransferRecord`] updates.
336 ///
337 /// # Returns
338 ///
339 /// Returns a listener ID used by
340 /// [`Self::unregister_global_progress_listener`].
341 ///
342 /// # Usage rules
343 ///
344 /// Keep callback execution short and panic-free. A heavy callback can slow
345 /// down global event delivery.
346 ///
347 /// # Errors
348 ///
349 /// Returns `LockPoisoned` when listener storage lock is poisoned.
350 ///
351 /// # Examples
352 ///
353 /// ```no_run
354 /// use rusty_cat::api::{MeowClient, MeowConfig};
355 ///
356 /// let client = MeowClient::new(MeowConfig::default());
357 /// let listener_id = client.register_global_progress_listener(|record| {
358 /// println!("task={} progress={:.2}", record.task_id(), record.progress());
359 /// })?;
360 /// let _ = listener_id;
361 /// # Ok::<(), rusty_cat::api::MeowError>(())
362 /// ```
363 pub fn register_global_progress_listener<F>(
364 &self,
365 listener: F,
366 ) -> Result<GlobalProgressListenerId, MeowError>
367 where
368 F: Fn(FileTransferRecord) + Send + Sync + 'static,
369 {
370 let id = GlobalProgressListenerId::new();
371 crate::meow_key_log!("listener", "register global listener: id={:?}", id);
372 let mut guard = self.global_progress_listener.write().map_err(|e| {
373 MeowError::from_code(
374 InnerErrorCode::LockPoisoned,
375 format!("register global listener lock poisoned: {}", e),
376 )
377 })?;
378 let mut next = guard.as_ref().to_vec();
379 next.push((id, Arc::new(listener)));
380 *guard = Arc::from(next);
381 Ok(id)
382 }
383
384 /// Unregisters one previously registered global progress listener.
385 ///
386 /// Returns `Ok(false)` when the ID does not exist.
387 ///
388 /// # Errors
389 ///
390 /// Returns `LockPoisoned` when listener storage lock is poisoned.
391 ///
392 /// # Examples
393 ///
394 /// ```no_run
395 /// use rusty_cat::api::{MeowClient, MeowConfig};
396 ///
397 /// let client = MeowClient::new(MeowConfig::default());
398 /// let id = client.register_global_progress_listener(|_| {})?;
399 /// let removed = client.unregister_global_progress_listener(id)?;
400 /// assert!(removed);
401 /// # Ok::<(), rusty_cat::api::MeowError>(())
402 /// ```
403 pub fn unregister_global_progress_listener(
404 &self,
405 id: GlobalProgressListenerId,
406 ) -> Result<bool, MeowError> {
407 let mut g = self.global_progress_listener.write().map_err(|e| {
408 MeowError::from_code(
409 InnerErrorCode::LockPoisoned,
410 format!("unregister global listener lock poisoned: {}", e),
411 )
412 })?;
413 if let Some(pos) = g.iter().position(|(k, _)| *k == id) {
414 let mut next = g.as_ref().to_vec();
415 next.remove(pos);
416 *g = Arc::from(next);
417 crate::meow_key_log!(
418 "listener",
419 "unregister global listener success: id={:?}",
420 id
421 );
422 Ok(true)
423 } else {
424 crate::meow_flow_log!("listener", "unregister global listener missed: id={:?}", id);
425 Ok(false)
426 }
427 }
428
429 /// Removes all registered global progress listeners.
430 ///
431 /// # Errors
432 ///
433 /// Returns `LockPoisoned` when listener storage lock is poisoned.
434 ///
435 /// # Examples
436 ///
437 /// ```no_run
438 /// use rusty_cat::api::{MeowClient, MeowConfig};
439 ///
440 /// let client = MeowClient::new(MeowConfig::default());
441 /// client.clear_global_listener()?;
442 /// # Ok::<(), rusty_cat::api::MeowError>(())
443 /// ```
444 pub fn clear_global_listener(&self) -> Result<(), MeowError> {
445 crate::meow_key_log!("listener", "clear all global listeners");
446 *self.global_progress_listener.write().map_err(|e| {
447 MeowError::from_code(
448 InnerErrorCode::LockPoisoned,
449 format!("clear global listeners lock poisoned: {}", e),
450 )
451 })? = Arc::from([]);
452 Ok(())
453 }
454
455 /// Sets or clears the global debug log listener.
456 ///
457 /// - Pass `Some(listener)` to set/replace.
458 /// - Pass `None` to clear.
459 ///
460 /// This affects all `MeowClient` instances in the current process.
461 ///
462 /// # Errors
463 ///
464 /// Returns [`DebugLogListenerError`] when the internal global listener lock
465 /// is poisoned.
466 ///
467 /// # Examples
468 ///
469 /// ```no_run
470 /// use std::sync::Arc;
471 /// use rusty_cat::api::{Log, MeowClient, MeowConfig};
472 ///
473 /// let client = MeowClient::new(MeowConfig::default());
474 /// client.set_debug_log_listener(Some(Arc::new(|log: Log| {
475 /// println!("{log}");
476 /// })))?;
477 ///
478 /// // Clear listener when no longer needed.
479 /// client.set_debug_log_listener(None)?;
480 /// # Ok::<(), rusty_cat::api::DebugLogListenerError>(())
481 /// ```
482 pub fn set_debug_log_listener(
483 &self,
484 listener: Option<DebugLogListener>,
485 ) -> Result<(), DebugLogListenerError> {
486 set_debug_log_listener(listener)
487 }
488}
489
490impl MeowClient {
491 /// Submits a transfer task to the internal scheduler and returns its
492 /// [`TaskId`].
493 ///
494 /// The actual upload/download execution is dispatched to an internal
495 /// worker system thread. This method only performs lightweight validation
496 /// and submission, so it does not block the caller thread waiting for full
497 /// transfer completion.
498 ///
499 /// `try_enqueue` is also the recovery entrypoint after process restart.
500 /// If the application was killed during a previous upload/download,
501 /// restart your process and call `try_enqueue` again to resume that
502 /// transfer workflow.
503 ///
504 /// # Back-pressure semantics (why the `try_` prefix)
505 ///
506 /// Internally this method uses
507 /// [`tokio::sync::mpsc::Sender::try_send`] to hand the `Enqueue` command
508 /// to the scheduler worker, **not** `send().await`. That means:
509 ///
510 /// - The `await` point in this function is used for task normalization
511 /// (e.g. resolving upload breakpoints, building an internal `InnerTask`), **not**
512 /// for waiting on command-queue capacity.
513 /// - If the command queue is momentarily full (bursty enqueue under
514 /// [`MeowConfig::command_queue_capacity`]), this method returns an
515 /// immediate `CommandSendFailed` error instead of suspending the
516 /// caller until a slot frees up.
517 /// - Other control APIs ([`Self::pause`], [`Self::resume`],
518 /// [`Self::cancel`], [`Self::snapshot`]) use `send().await` and **do**
519 /// wait for queue capacity. Only enqueue is fail-fast.
520 ///
521 /// Callers that want to batch-enqueue under burst load should either:
522 ///
523 /// 1. size [`MeowConfig::command_queue_capacity`] appropriately, or
524 /// 2. retry on `CommandSendFailed` with their own back-off, or
525 /// 3. rate-limit enqueue calls on the caller side.
526 ///
527 /// The name explicitly carries `try_` so this fail-fast behavior is
528 /// visible at the call site. If a fully-awaiting variant is introduced
529 /// later it should be named `enqueue` (without the `try_` prefix).
530 ///
531 /// # Parameters
532 ///
533 /// - `task`: Built by upload/download task builders.
534 /// - `progress_cb`: Per-task callback invoked with transfer progress.
535 /// - `complete_cb`: Callback fired once when task reaches
536 /// [`crate::transfer_status::TransferStatus::Complete`]. The second
537 /// argument is provider-defined payload returned by upload protocol
538 /// `complete_upload`; download tasks usually receive `None`.
539 ///
540 /// # Usage rules
541 ///
542 /// - `task` must be non-empty (required path/name/url and valid upload size).
543 /// - Callback should be lightweight and non-blocking.
544 /// - Store returned task ID for subsequent task control operations.
545 /// - `try_enqueue` is asynchronous task submission, not synchronous transfer.
546 /// - For restart recovery, re-enqueue the same logical task (same
547 /// upload/download target and compatible checkpoint context) so the
548 /// runtime can continue from existing local/remote progress.
549 ///
550 /// # Errors
551 ///
552 /// Returns:
553 /// - `ClientClosed` if the client was closed.
554 /// - `ParameterEmpty` if the task is invalid/empty.
555 /// - `CommandSendFailed` if the scheduler command queue is full at the
556 /// moment of submission (see back-pressure semantics above).
557 /// - Any runtime initialization errors from the executor.
558 ///
559 /// # Examples
560 ///
561 /// ```no_run
562 /// use rusty_cat::api::{DownloadPounceBuilder, MeowClient, MeowConfig};
563 ///
564 /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
565 /// let client = MeowClient::new(MeowConfig::default());
566 /// let task = DownloadPounceBuilder::new(
567 /// "example.bin",
568 /// "./downloads/example.bin",
569 /// 1024 * 1024,
570 /// "https://example.com/example.bin",
571 /// )
572 /// .build();
573 ///
574 /// let task_id = client
575 /// .try_enqueue(
576 /// task,
577 /// |record| {
578 /// println!("status={:?} progress={:.2}", record.status(), record.progress());
579 /// },
580 /// |task_id, payload| {
581 /// println!("task {task_id} completed, payload={payload:?}");
582 /// },
583 /// )
584 /// .await?;
585 /// println!("enqueued task: {task_id}");
586 /// # Ok(())
587 /// # }
588 /// ```
589 pub async fn try_enqueue<PCB, CCB>(
590 &self,
591 task: PounceTask,
592 progress_cb: PCB,
593 complete_cb: CCB,
594 ) -> Result<TaskId, MeowError>
595 where
596 PCB: Fn(FileTransferRecord) + Send + Sync + 'static,
597 CCB: Fn(TaskId, Option<String>) + Send + Sync + 'static,
598 {
599 self.ensure_open()?;
600 if task.is_empty() {
601 crate::meow_warn_log!("try_enqueue", "reject empty task");
602 return Err(MeowError::from_code1(InnerErrorCode::ParameterEmpty));
603 }
604
605 crate::meow_flow_log!(
606 "try_enqueue",
607 "task dir={:?} name={:?} size={} chunk={} method={:?} url={}",
608 task.direction,
609 task.file_name,
610 task.total_size,
611 task.chunk_size,
612 task.method,
613 crate::log::sanitize_url(&task.url)
614 );
615
616 let progress: ProgressCb = Arc::new(progress_cb);
617 let complete: Option<CompleteCb> = Some(Arc::new(complete_cb) as CompleteCb);
618 let callbacks = TaskCallbacks::new(Some(progress), complete);
619
620 let (def_up, def_down) = default_breakpoint_arcs();
621 let inner = InnerTask::from_pounce(
622 task,
623 self.config.breakpoint_download_http().clone(),
624 self.config.http_client_ref().cloned(),
625 def_up,
626 def_down,
627 )
628 .await?;
629
630 let task_id = self.get_exec()?.try_enqueue(inner, callbacks)?;
631 crate::meow_key_log!("try_enqueue", "try_enqueue success: task_id={:?}", task_id);
632 Ok(task_id)
633 }
634
635 /// Imports a transfer task in the **paused** state without scheduling it.
636 ///
637 /// This is the restart/restore entry point for callers that persist their
638 /// own transfer records: rebuild a [`PounceTask`] from your database, import
639 /// it here, and the task is registered into the scheduler as
640 /// [`TransferStatus::Paused`] **without** queueing, so it performs **zero
641 /// network or file I/O** until you explicitly start it.
642 ///
643 /// To start a previously imported task, call [`Self::resume`] with the
644 /// returned [`TaskId`]. A typical "restore N, start a user-selected subset"
645 /// flow imports every task with `try_enqueue_paused` and then calls
646 /// [`Self::resume`] only for the ids the user chose; the rest stay paused.
647 ///
648 /// # Difference from [`Self::try_enqueue`]
649 ///
650 /// - `try_enqueue` schedules immediately (the task becomes `Pending` and may
651 /// start transferring as soon as a concurrency slot is free).
652 /// - `try_enqueue_paused` registers the task as `Paused` and never queues it
653 /// until [`Self::resume`] is called.
654 ///
655 /// Back-pressure is identical: this method uses
656 /// [`tokio::sync::mpsc::Sender::try_send`] and fails fast with
657 /// `CommandSendFailed` if the command queue is full (see
658 /// [`Self::try_enqueue`] for the rationale behind the `try_` prefix).
659 ///
660 /// # Resume semantics after import
661 ///
662 /// When the imported task is later resumed, the resume point is recomputed
663 /// by the executor, **not** taken from any value passed here:
664 ///
665 /// - **Download**: resumes from the on-disk partial file length, so the
666 /// partial file must still exist at the task's `file_path`.
667 /// - **Upload**: resumes from the server-reported `next_byte` during the
668 /// upload `prepare` stage.
669 ///
670 /// # Progress reporting while paused
671 ///
672 /// The single `Paused` [`FileTransferRecord`] emitted on import reports
673 /// progress `0.0` because no `prepare` has run yet. Render the imported
674 /// task's real progress from your own persisted record; the SDK corrects it
675 /// after the first resume.
676 ///
677 /// # Parameters
678 ///
679 /// Same as [`Self::try_enqueue`]: a built `task`, a per-task `progress_cb`,
680 /// and a `complete_cb` fired once on terminal `Complete`.
681 ///
682 /// # Errors
683 ///
684 /// - `ClientClosed` if the client was closed.
685 /// - `ParameterEmpty` if the task is invalid/empty.
686 /// - `CommandSendFailed` if the scheduler command queue is full.
687 /// - Any runtime initialization errors from the executor.
688 ///
689 /// # Examples
690 ///
691 /// ```no_run
692 /// use rusty_cat::api::{DownloadPounceBuilder, MeowClient, MeowConfig};
693 ///
694 /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
695 /// let client = MeowClient::new(MeowConfig::default());
696 /// let task = DownloadPounceBuilder::new(
697 /// "example.bin",
698 /// "./downloads/example.bin",
699 /// 1024 * 1024,
700 /// "https://example.com/example.bin",
701 /// )
702 /// .build();
703 ///
704 /// // Import without starting it (no HTTP request, no file open).
705 /// let task_id = client
706 /// .try_enqueue_paused(task, |_record| {}, |_id, _payload| {})
707 /// .await?;
708 ///
709 /// // Later, when the user chooses to start this one:
710 /// client.resume(task_id).await?;
711 /// # Ok(())
712 /// # }
713 /// ```
714 pub async fn try_enqueue_paused<PCB, CCB>(
715 &self,
716 task: PounceTask,
717 progress_cb: PCB,
718 complete_cb: CCB,
719 ) -> Result<TaskId, MeowError>
720 where
721 PCB: Fn(FileTransferRecord) + Send + Sync + 'static,
722 CCB: Fn(TaskId, Option<String>) + Send + Sync + 'static,
723 {
724 self.ensure_open()?;
725 if task.is_empty() {
726 crate::meow_warn_log!("try_enqueue_paused", "reject empty task");
727 return Err(MeowError::from_code1(InnerErrorCode::ParameterEmpty));
728 }
729
730 crate::meow_flow_log!(
731 "try_enqueue_paused",
732 "task dir={:?} name={:?} size={} chunk={} method={:?} url={}",
733 task.direction,
734 task.file_name,
735 task.total_size,
736 task.chunk_size,
737 task.method,
738 crate::log::sanitize_url(&task.url)
739 );
740
741 let progress: ProgressCb = Arc::new(progress_cb);
742 let complete: Option<CompleteCb> = Some(Arc::new(complete_cb) as CompleteCb);
743 let callbacks = TaskCallbacks::new(Some(progress), complete);
744
745 let (def_up, def_down) = default_breakpoint_arcs();
746 let inner = InnerTask::from_pounce(
747 task,
748 self.config.breakpoint_download_http().clone(),
749 self.config.http_client_ref().cloned(),
750 def_up,
751 def_down,
752 )
753 .await?;
754
755 let task_id = self.get_exec()?.try_enqueue_paused(inner, callbacks)?;
756 crate::meow_key_log!(
757 "try_enqueue_paused",
758 "try_enqueue_paused success: task_id={:?}",
759 task_id
760 );
761 Ok(task_id)
762 }
763
764 /// Enqueues a task and `await`s until it reaches a terminal status.
765 ///
766 /// Wraps [`Self::try_enqueue`] with an internal oneshot channel so callers
767 /// do not have to write the channel + double-callback + single-send-guard
768 /// boilerplate themselves.
769 ///
770 /// # Returns
771 ///
772 /// - `Ok(TaskOutcome)` when the task reaches [`TransferStatus::Complete`].
773 /// - `Err(MeowError)` carrying the underlying failure for
774 /// [`TransferStatus::Failed`].
775 /// - `Err(MeowError)` with code [`InnerErrorCode::TaskCanceled`] for
776 /// [`TransferStatus::Canceled`].
777 ///
778 /// # Progress
779 ///
780 /// `progress_cb` receives every [`FileTransferRecord`] update, identical to
781 /// the per-task progress callback in [`Self::try_enqueue`].
782 ///
783 /// # Cancellation / timeout
784 ///
785 /// Dropping the returned future does **not** cancel the underlying transfer;
786 /// the task continues running in the executor. Use [`Self::cancel`] with
787 /// the task id (obtainable from `progress_cb`'s `record.task_id()`) to
788 /// abort an in-flight transfer.
789 ///
790 /// To cap wall-clock waiting time, wrap this future:
791 ///
792 /// ```ignore
793 /// let outcome = tokio::time::timeout(
794 /// std::time::Duration::from_secs(60),
795 /// client.enqueue_and_wait(task, |_| {}),
796 /// )
797 /// .await??;
798 /// ```
799 ///
800 /// # Errors
801 ///
802 /// In addition to the terminal-status errors above, propagates any error
803 /// from [`Self::try_enqueue`] (e.g. `ClientClosed`, `ParameterEmpty`,
804 /// `CommandSendFailed`).
805 ///
806 /// # Examples
807 ///
808 /// ```no_run
809 /// use rusty_cat::api::{DownloadPounceBuilder, MeowClient, MeowConfig};
810 ///
811 /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
812 /// let client = MeowClient::new(MeowConfig::default());
813 /// let task = DownloadPounceBuilder::new(
814 /// "example.bin",
815 /// "./downloads/example.bin",
816 /// 1024 * 1024,
817 /// "https://example.com/example.bin",
818 /// )
819 /// .build();
820 ///
821 /// let outcome = client
822 /// .enqueue_and_wait(task, |record| {
823 /// println!(
824 /// "task={} progress={:.2}",
825 /// record.task_id(),
826 /// record.progress()
827 /// );
828 /// })
829 /// .await?;
830 /// println!("task {} complete, payload={:?}", outcome.task_id, outcome.payload);
831 /// # Ok(())
832 /// # }
833 /// ```
834 pub async fn enqueue_and_wait<PCB>(
835 &self,
836 task: PounceTask,
837 progress_cb: PCB,
838 ) -> Result<TaskOutcome, MeowError>
839 where
840 PCB: Fn(FileTransferRecord) + Send + Sync + 'static,
841 {
842 let (tx, rx) = oneshot::channel::<TerminalMsg>();
843 let tx_slot: Arc<StdMutex<Option<oneshot::Sender<TerminalMsg>>>> =
844 Arc::new(StdMutex::new(Some(tx)));
845 let progress_slot = Arc::clone(&tx_slot);
846 let complete_slot = tx_slot;
847
848 self.try_enqueue(
849 task,
850 move |record: FileTransferRecord| {
851 progress_cb(record.clone());
852 match record.status() {
853 TransferStatus::Failed(err) => {
854 send_terminal_once(&progress_slot, Err(err.clone()));
855 }
856 TransferStatus::Canceled => {
857 send_terminal_once(
858 &progress_slot,
859 Err(MeowError::from_code_str(
860 InnerErrorCode::TaskCanceled,
861 "task was canceled",
862 )),
863 );
864 }
865 _ => {}
866 }
867 },
868 move |task_id, payload| {
869 send_terminal_once(&complete_slot, Ok((task_id, payload)));
870 },
871 )
872 .await?;
873
874 match rx.await {
875 Ok(Ok((task_id, payload))) => Ok(TaskOutcome { task_id, payload }),
876 Ok(Err(err)) => Err(err),
877 Err(_) => Err(MeowError::from_code_str(
878 InnerErrorCode::CommandResponseFailed,
879 "transfer terminal channel closed without notification",
880 )),
881 }
882 }
883
884 /// Pauses a running or pending PounceTask by ID.
885 ///
886 /// This API sends a control command to the internal scheduler worker
887 /// thread. It does not execute transfer pause logic on the caller thread.
888 ///
889 /// # Usage rules
890 ///
891 /// Call this with a valid task ID returned by [`Self::try_enqueue`] or
892 /// observed through [`Self::enqueue_and_wait`]'s progress callback.
893 ///
894 /// # Errors
895 ///
896 /// BinaryTask IDs return `InvalidTaskState` because binary tasks only
897 /// support cancellation. Other errors include `ClientClosed`,
898 /// `TaskNotFound`, or Pounce state-transition errors.
899 ///
900 /// # Examples
901 ///
902 /// ```no_run
903 /// use rusty_cat::api::{MeowClient, MeowConfig, TaskId};
904 ///
905 /// # async fn run(task_id: TaskId) -> Result<(), rusty_cat::api::MeowError> {
906 /// let client = MeowClient::new(MeowConfig::default());
907 /// client.pause(task_id).await?;
908 /// # Ok(())
909 /// # }
910 /// ```
911 pub async fn pause(&self, task_id: TaskId) -> Result<(), MeowError> {
912 self.ensure_open()?;
913 crate::meow_key_log!("client_api", "pause called: task_id={:?}", task_id);
914 if let Some(exec) = self.binary_executor.get() {
915 if exec.contains_task(task_id)? {
916 return Err(MeowError::from_code_str(
917 InnerErrorCode::InvalidTaskState,
918 "binary tasks do not support pause",
919 ));
920 }
921 }
922 self.get_exec()?.pause(task_id).await
923 }
924
925 /// Resumes a previously paused PounceTask.
926 ///
927 /// The same [`TaskId`] continues to identify the task after resume.
928 /// The resume command is forwarded to the internal scheduler worker
929 /// thread, so caller thread is not responsible for running transfer logic.
930 ///
931 /// # Errors
932 ///
933 /// Returns `ClientClosed`, `TaskNotFound`, or `InvalidTaskState`.
934 ///
935 /// # Examples
936 ///
937 /// ```no_run
938 /// use rusty_cat::api::{MeowClient, MeowConfig, TaskId};
939 ///
940 /// # async fn run(task_id: TaskId) -> Result<(), rusty_cat::api::MeowError> {
941 /// let client = MeowClient::new(MeowConfig::default());
942 /// client.resume(task_id).await?;
943 /// # Ok(())
944 /// # }
945 /// ```
946 pub async fn resume(&self, task_id: TaskId) -> Result<(), MeowError> {
947 self.ensure_open()?;
948 crate::meow_key_log!("client_api", "resume called: task_id={:?}", task_id);
949 if let Some(exec) = self.binary_executor.get() {
950 if exec.contains_task(task_id)? {
951 return Err(MeowError::from_code_str(
952 InnerErrorCode::InvalidTaskState,
953 "binary tasks do not support resume",
954 ));
955 }
956 }
957 self.get_exec()?.resume(task_id).await
958 }
959
960 /// Cancels a task by ID.
961 ///
962 /// Cancellation is routed to the isolated Binary executor for a live
963 /// BinaryTask ID; all other IDs retain the existing Pounce scheduler path.
964 ///
965 /// # Usage rules
966 ///
967 /// Cancellation is best-effort; protocol-specific cleanup may run.
968 ///
969 /// # Errors
970 ///
971 /// Returns `ClientClosed`, `TaskNotFound`, or runtime cancellation errors.
972 ///
973 /// # Examples
974 ///
975 /// ```no_run
976 /// use rusty_cat::api::{MeowClient, MeowConfig, TaskId};
977 ///
978 /// # async fn run(task_id: TaskId) -> Result<(), rusty_cat::api::MeowError> {
979 /// let client = MeowClient::new(MeowConfig::default());
980 /// client.cancel(task_id).await?;
981 /// # Ok(())
982 /// # }
983 /// ```
984 pub async fn cancel(&self, task_id: TaskId) -> Result<(), MeowError> {
985 self.ensure_open()?;
986 crate::meow_key_log!("client_api", "cancel called: task_id={:?}", task_id);
987 if let Some(exec) = self.binary_executor.get() {
988 if exec.contains_task(task_id)? {
989 return exec.cancel(task_id).await;
990 }
991 }
992 self.get_exec()?.cancel(task_id).await
993 }
994
995 /// Returns a snapshot of queue and active Pounce transfer groups.
996 ///
997 /// Useful for diagnostics and external monitoring dashboards.
998 /// BinaryTask state is intentionally excluded and never queried here.
999 ///
1000 /// # Errors
1001 ///
1002 /// Returns `ClientClosed`, runtime command delivery errors, or scheduler
1003 /// snapshot retrieval errors.
1004 ///
1005 /// # Examples
1006 ///
1007 /// ```no_run
1008 /// use rusty_cat::api::{MeowClient, MeowConfig};
1009 ///
1010 /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
1011 /// let client = MeowClient::new(MeowConfig::default());
1012 /// let snap = client.snapshot().await?;
1013 /// println!("queued={}, active={}", snap.queued_groups, snap.active_groups);
1014 /// # Ok(())
1015 /// # }
1016 /// ```
1017 pub async fn snapshot(&self) -> Result<TransferSnapshot, MeowError> {
1018 self.ensure_open()?;
1019 crate::meow_flow_log!("client_api", "snapshot called");
1020 self.get_exec()?.snapshot().await
1021 }
1022
1023 /// Closes this client and every initialized Pounce/Binary executor.
1024 ///
1025 /// `close` is the terminal lifecycle operation for a `MeowClient`. After
1026 /// it succeeds, this client stays permanently closed; submit more work by
1027 /// constructing a new `MeowClient` and enqueueing tasks there.
1028 ///
1029 /// After a successful close:
1030 ///
1031 /// - New task and control operations on this client are rejected with
1032 /// `ClientClosed`.
1033 /// - All known unfinished task groups (queued, paused, or active) receive
1034 /// a `Paused` progress notification through their task callback and all
1035 /// registered global listeners.
1036 /// - In-flight transfers are cancelled and the scheduler state is cleared.
1037 /// - Already submitted callback jobs are drained before returning.
1038 /// - The internal scheduler thread is joined, which drops its Tokio
1039 /// runtime and releases SDK-owned background execution resources.
1040 ///
1041 /// `Paused` is used for shutdown notifications rather than `Canceled` so
1042 /// callers can recreate a client later and re-enqueue the same logical
1043 /// transfer when they want to resume from available breakpoint state.
1044 ///
1045 /// # Idempotency
1046 ///
1047 /// Calling `close` more than once returns `ClientClosed`.
1048 /// A client that initialized BinaryExecutor performs mixed teardown on an
1049 /// SDK-owned coordinator, so dropping the close Future does not strand the
1050 /// lifecycle and does not require an additional caller-side Tokio runtime.
1051 /// If BinaryExecutor was never initialized, close does not create any
1052 /// Binary runtime, thread, HTTP client, or channel.
1053 ///
1054 /// # Retry behavior
1055 ///
1056 /// If BinaryExecutor was never initialized, the existing Pounce-only close
1057 /// failure behavior is preserved: the closed flag is rolled back so callers
1058 /// can retry. For a mixed client, a partial close never reopens business
1059 /// APIs; another `close` call retries only unfinished teardown.
1060 ///
1061 /// # Errors
1062 ///
1063 /// Returns `ClientClosed` when already closed, `InvalidTaskState` when
1064 /// synchronously awaited from this client's transfer callback dispatcher,
1065 /// or underlying executor close errors when shutdown is not completed.
1066 ///
1067 /// # Examples
1068 ///
1069 /// ```no_run
1070 /// use rusty_cat::api::{MeowClient, MeowConfig};
1071 ///
1072 /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
1073 /// let client = MeowClient::new(MeowConfig::default());
1074 /// client.close().await?;
1075 /// # Ok(())
1076 /// # }
1077 /// ```
1078 pub async fn close(&self) -> Result<(), MeowError> {
1079 // Close drains and joins the Pounce callback dispatcher before replying.
1080 // Reject on that same thread before touching any lifecycle bit or sending
1081 // a command; otherwise the callback waits for close while close waits for
1082 // the callback to return. Callers may schedule close elsewhere after the
1083 // callback returns without changing normal callback-drain semantics.
1084 if crate::inner::cb_dispatcher::is_callback_dispatcher_thread_for(
1085 &self.callback_dispatcher_owner,
1086 ) {
1087 return Err(MeowError::from_code_str(
1088 InnerErrorCode::InvalidTaskState,
1089 "close cannot be awaited from a transfer callback; schedule it after the callback returns",
1090 ));
1091 }
1092 let close_notification = self.close_notify.notified();
1093 tokio::pin!(close_notification);
1094 // `notify_waiters` does not retain a permit for a Future that has not
1095 // registered yet. Register before inspecting the lifecycle so a fast
1096 // concurrent close cannot publish completion between the state check
1097 // and this caller awaiting the notification.
1098 close_notification.as_mut().enable();
1099 let close_plan = {
1100 let mut lifecycle = self.binary_lifecycle.lock().map_err(|error| {
1101 MeowError::from_code(
1102 InnerErrorCode::LockPoisoned,
1103 format!("binary lifecycle lock poisoned: {error}"),
1104 )
1105 })?;
1106 match *lifecycle {
1107 BinaryLifecycle::Closing => None,
1108 BinaryLifecycle::Closed => return Err(client_closed_error()),
1109 BinaryLifecycle::CloseFailed {
1110 pounce_closed,
1111 binary_closed,
1112 } => {
1113 *lifecycle = BinaryLifecycle::Closing;
1114 Some((pounce_closed, binary_closed, true))
1115 }
1116 BinaryLifecycle::Open => {
1117 if self
1118 .closed
1119 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1120 .is_err()
1121 {
1122 return Err(client_closed_error());
1123 }
1124 *lifecycle = BinaryLifecycle::Closing;
1125 Some((false, false, self.binary_executor.get().is_some()))
1126 }
1127 }
1128 };
1129 let Some((pounce_already_closed, binary_already_closed, mixed)) = close_plan else {
1130 close_notification.await;
1131 return Err(client_closed_error());
1132 };
1133
1134 if !mixed {
1135 let result = if let Some(exec) = self.executor.get() {
1136 exec.close().await
1137 } else {
1138 Ok(())
1139 };
1140 if let Ok(mut lifecycle) = self.binary_lifecycle.lock() {
1141 if result.is_ok() {
1142 *lifecycle = BinaryLifecycle::Closed;
1143 } else {
1144 *lifecycle = BinaryLifecycle::Open;
1145 self.closed.store(false, Ordering::SeqCst);
1146 }
1147 }
1148 self.close_notify.notify_waiters();
1149 return result;
1150 }
1151
1152 let pounce = self.executor.get().cloned();
1153 let binary = self.binary_executor.get().cloned();
1154 let lifecycle = Arc::clone(&self.binary_lifecycle);
1155 let close_notify = Arc::clone(&self.close_notify);
1156 let progress = Arc::new(CloseProgress::new(
1157 pounce_already_closed,
1158 binary_already_closed,
1159 ));
1160 let (result_tx, result_rx) = oneshot::channel();
1161
1162 // Mixed teardown is detached from the caller's Future and runs on an
1163 // SDK-owned coordinator thread. This preserves cancellation safety
1164 // without adding a caller-side Tokio runtime requirement.
1165 let thread_lifecycle = Arc::clone(&lifecycle);
1166 let thread_notify = Arc::clone(&close_notify);
1167 let thread_progress = Arc::clone(&progress);
1168 let pounce_probe = pounce.clone();
1169 let binary_probe = binary.clone();
1170 let spawn_result = std::thread::Builder::new()
1171 .name("rusty-cat-close-supervisor".to_owned())
1172 .spawn(move || {
1173 let attempt_result = run_guarded_close_attempt(|| {
1174 let runtime = tokio::runtime::Builder::new_current_thread()
1175 .enable_all()
1176 .build()
1177 .map_err(|error| {
1178 MeowError::from_code(
1179 InnerErrorCode::RuntimeCreationFailedError,
1180 format!("create close supervisor runtime failed: {error}"),
1181 )
1182 })?;
1183 runtime.block_on(run_mixed_close_attempt(pounce, binary, &thread_progress))
1184 });
1185 reconcile_close_progress(&thread_progress, &pounce_probe, &binary_probe);
1186 let result =
1187 publish_mixed_close_result(&thread_lifecycle, &thread_progress, attempt_result);
1188 thread_notify.notify_waiters();
1189 let _ = result_tx.send(result);
1190 });
1191
1192 if let Err(error) = spawn_result {
1193 let spawn_error = MeowError::from_code(
1194 InnerErrorCode::RuntimeCreationFailedError,
1195 format!("spawn close supervisor failed: {error}"),
1196 );
1197 let result = publish_mixed_close_result(&lifecycle, &progress, Err(spawn_error));
1198 close_notify.notify_waiters();
1199 return result;
1200 }
1201
1202 result_rx.await.map_err(|error| {
1203 MeowError::from_code(
1204 InnerErrorCode::CommandResponseFailed,
1205 format!("close teardown task ended without a result: {error}"),
1206 )
1207 })?
1208 }
1209
1210 /// Returns whether this client is currently closed.
1211 ///
1212 /// # Examples
1213 ///
1214 /// ```no_run
1215 /// use rusty_cat::api::{MeowClient, MeowConfig};
1216 ///
1217 /// let client = MeowClient::new(MeowConfig::default());
1218 /// let _closed = client.is_closed();
1219 /// ```
1220 pub fn is_closed(&self) -> bool {
1221 self.closed.load(Ordering::SeqCst)
1222 }
1223}
1224
1225fn run_guarded_close_attempt<F>(attempt: F) -> Result<(), MeowError>
1226where
1227 F: FnOnce() -> Result<(), MeowError>,
1228{
1229 match std::panic::catch_unwind(AssertUnwindSafe(attempt)) {
1230 Ok(result) => result,
1231 Err(_) => Err(MeowError::from_code_str(
1232 InnerErrorCode::Unknown,
1233 "mixed close supervisor panicked",
1234 )),
1235 }
1236}
1237
1238struct CloseProgress {
1239 pounce_closed: AtomicBool,
1240 binary_closed: AtomicBool,
1241}
1242
1243impl CloseProgress {
1244 fn new(pounce_closed: bool, binary_closed: bool) -> Self {
1245 Self {
1246 pounce_closed: AtomicBool::new(pounce_closed),
1247 binary_closed: AtomicBool::new(binary_closed),
1248 }
1249 }
1250
1251 fn snapshot(&self) -> (bool, bool) {
1252 (
1253 self.pounce_closed.load(Ordering::SeqCst),
1254 self.binary_closed.load(Ordering::SeqCst),
1255 )
1256 }
1257}
1258
1259fn reconcile_close_progress(
1260 progress: &CloseProgress,
1261 pounce: &Option<Arc<Executor>>,
1262 binary: &Option<Arc<BinaryExecutor>>,
1263) {
1264 if pounce.as_ref().is_none_or(|exec| exec.is_close_complete()) {
1265 progress.pounce_closed.store(true, Ordering::SeqCst);
1266 }
1267 if binary.as_ref().is_none_or(|exec| exec.is_close_complete()) {
1268 progress.binary_closed.store(true, Ordering::SeqCst);
1269 }
1270}
1271
1272fn publish_mixed_close_result(
1273 lifecycle: &Arc<StdMutex<BinaryLifecycle>>,
1274 progress: &CloseProgress,
1275 attempt_result: Result<(), MeowError>,
1276) -> Result<(), MeowError> {
1277 let (pounce_closed, binary_closed) = progress.snapshot();
1278 let mut state = lifecycle.lock().map_err(|error| {
1279 MeowError::from_code(
1280 InnerErrorCode::LockPoisoned,
1281 format!("binary lifecycle lock poisoned after mixed close: {error}"),
1282 )
1283 })?;
1284 *state = if pounce_closed && binary_closed {
1285 BinaryLifecycle::Closed
1286 } else {
1287 BinaryLifecycle::CloseFailed {
1288 pounce_closed,
1289 binary_closed,
1290 }
1291 };
1292 attempt_result
1293}
1294
1295async fn run_mixed_close_attempt(
1296 pounce: Option<Arc<Executor>>,
1297 binary: Option<Arc<BinaryExecutor>>,
1298 progress: &CloseProgress,
1299) -> Result<(), MeowError> {
1300 let pounce_close = async {
1301 if progress.pounce_closed.load(Ordering::SeqCst) {
1302 Ok(())
1303 } else if let Some(executor) = pounce {
1304 let result = executor.close().await;
1305 if result.is_ok() || executor.is_close_complete() {
1306 progress.pounce_closed.store(true, Ordering::SeqCst);
1307 }
1308 result
1309 } else {
1310 progress.pounce_closed.store(true, Ordering::SeqCst);
1311 Ok(())
1312 }
1313 };
1314 let binary_close = async {
1315 if progress.binary_closed.load(Ordering::SeqCst) {
1316 Ok(())
1317 } else if let Some(executor) = binary {
1318 let result = executor.close().await;
1319 if result.is_ok() || executor.is_close_complete() {
1320 progress.binary_closed.store(true, Ordering::SeqCst);
1321 }
1322 result
1323 } else {
1324 progress.binary_closed.store(true, Ordering::SeqCst);
1325 Ok(())
1326 }
1327 };
1328 let (pounce_result, binary_result) = tokio::join!(pounce_close, binary_close);
1329 match (pounce_result, binary_result) {
1330 (Err(error), _) | (_, Err(error)) => Err(error),
1331 (Ok(()), Ok(())) => Ok(()),
1332 }
1333}
1334
1335fn send_terminal_once(
1336 slot: &Arc<StdMutex<Option<oneshot::Sender<TerminalMsg>>>>,
1337 msg: TerminalMsg,
1338) {
1339 if let Ok(mut guard) = slot.lock() {
1340 if let Some(sender) = guard.take() {
1341 let _ = sender.send(msg);
1342 }
1343 }
1344}
1345
1346fn client_closed_error() -> MeowError {
1347 MeowError::from_code_str(
1348 InnerErrorCode::ClientClosed,
1349 "meow client is already closed",
1350 )
1351}
1352
1353impl MeowClient {
1354 /// Enqueues one bounded, in-memory HTTP GET on an isolated executor.
1355 ///
1356 /// Binary tasks support [`Self::cancel`] only. They do not support
1357 /// pause/resume and are deliberately excluded from [`Self::snapshot`].
1358 /// The callback may run concurrently before this method returns. It owns a
1359 /// [`BinaryDownloadOutput`]; move or clone its `Bytes` when retaining data.
1360 /// The callback must return in bounded time and must not synchronously wait
1361 /// for `close()` on this same client because close drains callbacks.
1362 ///
1363 /// At most two binary HTTP requests run concurrently. At most 1024 accepted
1364 /// tasks may be queued, active, or waiting for their callback to return.
1365 pub fn try_enqueue_binary_task<CCB>(
1366 &self,
1367 task: BinaryTask,
1368 complete_cb: CCB,
1369 ) -> Result<TaskId, MeowError>
1370 where
1371 CCB: FnOnce(TaskId, Result<BinaryDownloadOutput, MeowError>) + Send + 'static,
1372 {
1373 let lifecycle = self.binary_lifecycle.lock().map_err(|error| {
1374 MeowError::from_code(
1375 InnerErrorCode::LockPoisoned,
1376 format!("binary lifecycle lock poisoned: {error}"),
1377 )
1378 })?;
1379 if !matches!(*lifecycle, BinaryLifecycle::Open) || self.closed.load(Ordering::SeqCst) {
1380 return Err(client_closed_error());
1381 }
1382 let binary_config = self
1383 .config
1384 .binary_download_config()
1385 .cloned()
1386 .unwrap_or_default();
1387 let parsed_url = task.validate(binary_config.max_body_bytes())?;
1388 let executor = self.get_binary_exec_locked()?;
1389 let callback: BinaryCompleteCb = Box::new(complete_cb);
1390 executor.try_enqueue(task, parsed_url, callback)
1391 }
1392}
1393
1394#[cfg(test)]
1395mod close_tests {
1396 use super::*;
1397
1398 #[test]
1399 fn supervisor_panic_preserves_completed_executor_progress() {
1400 let lifecycle = Arc::new(StdMutex::new(BinaryLifecycle::Closing));
1401 let progress = CloseProgress::new(false, false);
1402 let attempt = run_guarded_close_attempt(|| {
1403 progress.pounce_closed.store(true, Ordering::SeqCst);
1404 panic!("injected close panic after Pounce completed");
1405 });
1406 let error = publish_mixed_close_result(&lifecycle, &progress, attempt)
1407 .expect_err("panic must remain observable");
1408 assert_eq!(error.code(), InnerErrorCode::Unknown as i32);
1409 assert!(matches!(
1410 *lifecycle.lock().unwrap(),
1411 BinaryLifecycle::CloseFailed {
1412 pounce_closed: true,
1413 binary_closed: false,
1414 }
1415 ));
1416 }
1417
1418 #[test]
1419 fn fully_completed_teardown_publishes_closed_even_if_reporting_failed() {
1420 let lifecycle = Arc::new(StdMutex::new(BinaryLifecycle::Closing));
1421 let progress = CloseProgress::new(true, true);
1422 let error = publish_mixed_close_result(
1423 &lifecycle,
1424 &progress,
1425 Err(MeowError::from_code_str(
1426 InnerErrorCode::Unknown,
1427 "late close reporting failure",
1428 )),
1429 )
1430 .expect_err("reporting error remains observable");
1431 assert_eq!(error.code(), InnerErrorCode::Unknown as i32);
1432 assert!(matches!(
1433 *lifecycle.lock().unwrap(),
1434 BinaryLifecycle::Closed
1435 ));
1436 }
1437
1438 #[test]
1439 fn supervisor_start_failure_never_leaves_lifecycle_closing() {
1440 let lifecycle = Arc::new(StdMutex::new(BinaryLifecycle::Closing));
1441 let progress = CloseProgress::new(false, false);
1442 publish_mixed_close_result(
1443 &lifecycle,
1444 &progress,
1445 Err(MeowError::from_code_str(
1446 InnerErrorCode::RuntimeCreationFailedError,
1447 "injected supervisor thread start failure",
1448 )),
1449 )
1450 .expect_err("start failure");
1451 assert!(matches!(
1452 *lifecycle.lock().unwrap(),
1453 BinaryLifecycle::CloseFailed {
1454 pounce_closed: false,
1455 binary_closed: false,
1456 }
1457 ));
1458 }
1459}