mbeditor 0.13.1 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -0
  3. data/app/assets/javascripts/mbeditor/application.js +3 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  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 +429 -247
  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/LogPanel.js +3 -44
  17. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
  18. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  19. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  20. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  21. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  22. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  23. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  24. data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
  25. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  26. data/app/assets/javascripts/mbeditor/file_service.js +38 -39
  27. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  28. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  29. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  30. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  31. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  32. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  33. data/app/assets/stylesheets/mbeditor/editor.css +642 -248
  34. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  35. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  36. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  37. data/app/controllers/mbeditor/application_controller.rb +26 -3
  38. data/app/controllers/mbeditor/editors_controller.rb +117 -439
  39. data/app/services/mbeditor/archive_service.rb +137 -0
  40. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  41. data/app/services/mbeditor/editor_state_service.rb +14 -51
  42. data/app/services/mbeditor/file_history_service.rb +222 -0
  43. data/app/services/mbeditor/git_info_service.rb +6 -0
  44. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  45. data/app/services/mbeditor/lint_service.rb +137 -0
  46. data/app/services/mbeditor/locked_json_file.rb +67 -0
  47. data/app/services/mbeditor/process_runner.rb +32 -0
  48. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  49. data/app/services/mbeditor/search_replace_service.rb +8 -0
  50. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  51. data/lib/mbeditor/audit_log.rb +203 -0
  52. data/lib/mbeditor/configuration.rb +6 -1
  53. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  54. data/lib/mbeditor/route_map.rb +4 -0
  55. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  56. data/lib/mbeditor/version.rb +1 -1
  57. data/lib/mbeditor.rb +1 -0
  58. metadata +12 -2
@@ -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,10 +33,18 @@ 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
+
36
48
  # Grant the right to seed a room's shared document from disk, to exactly one
37
49
  # client. Without this every client that finds the room empty inserts the whole
38
50
  # file at offset 0, and Yjs merges those inserts into two concatenated copies —
@@ -40,11 +52,20 @@ module Mbeditor
40
52
  # a room the server has no state for: a fresh room, or one that was evicted
41
53
  # while both were idle. The claim is per room, so the loser waits for the
42
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.
43
62
  def claim_seed(path, now: monotonic)
44
63
  MUTEX.synchronize do
45
64
  room = touch(path, now)
46
65
  next false if room[:seed_claimed] || room[:snapshot] || !room[:deltas].empty?
47
66
 
67
+ next false unless acquire_claim(path, now)
68
+
48
69
  room[:seed_claimed] = true
49
70
  end
50
71
  end
@@ -66,25 +87,43 @@ module Mbeditor
66
87
  # The claimer can leave before it ever seeds (a tab closed during the
67
88
  # handshake). Releasing the claim on an empty, empty-handed room keeps the
68
89
  # next opener from deferring to content that will never arrive.
69
- room[:seed_claimed] = false if room[:subscribers].zero? && room[:snapshot].nil? && room[:deltas].empty?
90
+ if room[:subscribers].zero? && room[:snapshot].nil? && room[:deltas].empty?
91
+ room[:seed_claimed] = false
92
+ release_claim(path)
93
+ end
70
94
  end
71
95
  nil
72
96
  end
73
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).
74
102
  def record_update(path, bytes, now: monotonic)
75
103
  MUTEX.synchronize do
76
104
  room = touch(path, now)
77
- room[:deltas] << bytes
105
+ room[:seq] += 1
106
+ room[:deltas] << { seq: room[:seq], bytes: bytes }
78
107
  room[:deltas].shift while room[:deltas].size > MAX_DELTAS
108
+ room[:seq]
79
109
  end
80
- nil
81
110
  end
82
111
 
83
- 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)
84
117
  MUTEX.synchronize do
85
118
  room = touch(path, now)
86
119
  room[:snapshot] = bytes
87
- 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
88
127
  end
89
128
  nil
90
129
  end
@@ -94,9 +133,15 @@ module Mbeditor
94
133
  room = rooms[path]
95
134
  if room
96
135
  room[:last_activity] = now
97
- 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
+ }
98
143
  else
99
- result = { snapshot: nil, deltas: [] }
144
+ result = { snapshot: nil, deltas: [], delta_seqs: [], snapshot_seq: 0, seq: 0 }
100
145
  end
101
146
  maybe_sweep(now)
102
147
  result
@@ -112,6 +157,7 @@ module Mbeditor
112
157
  MUTEX.synchronize do
113
158
  @rooms = {}
114
159
  @last_sweep = nil
160
+ clear_claims
115
161
  end
116
162
  nil
117
163
  end
@@ -123,7 +169,8 @@ module Mbeditor
123
169
 
124
170
  def touch(path, now)
125
171
  new_room = !rooms.key?(path)
126
- room = (rooms[path] ||= { snapshot: nil, deltas: [], subscribers: 0, seed_claimed: false })
172
+ room = (rooms[path] ||= { snapshot: nil, deltas: [], subscribers: 0, seed_claimed: false,
173
+ seq: 0, snapshot_seq: 0 })
127
174
  room[:last_activity] = now
128
175
  maybe_sweep(now)
129
176
  evict_lru if new_room && rooms.size > ROOM_CAP
@@ -143,20 +190,102 @@ module Mbeditor
143
190
  private_class_method :maybe_sweep
144
191
 
145
192
  def evict_idle(now, grace)
146
- rooms.delete_if { |_path, room| room[:subscribers].zero? && (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
147
198
  end
148
199
  private_class_method :evict_idle
149
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.
150
204
  def evict_lru
151
- idle = rooms.reject { |_path, room| room[:subscribers].positive? }
152
- oldest = (idle.empty? ? rooms : idle).min_by { |_path, room| room[:last_activity] }
153
- 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)
154
211
  end
155
212
  private_class_method :evict_lru
156
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
+
157
285
  def monotonic
158
286
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
287
  end
160
288
  private_class_method :monotonic
161
289
  end
162
290
  end
291
+
@@ -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