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    ) -> bool {
152        match self.prepare_remote_save(document, target, force, close) {
153            Ok(()) => true,
154            Err(error) => {
155                self.message = error;
156                false
157            }
158        }
159    }
160    fn prepare_remote_save(
161        &mut self,
162        document: DocumentId,
163        target: Option<std::path::PathBuf>,
164        force: bool,
165        close: bool,
166    ) -> Result<(), String> {
167        if target.is_some() {
168            return Err("remote save-as is unsupported; no local fallback".into());
169        }
170        if self.remote_refresh_pending(document) {
171            return Err("remote refresh is pending".into());
172        }
173        if self.remote.writes.pending.contains_key(&document) {
174            return Err("remote write operation already pending".into());
175        }
176        if self
177            .remote
178            .writes
179            .attempts
180            .get(&document)
181            .is_some_and(|attempt| attempt.unconfirmed)
182        {
183            return Err(
184                "remote save outcome is unconfirmed; use :remote verify before another save".into(),
185            );
186        }
187        let doc = self.docs.get(document).ok_or("no such remote document")?;
188        let source = doc
189            .remote_metadata()
190            .ok_or("remote directories cannot be saved")?;
191        let permit = source
192            .write
193            .as_ref()
194            .ok_or("remote file is read-only; use :remote edit first")?;
195        if doc.buf.readonly && !force {
196            return Err("readonly buffer; remote write authority remains available via :w!".into());
197        }
198        if !source.window.is_complete() || self.remote_following(document) {
199            return Err("partial/following remote windows cannot be saved".into());
200        }
201        if permit.version.file() != &source.file {
202            return Err("remote write permit belongs to another file".into());
203        }
204        let id = permit.id;
205        let before = permit.version.clone();
206        let contents = doc.buf.snapshot();
207        let revision = doc.buf.revision();
208        self.remote.writes.attempts.insert(
209            document,
210            Attempt {
211                permit: id,
212                revision,
213                before: before.clone(),
214                contents: contents.clone(),
215                unconfirmed: false,
216            },
217        );
218        let result = self.start_remote_write(
219            document,
220            revision,
221            Some(id),
222            Work::Save {
223                before,
224                contents,
225                close,
226            },
227        );
228        if result.is_err() {
229            self.remote.writes.attempts.remove(&document);
230        }
231        result
232    }
233
234    pub(super) fn verify_remote_save(&mut self) -> Result<(), String> {
235        let document = self.current();
236        if self.remote.writes.pending.contains_key(&document) {
237            return Err("remote write operation already pending".into());
238        }
239        let attempt = self
240            .remote
241            .writes
242            .attempts
243            .get(&document)
244            .filter(|attempt| attempt.unconfirmed)
245            .ok_or("no unconfirmed remote save to verify")?;
246        let permit = self
247            .cur()
248            .remote_metadata()
249            .and_then(|source| source.write.as_ref())
250            .ok_or("remote write permit was revoked")?;
251        if permit.id != attempt.permit {
252            return Err("remote save attempt belongs to an old permit".into());
253        }
254        let work = Work::Verify {
255            before: attempt.before.clone(),
256            contents: attempt.contents.clone(),
257        };
258        self.start_remote_write(document, attempt.revision, Some(attempt.permit), work)
259    }
260
261    fn start_remote_write(
262        &mut self,
263        document: DocumentId,
264        revision: BufferRevision,
265        permit: Option<WorkerId>,
266        work: Work,
267    ) -> Result<(), String> {
268        let request = self.worker_ids.allocate().map_err(|error| error.message)?;
269        let ticket = Ticket {
270            request,
271            key: RemoteWriteKey {
272                document,
273                revision,
274                file: work.file().clone(),
275                permit,
276                focus: self.focus_epoch,
277                action: work.action(),
278            },
279        };
280        self.remote.writes.pending.insert(document, ticket.clone());
281        self.message = match ticket.key.action {
282            WriteAction::Enable => "checking remote edit authority",
283            WriteAction::Save { .. } => "saving remotely",
284            WriteAction::Verify => "verifying remote save",
285        }
286        .into();
287        match self.tape.request(
288            "remote.write",
289            &(&ticket, work.before(), work.contents().len_bytes()),
290        ) {
291            Ok(false) => return Ok(()),
292            Ok(true) => {}
293            Err(error) => {
294                self.remote.writes.pending.remove(&document);
295                return Err(format!("remote write replay diverged: {error}"));
296            }
297        }
298        let sender = self.io.tx.clone();
299        let handle = worker::spawn(
300            "remote-write",
301            move |outcome| {
302                let _ = sender.send(IoEvent::Remote(RemoteEvent::Write(Box::new(Completion {
303                    ticket,
304                    outcome,
305                }))));
306            },
307            move |token| Outcome::Success(work.execute(&token)),
308        );
309        self.worker_handles.insert(request, handle);
310        Ok(())
311    }
312
313    pub(super) fn remote_write_done(
314        &mut self,
315        completion: Completion<RemoteWriteKey, RemoteWriteResult>,
316    ) {
317        let document = completion.ticket.key.document;
318        if self.remote.writes.pending.get(&document) != Some(&completion.ticket) {
319            return;
320        }
321        self.remote.writes.pending.remove(&document);
322        self.worker_handles.remove(&completion.ticket.request);
323        let request = completion.ticket.request;
324        let key = completion.ticket.key;
325        let valid = self
326            .docs
327            .get(document)
328            .and_then(|doc| doc.remote_metadata())
329            .is_some_and(|source| {
330                source.file == key.file
331                    && match key.action {
332                        WriteAction::Enable => {
333                            source.write.is_none()
334                                && source.window.is_complete()
335                                && !self.remote_following(document)
336                                && self.doc(document).buf.revision() == key.revision
337                        }
338                        _ => source
339                            .write
340                            .as_ref()
341                            .is_some_and(|permit| Some(permit.id) == key.permit),
342                    }
343            });
344        if !valid {
345            self.collection_save_progress(document, false);
346            if key.action == WriteAction::Enable
347                && !self.docs.is_empty()
348                && self.current() == document
349            {
350                self.message =
351                    "remote edit admission cancelled: snapshot or follow state changed".into();
352            }
353            if matches!(key.action, WriteAction::Save { .. }) {
354                self.message = "remote save result discarded: source authority changed; disk outcome unconfirmed".into();
355                self.finish_save_feedback(document);
356            }
357            return;
358        }
359        match completion.outcome {
360            Outcome::Success(RemoteWriteResult::Enabled(version))
361                if key.action == WriteAction::Enable && version.file() == &key.file =>
362            {
363                let mut doc = self.doc_mut(document);
364                if let DocumentSource::Remote(source) = &mut doc.source {
365                    source.write = Some(WritePermit {
366                        id: request,
367                        version,
368                    });
369                    source.selection = strop_remote::ReadSelection::Full;
370                    doc.buf.readonly = false;
371                }
372                drop(doc);
373                self.message =
374                    "remote editing enabled (cooperative locks; other programs can still race)"
375                        .into();
376            }
377            Outcome::Success(RemoteWriteResult::Saved(receipt))
378                if matches!(key.action, WriteAction::Save { .. }) =>
379            {
380                return self.accept_remote_receipt(key, receipt);
381            }
382            Outcome::Success(RemoteWriteResult::Verified(Verification::Written(receipt)))
383                if key.action == WriteAction::Verify =>
384            {
385                return self.accept_remote_receipt(key, receipt);
386            }
387            Outcome::Success(RemoteWriteResult::Verified(Verification::Unchanged(version)))
388                if key.action == WriteAction::Verify =>
389            {
390                if let Some(attempt) = self.remote.writes.attempts.get(&document) {
391                    if attempt.before != version {
392                        self.remote_write_uncertain(
393                            document,
394                            "verification baseline differs".into(),
395                        );
396                        return;
397                    }
398                }
399                self.remote.writes.attempts.remove(&document);
400                self.message = "remote original is unchanged; local edits remain unsaved".into();
401            }
402            Outcome::Success(RemoteWriteResult::Refused(error)) => {
403                if error.is_unconfirmed() {
404                    self.remote_write_uncertain(document, error.to_string());
405                } else {
406                    if key.action != WriteAction::Verify
407                        || matches!(
408                            &error,
409                            RemoteSaveError::Refused {
410                                kind: transport::RefusalKind::Conflict,
411                                ..
412                            }
413                        )
414                    {
415                        self.remote.writes.attempts.remove(&document);
416                    }
417                    self.message = error.to_string();
418                }
419            }
420            Outcome::Cancelled(_) if key.action == WriteAction::Enable => {
421                self.message = "remote edit admission cancelled".into()
422            }
423            Outcome::Failed { failure, .. } if key.action == WriteAction::Enable => {
424                self.message = format!("remote edit admission failed: {}", failure.message)
425            }
426            Outcome::Cancelled(_) => self.remote_write_uncertain(
427                document,
428                "cancelled; the remote outcome is unconfirmed — :remote verify".into(),
429            ),
430            Outcome::Failed { failure, .. } => self.remote_write_uncertain(
431                document,
432                format!(
433                    "{}; remote outcome unconfirmed — :remote verify",
434                    failure.message
435                ),
436            ),
437            Outcome::Success(_) => self.remote_write_uncertain(
438                document,
439                "remote write result does not match its request".into(),
440            ),
441        }
442        if matches!(key.action, WriteAction::Save { .. }) {
443            self.finish_save_feedback(document);
444            self.collection_save_progress(document, false);
445        }
446    }
447
448    fn remote_write_uncertain(&mut self, document: DocumentId, message: String) {
449        if let Some(attempt) = self.remote.writes.attempts.get_mut(&document) {
450            attempt.unconfirmed = true;
451        }
452        self.message = message;
453        self.finish_save_feedback(document);
454        self.collection_save_progress(document, false);
455    }
456    fn accept_remote_receipt(&mut self, key: RemoteWriteKey, receipt: RemoteSaveReceipt) {
457        let version = receipt.into_version();
458        if version.file() != &key.file {
459            self.remote_write_uncertain(
460                key.document,
461                "receipt belongs to a different remote file".into(),
462            );
463            return;
464        }
465        let current = {
466            let mut doc = self.doc_mut(key.document);
467            let DocumentSource::Remote(source) = &mut doc.source else {
468                return;
469            };
470            let Some(permit) = source.write.as_mut() else {
471                return;
472            };
473            let size = version.size();
474            if Some(permit.id) != key.permit {
475                return;
476            }
477            permit.version = version;
478            source.window =
479                strop_remote::RemoteWindow::resolve(&strop_remote::ReadSelection::Full, size);
480            source.selection = strop_remote::ReadSelection::Full;
481            // Remote identity never becomes a local filesystem identity.
482            debug_assert!(doc.buf.path.is_none());
483            doc.buf.acknowledge_saved_revision(key.revision)
484        };
485        self.remote.writes.attempts.remove(&key.document);
486        self.message = if current {
487            "written remotely"
488        } else {
489            "remote snapshot written; newer edits remain unsaved"
490        }
491        .into();
492        self.finish_save_feedback(key.document);
493        self.collection_save_progress(key.document, current);
494        if current
495            && matches!(key.action, WriteAction::Save { close: true })
496            && !self.docs.is_empty()
497            && self.current() == key.document
498            && self.focus_epoch == key.focus
499        {
500            self.close_pane_or_buffer(false);
501        }
502    }
503
504    pub(crate) fn revoke_remote_write(&mut self, document: DocumentId) {
505        if let Some(doc) = self.docs.get_mut(document) {
506            if let DocumentSource::Remote(source) = &mut doc.source {
507                source.write = None;
508                doc.buf.readonly = true;
509            }
510        }
511        self.remote.writes.attempts.remove(&document);
512        if let Some(ticket) = self.remote.writes.pending.remove(&document) {
513            if let Some(handle) = self.worker_handles.remove(&ticket.request) {
514                handle.cancel(CancelReason::OwnerClosed);
515            }
516        }
517    }
518    pub(crate) fn remote_write_blocks_refresh(&self, document: DocumentId) -> bool {
519        self.remote.writes.pending.contains_key(&document)
520            || self
521                .remote
522                .writes
523                .attempts
524                .get(&document)
525                .is_some_and(|attempt| attempt.unconfirmed)
526    }
527    pub(crate) fn cancel_remote_write(&mut self, document: DocumentId) {
528        if let Some(ticket) = self.remote.writes.pending.get(&document).cloned() {
529            if ticket.key.action == WriteAction::Enable {
530                self.remote.writes.pending.remove(&document);
531                self.message = "remote edit admission cancelled".into();
532            }
533            if let Some(handle) = self.worker_handles.remove(&ticket.request) {
534                handle.cancel(CancelReason::Dismissed);
535            }
536        }
537    }
538    pub(crate) fn remote_write_pending(&self, request: WorkerId) -> bool {
539        self.remote.writes.pending.values().any(|ticket| {
540            ticket.request == request
541                && matches!(
542                    ticket.key.action,
543                    WriteAction::Save { .. } | WriteAction::Verify
544                )
545        })
546    }
547    pub fn remote_write_status(&self) -> Option<&'static str> {
548        if self.docs.is_empty() {
549            return None;
550        }
551        let pending = self.remote.writes.pending.get(&self.current())?;
552        Some(match pending.key.action {
553            WriteAction::Enable => "checking edit",
554            WriteAction::Save { .. } => "saving",
555            WriteAction::Verify => "verifying save",
556        })
557    }
558    pub(crate) fn remote_edit_authorized(&self) -> bool {
559        self.cur()
560            .remote_metadata()
561            .is_some_and(|source| source.write.is_some())
562    }
563}
564
565#[cfg(test)]
566mod tests;