Skip to main content

strop_engine/editor/remote/
save.rs

1//! Remote write authority is per document incarnation. Attempts outlive their
2//! cancellable worker so a lost receipt can be reconciled without losing edits.
3use super::RemoteEvent;
4use crate::editor::document::DocumentSource;
5use crate::editor::io::IoEvent;
6use crate::editor::Editor;
7use ropey::Rope;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use strop_core::id::{BufferRevision, DocumentId};
11use strop_core::worker::{self, CancelReason, Completion, Outcome, Ticket, WorkerId};
12use strop_remote::save::{
13    self as transport, RemoteSaveError, RemoteSaveReceipt, RemoteVersion, Verification,
14};
15use strop_workspace::RemoteFile;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub(crate) struct WritePermit {
19    id: WorkerId,
20    version: RemoteVersion,
21}
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub(crate) enum WriteAction {
24    Enable,
25    Save { close: bool },
26    Verify,
27}
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct RemoteWriteKey {
30    document: DocumentId,
31    revision: BufferRevision,
32    file: RemoteFile,
33    permit: Option<WorkerId>,
34    focus: u64,
35    action: WriteAction,
36}
37#[derive(Serialize, Deserialize)]
38pub enum RemoteWriteResult {
39    Enabled(RemoteVersion),
40    Saved(RemoteSaveReceipt),
41    Verified(Verification),
42    Refused(RemoteSaveError),
43}
44#[derive(Default)]
45pub(crate) struct WriteState {
46    pending: HashMap<DocumentId, Ticket<RemoteWriteKey>>,
47    attempts: HashMap<DocumentId, Attempt>,
48}
49struct Attempt {
50    permit: WorkerId,
51    revision: BufferRevision,
52    before: RemoteVersion,
53    contents: Rope,
54    unconfirmed: bool,
55}
56enum Work {
57    Enable {
58        file: RemoteFile,
59        contents: Rope,
60    },
61    Save {
62        before: RemoteVersion,
63        contents: Rope,
64        close: bool,
65    },
66    Verify {
67        before: RemoteVersion,
68        contents: Rope,
69    },
70}
71impl Work {
72    fn action(&self) -> WriteAction {
73        match self {
74            Self::Enable { .. } => WriteAction::Enable,
75            Self::Save { close, .. } => WriteAction::Save { close: *close },
76            Self::Verify { .. } => WriteAction::Verify,
77        }
78    }
79    fn file(&self) -> &RemoteFile {
80        match self {
81            Self::Enable { file, .. } => file,
82            Self::Save { before, .. } | Self::Verify { before, .. } => before.file(),
83        }
84    }
85    fn before(&self) -> Option<&RemoteVersion> {
86        match self {
87            Self::Enable { .. } => None,
88            Self::Save { before, .. } | Self::Verify { before, .. } => Some(before),
89        }
90    }
91    fn contents(&self) -> &Rope {
92        match self {
93            Self::Enable { contents, .. }
94            | Self::Save { contents, .. }
95            | Self::Verify { contents, .. } => contents,
96        }
97    }
98    fn execute(self, token: &worker::CancelToken) -> RemoteWriteResult {
99        let result = match self {
100            Self::Enable { file, contents } => {
101                transport::prepare_edit(&file, &contents, token).map(RemoteWriteResult::Enabled)
102            }
103            Self::Save {
104                before, contents, ..
105            } => transport::save(&before, &contents, token).map(RemoteWriteResult::Saved),
106            Self::Verify { before, contents } => {
107                transport::verify(&before, &contents, token).map(RemoteWriteResult::Verified)
108            }
109        };
110        result.unwrap_or_else(RemoteWriteResult::Refused)
111    }
112}
113impl WriteState {
114    pub(super) fn pending(&self) -> bool {
115        !self.pending.is_empty()
116    }
117}
118
119impl Editor {
120    pub(super) fn enable_remote_edit(&mut self) -> Result<(), String> {
121        let document = self.current();
122        if self.remote_refresh_pending(document) {
123            return Err("remote refresh is pending".into());
124        }
125        let source = self
126            .cur()
127            .remote_metadata()
128            .ok_or("open a remote regular file first")?;
129        if source.write.is_some() {
130            return Err("remote editing is already enabled".into());
131        }
132        if !self.remote_window_complete() {
133            return Err("remote editing requires a complete, non-following snapshot".into());
134        }
135        if self.remote.writes.pending.contains_key(&document) {
136            return Err("remote write operation already pending".into());
137        }
138        let work = Work::Enable {
139            file: source.file.clone(),
140            contents: self.buf().snapshot(),
141        };
142        self.start_remote_write(document, self.buf().revision(), None, work)
143    }
144
145    pub(crate) fn request_remote_save(
146        &mut self,
147        document: DocumentId,
148        target: Option<std::path::PathBuf>,
149        force: bool,
150        close: bool,
151    ) {
152        let result = self.prepare_remote_save(document, target, force, close);
153        if let Err(error) = result {
154            self.message = error;
155        }
156    }
157    fn prepare_remote_save(
158        &mut self,
159        document: DocumentId,
160        target: Option<std::path::PathBuf>,
161        force: bool,
162        close: bool,
163    ) -> Result<(), String> {
164        if target.is_some() {
165            return Err("remote save-as is unsupported; no local fallback".into());
166        }
167        if self.remote_refresh_pending(document) {
168            return Err("remote refresh is pending".into());
169        }
170        if self.remote.writes.pending.contains_key(&document) {
171            return Err("remote write operation already pending".into());
172        }
173        if self
174            .remote
175            .writes
176            .attempts
177            .get(&document)
178            .is_some_and(|attempt| attempt.unconfirmed)
179        {
180            return Err(
181                "remote save outcome is unconfirmed; use :remote verify before another save".into(),
182            );
183        }
184        let doc = self.docs.get(document).ok_or("no such remote document")?;
185        let source = doc
186            .remote_metadata()
187            .ok_or("remote directories cannot be saved")?;
188        let permit = source
189            .write
190            .as_ref()
191            .ok_or("remote file is read-only; use :remote edit first")?;
192        if doc.buf.readonly && !force {
193            return Err("readonly buffer; remote write authority remains available via :w!".into());
194        }
195        if !source.window.is_complete() || self.remote_following(document) {
196            return Err("partial/following remote windows cannot be saved".into());
197        }
198        if permit.version.file() != &source.file {
199            return Err("remote write permit belongs to another file".into());
200        }
201        let id = permit.id;
202        let before = permit.version.clone();
203        let contents = doc.buf.snapshot();
204        let revision = doc.buf.revision();
205        self.remote.writes.attempts.insert(
206            document,
207            Attempt {
208                permit: id,
209                revision,
210                before: before.clone(),
211                contents: contents.clone(),
212                unconfirmed: false,
213            },
214        );
215        let result = self.start_remote_write(
216            document,
217            revision,
218            Some(id),
219            Work::Save {
220                before,
221                contents,
222                close,
223            },
224        );
225        if result.is_err() {
226            self.remote.writes.attempts.remove(&document);
227        }
228        result
229    }
230
231    pub(super) fn verify_remote_save(&mut self) -> Result<(), String> {
232        let document = self.current();
233        if self.remote.writes.pending.contains_key(&document) {
234            return Err("remote write operation already pending".into());
235        }
236        let attempt = self
237            .remote
238            .writes
239            .attempts
240            .get(&document)
241            .filter(|attempt| attempt.unconfirmed)
242            .ok_or("no unconfirmed remote save to verify")?;
243        let permit = self
244            .cur()
245            .remote_metadata()
246            .and_then(|source| source.write.as_ref())
247            .ok_or("remote write permit was revoked")?;
248        if permit.id != attempt.permit {
249            return Err("remote save attempt belongs to an old permit".into());
250        }
251        let work = Work::Verify {
252            before: attempt.before.clone(),
253            contents: attempt.contents.clone(),
254        };
255        self.start_remote_write(document, attempt.revision, Some(attempt.permit), work)
256    }
257
258    fn start_remote_write(
259        &mut self,
260        document: DocumentId,
261        revision: BufferRevision,
262        permit: Option<WorkerId>,
263        work: Work,
264    ) -> Result<(), String> {
265        let request = self.worker_ids.allocate().map_err(|error| error.message)?;
266        let ticket = Ticket {
267            request,
268            key: RemoteWriteKey {
269                document,
270                revision,
271                file: work.file().clone(),
272                permit,
273                focus: self.focus_epoch,
274                action: work.action(),
275            },
276        };
277        self.remote.writes.pending.insert(document, ticket.clone());
278        self.message = match ticket.key.action {
279            WriteAction::Enable => "checking remote edit authority",
280            WriteAction::Save { .. } => "saving remotely",
281            WriteAction::Verify => "verifying remote save",
282        }
283        .into();
284        match self.tape.request(
285            "remote.write",
286            &(&ticket, work.before(), work.contents().len_bytes()),
287        ) {
288            Ok(false) => return Ok(()),
289            Ok(true) => {}
290            Err(error) => {
291                self.remote.writes.pending.remove(&document);
292                return Err(format!("remote write replay diverged: {error}"));
293            }
294        }
295        let sender = self.io.tx.clone();
296        let handle = worker::spawn(
297            "remote-write",
298            move |outcome| {
299                let _ = sender.send(IoEvent::Remote(RemoteEvent::Write(Box::new(Completion {
300                    ticket,
301                    outcome,
302                }))));
303            },
304            move |token| Outcome::Success(work.execute(&token)),
305        );
306        self.worker_handles.insert(request, handle);
307        Ok(())
308    }
309
310    pub(super) fn remote_write_done(
311        &mut self,
312        completion: Completion<RemoteWriteKey, RemoteWriteResult>,
313    ) {
314        let document = completion.ticket.key.document;
315        if self.remote.writes.pending.get(&document) != Some(&completion.ticket) {
316            return;
317        }
318        self.remote.writes.pending.remove(&document);
319        self.worker_handles.remove(&completion.ticket.request);
320        let request = completion.ticket.request;
321        let key = completion.ticket.key;
322        let valid = self
323            .docs
324            .get(document)
325            .and_then(|doc| doc.remote_metadata())
326            .is_some_and(|source| {
327                source.file == key.file
328                    && match key.action {
329                        WriteAction::Enable => {
330                            source.write.is_none()
331                                && source.window.is_complete()
332                                && !self.remote_following(document)
333                                && self.doc(document).buf.revision() == key.revision
334                        }
335                        _ => source
336                            .write
337                            .as_ref()
338                            .is_some_and(|permit| Some(permit.id) == key.permit),
339                    }
340            });
341        if !valid {
342            if key.action == WriteAction::Enable
343                && !self.docs.is_empty()
344                && self.current() == document
345            {
346                self.message =
347                    "remote edit admission cancelled: snapshot or follow state changed".into();
348            }
349            return;
350        }
351        match completion.outcome {
352            Outcome::Success(RemoteWriteResult::Enabled(version))
353                if key.action == WriteAction::Enable && version.file() == &key.file =>
354            {
355                let mut doc = self.doc_mut(document);
356                if let DocumentSource::Remote(source) = &mut doc.source {
357                    source.write = Some(WritePermit {
358                        id: request,
359                        version,
360                    });
361                    source.selection = strop_remote::ReadSelection::Full;
362                    doc.buf.readonly = false;
363                }
364                drop(doc);
365                self.message =
366                    "remote editing enabled (cooperative locks; other programs can still race)"
367                        .into();
368            }
369            Outcome::Success(RemoteWriteResult::Saved(receipt))
370                if matches!(key.action, WriteAction::Save { .. }) =>
371            {
372                self.accept_remote_receipt(key, receipt)
373            }
374            Outcome::Success(RemoteWriteResult::Verified(Verification::Written(receipt)))
375                if key.action == WriteAction::Verify =>
376            {
377                self.accept_remote_receipt(key, receipt)
378            }
379            Outcome::Success(RemoteWriteResult::Verified(Verification::Unchanged(version)))
380                if key.action == WriteAction::Verify =>
381            {
382                if let Some(attempt) = self.remote.writes.attempts.get(&document) {
383                    if attempt.before != version {
384                        self.remote_write_uncertain(
385                            document,
386                            "verification baseline differs".into(),
387                        );
388                        return;
389                    }
390                }
391                self.remote.writes.attempts.remove(&document);
392                self.message = "remote original is unchanged; local edits remain unsaved".into();
393            }
394            Outcome::Success(RemoteWriteResult::Refused(error)) => {
395                if error.is_unconfirmed() {
396                    self.remote_write_uncertain(document, error.to_string());
397                } else {
398                    if key.action != WriteAction::Verify
399                        || matches!(
400                            &error,
401                            RemoteSaveError::Refused {
402                                kind: transport::RefusalKind::Conflict,
403                                ..
404                            }
405                        )
406                    {
407                        self.remote.writes.attempts.remove(&document);
408                    }
409                    self.message = error.to_string();
410                }
411            }
412            Outcome::Cancelled(_) if key.action == WriteAction::Enable => {
413                self.message = "remote edit admission cancelled".into()
414            }
415            Outcome::Failed { failure, .. } if key.action == WriteAction::Enable => {
416                self.message = format!("remote edit admission failed: {}", failure.message)
417            }
418            Outcome::Cancelled(_) => self.remote_write_uncertain(
419                document,
420                "cancelled; the remote outcome is unconfirmed — :remote verify".into(),
421            ),
422            Outcome::Failed { failure, .. } => self.remote_write_uncertain(
423                document,
424                format!(
425                    "{}; remote outcome unconfirmed — :remote verify",
426                    failure.message
427                ),
428            ),
429            Outcome::Success(_) => self.remote_write_uncertain(
430                document,
431                "remote write result does not match its request".into(),
432            ),
433        }
434    }
435
436    fn remote_write_uncertain(&mut self, document: DocumentId, message: String) {
437        if let Some(attempt) = self.remote.writes.attempts.get_mut(&document) {
438            attempt.unconfirmed = true;
439        }
440        self.message = message;
441    }
442    fn accept_remote_receipt(&mut self, key: RemoteWriteKey, receipt: RemoteSaveReceipt) {
443        let version = receipt.into_version();
444        if version.file() != &key.file {
445            self.remote_write_uncertain(
446                key.document,
447                "receipt belongs to a different remote file".into(),
448            );
449            return;
450        }
451        let current = {
452            let mut doc = self.doc_mut(key.document);
453            let DocumentSource::Remote(source) = &mut doc.source else {
454                return;
455            };
456            let Some(permit) = source.write.as_mut() else {
457                return;
458            };
459            let size = version.size();
460            if Some(permit.id) != key.permit {
461                return;
462            }
463            permit.version = version;
464            source.window =
465                strop_remote::RemoteWindow::resolve(&strop_remote::ReadSelection::Full, size);
466            source.selection = strop_remote::ReadSelection::Full;
467            // Remote identity never becomes a local filesystem identity.
468            debug_assert!(doc.buf.path.is_none());
469            doc.buf.acknowledge_saved_revision(key.revision)
470        };
471        self.remote.writes.attempts.remove(&key.document);
472        self.message = if current {
473            "written remotely"
474        } else {
475            "remote snapshot written; newer edits remain unsaved"
476        }
477        .into();
478        if current
479            && matches!(key.action, WriteAction::Save { close: true })
480            && !self.docs.is_empty()
481            && self.current() == key.document
482            && self.focus_epoch == key.focus
483        {
484            self.close_pane_or_buffer(false);
485        }
486    }
487
488    pub(crate) fn revoke_remote_write(&mut self, document: DocumentId) {
489        if let Some(doc) = self.docs.get_mut(document) {
490            if let DocumentSource::Remote(source) = &mut doc.source {
491                source.write = None;
492                doc.buf.readonly = true;
493            }
494        }
495        self.remote.writes.attempts.remove(&document);
496        if let Some(ticket) = self.remote.writes.pending.remove(&document) {
497            if let Some(handle) = self.worker_handles.remove(&ticket.request) {
498                handle.cancel(CancelReason::OwnerClosed);
499            }
500        }
501    }
502    pub(crate) fn remote_write_blocks_refresh(&self, document: DocumentId) -> bool {
503        self.remote.writes.pending.contains_key(&document)
504            || self
505                .remote
506                .writes
507                .attempts
508                .get(&document)
509                .is_some_and(|attempt| attempt.unconfirmed)
510    }
511    pub(crate) fn cancel_remote_write(&mut self, document: DocumentId) {
512        if let Some(ticket) = self.remote.writes.pending.get(&document).cloned() {
513            if ticket.key.action == WriteAction::Enable {
514                self.remote.writes.pending.remove(&document);
515                self.message = "remote edit admission cancelled".into();
516            }
517            if let Some(handle) = self.worker_handles.remove(&ticket.request) {
518                handle.cancel(CancelReason::Dismissed);
519            }
520        }
521    }
522    pub(crate) fn remote_write_pending(&self, request: WorkerId) -> bool {
523        self.remote.writes.pending.values().any(|ticket| {
524            ticket.request == request
525                && matches!(
526                    ticket.key.action,
527                    WriteAction::Save { .. } | WriteAction::Verify
528                )
529        })
530    }
531    pub fn remote_write_status(&self) -> Option<&'static str> {
532        if self.docs.is_empty() {
533            return None;
534        }
535        let pending = self.remote.writes.pending.get(&self.current())?;
536        Some(match pending.key.action {
537            WriteAction::Enable => "checking edit",
538            WriteAction::Save { .. } => "saving",
539            WriteAction::Verify => "verifying save",
540        })
541    }
542    pub(crate) fn remote_edit_authorized(&self) -> bool {
543        self.cur()
544            .remote_metadata()
545            .is_some_and(|source| source.write.is_some())
546    }
547}
548
549#[cfg(test)]
550mod tests;