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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. 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 — it then waits for the next
33
- # snapshot instead of syncing from the buffer.
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[:deltas] << bytes
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
- def replace_snapshot(path, bytes, now: monotonic)
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
- room[:deltas] = []
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 = { snapshot: room[:snapshot], deltas: room[:deltas].dup }
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 { |_path, room| (now - room[:last_activity]) > grace }
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.min_by { |_path, room| room[:last_activity] }
114
- rooms.delete(oldest.first) if oldest
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
- read_json(workspace_path)
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
- read_json(branch_states_path)[branch] || {}
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
- path = branch_states_path
41
- FileUtils.mkdir_p(path.dirname)
42
- with_lock(path) do
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
- atomic_write(path, existing.to_json)
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(path) do
57
+ file.with_lock do
59
58
  all = begin
60
- read_json(path)
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
- atomic_write(path, all.to_json)
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
- path = workspace_path
78
- FileUtils.mkdir_p(path.dirname)
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
- # Readers take no lock at all: every write lands by rename, so a read sees
86
- # either the whole previous file or the whole new one — never the empty
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