text_document/operation.rs
1//! Typed long operation handle.
2
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use crate::Result;
7
8use common::long_operation::{OperationCompletion, OperationStatus};
9use frontend::AppContext;
10
11/// Backstop for a completion signal that never arrives.
12///
13/// This is **not** a polling interval: a wait is woken by the manager's condvar
14/// the instant its operation publishes completion, so in normal operation this
15/// timeout is never reached. It exists only so that a signal lost to an
16/// abnormally-terminated worker — one killed between storing its result and
17/// publishing — degrades into a slow re-check instead of a permanent hang.
18const COMPLETION_BACKSTOP: Duration = Duration::from_secs(1);
19
20/// Function that reads the long-operation manager for a result.
21type ResultFn<T> = Box<dyn Fn(&AppContext, &str) -> Option<Result<T>> + Send>;
22
23/// Shared state for a single long operation.
24pub(crate) struct OperationState {
25 ctx: AppContext,
26}
27
28impl OperationState {
29 pub fn new(ctx: &AppContext) -> Self {
30 Self { ctx: ctx.clone() }
31 }
32}
33
34/// A handle to a running long operation (Markdown/HTML import, DOCX export).
35///
36/// Provides typed access to progress, cancellation, and the result.
37/// Progress events are also emitted via [`DocumentEvent::LongOperationProgress`](crate::DocumentEvent::LongOperationProgress)
38/// and [`DocumentEvent::LongOperationFinished`](crate::DocumentEvent::LongOperationFinished)
39/// for the callback/polling path.
40///
41/// Retrieve the result via [`wait()`](Self::wait) (blocking, consumes the handle)
42/// or [`try_result()`](Self::try_result) (non-blocking, can be called repeatedly).
43pub struct Operation<T> {
44 id: String,
45 state: OperationState,
46 result_fn: ResultFn<T>,
47}
48
49impl<T> Operation<T> {
50 pub(crate) fn new(id: String, ctx: &AppContext, result_fn: ResultFn<T>) -> Self {
51 Self {
52 id,
53 state: OperationState::new(ctx),
54 result_fn,
55 }
56 }
57
58 /// The operation ID (for matching with [`DocumentEvent`](crate::DocumentEvent) variants).
59 pub fn id(&self) -> &str {
60 &self.id
61 }
62
63 /// Get the current progress, if available.
64 /// Returns `(percent, message)` where percent is 0.0–100.0.
65 pub fn progress(&self) -> Option<(f64, String)> {
66 let mgr = self.state.ctx.long_operation_manager.lock();
67 mgr.get_operation_progress(&self.id)
68 .map(|p| (p.percentage as f64, p.message.unwrap_or_default()))
69 }
70
71 /// Returns `true` if the operation has finished (success or failure).
72 ///
73 /// Reads the completion signal, not just the result slot: a cancelled or
74 /// failed operation stores no result but is very much finished.
75 pub fn is_done(&self) -> bool {
76 self.completion().is_finished(&self.id)
77 }
78
79 /// This operation's completion signal, with the manager lock **released**.
80 ///
81 /// Never block while holding `long_operation_manager`: a wait lasts as long
82 /// as the operation, and every other operation query would queue behind it.
83 fn completion(&self) -> Arc<OperationCompletion> {
84 self.state
85 .ctx
86 .long_operation_manager
87 .lock()
88 .completion_signal()
89 }
90
91 /// The error for an operation that finished without producing a result —
92 /// which means it was cancelled, or it failed.
93 fn terminal_error(&self) -> crate::DocumentError {
94 let status = self
95 .state
96 .ctx
97 .long_operation_manager
98 .lock()
99 .get_operation_status(&self.id);
100 match status {
101 Some(OperationStatus::Failed(err)) => anyhow::anyhow!("{err}").into(),
102 Some(OperationStatus::Cancelled) => anyhow::anyhow!("operation was cancelled").into(),
103 // Finished, no result, no live handle: it was cleaned up out from
104 // under us, so the outcome is no longer knowable.
105 _ => anyhow::anyhow!("operation finished without producing a result").into(),
106 }
107 }
108
109 /// Cancel the operation. No-op if already finished.
110 pub fn cancel(&self) {
111 self.state
112 .ctx
113 .long_operation_manager
114 .lock()
115 .cancel_operation(&self.id);
116 }
117
118 /// Block the calling thread until the operation completes and return
119 /// the typed result. Consumes the handle.
120 ///
121 /// The wait is **signal-driven**: the manager wakes it the moment the
122 /// operation publishes completion, so a short operation costs only its own
123 /// runtime. It previously re-checked on a 50 ms timer, which put a ~50 ms
124 /// floor under *every* call however trivial the work — invisible for one
125 /// import, but seconds of dead time for a caller loading documents in a loop.
126 ///
127 /// A cancelled or failed operation now returns `Err` rather than blocking
128 /// forever: neither ever stores a result, so the old "loop until a result
129 /// appears" never terminated for them.
130 pub fn wait(self) -> Result<T> {
131 let completion = self.completion();
132 loop {
133 // Read completion *before* polling. The worker stores its result
134 // before publishing, so if the operation had already finished at this
135 // point, the read below is authoritative — a miss then means no
136 // result was ever produced, not that one has yet to land.
137 let finished = completion.is_finished(&self.id);
138 if let Some(result) = (self.result_fn)(&self.state.ctx, &self.id) {
139 return result;
140 }
141 if finished {
142 return Err(self.terminal_error());
143 }
144 completion.wait_for(&self.id, Some(COMPLETION_BACKSTOP));
145 }
146 }
147
148 /// Block until the operation completes or the timeout expires.
149 /// Returns `None` if the timeout elapsed before the operation finished.
150 ///
151 /// Like [`wait`](Self::wait), this blocks on the completion signal rather
152 /// than a timer, so it returns as soon as the operation ends instead of on
153 /// the next 50 ms boundary.
154 pub fn wait_timeout(self, timeout: Duration) -> Option<Result<T>> {
155 let completion = self.completion();
156 let deadline = Instant::now() + timeout;
157 loop {
158 // Same ordering rule as `wait`: completion first, then the result.
159 let finished = completion.is_finished(&self.id);
160 if let Some(result) = (self.result_fn)(&self.state.ctx, &self.id) {
161 return Some(result);
162 }
163 if finished {
164 return Some(Err(self.terminal_error()));
165 }
166 let remaining = deadline.saturating_duration_since(Instant::now());
167 if remaining.is_zero() {
168 return None;
169 }
170 // Wait out the real remaining time in one blocking call — capped by
171 // the backstop so a lost signal still re-checks — rather than in
172 // 50 ms slices.
173 completion.wait_for(&self.id, Some(remaining.min(COMPLETION_BACKSTOP)));
174 }
175 }
176
177 /// Non-blocking: returns the result if the operation has completed,
178 /// `None` if still running. Can be called repeatedly.
179 pub fn try_result(&mut self) -> Option<Result<T>> {
180 (self.result_fn)(&self.state.ctx, &self.id)
181 }
182}
183
184// ── Result types ────────────────────────────────────────────────
185
186/// Result of a Markdown import (`set_markdown`).
187#[derive(Debug, Clone)]
188pub struct MarkdownImportResult {
189 pub block_count: usize,
190}
191
192/// Result of an HTML import (`set_html`).
193#[derive(Debug, Clone)]
194pub struct HtmlImportResult {
195 pub block_count: usize,
196}
197
198/// Result of a djot import (`set_djot`).
199#[derive(Debug, Clone)]
200pub struct DjotImportResult {
201 pub block_count: usize,
202}
203
204/// Result of a DOCX export (`to_docx`).
205#[derive(Debug, Clone)]
206pub struct DocxExportResult {
207 pub file_path: String,
208 pub paragraph_count: usize,
209}
210
211/// Result of an EPUB export (`to_epub`).
212#[derive(Debug, Clone)]
213pub struct EpubExportResult {
214 pub file_path: String,
215 pub chapter_count: usize,
216}
217
218/// Result of a PDF export (`to_pdf`).
219#[derive(Debug, Clone)]
220pub struct PdfExportResult {
221 pub file_path: String,
222 pub page_count: usize,
223}