mbeditor 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -1
- data/app/assets/javascripts/mbeditor/application.js +3 -1
- data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
- data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
- data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
- data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
- data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
- data/app/assets/javascripts/mbeditor/file_import.js +13 -15
- data/app/assets/javascripts/mbeditor/file_service.js +46 -57
- data/app/assets/javascripts/mbeditor/git_service.js +15 -1
- data/app/assets/javascripts/mbeditor/history_service.js +5 -13
- data/app/assets/javascripts/mbeditor/search_service.js +29 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
- data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
- data/app/assets/stylesheets/mbeditor/application.css +6 -1
- data/app/assets/stylesheets/mbeditor/editor.css +762 -297
- data/app/assets/stylesheets/mbeditor/glass.css +163 -0
- data/app/assets/stylesheets/mbeditor/themes.css +90 -30
- data/app/channels/mbeditor/collaboration_channel.rb +25 -6
- data/app/controllers/mbeditor/application_controller.rb +26 -3
- data/app/controllers/mbeditor/editors_controller.rb +125 -465
- data/app/services/mbeditor/archive_service.rb +137 -0
- data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- data/app/services/mbeditor/editor_state_service.rb +14 -51
- data/app/services/mbeditor/file_history_service.rb +222 -0
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/js_globals_service.rb +12 -1
- data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
- data/app/services/mbeditor/lint_service.rb +137 -0
- data/app/services/mbeditor/locked_json_file.rb +67 -0
- data/app/services/mbeditor/process_runner.rb +32 -0
- data/app/services/mbeditor/rubocop_run_service.rb +17 -5
- data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
- data/app/services/mbeditor/schema_service.rb +8 -2
- data/app/services/mbeditor/search_replace_service.rb +19 -2
- data/app/services/mbeditor/test_runner_service.rb +3 -56
- data/app/views/layouts/mbeditor/application.html.erb +1 -1
- data/lib/mbeditor/audit_log.rb +203 -0
- data/lib/mbeditor/configuration.rb +6 -9
- data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
- data/lib/mbeditor/route_map.rb +4 -1
- data/lib/mbeditor/ruby_lsp_client.rb +82 -14
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/lib/tasks/mbeditor.rake +23 -0
- metadata +14 -3
- data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
module Mbeditor
|
|
8
|
+
# Bundles a selection of workspace files and directories into one .tar.gz,
|
|
9
|
+
# for the explorer's multi-select download.
|
|
10
|
+
#
|
|
11
|
+
# tar.gz rather than zip because Gem::Package::TarWriter and Zlib ship with
|
|
12
|
+
# Ruby; a zip writer would mean adding rubyzip to a gemspec that declares
|
|
13
|
+
# only rails and sprockets-rails.
|
|
14
|
+
#
|
|
15
|
+
# Callers pass paths that have already been through resolve_path. The walk
|
|
16
|
+
# re-checks every entry it discovers anyway — see #collect.
|
|
17
|
+
class ArchiveService
|
|
18
|
+
# Same caps as #import (EditorsController::IMPORT_MAX_FILES /
|
|
19
|
+
# IMPORT_MAX_TOTAL_BYTES). Deliberately a refusal, not a truncation: an
|
|
20
|
+
# archive that is silently short looks complete when it is opened.
|
|
21
|
+
MAX_FILES = 100
|
|
22
|
+
MAX_TOTAL_BYTES = 50 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
class LimitExceededError < StandardError; end
|
|
25
|
+
|
|
26
|
+
def initialize(workspace_root)
|
|
27
|
+
@root = workspace_root.to_s.chomp("/")
|
|
28
|
+
@matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: @root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# +full_paths+ are absolute paths inside the workspace. Returns the
|
|
32
|
+
# .tar.gz bytes; 50 MB compresses fine in memory, so there is no streaming
|
|
33
|
+
# response to keep alive.
|
|
34
|
+
def build(full_paths)
|
|
35
|
+
entries = collect(full_paths)
|
|
36
|
+
raise "Nothing to archive" if entries.empty?
|
|
37
|
+
|
|
38
|
+
if entries.length > MAX_FILES
|
|
39
|
+
raise LimitExceededError, "Too many files (#{entries.length}). Limit is #{MAX_FILES}."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
total = entries.sum { |e| e[:size] }
|
|
43
|
+
if total > MAX_TOTAL_BYTES
|
|
44
|
+
raise LimitExceededError,
|
|
45
|
+
"Selection is too large (#{human(total)}). Limit is #{human(MAX_TOTAL_BYTES)}."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
write(entries)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Breadth-first walk of the selection, yielding regular files only.
|
|
54
|
+
def collect(full_paths)
|
|
55
|
+
entries = []
|
|
56
|
+
seen_dirs = {}
|
|
57
|
+
queue = full_paths.dup
|
|
58
|
+
|
|
59
|
+
until queue.empty?
|
|
60
|
+
path = queue.shift
|
|
61
|
+
# Containment is re-checked on every entry the walk discovers, not just
|
|
62
|
+
# on the paths the request named. A directory being inside the
|
|
63
|
+
# workspace says nothing about what turns up underneath it, and this is
|
|
64
|
+
# the only check standing between a name on disk and bytes in a file
|
|
65
|
+
# the user downloads.
|
|
66
|
+
next unless contained?(path)
|
|
67
|
+
|
|
68
|
+
rel = relative(path)
|
|
69
|
+
next if rel.start_with?("/") || rel.split("/").include?("..")
|
|
70
|
+
next if !rel.empty? && @matcher.excluded?(rel)
|
|
71
|
+
|
|
72
|
+
st = safe_lstat(path)
|
|
73
|
+
next if st.nil?
|
|
74
|
+
|
|
75
|
+
# Symlinks are skipped, which is deliberately stricter than
|
|
76
|
+
# ApplicationController#resolve_path, which follows them. That policy
|
|
77
|
+
# is about opening the one file a developer named and put in the tree.
|
|
78
|
+
# A recursive walk is a different question: following a link here would
|
|
79
|
+
# copy bytes from outside the workspace into a file the user
|
|
80
|
+
# downloads, and a link back up the tree loops forever. Neither is
|
|
81
|
+
# something the developer asked for by selecting a folder.
|
|
82
|
+
next if st.symlink?
|
|
83
|
+
|
|
84
|
+
if st.directory?
|
|
85
|
+
# Cycle guard even so — the symlink skip already rules out the usual
|
|
86
|
+
# loop, but a bind mount or a hard-linked directory does not need one.
|
|
87
|
+
key = [st.dev, st.ino]
|
|
88
|
+
next if seen_dirs.key?(key)
|
|
89
|
+
|
|
90
|
+
seen_dirs[key] = true
|
|
91
|
+
queue.concat(Dir.children(path).sort.map { |name| File.join(path, name) })
|
|
92
|
+
elsif st.file?
|
|
93
|
+
entries << { path: path, name: rel, mode: st.mode & 0o777, size: st.size }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
entries
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def write(entries)
|
|
101
|
+
buffer = StringIO.new(+"".b)
|
|
102
|
+
Zlib::GzipWriter.wrap(buffer) do |gz|
|
|
103
|
+
Gem::Package::TarWriter.new(gz) do |tar|
|
|
104
|
+
entries.each do |entry|
|
|
105
|
+
# Read first, then declare the size from the bytes in hand:
|
|
106
|
+
# TarWriter bounds the entry to the size it was given, so a file
|
|
107
|
+
# that changed since it was stat-ed would otherwise write a short
|
|
108
|
+
# or over-long member.
|
|
109
|
+
data = File.binread(entry[:path])
|
|
110
|
+
tar.add_file_simple(entry[:name], entry[:mode], data.bytesize) { |io| io.write(data) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
buffer.string
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def contained?(path)
|
|
118
|
+
full = File.expand_path(path)
|
|
119
|
+
full == @root || full.start_with?("#{@root}/")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def relative(path)
|
|
123
|
+
path == @root ? "" : path.delete_prefix("#{@root}/")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A file can vanish between listing its directory and stat-ing it.
|
|
127
|
+
def safe_lstat(path)
|
|
128
|
+
File.lstat(path)
|
|
129
|
+
rescue Errno::ENOENT
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def human(bytes)
|
|
134
|
+
ActiveSupport::NumberHelper.number_to_human_size(bytes)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
3
7
|
module Mbeditor
|
|
4
8
|
# Thread-safe, in-memory cache of opaque Yjs bytes per active file. It never
|
|
5
9
|
# interprets the bytes (no Ruby CRDT): it only buffers the latest snapshot plus
|
|
@@ -29,24 +33,97 @@ module Mbeditor
|
|
|
29
33
|
|
|
30
34
|
# Hard cap on buffered deltas per room; a long editing session on one file
|
|
31
35
|
# would otherwise grow it without limit. Over the cap the oldest go, which
|
|
32
|
-
# costs a late joiner an incomplete replay —
|
|
33
|
-
# snapshot instead of
|
|
36
|
+
# costs a late joiner an incomplete replay — the client detects the missing
|
|
37
|
+
# dependencies and asks a peer for a full snapshot instead of attaching to
|
|
38
|
+
# the partial replay (see CollaborationService#_maybeAttach).
|
|
34
39
|
MAX_DELTAS = 500
|
|
35
40
|
|
|
41
|
+
# A seed claim left behind by a crashed process would wedge the room forever
|
|
42
|
+
# (every later opener defers to content that never comes). Claims older than
|
|
43
|
+
# this are treated as abandoned and may be taken over. It matches GRACE_TTL
|
|
44
|
+
# because a room that has been idle that long is itself evictable, so the
|
|
45
|
+
# claim and the room it guards expire together.
|
|
46
|
+
CLAIM_TTL = GRACE_TTL
|
|
47
|
+
|
|
48
|
+
# Grant the right to seed a room's shared document from disk, to exactly one
|
|
49
|
+
# client. Without this every client that finds the room empty inserts the whole
|
|
50
|
+
# file at offset 0, and Yjs merges those inserts into two concatenated copies —
|
|
51
|
+
# the file appended to itself. That happens whenever two editor tabs attach to
|
|
52
|
+
# a room the server has no state for: a fresh room, or one that was evicted
|
|
53
|
+
# while both were idle. The claim is per room, so the loser waits for the
|
|
54
|
+
# winner's content instead of inventing its own.
|
|
55
|
+
#
|
|
56
|
+
# The in-memory flag only covers one process. With a cross-process cable
|
|
57
|
+
# adapter (redis/postgres/solid_cable) and more than one Puma worker, two
|
|
58
|
+
# clients can reach two different processes whose stores are both empty, so
|
|
59
|
+
# the claim is also serialized through a lock file under tmp/ (shared by
|
|
60
|
+
# every process of the app). `File::CREAT | File::EXCL` is the atomic test,
|
|
61
|
+
# so exactly one process wins even in the race.
|
|
62
|
+
def claim_seed(path, now: monotonic)
|
|
63
|
+
MUTEX.synchronize do
|
|
64
|
+
room = touch(path, now)
|
|
65
|
+
next false if room[:seed_claimed] || room[:snapshot] || !room[:deltas].empty?
|
|
66
|
+
|
|
67
|
+
next false unless acquire_claim(path, now)
|
|
68
|
+
|
|
69
|
+
room[:seed_claimed] = true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Subscriber accounting. A room with a live subscriber must never be evicted:
|
|
74
|
+
# eviction empties the server's copy while clients still hold content, and the
|
|
75
|
+
# next client to attach would be granted a seed it must not perform.
|
|
76
|
+
def join(path, now: monotonic)
|
|
77
|
+
MUTEX.synchronize { touch(path, now)[:subscribers] += 1 }
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def leave(path, now: monotonic)
|
|
82
|
+
MUTEX.synchronize do
|
|
83
|
+
room = rooms[path]
|
|
84
|
+
next unless room
|
|
85
|
+
|
|
86
|
+
room[:subscribers] -= 1 if room[:subscribers] > 0
|
|
87
|
+
# The claimer can leave before it ever seeds (a tab closed during the
|
|
88
|
+
# handshake). Releasing the claim on an empty, empty-handed room keeps the
|
|
89
|
+
# next opener from deferring to content that will never arrive.
|
|
90
|
+
if room[:subscribers].zero? && room[:snapshot].nil? && room[:deltas].empty?
|
|
91
|
+
room[:seed_claimed] = false
|
|
92
|
+
release_claim(path)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Record a delta and return the room-local sequence number assigned to it.
|
|
99
|
+
# The sequence lets a client tell the server how far its full-state snapshot
|
|
100
|
+
# reaches, so replace_snapshot can keep exactly the deltas the snapshot does
|
|
101
|
+
# not already contain (see #97).
|
|
36
102
|
def record_update(path, bytes, now: monotonic)
|
|
37
103
|
MUTEX.synchronize do
|
|
38
104
|
room = touch(path, now)
|
|
39
|
-
room[:
|
|
105
|
+
room[:seq] += 1
|
|
106
|
+
room[:deltas] << { seq: room[:seq], bytes: bytes }
|
|
40
107
|
room[:deltas].shift while room[:deltas].size > MAX_DELTAS
|
|
108
|
+
room[:seq]
|
|
41
109
|
end
|
|
42
|
-
nil
|
|
43
110
|
end
|
|
44
111
|
|
|
45
|
-
|
|
112
|
+
# Swap the cached snapshot. Deltas up to +applied_seq+ are already folded into
|
|
113
|
+
# the snapshot and are dropped; anything recorded after that point stays, so a
|
|
114
|
+
# delta that crossed the snapshot in flight is not lost from the buffer.
|
|
115
|
+
# +applied_seq+ nil (legacy/unknown) clears the buffer as before.
|
|
116
|
+
def replace_snapshot(path, bytes, applied_seq: nil, now: monotonic)
|
|
46
117
|
MUTEX.synchronize do
|
|
47
118
|
room = touch(path, now)
|
|
48
119
|
room[:snapshot] = bytes
|
|
49
|
-
|
|
120
|
+
if applied_seq
|
|
121
|
+
room[:deltas] = room[:deltas].select { |d| d[:seq] > applied_seq }
|
|
122
|
+
room[:snapshot_seq] = applied_seq
|
|
123
|
+
else
|
|
124
|
+
room[:deltas] = []
|
|
125
|
+
room[:snapshot_seq] = room[:seq]
|
|
126
|
+
end
|
|
50
127
|
end
|
|
51
128
|
nil
|
|
52
129
|
end
|
|
@@ -56,9 +133,15 @@ module Mbeditor
|
|
|
56
133
|
room = rooms[path]
|
|
57
134
|
if room
|
|
58
135
|
room[:last_activity] = now
|
|
59
|
-
result = {
|
|
136
|
+
result = {
|
|
137
|
+
snapshot: room[:snapshot],
|
|
138
|
+
deltas: room[:deltas].map { |d| d[:bytes] },
|
|
139
|
+
delta_seqs: room[:deltas].map { |d| d[:seq] },
|
|
140
|
+
snapshot_seq: room[:snapshot_seq],
|
|
141
|
+
seq: room[:seq]
|
|
142
|
+
}
|
|
60
143
|
else
|
|
61
|
-
result = { snapshot: nil, deltas: [] }
|
|
144
|
+
result = { snapshot: nil, deltas: [], delta_seqs: [], snapshot_seq: 0, seq: 0 }
|
|
62
145
|
end
|
|
63
146
|
maybe_sweep(now)
|
|
64
147
|
result
|
|
@@ -74,6 +157,7 @@ module Mbeditor
|
|
|
74
157
|
MUTEX.synchronize do
|
|
75
158
|
@rooms = {}
|
|
76
159
|
@last_sweep = nil
|
|
160
|
+
clear_claims
|
|
77
161
|
end
|
|
78
162
|
nil
|
|
79
163
|
end
|
|
@@ -85,7 +169,8 @@ module Mbeditor
|
|
|
85
169
|
|
|
86
170
|
def touch(path, now)
|
|
87
171
|
new_room = !rooms.key?(path)
|
|
88
|
-
room = (rooms[path] ||= { snapshot: nil, deltas: []
|
|
172
|
+
room = (rooms[path] ||= { snapshot: nil, deltas: [], subscribers: 0, seed_claimed: false,
|
|
173
|
+
seq: 0, snapshot_seq: 0 })
|
|
89
174
|
room[:last_activity] = now
|
|
90
175
|
maybe_sweep(now)
|
|
91
176
|
evict_lru if new_room && rooms.size > ROOM_CAP
|
|
@@ -105,19 +190,102 @@ module Mbeditor
|
|
|
105
190
|
private_class_method :maybe_sweep
|
|
106
191
|
|
|
107
192
|
def evict_idle(now, grace)
|
|
108
|
-
rooms.delete_if
|
|
193
|
+
rooms.delete_if do |path, room|
|
|
194
|
+
evict = room[:subscribers].zero? && (now - room[:last_activity]) > grace
|
|
195
|
+
release_claim(path) if evict
|
|
196
|
+
evict
|
|
197
|
+
end
|
|
109
198
|
end
|
|
110
199
|
private_class_method :evict_idle
|
|
111
200
|
|
|
201
|
+
# Only idle rooms are evictable, per the invariant on join: emptying a room
|
|
202
|
+
# clients still hold content for gets the next opener granted a seed it must
|
|
203
|
+
# not perform. With every room subscribed the cap is simply exceeded.
|
|
112
204
|
def evict_lru
|
|
113
|
-
oldest = rooms.
|
|
114
|
-
|
|
205
|
+
oldest = rooms.reject { |_path, room| room[:subscribers].positive? }
|
|
206
|
+
.min_by { |_path, room| room[:last_activity] }
|
|
207
|
+
return unless oldest
|
|
208
|
+
|
|
209
|
+
release_claim(oldest.first)
|
|
210
|
+
rooms.delete(oldest.first)
|
|
115
211
|
end
|
|
116
212
|
private_class_method :evict_lru
|
|
117
213
|
|
|
214
|
+
# ── cross-process seed claim ────────────────────────────────────────────
|
|
215
|
+
#
|
|
216
|
+
# The workspace tmp/ directory is shared by every process of the app (the
|
|
217
|
+
# same tree WorkspaceRootResolver points at), so a claim file there is
|
|
218
|
+
# visible to all of them without new infrastructure.
|
|
219
|
+
|
|
220
|
+
def claim_dir
|
|
221
|
+
root = begin
|
|
222
|
+
WorkspaceRootResolver.call.to_s
|
|
223
|
+
rescue StandardError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
root = Rails.root.to_s if (root.nil? || root.empty?) && defined?(Rails) && Rails.respond_to?(:root)
|
|
227
|
+
root = Dir.tmpdir if root.nil? || root.empty?
|
|
228
|
+
File.join(root, "tmp", "mbeditor_collab_claims")
|
|
229
|
+
end
|
|
230
|
+
private_class_method :claim_dir
|
|
231
|
+
|
|
232
|
+
def claim_path(path)
|
|
233
|
+
File.join(claim_dir, Digest::SHA256.hexdigest(path.to_s))
|
|
234
|
+
end
|
|
235
|
+
private_class_method :claim_path
|
|
236
|
+
|
|
237
|
+
# Atomically take the claim, or refuse when another process holds a live one.
|
|
238
|
+
# Returns true only when this call created the file.
|
|
239
|
+
def acquire_claim(path, now)
|
|
240
|
+
file = claim_path(path)
|
|
241
|
+
if File.exist?(file)
|
|
242
|
+
return false if (Time.now - File.mtime(file)) <= CLAIM_TTL
|
|
243
|
+
|
|
244
|
+
# Abandoned by a crashed/evicted process; reclaim it.
|
|
245
|
+
begin
|
|
246
|
+
File.delete(file)
|
|
247
|
+
rescue Errno::ENOENT
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
FileUtils.mkdir_p(claim_dir)
|
|
253
|
+
begin
|
|
254
|
+
File.open(file, File::WRONLY | File::CREAT | File::EXCL) { |f| f.write(now.to_s) }
|
|
255
|
+
true
|
|
256
|
+
rescue Errno::EEXIST
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
rescue SystemCallError, IOError
|
|
260
|
+
# No writable tmp/ (e.g. a read-only checkout). Fall back to the in-memory
|
|
261
|
+
# claim rather than crashing the channel; cross-process serialization is
|
|
262
|
+
# lost, but the alternative is no collaboration at all.
|
|
263
|
+
true
|
|
264
|
+
end
|
|
265
|
+
private_class_method :acquire_claim
|
|
266
|
+
|
|
267
|
+
def release_claim(path)
|
|
268
|
+
File.delete(claim_path(path))
|
|
269
|
+
rescue SystemCallError
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
private_class_method :release_claim
|
|
273
|
+
|
|
274
|
+
def clear_claims
|
|
275
|
+
Dir.glob(File.join(claim_dir, "*")).each do |file|
|
|
276
|
+
File.delete(file)
|
|
277
|
+
rescue SystemCallError
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
rescue StandardError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
private_class_method :clear_claims
|
|
284
|
+
|
|
118
285
|
def monotonic
|
|
119
286
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
120
287
|
end
|
|
121
288
|
private_class_method :monotonic
|
|
122
289
|
end
|
|
123
290
|
end
|
|
291
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
|
|
5
|
+
module Mbeditor
|
|
6
|
+
# Finds files that look like they were written back to disk with their own
|
|
7
|
+
# content appended to themselves.
|
|
8
|
+
#
|
|
9
|
+
# The corruption this detects came from the collaborative buffer: two clients
|
|
10
|
+
# that each found an empty shared document seeded it from disk, and Yjs merged
|
|
11
|
+
# both inserts at offset 0 into two concatenated copies. That bug is fixed
|
|
12
|
+
# (CollaborationDocStore.claim_seed), but files saved while it was live are
|
|
13
|
+
# still on disk, so there has to be a way to find them.
|
|
14
|
+
#
|
|
15
|
+
# Two signals, cheapest first:
|
|
16
|
+
#
|
|
17
|
+
# * :exact — the file is byte-for-byte X + X. What a duplicated save
|
|
18
|
+
# produces before anyone edits it again.
|
|
19
|
+
# * :repeated_block — the first ANCHOR_LINES lines of the file occur again
|
|
20
|
+
# later, verbatim. Survives edits made after the
|
|
21
|
+
# corruption, which is the common case for a file noticed
|
|
22
|
+
# days later.
|
|
23
|
+
#
|
|
24
|
+
# Report-only. Nothing here writes.
|
|
25
|
+
class DuplicateContentScanner
|
|
26
|
+
ANCHOR_LINES = 10
|
|
27
|
+
MIN_LINES = 20 # below this, a repeat is unremarkable
|
|
28
|
+
MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
Finding = Struct.new(:path, :reason, :line, :lines, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
def self.check(content)
|
|
33
|
+
return nil if content.nil? || content.empty?
|
|
34
|
+
|
|
35
|
+
lines = content.lines
|
|
36
|
+
return nil if lines.size < MIN_LINES
|
|
37
|
+
|
|
38
|
+
half = content.bytesize / 2
|
|
39
|
+
if content.bytesize.even? && content.byteslice(0, half) == content.byteslice(half, half)
|
|
40
|
+
return { reason: :exact, line: (lines.size / 2) + 1 }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
at = repeated_anchor_line(lines)
|
|
44
|
+
at ? { reason: :repeated_block, line: at } : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Line number (1-based) of a later verbatim recurrence of the file's opening
|
|
48
|
+
# block, or nil. The anchor starts at the first non-blank line so a leading
|
|
49
|
+
# blank does not shift it out of alignment with its copy.
|
|
50
|
+
def self.repeated_anchor_line(lines)
|
|
51
|
+
start = lines.index { |l| !l.strip.empty? }
|
|
52
|
+
return nil if start.nil? || (start + ANCHOR_LINES) > lines.size
|
|
53
|
+
|
|
54
|
+
anchor = lines[start, ANCHOR_LINES]
|
|
55
|
+
# A block of identical lines (padding, a long table) repeats for boring
|
|
56
|
+
# reasons; require the anchor itself to carry some variety.
|
|
57
|
+
return nil if anchor.uniq.size < 3
|
|
58
|
+
|
|
59
|
+
idx = ((start + 1)..(lines.size - ANCHOR_LINES)).find { |i| lines[i, ANCHOR_LINES] == anchor }
|
|
60
|
+
idx && (idx + 1)
|
|
61
|
+
end
|
|
62
|
+
private_class_method :repeated_anchor_line
|
|
63
|
+
|
|
64
|
+
def initialize(root, excluded: Mbeditor.configuration.excluded_paths)
|
|
65
|
+
@root = File.expand_path(root.to_s)
|
|
66
|
+
@matcher = ExclusionMatcher.new(excluded, root: @root)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def call
|
|
70
|
+
findings = []
|
|
71
|
+
Find.find(@root) do |path|
|
|
72
|
+
rel = path == @root ? "" : path.delete_prefix("#{@root}/")
|
|
73
|
+
if File.directory?(path)
|
|
74
|
+
Find.prune if !rel.empty? && @matcher.excluded?(rel)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
next if @matcher.excluded?(rel)
|
|
78
|
+
next unless File.file?(path) && File.size(path).between?(1, MAX_FILE_SIZE)
|
|
79
|
+
|
|
80
|
+
content = read_text(path)
|
|
81
|
+
next unless content
|
|
82
|
+
|
|
83
|
+
hit = self.class.check(content)
|
|
84
|
+
next unless hit
|
|
85
|
+
|
|
86
|
+
findings << Finding.new(path: rel, reason: hit[:reason], line: hit[:line], lines: content.lines.size)
|
|
87
|
+
end
|
|
88
|
+
findings.sort_by { |f| [f.reason == :exact ? 0 : 1, f.path] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# nil for anything that is not readable UTF-8 text — a NUL byte is the same
|
|
94
|
+
# binary test the rest of the editor uses.
|
|
95
|
+
def read_text(path)
|
|
96
|
+
content = File.read(path, encoding: "UTF-8")
|
|
97
|
+
return nil unless content.valid_encoding?
|
|
98
|
+
return nil if content.include?("\0")
|
|
99
|
+
|
|
100
|
+
content
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -14,7 +14,6 @@ module Mbeditor
|
|
|
14
14
|
# is acquired non-blocking with a bounded retry and gives up with a clear
|
|
15
15
|
# error rather than hanging the worker.
|
|
16
16
|
DEFAULT_LOCK_TIMEOUT = 5.0
|
|
17
|
-
LOCK_RETRY_INTERVAL = 0.01
|
|
18
17
|
|
|
19
18
|
def initialize(workspace_root, lock_timeout: DEFAULT_LOCK_TIMEOUT)
|
|
20
19
|
@root = workspace_root
|
|
@@ -22,13 +21,13 @@ module Mbeditor
|
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
def read_state
|
|
25
|
-
|
|
24
|
+
locked_json_file(workspace_path).read
|
|
26
25
|
rescue JSON::ParserError, Errno::ENOENT
|
|
27
26
|
{}
|
|
28
27
|
end
|
|
29
28
|
|
|
30
29
|
def read_branch_state(branch)
|
|
31
|
-
|
|
30
|
+
locked_json_file(branch_states_path).read[branch] || {}
|
|
32
31
|
rescue JSON::ParserError, Errno::ENOENT
|
|
33
32
|
{}
|
|
34
33
|
end
|
|
@@ -37,16 +36,15 @@ module Mbeditor
|
|
|
37
36
|
raise InvalidBranchError, "Invalid branch name" unless branch.match?(SAFE_BRANCH_NAME)
|
|
38
37
|
payload_json = state.to_json
|
|
39
38
|
raise PayloadTooLargeError, "State payload too large" if payload_json.bytesize > STATE_MAX_BYTES
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
existing = read_json(path)
|
|
39
|
+
file = locked_json_file(branch_states_path)
|
|
40
|
+
file.with_lock do
|
|
41
|
+
existing = file.read
|
|
44
42
|
# Auto-save fires on a timer even with no changes; skip the full-file
|
|
45
43
|
# rewrite when this branch's entry is already identical.
|
|
46
44
|
next if existing[branch] == JSON.parse(payload_json)
|
|
47
45
|
|
|
48
46
|
existing[branch] = state
|
|
49
|
-
|
|
47
|
+
file.write(existing)
|
|
50
48
|
end
|
|
51
49
|
nil
|
|
52
50
|
end
|
|
@@ -54,10 +52,11 @@ module Mbeditor
|
|
|
54
52
|
def prune_branch_states(active_branches:)
|
|
55
53
|
path = branch_states_path
|
|
56
54
|
return [] unless File.exist?(path)
|
|
55
|
+
file = locked_json_file(path)
|
|
57
56
|
pruned = []
|
|
58
|
-
with_lock
|
|
57
|
+
file.with_lock do
|
|
59
58
|
all = begin
|
|
60
|
-
|
|
59
|
+
file.read
|
|
61
60
|
rescue JSON::ParserError => e
|
|
62
61
|
Rails.logger.error("[mbeditor] EditorStateService#prune_branch_states: discarding corrupt branch_states JSON at #{path}: #{e.message}")
|
|
63
62
|
{}
|
|
@@ -65,7 +64,7 @@ module Mbeditor
|
|
|
65
64
|
pruned = all.keys - active_branches
|
|
66
65
|
if pruned.any?
|
|
67
66
|
pruned.each { |b| all.delete(b) }
|
|
68
|
-
|
|
67
|
+
file.write(all)
|
|
69
68
|
end
|
|
70
69
|
end
|
|
71
70
|
pruned
|
|
@@ -74,51 +73,15 @@ module Mbeditor
|
|
|
74
73
|
def write_state(state)
|
|
75
74
|
payload = state.to_json
|
|
76
75
|
raise PayloadTooLargeError, "State payload too large" if payload.bytesize > STATE_MAX_BYTES
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
with_lock(path) { atomic_write(path, payload) }
|
|
76
|
+
file = locked_json_file(workspace_path)
|
|
77
|
+
file.with_lock { file.write(payload) }
|
|
80
78
|
nil
|
|
81
79
|
end
|
|
82
80
|
|
|
83
81
|
private
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
# window a truncate-then-write leaves, which readers swallowed as {}.
|
|
88
|
-
def read_json(path)
|
|
89
|
-
return {} unless File.exist?(path)
|
|
90
|
-
|
|
91
|
-
raw = File.read(path)
|
|
92
|
-
raw.empty? ? {} : JSON.parse(raw)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def atomic_write(path, payload)
|
|
96
|
-
tmp = "#{path}.tmp"
|
|
97
|
-
File.write(tmp, payload)
|
|
98
|
-
File.rename(tmp, path)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# The lock sits on a sidecar file, not on the state file: the state file is
|
|
102
|
-
# replaced by rename, so a lock held on the inode it had before the write
|
|
103
|
-
# would exclude nobody afterwards.
|
|
104
|
-
def with_lock(path)
|
|
105
|
-
File.open("#{path}.lock", File::RDWR | File::CREAT) do |f|
|
|
106
|
-
lock_exclusive!(f)
|
|
107
|
-
yield
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Acquire an exclusive lock without blocking forever. Retries the
|
|
112
|
-
# non-blocking flock until @lock_timeout elapses, then raises so the caller
|
|
113
|
-
# fails fast instead of pinning a worker on a stuck holder.
|
|
114
|
-
def lock_exclusive!(file)
|
|
115
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
|
|
116
|
-
until file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
117
|
-
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
118
|
-
raise LockTimeoutError, "could not acquire state lock within #{@lock_timeout}s"
|
|
119
|
-
end
|
|
120
|
-
sleep LOCK_RETRY_INTERVAL
|
|
121
|
-
end
|
|
83
|
+
def locked_json_file(path)
|
|
84
|
+
LockedJsonFile.new(path, lock_timeout: @lock_timeout, error_class: LockTimeoutError)
|
|
122
85
|
end
|
|
123
86
|
|
|
124
87
|
def workspace_path
|