zipatch_rs/apply/observer.rs
1//! Progress and cancellation hooks for the apply layer.
2//!
3//! [`ApplyObserver`] is the user-visible instrumentation surface for a long-
4//! running [`apply_patch`](crate::ApplyConfig::apply_patch) call. It is designed
5//! around two concrete needs from the downstream `gaveloc-patcher` consumer:
6//!
7//! - **Smooth UI progress** — a desktop launcher needs to drive a progress
8//! bar while applying multi-GB FFXIV patches. Per-chunk events plus a
9//! running byte counter are enough to compute "X of Y bytes applied"
10//! without ever buffering the full patch in memory.
11//! - **User-initiated cancellation** — a single [`SqpkFile`] `AddFile`
12//! chunk can carry hundreds of megabytes of DEFLATE blocks. Chunk-
13//! boundary checks are not enough; cancellation must be observable
14//! *inside* the long-running block loop. The cheap
15//! [`ApplyObserver::should_cancel`] predicate is polled between blocks.
16//!
17//! # Trait, not closure
18//!
19//! The observer is a **trait** rather than a closure so that callers can
20//! own state across event methods (e.g. a UI handle, an [`mpsc::Sender`],
21//! a cancellation token) without juggling shared captures. Every method has
22//! a no-op default, so implementors only override what they care about.
23//!
24//! There is **no** blanket impl for closures. The trait carries
25//! [`ApplyObserver::should_cancel`] alongside
26//! [`ApplyObserver::on_chunk_applied`], and a closure can only carry one of
27//! the two — silently filling in the "never cancel" default would disable
28//! the cancellation path the consumer almost certainly wants. Implementors
29//! pass a struct (typically owning an [`AtomicBool`](std::sync::atomic::AtomicBool)
30//! cancellation flag) and override exactly the methods they need.
31//!
32//! # Default observer
33//!
34//! [`ApplyConfig`](crate::ApplyConfig) defaults to a no-op observer when
35//! none is configured. Parsing-only consumers
36//! ([`ZiPatchReader`](crate::ZiPatchReader) without
37//! [`apply_patch`](crate::ApplyConfig::apply_patch)) pay nothing — the observer
38//! is exclusively an apply-layer concept.
39//!
40//! [`SqpkFile`]: crate::chunk::sqpk::SqpkFile
41//! [`mpsc::Sender`]: std::sync::mpsc::Sender
42
43use crate::newtypes::ChunkTag;
44use std::ops::ControlFlow;
45
46/// One chunk-applied event delivered to an [`ApplyObserver`].
47///
48/// Fired after each top-level chunk's apply has completed successfully. The
49/// `index` field is the zero-based ordinal of the chunk in the patch stream,
50/// counting every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader) (the
51/// internal `EOF_` terminator is not yielded and is not counted). The `kind`
52/// is the 4-byte ASCII wire tag of the chunk, which lets the consumer
53/// categorise events without needing to match on the [`Chunk`](crate::Chunk)
54/// enum directly. `bytes_read` is the running total of bytes consumed from
55/// the patch stream up to and including this chunk's frame (length prefix,
56/// tag, body, and CRC32).
57///
58/// # Stability
59///
60/// The struct is `#[non_exhaustive]`. New fields may be added in future minor
61/// versions (per-chunk elapsed time, decompressed byte counts, etc.).
62/// Construct in tests via the [`ChunkEvent::new`] associated function rather
63/// than struct-literal syntax.
64#[non_exhaustive]
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct ChunkEvent {
67 /// Zero-based index of the chunk within the patch stream.
68 ///
69 /// Counts every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader),
70 /// in stream order. The internal `EOF_` terminator is not counted.
71 pub index: usize,
72 /// 4-byte ASCII wire tag of the chunk (e.g. [`ChunkTag::SQPK`],
73 /// [`ChunkTag::ADIR`]).
74 ///
75 /// `EOF_` will never appear here because the reader consumes that chunk
76 /// internally. New tags introduced by Square Enix would surface as
77 /// [`ParseError::UnknownChunkTag`](crate::ParseError::UnknownChunkTag)
78 /// before any event fires for them.
79 pub kind: ChunkTag,
80 /// Running total of bytes consumed from the patch stream, measured from
81 /// the start of the patch file (including the 12-byte magic prefix).
82 ///
83 /// Monotonically non-decreasing across successive events; equal to the
84 /// stream position immediately after the chunk's CRC32 was read. Useful
85 /// for driving a `bytes_applied / total_patch_size` progress bar.
86 pub bytes_read: u64,
87}
88
89impl ChunkEvent {
90 /// Construct a [`ChunkEvent`] from its component fields.
91 ///
92 /// Primarily intended for unit-test fixtures. Production code receives
93 /// events from the apply driver and does not need to construct them.
94 #[must_use]
95 pub fn new(index: usize, kind: ChunkTag, bytes_read: u64) -> Self {
96 Self {
97 index,
98 kind,
99 bytes_read,
100 }
101 }
102
103 /// Returns the chunk's [`kind`](Self::kind) tag as a `&str` if it is
104 /// valid UTF-8.
105 ///
106 /// All wire tags defined by the `ZiPatch` format are 4-byte ASCII
107 /// (`SQPK`, `ADIR`, `APLY`, …), so this returns `Some(&str)` in
108 /// practice. The fallible variant exists because the underlying field is
109 /// 4 raw bytes, which by itself does not constrain the contents to
110 /// UTF-8 — a forward-compatible future tag or a corrupt event constructed
111 /// in tests could in principle carry non-ASCII bytes.
112 ///
113 /// # Example
114 ///
115 /// ```
116 /// use zipatch_rs::ChunkEvent;
117 /// use zipatch_rs::newtypes::ChunkTag;
118 /// let ev = ChunkEvent::new(0, ChunkTag::SQPK, 96);
119 /// assert_eq!(ev.kind_str(), Some("SQPK"));
120 ///
121 /// let bad = ChunkEvent::new(0, ChunkTag::new([0xFF, 0xFE, 0, 0]), 0);
122 /// assert_eq!(bad.kind_str(), None);
123 /// ```
124 #[must_use]
125 pub fn kind_str(&self) -> Option<&str> {
126 self.kind.as_str()
127 }
128}
129
130/// Hook trait for observing apply-time progress and signalling cancellation.
131///
132/// All methods
133/// have no-op defaults so implementors override only what they need.
134///
135/// # Threading
136///
137/// An observer is borrowed mutably by the apply driver for the lifetime of
138/// the [`apply_patch`](crate::ApplyConfig::apply_patch) call. There is no
139/// internal synchronisation: implementors that need to forward events to
140/// another thread should do so via channels they own.
141///
142/// The trait has `Send + Sync` supertrait bounds so a boxed observer can
143/// be constructed on one thread and driven on another — the typical UI
144/// pattern is to construct the observer (often around an
145/// [`mpsc::Sender`](std::sync::mpsc::Sender) or an
146/// [`AtomicBool`](std::sync::atomic::AtomicBool) cancellation flag) on the
147/// UI thread, hand it to an [`ApplyConfig`](crate::ApplyConfig), and ship
148/// the context to an apply worker. `Sync` costs nothing for the realistic
149/// implementations (channel senders, atomics, `Arc<Mutex<_>>`) and lets the
150/// observer be shared by reference if a downstream consumer ever needs to.
151///
152/// # Async usage
153///
154/// Both [`Self::on_chunk_applied`] and [`Self::should_cancel`] run inline
155/// with the apply loop and are intentionally synchronous — see the
156/// crate-level "Async usage" section for the rationale. The cancellation
157/// poll in particular is called once per `SqpkFile` `AddFile` block and
158/// must be cheap (an atomic-bool load is the canonical implementation),
159/// which makes it a poor fit for `async` even hypothetically.
160///
161/// Async consumers wrap the whole apply call in
162/// `tokio::task::spawn_blocking` and use an
163/// [`AtomicBool`](std::sync::atomic::AtomicBool) cancellation flag the
164/// async side can flip from a `tokio::select!` arm or a cancellation
165/// token. Per-chunk events that need to reach an async UI go through a
166/// channel whose `Sender` lives inside [`Self::on_chunk_applied`] and
167/// whose `Receiver` is polled from the async task.
168///
169/// # Example
170///
171/// ```no_run
172/// use std::ops::ControlFlow;
173/// use zipatch_rs::{ApplyConfig, ApplyObserver, ChunkEvent, open_patch};
174///
175/// struct Progress {
176/// total: u64,
177/// applied: u64,
178/// }
179///
180/// impl ApplyObserver for Progress {
181/// fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
182/// self.applied = ev.bytes_read;
183/// println!("progress: {}/{}", self.applied, self.total);
184/// ControlFlow::Continue(())
185/// }
186/// }
187///
188/// let mut ctx = ApplyConfig::new("/opt/ffxiv/game")
189/// .with_observer(Progress { total: 12_345_678, applied: 0 });
190/// let reader = open_patch("patch.patch").unwrap();
191/// ctx.apply_patch(reader).unwrap();
192/// ```
193pub trait ApplyObserver: Send + Sync {
194 /// Called after each top-level chunk has been applied successfully.
195 ///
196 /// Returning [`ControlFlow::Break`] aborts the apply loop immediately;
197 /// the apply call returns [`ApplyError::Cancelled`].
198 ///
199 /// Not invoked when the chunk's apply itself fails — the error
200 /// propagates from [`apply_patch`](crate::ApplyConfig::apply_patch) without
201 /// firing this method. The event is therefore a "chunk succeeded"
202 /// signal, not a "chunk attempted" one.
203 ///
204 /// The default implementation does nothing and continues.
205 ///
206 /// [`ApplyError::Cancelled`]: crate::ApplyError::Cancelled
207 fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
208 let _ = ev;
209 ControlFlow::Continue(())
210 }
211
212 /// Polled inside long-running chunks to check for user cancellation.
213 ///
214 /// Implementors should make this method **cheap** — it is called once
215 /// per block inside the
216 /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) `AddFile` loop and on
217 /// every iteration of any future fine-grained loop the apply layer adds.
218 /// A simple atomic-bool load is the recommended implementation.
219 ///
220 /// Polled before each block within long-running chunks (currently only
221 /// `SQPK F` `AddFile`). Once a block's I/O has started, it completes —
222 /// cancellation takes effect at the next block boundary, not mid-write.
223 /// This means the last block of any chunk always finishes once started.
224 ///
225 /// Returning `true` causes the current apply operation to abort at the
226 /// next checkpoint with [`ApplyError::Cancelled`].
227 ///
228 /// The default implementation always returns `false`.
229 ///
230 /// [`ApplyError::Cancelled`]: crate::ApplyError::Cancelled
231 fn should_cancel(&mut self) -> bool {
232 false
233 }
234}
235
236/// No-op observer used by [`ApplyConfig`](crate::ApplyConfig) when none
237/// is configured.
238///
239/// Public because [`ApplyConfig::with_observer`](crate::ApplyConfig::with_observer)
240/// is generic over `impl ApplyObserver + 'static` and callers occasionally need
241/// to name the default in `Box<dyn ApplyObserver>`-typed fields. All trait
242/// methods use the trait defaults.
243#[derive(Debug, Default, Clone, Copy)]
244pub struct NoopObserver;
245
246impl ApplyObserver for NoopObserver {}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 // --- Thread-safety bounds ---
253
254 #[test]
255 fn boxed_observer_is_send_and_sync() {
256 fn assert_send_sync<T: Send + Sync + ?Sized>() {}
257 assert_send_sync::<dyn ApplyObserver>();
258 assert_send_sync::<Box<dyn ApplyObserver>>();
259 let boxed: Box<dyn ApplyObserver> = Box::new(NoopObserver);
260 std::thread::spawn(move || {
261 let _ = boxed;
262 })
263 .join()
264 .unwrap();
265 }
266
267 // --- NoopObserver ---
268
269 #[test]
270 fn noop_observer_on_chunk_applied_always_continues() {
271 let mut obs = NoopObserver;
272 let ev = ChunkEvent::new(0, ChunkTag::ADIR, 32);
273 assert_eq!(
274 obs.on_chunk_applied(ev),
275 ControlFlow::Continue(()),
276 "NoopObserver must never break"
277 );
278 }
279
280 #[test]
281 fn noop_observer_should_cancel_always_false() {
282 let mut obs = NoopObserver;
283 // Call multiple times; must stay false — never flip to true.
284 for _ in 0..5 {
285 assert!(
286 !obs.should_cancel(),
287 "NoopObserver must never request cancellation"
288 );
289 }
290 }
291
292 // --- ChunkEvent ---
293
294 #[test]
295 fn chunk_event_new_stores_all_fields_exactly() {
296 let ev = ChunkEvent::new(7, ChunkTag::SQPK, 1024);
297 assert_eq!(ev.index, 7, "index field mismatch");
298 assert_eq!(ev.kind, ChunkTag::SQPK, "kind field mismatch");
299 assert_eq!(ev.bytes_read, 1024, "bytes_read field mismatch");
300 }
301
302 #[test]
303 fn chunk_event_clone_and_eq_are_consistent() {
304 let ev = ChunkEvent::new(3, ChunkTag::ADIR, 512);
305 let cloned = ev;
306 assert_eq!(
307 ev, cloned,
308 "ChunkEvent must be Copy/Eq with field-wise equality"
309 );
310 }
311
312 #[test]
313 fn chunk_event_kind_str_returns_some_for_ascii_tag() {
314 let ev = ChunkEvent::new(0, ChunkTag::ADIR, 0);
315 assert_eq!(ev.kind_str(), Some("ADIR"));
316 let ev = ChunkEvent::new(0, ChunkTag::SQPK, 0);
317 assert_eq!(ev.kind_str(), Some("SQPK"));
318 }
319
320 #[test]
321 fn chunk_event_kind_str_returns_none_for_invalid_utf8() {
322 let ev = ChunkEvent::new(0, ChunkTag::new([0xFF, 0xFE, 0xFD, 0xFC]), 0);
323 assert_eq!(ev.kind_str(), None, "non-UTF-8 tag bytes must produce None");
324 }
325}