Module: Canopus::Workspace::Recoverable

Defined in:
lib/canopus/workspace/recoverable.rb

Constant Summary collapse

MAX_DRAFT_BYTES =
10 << 20
MAX_SNAPSHOT_BYTES =
64 << 20
SNAPSHOT_OVERHEAD_BYTES =
4 << 20
JSON_ESCAPE_FACTOR =
6

Instance Method Summary collapse

Instance Method Details

#close_recovery(clean: true) ⇒ Object



99
100
101
102
103
104
105
106
# File 'lib/canopus/workspace/recoverable.rb', line 99

def close_recovery(clean: true)
  (@recovery_mutex ||= Mutex.new).synchronize do
    remove_recovery_snapshot if clean
    @recovery_lock&.close
    @recovery_lock = nil
    remove_recovery_lock if clean
  end
end

#offer_recovery ⇒ Object



24
25
26
27
28
29
30
31
32
# File 'lib/canopus/workspace/recoverable.rb', line 24

def offer_recovery
  return false if @palette || !(candidate = recovery_candidates.first)
  time = File.mtime(candidate).getlocal.strftime("%Y-%m-%d %H:%M:%S")
  self.palette = {kind: :recovery, query: "Recover unsaved changes from #{time}?", index: 0,
    matches: ["Restore", "Discard"], candidate: candidate}
  true
rescue SystemCallError
  false
end

#poll_recovery(now: Process.clock_gettime(Process::CLOCK_MONOTONIC), force: false) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/canopus/workspace/recoverable.rb', line 59

def poll_recovery(now: Process.clock_gettime(Process::CLOCK_MONOTONIC), force: false)
  recovery = @settings["recovery"]
  return false if @closed
  interval = recovery["interval"] / 1000.0
  return false if !force && @last_recovery_poll && now - @last_recovery_poll < interval
  @last_recovery_poll = now
  (@recovery_mutex ||= Mutex.new).synchronize do
    dirty = recovery_buffers.select(&:dirty?)
    eligible = dirty.reject { |buffer| buffer.rope.bytesize > MAX_DRAFT_BYTES }
    state = [recovery["enabled"], *dirty.map { |buffer| [buffer.object_id, buffer.version, buffer.path] }]
    return false if !force && state == @recovery_state
    if !recovery["enabled"] || eligible.empty?
      remove_recovery_snapshot
      notify("Crash recovery skipped #{dirty.length} buffer(s) larger than 10 MiB") if recovery["enabled"] && !dirty.empty?
      @recovery_state = state
      return false
    end
    if recovery_snapshot_too_large?(eligible)
      remove_recovery_snapshot
      notify("Crash recovery drafts exceeded the 64 MiB snapshot budget and were not retained")
      @recovery_state = state
      return false
    end
    ensure_recovery_storage
    save_session(@recovery_path, max_draft_bytes: MAX_DRAFT_BYTES)
    if File.size(@recovery_path) > MAX_SNAPSHOT_BYTES
      remove_recovery_snapshot
      notify("Crash recovery snapshot exceeded 64 MiB and was not retained")
      @recovery_state = state
      return false
    end
    notify("Crash recovery skipped #{dirty.length - eligible.length} buffer(s) larger than 10 MiB") if dirty.length != eligible.length
    @recovery_state = state
    true
  end
rescue StandardError => error
  notify("Crash recovery failed: #{error.message}")
  false
end

#preserve_recovery! ⇒ Object



108
# File 'lib/canopus/workspace/recoverable.rb', line 108

def preserve_recovery! = @preserve_recovery = true

#recovery_candidates ⇒ Object



14
15
16
17
18
19
20
21
22
# File 'lib/canopus/workspace/recoverable.rb', line 14

def recovery_candidates
  return [] unless @settings["recovery"]["enabled"] && recovery_directory_safe?
  ignored = @ignored_recovery_candidates || []
  Dir.glob(File.join(recovery_directory, "*.json")).reject do |path|
    path == @recovery_path || ignored.include?(path) || recovery_candidate_locked?(path) || !valid_recovery_candidate?(path)
  end.sort_by { |path| -File.mtime(path).to_f }
rescue SystemCallError
  []
end

#recovery_directory ⇒ Object



12
# File 'lib/canopus/workspace/recoverable.rb', line 12

def recovery_directory = File.join(@root, ".canopus", "recovery")

#resolve_recovery(choice) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/canopus/workspace/recoverable.rb', line 34

def resolve_recovery(choice)
  prompt = @palette
  return false unless prompt&.dig(:kind) == :recovery
  candidate = prompt.fetch(:candidate)
  self.palette = nil
  unless !recovery_candidate_locked?(candidate) && valid_recovery_candidate?(candidate)
    raise Error, "recovery snapshot is no longer safe to read"
  end
  if choice == :restore
    open_paths = @panes.flat_map { |pane| pane.editors.filter_map { |current| current.buffer.path } }.uniq
    restore_session(candidate)
    open_paths.each { |path| open(path) }
    discard_recovery(candidate) if !@settings["recovery"]["enabled"] || poll_recovery(force: true)
    self.message = "Recovered unsaved changes"
  else
    raise Error, "recovery snapshot is no longer safe to discard" unless discard_recovery(candidate)
    self.message = "Discarded recovered changes"
  end
  true
rescue StandardError => error
  (@ignored_recovery_candidates ||= []) << candidate if candidate
  notify("Recovery ignored: #{error.message}")
  false
end