Skip to main content

rusty_cat/
meow_client.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock};
3
4use tokio::sync::oneshot;
5
6use crate::dflt::default_http_transfer::{
7    build_internal_client, default_breakpoint_arcs, DefaultHttpTransfer,
8};
9use crate::error::{InnerErrorCode, MeowError};
10use crate::file_transfer_record::FileTransferRecord;
11use crate::ids::{GlobalProgressListenerId, TaskId};
12use crate::inner::executor::Executor;
13use crate::inner::inner_task::InnerTask;
14use crate::inner::task_callbacks::{CompleteCb, ProgressCb, TaskCallbacks};
15use crate::log::{set_debug_log_listener, DebugLogListener, DebugLogListenerError};
16use crate::meow_config::MeowConfig;
17use crate::pounce_task::PounceTask;
18use crate::transfer_snapshot::TransferSnapshot;
19use crate::transfer_status::TransferStatus;
20
21/// Callback type for globally observing task progress events.
22///
23/// The callback is invoked from runtime worker context. Keep callback logic
24/// fast and non-blocking to avoid delaying event processing.
25pub type GlobalProgressListener = ProgressCb;
26
27/// Main entry point of the `rusty-cat` SDK.
28///
29/// `MeowClient` owns runtime state and provides high-level operations:
30/// enqueue, pause, resume, cancel, snapshot, and close.
31///
32/// # Usage pattern
33///
34/// 1. Create [`MeowConfig`].
35/// 2. Construct `MeowClient::new(config)`.
36/// 3. Build tasks with upload/download builders.
37/// 4. Call [`Self::enqueue`] and store returned [`TaskId`].
38/// 5. Control task lifecycle with pause/resume/cancel.
39/// 6. Call [`Self::close`] during shutdown.
40///
41/// # Lifecycle contract: you **must** call [`Self::close`]
42///
43/// The background scheduler runs on a dedicated [`std::thread`] that drives
44/// its own Tokio runtime. The clean shutdown protocol is an explicit
45/// `close().await` command which:
46///
47/// - cancels in-flight transfers,
48/// - flushes `Paused` status events to user callbacks for every known group,
49/// - drains already submitted callback jobs,
50/// - joins the scheduler thread and lets the runtime drop.
51///
52/// Forgetting to call `close` leaves the scheduler thread alive until all
53/// command senders are dropped (which does happen when `MeowClient` is
54/// dropped, but only as a fallback). When that fallback path runs, the
55/// guarantees above do **not** hold: callers may miss terminal status
56/// events, in-flight HTTP transfers are aborted abruptly, and for long-lived
57/// SDK hosts (servers, mobile runtimes, etc.) the misuse is nearly
58/// impossible to debug from the outside.
59///
60/// To help surface this misuse the internal executor implements a
61/// **best-effort [`Drop`]** that, when `close` was never called:
62///
63/// - emits a `Warn`-level log via the debug log listener (tag
64///   `"executor_drop"`),
65/// - performs a non-blocking `try_send` of a final `Close` command so the
66///   worker still has a chance to drain its state,
67/// - then drops the command sender, causing the worker loop to exit on its
68///   own.
69///
70/// This is a safety net, **not** a substitute for calling `close`. Treat
71/// `close().await` as a mandatory step in your shutdown sequence.
72///
73/// # Sharing across tasks / threads
74///
75/// `MeowClient` **intentionally does not implement [`Clone`]**.
76///
77/// The client owns a lazily-initialized [`Executor`] (a single background
78/// worker loop plus its task table, scheduler state and shutdown flag). A
79/// naive field-by-field `Clone` would copy the `OnceLock<Executor>` *before*
80/// it was initialized, letting different clones each spin up their **own**
81/// executor on first use. The result would be:
82///
83/// - multiple independent task tables (tasks enqueued via one clone are
84///   invisible to `pause` / `resume` / `cancel` / `snapshot` on another);
85/// - concurrency limits ([`MeowConfig::max_upload_concurrency`] /
86///   [`MeowConfig::max_download_concurrency`]) silently multiplied by the
87///   number of clones;
88/// - [`Self::close`] only shutting down one of the worker loops, leaking the
89///   rest.
90///
91/// To share a client across tasks or threads, wrap it in [`std::sync::Arc`]
92/// and clone the `Arc` instead:
93///
94/// ```no_run
95/// use std::sync::Arc;
96/// use rusty_cat::api::{MeowClient, MeowConfig};
97///
98/// let client = Arc::new(MeowClient::new(MeowConfig::default()));
99/// let client_for_task = Arc::clone(&client);
100/// tokio::spawn(async move {
101///     let _ = client_for_task; // use the shared client here
102/// });
103/// ```
104/// Outcome of a task that reached [`TransferStatus::Complete`].
105///
106/// Returned by [`MeowClient::enqueue_and_wait`].
107#[derive(Debug, Clone)]
108pub struct TaskOutcome {
109    /// Task identifier returned by the underlying scheduler.
110    pub task_id: TaskId,
111    /// Provider-defined payload returned by upload protocol's `complete_upload`.
112    /// Download tasks usually receive `None`.
113    pub payload: Option<String>,
114}
115
116pub struct MeowClient {
117    /// Lazily initialized task executor.
118    ///
119    /// Deliberately **not** wrapped in `Arc`: `MeowClient` is not `Clone`, so
120    /// there is exactly one owner of this `OnceLock`. Share the whole client
121    /// via `Arc<MeowClient>` when multi-owner access is needed.
122    executor: OnceLock<Executor>,
123    executor_init: StdMutex<()>,
124    /// Immutable runtime configuration.
125    config: MeowConfig,
126    /// Global listeners receiving progress records for all tasks.
127    global_progress_listener: Arc<RwLock<Vec<(GlobalProgressListenerId, GlobalProgressListener)>>>,
128    /// Global closed flag. Once set to `true`, task control APIs reject calls.
129    closed: Arc<AtomicBool>,
130}
131
132impl std::fmt::Debug for MeowClient {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("MeowClient")
135            .field("config", &self.config)
136            .field("global_progress_listener", &"..")
137            .finish()
138    }
139}
140
141impl MeowClient {
142    /// Creates a new client with the provided configuration.
143    ///
144    /// The internal executor is initialized lazily on first task operation.
145    ///
146    /// # Examples
147    ///
148    /// ```no_run
149    /// use rusty_cat::api::{MeowClient, MeowConfig};
150    ///
151    /// let config = MeowConfig::default();
152    /// let client = MeowClient::new(config);
153    /// let _ = client;
154    /// ```
155    pub fn new(config: MeowConfig) -> Self {
156        MeowClient {
157            executor: Default::default(),
158            executor_init: StdMutex::new(()),
159            config,
160            global_progress_listener: Arc::new(RwLock::new(Vec::new())),
161            closed: Arc::new(AtomicBool::new(false)),
162        }
163    }
164
165    /// Returns a `reqwest::Client` aligned with this client's configuration.
166    ///
167    /// - If [`MeowConfigBuilder::http_client`](crate::api::MeowConfigBuilder::http_client)
168    ///   injected a custom client, this
169    ///   returns its clone.
170    /// - Otherwise, this builds a new client from `http_timeout` and
171    ///   `tcp_keepalive`.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`MeowError`] with `HttpClientBuildFailed` when client creation
176    /// fails.
177    ///
178    /// # Examples
179    ///
180    /// ```no_run
181    /// use rusty_cat::api::{MeowClient, MeowConfig};
182    ///
183    /// let client = MeowClient::new(MeowConfig::default());
184    /// let http = client.http_client()?;
185    /// let _ = http;
186    /// # Ok::<(), rusty_cat::api::MeowError>(())
187    /// ```
188    pub fn http_client(&self) -> Result<reqwest::Client, MeowError> {
189        if let Some(c) = self.config.http_client_ref() {
190            return Ok(c.clone());
191        }
192        // Build through the shared helper so this client carries the exact same
193        // transport policy (connect timeout + idle connection pool) as the
194        // transfer backend, rather than reqwest's bare defaults.
195        build_internal_client(self.config.http_timeout(), self.config.tcp_keepalive())
196            .map_err(|e| {
197                MeowError::from_source(
198                    InnerErrorCode::HttpClientBuildFailed,
199                    format!(
200                        "build reqwest client failed (timeout={:?}, keepalive={:?})",
201                        self.config.http_timeout(),
202                        self.config.tcp_keepalive()
203                    ),
204                    e,
205                )
206            })
207    }
208
209    fn get_exec(&self) -> Result<&Executor, MeowError> {
210        if let Some(exec) = self.executor.get() {
211            crate::meow_flow_log!("executor", "reuse existing executor");
212            return Ok(exec);
213        }
214
215        let _init_guard = self.executor_init.lock().map_err(|e| {
216            MeowError::from_code(
217                InnerErrorCode::LockPoisoned,
218                format!("executor init lock poisoned: {}", e),
219            )
220        })?;
221        if let Some(exec) = self.executor.get() {
222            crate::meow_flow_log!(
223                "executor",
224                "reuse executor initialized by concurrent caller"
225            );
226            return Ok(exec);
227        }
228
229        let default_http_transfer = DefaultHttpTransfer::try_with_http_timeouts(
230            self.config.http_timeout(),
231            self.config.tcp_keepalive(),
232        )?;
233        crate::meow_key_log!(
234            "executor",
235            "initializing DefaultHttpTransfer (timeout={:?}, tcp_keepalive={:?})",
236            self.config.http_timeout(),
237            self.config.tcp_keepalive()
238        );
239        let exec = Executor::new(
240            self.config.clone(),
241            Arc::new(default_http_transfer),
242            self.global_progress_listener.clone(),
243        )?;
244        self.executor.set(exec).map_err(|_| {
245            crate::meow_error_log!(
246                "executor",
247                "executor init race failed while holding init lock"
248            );
249            MeowError::from_code_str(
250                InnerErrorCode::RuntimeCreationFailedError,
251                "executor init race failed",
252            )
253        })?;
254        self.executor.get().ok_or_else(|| {
255            crate::meow_error_log!(
256                "executor",
257                "executor init race failed after set; returning RuntimeCreationFailedError"
258            );
259            MeowError::from_code_str(
260                InnerErrorCode::RuntimeCreationFailedError,
261                "executor init race failed",
262            )
263        })
264    }
265
266    /// Ensures the client is still open.
267    ///
268    /// Returns `ClientClosed` if [`Self::close`] was called successfully.
269    fn ensure_open(&self) -> Result<(), MeowError> {
270        if self.closed.load(Ordering::SeqCst) {
271            crate::meow_flow_log!("client", "ensure_open failed: client already closed");
272            Err(MeowError::from_code_str(
273                InnerErrorCode::ClientClosed,
274                "meow client is already closed",
275            ))
276        } else {
277            Ok(())
278        }
279    }
280
281    /// Registers a global progress listener for all tasks.
282    ///
283    /// # Parameters
284    ///
285    /// - `listener`: Callback receiving [`FileTransferRecord`] updates.
286    ///
287    /// # Returns
288    ///
289    /// Returns a listener ID used by
290    /// [`Self::unregister_global_progress_listener`].
291    ///
292    /// # Usage rules
293    ///
294    /// Keep callback execution short and panic-free. A heavy callback can slow
295    /// down global event delivery.
296    ///
297    /// # Errors
298    ///
299    /// Returns `LockPoisoned` when listener storage lock is poisoned.
300    ///
301    /// # Examples
302    ///
303    /// ```no_run
304    /// use rusty_cat::api::{MeowClient, MeowConfig};
305    ///
306    /// let client = MeowClient::new(MeowConfig::default());
307    /// let listener_id = client.register_global_progress_listener(|record| {
308    ///     println!("task={} progress={:.2}", record.task_id(), record.progress());
309    /// })?;
310    /// let _ = listener_id;
311    /// # Ok::<(), rusty_cat::api::MeowError>(())
312    /// ```
313    pub fn register_global_progress_listener<F>(
314        &self,
315        listener: F,
316    ) -> Result<GlobalProgressListenerId, MeowError>
317    where
318        F: Fn(FileTransferRecord) + Send + Sync + 'static,
319    {
320        let id = GlobalProgressListenerId::new();
321        crate::meow_key_log!("listener", "register global listener: id={:?}", id);
322        let mut guard = self.global_progress_listener.write().map_err(|e| {
323            MeowError::from_code(
324                InnerErrorCode::LockPoisoned,
325                format!("register global listener lock poisoned: {}", e),
326            )
327        })?;
328        guard.push((id, Arc::new(listener)));
329        Ok(id)
330    }
331
332    /// Unregisters one previously registered global progress listener.
333    ///
334    /// Returns `Ok(false)` when the ID does not exist.
335    ///
336    /// # Errors
337    ///
338    /// Returns `LockPoisoned` when listener storage lock is poisoned.
339    ///
340    /// # Examples
341    ///
342    /// ```no_run
343    /// use rusty_cat::api::{MeowClient, MeowConfig};
344    ///
345    /// let client = MeowClient::new(MeowConfig::default());
346    /// let id = client.register_global_progress_listener(|_| {})?;
347    /// let removed = client.unregister_global_progress_listener(id)?;
348    /// assert!(removed);
349    /// # Ok::<(), rusty_cat::api::MeowError>(())
350    /// ```
351    pub fn unregister_global_progress_listener(
352        &self,
353        id: GlobalProgressListenerId,
354    ) -> Result<bool, MeowError> {
355        let mut g = self.global_progress_listener.write().map_err(|e| {
356            MeowError::from_code(
357                InnerErrorCode::LockPoisoned,
358                format!("unregister global listener lock poisoned: {}", e),
359            )
360        })?;
361        if let Some(pos) = g.iter().position(|(k, _)| *k == id) {
362            g.remove(pos);
363            crate::meow_key_log!(
364                "listener",
365                "unregister global listener success: id={:?}",
366                id
367            );
368            Ok(true)
369        } else {
370            crate::meow_flow_log!("listener", "unregister global listener missed: id={:?}", id);
371            Ok(false)
372        }
373    }
374
375    /// Removes all registered global progress listeners.
376    ///
377    /// # Errors
378    ///
379    /// Returns `LockPoisoned` when listener storage lock is poisoned.
380    ///
381    /// # Examples
382    ///
383    /// ```no_run
384    /// use rusty_cat::api::{MeowClient, MeowConfig};
385    ///
386    /// let client = MeowClient::new(MeowConfig::default());
387    /// client.clear_global_listener()?;
388    /// # Ok::<(), rusty_cat::api::MeowError>(())
389    /// ```
390    pub fn clear_global_listener(&self) -> Result<(), MeowError> {
391        crate::meow_key_log!("listener", "clear all global listeners");
392        self.global_progress_listener
393            .write()
394            .map_err(|e| {
395                MeowError::from_code(
396                    InnerErrorCode::LockPoisoned,
397                    format!("clear global listeners lock poisoned: {}", e),
398                )
399            })?
400            .clear();
401        Ok(())
402    }
403
404    /// Sets or clears the global debug log listener.
405    ///
406    /// - Pass `Some(listener)` to set/replace.
407    /// - Pass `None` to clear.
408    ///
409    /// This affects all `MeowClient` instances in the current process.
410    ///
411    /// # Errors
412    ///
413    /// Returns [`DebugLogListenerError`] when the internal global listener lock
414    /// is poisoned.
415    ///
416    /// # Examples
417    ///
418    /// ```no_run
419    /// use std::sync::Arc;
420    /// use rusty_cat::api::{Log, MeowClient, MeowConfig};
421    ///
422    /// let client = MeowClient::new(MeowConfig::default());
423    /// client.set_debug_log_listener(Some(Arc::new(|log: Log| {
424    ///     println!("{log}");
425    /// })))?;
426    ///
427    /// // Clear listener when no longer needed.
428    /// client.set_debug_log_listener(None)?;
429    /// # Ok::<(), rusty_cat::api::DebugLogListenerError>(())
430    /// ```
431    pub fn set_debug_log_listener(
432        &self,
433        listener: Option<DebugLogListener>,
434    ) -> Result<(), DebugLogListenerError> {
435        set_debug_log_listener(listener)
436    }
437}
438
439impl MeowClient {
440    /// Submits a transfer task to the internal scheduler and returns its
441    /// [`TaskId`].
442    ///
443    /// The actual upload/download execution is dispatched to an internal
444    /// worker system thread. This method only performs lightweight validation
445    /// and submission, so it does not block the caller thread waiting for full
446    /// transfer completion.
447    ///
448    /// `try_enqueue` is also the recovery entrypoint after process restart.
449    /// If the application was killed during a previous upload/download,
450    /// restart your process and call `try_enqueue` again to resume that
451    /// transfer workflow.
452    ///
453    /// # Back-pressure semantics (why the `try_` prefix)
454    ///
455    /// Internally this method uses
456    /// [`tokio::sync::mpsc::Sender::try_send`] to hand the `Enqueue` command
457    /// to the scheduler worker, **not** `send().await`. That means:
458    ///
459    /// - The `await` point in this function is used for task normalization
460    ///   (e.g. resolving upload breakpoints, building [`InnerTask`]), **not**
461    ///   for waiting on command-queue capacity.
462    /// - If the command queue is momentarily full (bursty enqueue under
463    ///   [`MeowConfig::command_queue_capacity`]), this method returns an
464    ///   immediate `CommandSendFailed` error instead of suspending the
465    ///   caller until a slot frees up.
466    /// - Other control APIs ([`Self::pause`], [`Self::resume`],
467    ///   [`Self::cancel`], [`Self::snapshot`]) use `send().await` and **do**
468    ///   wait for queue capacity. Only enqueue is fail-fast.
469    ///
470    /// Callers that want to batch-enqueue under burst load should either:
471    ///
472    /// 1. size [`MeowConfig::command_queue_capacity`] appropriately, or
473    /// 2. retry on `CommandSendFailed` with their own back-off, or
474    /// 3. rate-limit enqueue calls on the caller side.
475    ///
476    /// The name explicitly carries `try_` so this fail-fast behavior is
477    /// visible at the call site. If a fully-awaiting variant is introduced
478    /// later it should be named `enqueue` (without the `try_` prefix).
479    ///
480    /// # Parameters
481    ///
482    /// - `task`: Built by upload/download task builders.
483    /// - `progress_cb`: Per-task callback invoked with transfer progress.
484    /// - `complete_cb`: Callback fired once when task reaches
485    ///   [`crate::transfer_status::TransferStatus::Complete`]. The second
486    ///   argument is provider-defined payload returned by upload protocol
487    ///   `complete_upload`; download tasks usually receive `None`.
488    ///
489    /// # Usage rules
490    ///
491    /// - `task` must be non-empty (required path/name/url and valid upload size).
492    /// - Callback should be lightweight and non-blocking.
493    /// - Store returned task ID for subsequent task control operations.
494    /// - `try_enqueue` is asynchronous task submission, not synchronous transfer.
495    /// - For restart recovery, re-enqueue the same logical task (same
496    ///   upload/download target and compatible checkpoint context) so the
497    ///   runtime can continue from existing local/remote progress.
498    ///
499    /// # Errors
500    ///
501    /// Returns:
502    /// - `ClientClosed` if the client was closed.
503    /// - `ParameterEmpty` if the task is invalid/empty.
504    /// - `CommandSendFailed` if the scheduler command queue is full at the
505    ///   moment of submission (see back-pressure semantics above).
506    /// - Any runtime initialization errors from the executor.
507    ///
508    /// # Examples
509    ///
510    /// ```no_run
511    /// use rusty_cat::api::{DownloadPounceBuilder, MeowClient, MeowConfig};
512    ///
513    /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
514    /// let client = MeowClient::new(MeowConfig::default());
515    /// let task = DownloadPounceBuilder::new(
516    ///     "example.bin",
517    ///     "./downloads/example.bin",
518    ///     1024 * 1024,
519    ///     "https://example.com/example.bin",
520    /// )
521    /// .build();
522    ///
523    /// let task_id = client
524    ///     .try_enqueue(
525    ///         task,
526    ///         |record| {
527    ///             println!("status={:?} progress={:.2}", record.status(), record.progress());
528    ///         },
529    ///         |task_id, payload| {
530    ///             println!("task {task_id} completed, payload={payload:?}");
531    ///         },
532    ///     )
533    ///     .await?;
534    /// println!("enqueued task: {task_id}");
535    /// # Ok(())
536    /// # }
537    /// ```
538    pub async fn try_enqueue<PCB, CCB>(
539        &self,
540        task: PounceTask,
541        progress_cb: PCB,
542        complete_cb: CCB,
543    ) -> Result<TaskId, MeowError>
544    where
545        PCB: Fn(FileTransferRecord) + Send + Sync + 'static,
546        CCB: Fn(TaskId, Option<String>) + Send + Sync + 'static,
547    {
548        self.ensure_open()?;
549        if task.is_empty() {
550            crate::meow_warn_log!("try_enqueue", "reject empty task");
551            return Err(MeowError::from_code1(InnerErrorCode::ParameterEmpty));
552        }
553
554        crate::meow_flow_log!(
555            "try_enqueue",
556            "task dir={:?} name={:?} size={} chunk={} method={:?} url={}",
557            task.direction,
558            task.file_name,
559            task.total_size,
560            task.chunk_size,
561            task.method,
562            crate::log::sanitize_url(&task.url)
563        );
564
565        let progress: ProgressCb = Arc::new(progress_cb);
566        let complete: Option<CompleteCb> = Some(Arc::new(complete_cb) as CompleteCb);
567        let callbacks = TaskCallbacks::new(Some(progress), complete);
568
569        let (def_up, def_down) = default_breakpoint_arcs();
570        let inner = InnerTask::from_pounce(
571            task,
572            self.config.breakpoint_download_http().clone(),
573            self.config.http_client_ref().cloned(),
574            def_up,
575            def_down,
576        )
577        .await?;
578
579        let task_id = self.get_exec()?.try_enqueue(inner, callbacks)?;
580        crate::meow_key_log!("try_enqueue", "try_enqueue success: task_id={:?}", task_id);
581        Ok(task_id)
582    }
583
584    /// Imports a transfer task in the **paused** state without scheduling it.
585    ///
586    /// This is the restart/restore entry point for callers that persist their
587    /// own transfer records: rebuild a [`PounceTask`] from your database, import
588    /// it here, and the task is registered into the scheduler as
589    /// [`TransferStatus::Paused`] **without** queueing, so it performs **zero
590    /// network or file I/O** until you explicitly start it.
591    ///
592    /// To start a previously imported task, call [`Self::resume`] with the
593    /// returned [`TaskId`]. A typical "restore N, start a user-selected subset"
594    /// flow imports every task with `try_enqueue_paused` and then calls
595    /// [`Self::resume`] only for the ids the user chose; the rest stay paused.
596    ///
597    /// # Difference from [`Self::try_enqueue`]
598    ///
599    /// - `try_enqueue` schedules immediately (the task becomes `Pending` and may
600    ///   start transferring as soon as a concurrency slot is free).
601    /// - `try_enqueue_paused` registers the task as `Paused` and never queues it
602    ///   until [`Self::resume`] is called.
603    ///
604    /// Back-pressure is identical: this method uses
605    /// [`tokio::sync::mpsc::Sender::try_send`] and fails fast with
606    /// `CommandSendFailed` if the command queue is full (see
607    /// [`Self::try_enqueue`] for the rationale behind the `try_` prefix).
608    ///
609    /// # Resume semantics after import
610    ///
611    /// When the imported task is later resumed, the resume point is recomputed
612    /// by the executor, **not** taken from any value passed here:
613    ///
614    /// - **Download**: resumes from the on-disk partial file length, so the
615    ///   partial file must still exist at the task's `file_path`.
616    /// - **Upload**: resumes from the server-reported `next_byte` during the
617    ///   upload `prepare` stage.
618    ///
619    /// # Progress reporting while paused
620    ///
621    /// The single `Paused` [`FileTransferRecord`] emitted on import reports
622    /// progress `0.0` because no `prepare` has run yet. Render the imported
623    /// task's real progress from your own persisted record; the SDK corrects it
624    /// after the first resume.
625    ///
626    /// # Parameters
627    ///
628    /// Same as [`Self::try_enqueue`]: a built `task`, a per-task `progress_cb`,
629    /// and a `complete_cb` fired once on terminal `Complete`.
630    ///
631    /// # Errors
632    ///
633    /// - `ClientClosed` if the client was closed.
634    /// - `ParameterEmpty` if the task is invalid/empty.
635    /// - `CommandSendFailed` if the scheduler command queue is full.
636    /// - Any runtime initialization errors from the executor.
637    ///
638    /// # Examples
639    ///
640    /// ```no_run
641    /// use rusty_cat::api::{DownloadPounceBuilder, MeowClient, MeowConfig};
642    ///
643    /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
644    /// let client = MeowClient::new(MeowConfig::default());
645    /// let task = DownloadPounceBuilder::new(
646    ///     "example.bin",
647    ///     "./downloads/example.bin",
648    ///     1024 * 1024,
649    ///     "https://example.com/example.bin",
650    /// )
651    /// .build();
652    ///
653    /// // Import without starting it (no HTTP request, no file open).
654    /// let task_id = client
655    ///     .try_enqueue_paused(task, |_record| {}, |_id, _payload| {})
656    ///     .await?;
657    ///
658    /// // Later, when the user chooses to start this one:
659    /// client.resume(task_id).await?;
660    /// # Ok(())
661    /// # }
662    /// ```
663    pub async fn try_enqueue_paused<PCB, CCB>(
664        &self,
665        task: PounceTask,
666        progress_cb: PCB,
667        complete_cb: CCB,
668    ) -> Result<TaskId, MeowError>
669    where
670        PCB: Fn(FileTransferRecord) + Send + Sync + 'static,
671        CCB: Fn(TaskId, Option<String>) + Send + Sync + 'static,
672    {
673        self.ensure_open()?;
674        if task.is_empty() {
675            crate::meow_warn_log!("try_enqueue_paused", "reject empty task");
676            return Err(MeowError::from_code1(InnerErrorCode::ParameterEmpty));
677        }
678
679        crate::meow_flow_log!(
680            "try_enqueue_paused",
681            "task dir={:?} name={:?} size={} chunk={} method={:?} url={}",
682            task.direction,
683            task.file_name,
684            task.total_size,
685            task.chunk_size,
686            task.method,
687            crate::log::sanitize_url(&task.url)
688        );
689
690        let progress: ProgressCb = Arc::new(progress_cb);
691        let complete: Option<CompleteCb> = Some(Arc::new(complete_cb) as CompleteCb);
692        let callbacks = TaskCallbacks::new(Some(progress), complete);
693
694        let (def_up, def_down) = default_breakpoint_arcs();
695        let inner = InnerTask::from_pounce(
696            task,
697            self.config.breakpoint_download_http().clone(),
698            self.config.http_client_ref().cloned(),
699            def_up,
700            def_down,
701        )
702        .await?;
703
704        let task_id = self.get_exec()?.try_enqueue_paused(inner, callbacks)?;
705        crate::meow_key_log!(
706            "try_enqueue_paused",
707            "try_enqueue_paused success: task_id={:?}",
708            task_id
709        );
710        Ok(task_id)
711    }
712
713    /// Enqueues a task and `await`s until it reaches a terminal status.
714    ///
715    /// Wraps [`Self::try_enqueue`] with an internal oneshot channel so callers
716    /// do not have to write the channel + double-callback + single-send-guard
717    /// boilerplate themselves.
718    ///
719    /// # Returns
720    ///
721    /// - `Ok(TaskOutcome)` when the task reaches [`TransferStatus::Complete`].
722    /// - `Err(MeowError)` carrying the underlying failure for
723    ///   [`TransferStatus::Failed`].
724    /// - `Err(MeowError)` with code [`InnerErrorCode::TaskCanceled`] for
725    ///   [`TransferStatus::Canceled`].
726    ///
727    /// # Progress
728    ///
729    /// `progress_cb` receives every [`FileTransferRecord`] update, identical to
730    /// the per-task progress callback in [`Self::try_enqueue`].
731    ///
732    /// # Cancellation / timeout
733    ///
734    /// Dropping the returned future does **not** cancel the underlying transfer;
735    /// the task continues running in the executor. Use [`Self::cancel`] with
736    /// the task id (obtainable from `progress_cb`'s `record.task_id()`) to
737    /// abort an in-flight transfer.
738    ///
739    /// To cap wall-clock waiting time, wrap this future:
740    ///
741    /// ```ignore
742    /// let outcome = tokio::time::timeout(
743    ///     std::time::Duration::from_secs(60),
744    ///     client.enqueue_and_wait(task, |_| {}),
745    /// )
746    /// .await??;
747    /// ```
748    ///
749    /// # Errors
750    ///
751    /// In addition to the terminal-status errors above, propagates any error
752    /// from [`Self::try_enqueue`] (e.g. `ClientClosed`, `ParameterEmpty`,
753    /// `CommandSendFailed`).
754    ///
755    /// # Examples
756    ///
757    /// ```no_run
758    /// use rusty_cat::api::{DownloadPounceBuilder, MeowClient, MeowConfig};
759    ///
760    /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
761    /// let client = MeowClient::new(MeowConfig::default());
762    /// let task = DownloadPounceBuilder::new(
763    ///     "example.bin",
764    ///     "./downloads/example.bin",
765    ///     1024 * 1024,
766    ///     "https://example.com/example.bin",
767    /// )
768    /// .build();
769    ///
770    /// let outcome = client
771    ///     .enqueue_and_wait(task, |record| {
772    ///         println!(
773    ///             "task={} progress={:.2}",
774    ///             record.task_id(),
775    ///             record.progress()
776    ///         );
777    ///     })
778    ///     .await?;
779    /// println!("task {} complete, payload={:?}", outcome.task_id, outcome.payload);
780    /// # Ok(())
781    /// # }
782    /// ```
783    pub async fn enqueue_and_wait<PCB>(
784        &self,
785        task: PounceTask,
786        progress_cb: PCB,
787    ) -> Result<TaskOutcome, MeowError>
788    where
789        PCB: Fn(FileTransferRecord) + Send + Sync + 'static,
790    {
791        type TerminalMsg = Result<(TaskId, Option<String>), MeowError>;
792        let (tx, rx) = oneshot::channel::<TerminalMsg>();
793        let tx_slot: Arc<StdMutex<Option<oneshot::Sender<TerminalMsg>>>> =
794            Arc::new(StdMutex::new(Some(tx)));
795        let progress_slot = Arc::clone(&tx_slot);
796        let complete_slot = tx_slot;
797
798        self.try_enqueue(
799            task,
800            move |record: FileTransferRecord| {
801                progress_cb(record.clone());
802                match record.status() {
803                    TransferStatus::Failed(err) => {
804                        send_terminal_once(&progress_slot, Err(err.clone()));
805                    }
806                    TransferStatus::Canceled => {
807                        send_terminal_once(
808                            &progress_slot,
809                            Err(MeowError::from_code_str(
810                                InnerErrorCode::TaskCanceled,
811                                "task was canceled",
812                            )),
813                        );
814                    }
815                    _ => {}
816                }
817            },
818            move |task_id, payload| {
819                send_terminal_once(&complete_slot, Ok((task_id, payload)));
820            },
821        )
822        .await?;
823
824        match rx.await {
825            Ok(Ok((task_id, payload))) => Ok(TaskOutcome { task_id, payload }),
826            Ok(Err(err)) => Err(err),
827            Err(_) => Err(MeowError::from_code_str(
828                InnerErrorCode::CommandResponseFailed,
829                "transfer terminal channel closed without notification",
830            )),
831        }
832    }
833
834    /// Pauses a running or pending task by ID.
835    ///
836    /// This API sends a control command to the internal scheduler worker
837    /// thread. It does not execute transfer pause logic on the caller thread.
838    ///
839    /// # Usage rules
840    ///
841    /// Call this with a valid task ID returned by [`Self::enqueue`].
842    ///
843    /// # Errors
844    ///
845    /// Returns `ClientClosed`, `TaskNotFound`, or state-transition errors.
846    ///
847    /// # Examples
848    ///
849    /// ```no_run
850    /// use rusty_cat::api::{MeowClient, MeowConfig, TaskId};
851    ///
852    /// # async fn run(task_id: TaskId) -> Result<(), rusty_cat::api::MeowError> {
853    /// let client = MeowClient::new(MeowConfig::default());
854    /// client.pause(task_id).await?;
855    /// # Ok(())
856    /// # }
857    /// ```
858    pub async fn pause(&self, task_id: TaskId) -> Result<(), MeowError> {
859        self.ensure_open()?;
860        crate::meow_key_log!("client_api", "pause called: task_id={:?}", task_id);
861        self.get_exec()?.pause(task_id).await
862    }
863
864    /// Resumes a previously paused task.
865    ///
866    /// The same [`TaskId`] continues to identify the task after resume.
867    /// The resume command is forwarded to the internal scheduler worker
868    /// thread, so caller thread is not responsible for running transfer logic.
869    ///
870    /// # Errors
871    ///
872    /// Returns `ClientClosed`, `TaskNotFound`, or `InvalidTaskState`.
873    ///
874    /// # Examples
875    ///
876    /// ```no_run
877    /// use rusty_cat::api::{MeowClient, MeowConfig, TaskId};
878    ///
879    /// # async fn run(task_id: TaskId) -> Result<(), rusty_cat::api::MeowError> {
880    /// let client = MeowClient::new(MeowConfig::default());
881    /// client.resume(task_id).await?;
882    /// # Ok(())
883    /// # }
884    /// ```
885    pub async fn resume(&self, task_id: TaskId) -> Result<(), MeowError> {
886        self.ensure_open()?;
887        crate::meow_key_log!("client_api", "resume called: task_id={:?}", task_id);
888        self.get_exec()?.resume(task_id).await
889    }
890
891    /// Cancels a task by ID.
892    ///
893    /// Cancellation is requested through the internal scheduler worker thread.
894    /// Transfer cancellation execution happens in background runtime workers.
895    ///
896    /// # Usage rules
897    ///
898    /// Cancellation is best-effort; protocol-specific cleanup may run.
899    ///
900    /// # Errors
901    ///
902    /// Returns `ClientClosed`, `TaskNotFound`, or runtime cancellation errors.
903    ///
904    /// # Examples
905    ///
906    /// ```no_run
907    /// use rusty_cat::api::{MeowClient, MeowConfig, TaskId};
908    ///
909    /// # async fn run(task_id: TaskId) -> Result<(), rusty_cat::api::MeowError> {
910    /// let client = MeowClient::new(MeowConfig::default());
911    /// client.cancel(task_id).await?;
912    /// # Ok(())
913    /// # }
914    /// ```
915    pub async fn cancel(&self, task_id: TaskId) -> Result<(), MeowError> {
916        self.ensure_open()?;
917        crate::meow_key_log!("client_api", "cancel called: task_id={:?}", task_id);
918        self.get_exec()?.cancel(task_id).await
919    }
920
921    /// Returns a snapshot of queue and active transfer groups.
922    ///
923    /// Useful for diagnostics and external monitoring dashboards.
924    /// Snapshot collection is coordinated by internal scheduler worker state.
925    ///
926    /// # Errors
927    ///
928    /// Returns `ClientClosed`, runtime command delivery errors, or scheduler
929    /// snapshot retrieval errors.
930    ///
931    /// # Examples
932    ///
933    /// ```no_run
934    /// use rusty_cat::api::{MeowClient, MeowConfig};
935    ///
936    /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
937    /// let client = MeowClient::new(MeowConfig::default());
938    /// let snap = client.snapshot().await?;
939    /// println!("queued={}, active={}", snap.queued_groups, snap.active_groups);
940    /// # Ok(())
941    /// # }
942    /// ```
943    pub async fn snapshot(&self) -> Result<TransferSnapshot, MeowError> {
944        self.ensure_open()?;
945        crate::meow_flow_log!("client_api", "snapshot called");
946        self.get_exec()?.snapshot().await
947    }
948
949    /// Closes this client and its underlying executor.
950    ///
951    /// `close` is the terminal lifecycle operation for a `MeowClient`. After
952    /// it succeeds, this client stays permanently closed; submit more work by
953    /// constructing a new `MeowClient` and enqueueing tasks there.
954    ///
955    /// After a successful close:
956    ///
957    /// - New task and control operations on this client are rejected with
958    ///   `ClientClosed`.
959    /// - All known unfinished task groups (queued, paused, or active) receive
960    ///   a `Paused` progress notification through their task callback and all
961    ///   registered global listeners.
962    /// - In-flight transfers are cancelled and the scheduler state is cleared.
963    /// - Already submitted callback jobs are drained before returning.
964    /// - The internal scheduler thread is joined, which drops its Tokio
965    ///   runtime and releases SDK-owned background execution resources.
966    ///
967    /// `Paused` is used for shutdown notifications rather than `Canceled` so
968    /// callers can recreate a client later and re-enqueue the same logical
969    /// transfer when they want to resume from available breakpoint state.
970    ///
971    /// # Idempotency
972    ///
973    /// Calling `close` more than once returns `ClientClosed`.
974    ///
975    /// # Retry behavior
976    ///
977    /// If executor close fails, the closed flag is rolled back so caller can
978    /// retry close. A successful close is not restartable.
979    ///
980    /// # Errors
981    ///
982    /// Returns `ClientClosed` when already closed, or underlying executor close
983    /// errors when shutdown is not completed.
984    ///
985    /// # Examples
986    ///
987    /// ```no_run
988    /// use rusty_cat::api::{MeowClient, MeowConfig};
989    ///
990    /// # async fn run() -> Result<(), rusty_cat::api::MeowError> {
991    /// let client = MeowClient::new(MeowConfig::default());
992    /// client.close().await?;
993    /// # Ok(())
994    /// # }
995    /// ```
996    pub async fn close(&self) -> Result<(), MeowError> {
997        if self
998            .closed
999            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1000            .is_err()
1001        {
1002            crate::meow_flow_log!("client_api", "close rejected: already closed");
1003            return Err(MeowError::from_code_str(
1004                InnerErrorCode::ClientClosed,
1005                "meow client is already closed",
1006            ));
1007        }
1008        if let Some(exec) = self.executor.get() {
1009            crate::meow_key_log!("client_api", "close forwarding to executor");
1010            if let Err(e) = exec.close().await {
1011                // Roll back closed flag so caller can retry close.
1012                self.closed.store(false, Ordering::SeqCst);
1013                return Err(e);
1014            }
1015            Ok(())
1016        } else {
1017            crate::meow_key_log!("client_api", "close with no executor initialized");
1018            Ok(())
1019        }
1020    }
1021
1022    /// Returns whether this client is currently closed.
1023    ///
1024    /// # Examples
1025    ///
1026    /// ```no_run
1027    /// use rusty_cat::api::{MeowClient, MeowConfig};
1028    ///
1029    /// let client = MeowClient::new(MeowConfig::default());
1030    /// let _closed = client.is_closed();
1031    /// ```
1032    pub fn is_closed(&self) -> bool {
1033        self.closed.load(Ordering::SeqCst)
1034    }
1035}
1036
1037fn send_terminal_once(
1038    slot: &Arc<StdMutex<Option<oneshot::Sender<Result<(TaskId, Option<String>), MeowError>>>>>,
1039    msg: Result<(TaskId, Option<String>), MeowError>,
1040) {
1041    if let Ok(mut guard) = slot.lock() {
1042        if let Some(sender) = guard.take() {
1043            let _ = sender.send(msg);
1044        }
1045    }
1046}