Skip to main content

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}