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}