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