cohere-transcribe 0.1.2 → 0.1.3

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +9 -7
  4. data/ext/cohere_transcribe_native/.gitignore +6 -0
  5. data/ext/cohere_transcribe_native/CMakeLists.txt +8 -0
  6. data/ext/cohere_transcribe_native/audio_abi.cpp +20 -0
  7. data/ext/cohere_transcribe_native/audio_exports.macos +1 -0
  8. data/ext/cohere_transcribe_native/audio_exports.map +1 -0
  9. data/ext/cohere_transcribe_native/test/abi_smoke.rb +26 -0
  10. data/lib/cohere/transcribe/alignment/aligner.rb +40 -7
  11. data/lib/cohere/transcribe/asr/native.rb +43 -97
  12. data/lib/cohere/transcribe/audio/decoder.rb +184 -75
  13. data/lib/cohere/transcribe/audio/ffmpeg_native.rb +84 -32
  14. data/lib/cohere/transcribe/audio/segmentation.rb +1 -0
  15. data/lib/cohere/transcribe/cli.rb +30 -22
  16. data/lib/cohere/transcribe/configuration.rb +13 -0
  17. data/lib/cohere/transcribe/constants.rb +1 -1
  18. data/lib/cohere/transcribe/doctor.rb +2 -1
  19. data/lib/cohere/transcribe/hub.rb +446 -58
  20. data/lib/cohere/transcribe/input.rb +18 -3
  21. data/lib/cohere/transcribe/internal/interruptible_native_call.rb +70 -0
  22. data/lib/cohere/transcribe/internal/session_ownership.rb +61 -0
  23. data/lib/cohere/transcribe/internal/utf8.rb +19 -0
  24. data/lib/cohere/transcribe/model_identity.rb +10 -1
  25. data/lib/cohere/transcribe/output/publication.rb +95 -86
  26. data/lib/cohere/transcribe/pytorch_checkpoint.rb +38 -22
  27. data/lib/cohere/transcribe/runtime/engine.rb +37 -16
  28. data/lib/cohere/transcribe/runtime/preparation.rb +212 -35
  29. data/lib/cohere/transcribe/runtime/resources.rb +28 -62
  30. data/lib/cohere/transcribe/state/checkpoint.rb +3 -2
  31. data/lib/cohere/transcribe/state/io.rb +217 -117
  32. data/lib/cohere/transcribe/state/locking.rb +383 -64
  33. data/lib/cohere/transcribe/state/manifest.rb +21 -22
  34. data/lib/cohere/transcribe/types.rb +25 -11
  35. data/lib/cohere/transcribe/version.rb +1 -1
  36. data/sig/cohere/transcribe.rbs +1 -0
  37. metadata +5 -1
@@ -2,19 +2,28 @@
2
2
 
3
3
  require "digest"
4
4
  require "fileutils"
5
+ require "rubygems/version"
5
6
  require "tmpdir"
6
7
 
7
8
  module Cohere
8
9
  module Transcribe
9
10
  module State
10
11
  LOCK_DIRECTORY_PREFIX = "cohere-transcribe"
12
+ LOCK_DIRECTORY_NAME = ".cohere-transcribe-locks"
11
13
  LOCK_SUFFIX = ".lock"
14
+ LEGACY_LOCK_REMOVAL_VERSION = "0.2.0"
12
15
 
13
- OutputLockTarget = Data.define(:path, :identity) do
14
- def sort_key
15
- [path.to_s.downcase, identity.downcase]
16
+ OutputLockTarget = Data.define(:path, :identity)
17
+ LockPath = Data.define(:path, :role) do
18
+ def canonical?
19
+ role == :canonical
20
+ end
21
+
22
+ def legacy?
23
+ role == :legacy
16
24
  end
17
25
  end
26
+ private_constant :LockPath
18
27
 
19
28
  class OutputSetLock
20
29
  @guard = Mutex.new
@@ -23,79 +32,173 @@ module Cohere
23
32
  class << self
24
33
  attr_reader :guard, :active
25
34
 
26
- def acquire(target, blocking: false, operation_hook: nil)
35
+ def acquire(target, blocking: false, operation_hook: nil, ownership_hook: nil)
36
+ State.ensure_lock_acquisition_allowed!(target)
37
+ completed = false
38
+ failed = false
27
39
  guard.synchronize do
28
- handle = nil
29
- acquired = false
30
- key = target.path.to_s
31
- if active.key?(key)
40
+ handles = []
41
+ lock = nil
42
+ lock_paths = State.lock_path_specs_for_target(target)
43
+ paths = lock_paths.map(&:path)
44
+ keys = paths.map(&:to_s)
45
+ if keys.any? { |key| active.key?(key) }
32
46
  raise TranscriptionRuntimeError,
33
47
  "Another transcription job owns output set #{target.identity}"
34
48
  end
35
49
 
36
- handle = State.open_lock_file(target.path)
37
- operation = File::LOCK_EX
38
- operation |= File::LOCK_NB unless blocking
39
- unless handle.flock(operation)
40
- handle.close
41
- raise TranscriptionRuntimeError,
42
- "Another transcription process owns output set #{target.identity} (lock #{target.path})"
50
+ lock_paths.each_with_index do |lock_path, index|
51
+ path = lock_path.path
52
+ handle = nil
53
+ Thread.handle_interrupt(Object => :never) do
54
+ handle = State.open_lock_file(path, shared: lock_path.canonical?)
55
+ handles << handle
56
+ end
57
+ operation = File::LOCK_EX
58
+ operation |= File::LOCK_NB unless blocking
59
+ unless State.acquire_exclusive_file_lock(handle, operation, target: target, path: path)
60
+ raise TranscriptionRuntimeError,
61
+ "Another transcription process owns output set #{target.identity} (lock #{path})"
62
+ end
63
+ operation_hook&.call(:after_flock, target) if index.zero?
64
+ State.verify_lock_identity!(
65
+ path,
66
+ handle,
67
+ message: "Output lock changed while acquiring #{target.identity}"
68
+ )
69
+ State.refresh_legacy_lock(handle) if lock_path.legacy?
43
70
  end
44
- operation_hook&.call(:after_flock, target)
45
- State.verify_lock_identity!(
46
- target.path,
47
- handle,
48
- message: "Output lock changed while acquiring #{target.identity}"
49
- )
50
71
 
51
- lock = new(target, handle, key)
52
- active[key] = lock
53
- acquired = true
72
+ lock = new(target, handles, keys, roles: lock_paths.map(&:role))
73
+ keys.each do |key|
74
+ active[key] = lock
75
+ end
76
+ ownership_hook&.call(lock)
77
+ completed = true
54
78
  lock
55
79
  rescue Errno::EWOULDBLOCK, Errno::EAGAIN
56
- handle&.close
80
+ failed = true
81
+ raise TranscriptionRuntimeError,
82
+ "Another transcription process owns output set #{target.identity}"
83
+ rescue SystemCallError => e
84
+ failed = true
57
85
  raise TranscriptionRuntimeError,
58
- "Another transcription process owns output set #{target.identity} (lock #{target.path})"
59
- rescue Interrupt, SystemExit, StandardError
60
- handle&.close
86
+ "Cannot acquire output lock for #{target.identity}: #{e.message}",
87
+ cause: e
88
+ rescue Exception # rubocop:disable Lint/RescueException -- record only this acquisition's unwind
89
+ failed = true
61
90
  raise
62
91
  ensure
63
- handle.close if handle && !acquired && !handle.closed?
92
+ unless completed && !failed && !State.lock_acquisition_unwinding?
93
+ Thread.handle_interrupt(Object => :never) do
94
+ delete_active_keys(lock, keys || [])
95
+ close_lock_handles(handles || [])
96
+ lock&.mark_released!
97
+ end
98
+ end
64
99
  end
65
100
  end
66
101
 
67
102
  def release_all
68
- guard.synchronize { active.values.dup }.each(&:release)
103
+ guard.synchronize { active.values.uniq }.each(&:release)
104
+ end
105
+
106
+ def close_lock_handles(handles)
107
+ first_error = nil
108
+ Thread.handle_interrupt(Object => :never) do
109
+ handles.reverse_each do |handle|
110
+ begin
111
+ next if handle.closed?
112
+ rescue Exception => e # rubocop:disable Lint/RescueException
113
+ first_error ||= e
114
+ end
115
+
116
+ begin
117
+ handle.flock(File::LOCK_UN)
118
+ rescue Exception => e # rubocop:disable Lint/RescueException
119
+ first_error ||= e
120
+ end
121
+ begin
122
+ handle.close
123
+ rescue Exception => e # rubocop:disable Lint/RescueException
124
+ first_error ||= e
125
+ end
126
+ end
127
+ end
128
+ first_error
129
+ end
130
+
131
+ def delete_active_keys(lock, keys)
132
+ first_error = nil
133
+ keys.reverse_each do |key|
134
+ active.delete(key) if active[key].equal?(lock)
135
+ rescue Exception => e # rubocop:disable Lint/RescueException
136
+ first_error ||= e
137
+ end
138
+ first_error
69
139
  end
70
140
  end
71
141
 
72
142
  attr_reader :target
73
143
 
74
- def initialize(target, handle, key)
144
+ def initialize(target, handles, keys, roles: nil)
75
145
  @target = target
76
- @handle = handle
77
- @key = key
146
+ @handles = handles.freeze
147
+ @keys = keys.freeze
148
+ @roles = (roles || Array.new(keys.length, :primary)).freeze
78
149
  @released = false
79
150
  end
80
151
 
81
- def release
152
+ def verify!
82
153
  self.class.guard.synchronize do
83
- return if @released
154
+ if @released
155
+ raise TranscriptionRuntimeError,
156
+ "Output lock was released before committing #{target.identity}"
157
+ end
84
158
 
85
- begin
86
- @handle.flock(File::LOCK_UN)
87
- ensure
88
- @handle.close
89
- @released = true
90
- self.class.active.delete(@key)
159
+ canonical_held = @roles.include?(:canonical)
160
+ @keys.zip(@handles, @roles).each do |path, handle, role|
161
+ State.verify_lock_identity!(
162
+ path,
163
+ handle,
164
+ message: "Output lock changed while held for #{target.identity}"
165
+ )
166
+ rescue TranscriptionRuntimeError => e
167
+ # The output-adjacent lock is authoritative. A tmp cleaner may
168
+ # remove the 0.1.x compatibility path; replacement is still fatal.
169
+ raise unless canonical_held && role == :legacy && State.missing_lock_path_error?(e)
91
170
  end
92
171
  end
93
172
  nil
94
173
  end
95
174
 
175
+ def release
176
+ cleanup_error = nil
177
+ Thread.handle_interrupt(Object => :never) do
178
+ self.class.guard.synchronize do
179
+ return if @released
180
+
181
+ begin
182
+ handle_error = self.class.close_lock_handles(@handles)
183
+ registration_error = self.class.delete_active_keys(self, @keys)
184
+ cleanup_error = handle_error || registration_error
185
+ ensure
186
+ @released = true
187
+ end
188
+ end
189
+ end
190
+ raise cleanup_error if cleanup_error
191
+
192
+ nil
193
+ end
194
+
96
195
  def released?
97
196
  @released
98
197
  end
198
+
199
+ def mark_released!
200
+ @released = true
201
+ end
99
202
  end
100
203
 
101
204
  module_function
@@ -103,42 +206,112 @@ module Cohere
103
206
  def lock_target_for_outputs(output_paths)
104
207
  parent, stem = output_parent_and_stem(output_paths)
105
208
  identity = parent.join(stem).expand_path.cleanpath.to_s
106
- digest = Digest::SHA256.hexdigest(identity.downcase)
107
209
  OutputLockTarget.new(
108
- path: lock_registry_directory.join("#{digest}#{LOCK_SUFFIX}").freeze,
210
+ path: canonical_lock_path(identity).freeze,
109
211
  identity: identity.freeze
110
212
  )
111
213
  end
112
214
 
113
215
  def with_output_lock(target, blocking: false)
114
- lock = OutputSetLock.acquire(target, blocking: blocking)
115
- yield lock
116
- ensure
117
- lock&.release
216
+ lock = nil
217
+ primary_error = nil
218
+ Thread.handle_interrupt(Object => :never) do
219
+ Thread.handle_interrupt(Object => :on_blocking) do
220
+ OutputSetLock.acquire(
221
+ target,
222
+ blocking: blocking,
223
+ ownership_hook: ->(acquired_lock) { lock = acquired_lock }
224
+ )
225
+ end
226
+ Thread.handle_interrupt(Object => :immediate) { yield lock }
227
+ rescue Exception => e # rubocop:disable Lint/RescueException -- preserve protected-work failures
228
+ primary_error = e
229
+ raise
230
+ ensure
231
+ begin
232
+ lock&.release
233
+ rescue Exception # rubocop:disable Lint/RescueException -- preserve protected-work failures
234
+ raise unless primary_error || lock_acquisition_unwinding?
235
+ end
236
+ end
237
+ end
238
+
239
+ def lock_paths_for_target(target)
240
+ lock_path_specs_for_target(target).map(&:path).freeze
241
+ end
242
+
243
+ def lock_path_specs_for_target(target)
244
+ primary = Pathname(target.path).expand_path.cleanpath
245
+ canonical = canonical_lock_path(target.identity)
246
+ legacy = legacy_temporary_lock_path(target.identity)
247
+ unless primary == canonical
248
+ role = primary == legacy ? :legacy : :custom
249
+ return [LockPath.new(path: primary.freeze, role: role)].freeze
250
+ end
251
+
252
+ # Remove this temporary-registry compatibility path in 0.2.0. Until
253
+ # then, acquiring both paths keeps released 0.1.2 processes coordinated
254
+ # with output-adjacent locks.
255
+ paths = [LockPath.new(path: primary.freeze, role: :canonical)]
256
+ paths << LockPath.new(path: legacy.freeze, role: :legacy) if legacy_lock_compatibility_enabled? && legacy != primary
257
+ paths.freeze
118
258
  end
119
259
 
120
- def lock_registry_directory
260
+ def legacy_lock_compatibility_enabled?(version = VERSION)
261
+ Gem::Version.new(version) < Gem::Version.new(LEGACY_LOCK_REMOVAL_VERSION)
262
+ end
263
+
264
+ def canonical_lock_path(identity)
265
+ identity = Pathname(identity).expand_path.cleanpath
266
+ lock_registry_directory(identity.dirname).join(lock_filename(identity.to_s))
267
+ end
268
+
269
+ def lock_registry_directory(output_parent)
270
+ Pathname(output_parent).expand_path.cleanpath.join(LOCK_DIRECTORY_NAME)
271
+ end
272
+
273
+ def legacy_temporary_lock_path(identity)
121
274
  scope = Process.respond_to?(:uid) ? Process.uid.to_s : Digest::SHA256.hexdigest(Dir.home)[0, 16]
122
- Pathname(Dir.tmpdir).join("#{LOCK_DIRECTORY_PREFIX}-#{scope}")
275
+ Pathname(Dir.tmpdir).expand_path.join("#{LOCK_DIRECTORY_PREFIX}-#{scope}", lock_filename(identity))
276
+ end
277
+
278
+ def lock_filename(identity)
279
+ "#{Digest::SHA256.hexdigest(identity.downcase)}#{LOCK_SUFFIX}"
123
280
  end
124
281
 
125
- def open_lock_file(path)
282
+ def open_lock_file(path, shared: false)
126
283
  path = Pathname(path)
127
284
  succeeded = false
128
- validate_lock_directory!(path.dirname)
285
+ created = false
286
+ validate_lock_directory!(path.dirname, shared: shared)
129
287
  inspect_lock_path!(path)
130
- flags = File::RDWR | File::CREAT
288
+ flags = File::RDWR
131
289
  flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
132
290
  flags |= File::CLOEXEC if defined?(File::CLOEXEC)
133
- descriptor = ::IO.sysopen(path.to_s, flags, 0o600)
134
- handle = File.new(descriptor, "r+", autoclose: true)
135
- descriptor = nil
291
+ mode = shared ? shared_lock_file_mode(path.dirname.lstat) : 0o600
292
+ begin
293
+ descriptor, created = open_or_create_lock_descriptor(path, flags, mode)
294
+ access = "r+"
295
+ rescue Errno::EACCES, Errno::EROFS => e
296
+ raise unless shared
297
+
298
+ handle, access = open_shared_lock_after_write_denial(path, flags, mode, e)
299
+ end
300
+ unless handle
301
+ handle = File.new(descriptor, access, autoclose: true)
302
+ descriptor = nil
303
+ end
136
304
  opened = verify_lock_identity!(
137
305
  path,
138
306
  handle,
139
307
  message: "Output lock changed while it was being opened or is not regular"
140
308
  )
141
- validate_owned_private_file!(opened, path)
309
+ if shared
310
+ owned_writable = access == "r+" && Process.respond_to?(:uid) && opened.uid == Process.uid
311
+ apply_lock_mode_if_supported(handle, mode) if (created || owned_writable) && (opened.mode & 0o777) != mode
312
+ else
313
+ validate_owned_private_file!(opened, path)
314
+ end
142
315
  succeeded = true
143
316
  handle
144
317
  rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO => e
@@ -151,17 +324,37 @@ module Cohere
151
324
  end
152
325
  end
153
326
 
154
- def validate_lock_directory!(path)
155
- begin
156
- Dir.mkdir(path, 0o700)
157
- rescue Errno::EEXIST
158
- nil
159
- rescue SystemCallError => e
160
- raise TranscriptionRuntimeError, "Cannot prepare output lock directory #{path}: #{e.message}"
327
+ def open_or_create_lock_descriptor(path, flags, mode)
328
+ 2.times do
329
+ return [::IO.sysopen(path.to_s, flags, mode), false]
330
+ rescue Errno::ENOENT
331
+ begin
332
+ return [::IO.sysopen(path.to_s, flags | File::CREAT | File::EXCL, mode), true]
333
+ rescue Errno::EEXIST
334
+ next
335
+ end
336
+ end
337
+
338
+ raise Errno::ENOENT, path.to_s
339
+ end
340
+
341
+ def validate_lock_directory!(path, shared: false)
342
+ if shared
343
+ create_shared_lock_directory(path)
344
+ else
345
+ begin
346
+ FileUtils.mkdir_p(path, mode: 0o700)
347
+ rescue SystemCallError => e
348
+ raise TranscriptionRuntimeError, "Cannot prepare output lock directory #{path}: #{e.message}"
349
+ end
161
350
  end
162
351
  stat = path.lstat
163
352
  raise TranscriptionRuntimeError, "Output lock directory is not a real directory: #{path}" unless stat.directory? && !stat.symlink?
164
353
 
354
+ if shared
355
+ validate_shared_lock_directory_mode!(path, stat)
356
+ return
357
+ end
165
358
  return if Gem.win_platform?
166
359
 
167
360
  if Process.respond_to?(:uid) && stat.uid != Process.uid
@@ -176,6 +369,132 @@ module Cohere
176
369
  raise TranscriptionRuntimeError, "Cannot prepare output lock directory #{path}: #{e.message}"
177
370
  end
178
371
 
372
+ def create_shared_lock_directory(path)
373
+ parent = path.dirname.lstat
374
+ unless parent.directory? && !parent.symlink?
375
+ raise TranscriptionRuntimeError, "Output lock parent is not a real directory: #{path.dirname}"
376
+ end
377
+
378
+ mode = (parent.mode & 0o3777) | 0o700
379
+ mode |= 0o1000 if mode.anybits?(0o022)
380
+ begin
381
+ Dir.mkdir(path, mode)
382
+ apply_lock_mode_if_supported(path, mode)
383
+ rescue Errno::EEXIST
384
+ existing = path.lstat
385
+ owned = Process.respond_to?(:uid) && existing.uid == Process.uid
386
+ mode_can_be_updated = !Gem.win_platform? && existing.directory? && owned
387
+ apply_lock_mode_if_supported(path, mode) if mode_can_be_updated && (existing.mode & 0o3777) != mode
388
+ rescue SystemCallError => e
389
+ raise TranscriptionRuntimeError, "Cannot prepare output lock directory #{path}: #{e.message}"
390
+ end
391
+ end
392
+
393
+ def validate_shared_lock_directory_mode!(path, stat)
394
+ return if Gem.win_platform? || !Process.respond_to?(:uid)
395
+ return if stat.uid == Process.uid
396
+ return if stat.mode.nobits?(0o022) || stat.mode.anybits?(0o1000)
397
+
398
+ raise TranscriptionRuntimeError,
399
+ "Output lock directory is writable by multiple users but has no sticky bit: #{path}. " \
400
+ "Ask its owner or administrator to add the sticky bit, or remove it when no transcription job is running"
401
+ end
402
+
403
+ def shared_lock_file_mode(directory_stat)
404
+ mode = 0o600
405
+ mode |= 0o060 if directory_stat.mode.anybits?(0o020) && directory_stat.mode.anybits?(0o010)
406
+ mode |= 0o006 if directory_stat.mode.anybits?(0o002) && directory_stat.mode.anybits?(0o001)
407
+ mode
408
+ end
409
+
410
+ def apply_lock_mode_if_supported(target, mode)
411
+ target.chmod(mode)
412
+ rescue SystemCallError => e
413
+ unsupported = [Errno::EPERM::Errno]
414
+ unsupported << Errno::EROFS::Errno
415
+ unsupported << Errno::EOPNOTSUPP::Errno if defined?(Errno::EOPNOTSUPP)
416
+ unsupported << Errno::ENOTSUP::Errno if defined?(Errno::ENOTSUP)
417
+ raise unless unsupported.include?(e.errno)
418
+
419
+ nil
420
+ end
421
+
422
+ def refresh_legacy_lock(handle)
423
+ handle.rewind
424
+ handle.write("#{Process.pid}\n")
425
+ handle.flush
426
+ handle.truncate(handle.pos)
427
+ nil
428
+ end
429
+
430
+ def acquire_exclusive_file_lock(handle, operation, target:, path:)
431
+ handle.flock(operation)
432
+ rescue Errno::EBADF => e
433
+ raise TranscriptionRuntimeError,
434
+ "Cannot acquire an exclusive output lock for #{target.identity}: " \
435
+ "the filesystem requires a writable lock file at #{path}",
436
+ cause: e
437
+ end
438
+
439
+ def open_shared_lock_after_write_denial(path, flags, mode, original_error)
440
+ descriptor = ::IO.sysopen(path.to_s, readonly_lock_flags)
441
+ readonly = File.new(descriptor, "r", autoclose: true)
442
+ descriptor = nil
443
+ opened = verify_lock_identity!(
444
+ path,
445
+ readonly,
446
+ message: "Output lock changed while opening it read-only"
447
+ )
448
+ if original_error.is_a?(Errno::EACCES) &&
449
+ Process.respond_to?(:uid) && opened.uid == Process.uid
450
+ apply_lock_mode_if_supported(readonly, mode)
451
+ begin
452
+ descriptor = ::IO.sysopen(path.to_s, flags, mode)
453
+ writable = File.new(descriptor, "r+", autoclose: true)
454
+ descriptor = nil
455
+ readonly.close
456
+ readonly = nil
457
+ handle = writable
458
+ writable = nil
459
+ return [handle, "r+"]
460
+ rescue Errno::EACCES, Errno::EROFS
461
+ nil
462
+ end
463
+ end
464
+
465
+ handle = readonly
466
+ readonly = nil
467
+ [handle, "r"]
468
+ ensure
469
+ readonly&.close unless readonly&.closed?
470
+ writable&.close unless writable&.closed?
471
+ ::IO.new(descriptor).close if descriptor
472
+ end
473
+
474
+ def readonly_lock_flags
475
+ readonly_flags = File::RDONLY
476
+ readonly_flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
477
+ readonly_flags |= File::CLOEXEC if defined?(File::CLOEXEC)
478
+ readonly_flags
479
+ end
480
+
481
+ def ensure_lock_acquisition_allowed!(target)
482
+ return unless lock_acquisition_unwinding?
483
+
484
+ raise TranscriptionRuntimeError,
485
+ "Cannot acquire output lock for #{target.identity} while the current thread is terminating"
486
+ end
487
+
488
+ # Ruby exposes an in-progress Thread#kill unwind only through #status;
489
+ # pending_interrupt? is already false while ensure blocks are running.
490
+ def lock_acquisition_unwinding?
491
+ Thread.current.status == "aborting"
492
+ end
493
+
494
+ def missing_lock_path_error?(error)
495
+ error.cause.is_a?(Errno::ENOENT)
496
+ end
497
+
179
498
  def inspect_lock_path!(path)
180
499
  stat = path.lstat
181
500
  return if stat.file? && !stat.symlink?
@@ -202,7 +521,7 @@ module Cohere
202
521
  current = Pathname(path).lstat
203
522
  return opened if opened.file? && same_file?(opened, current)
204
523
 
205
- raise TranscriptionRuntimeError, "#{message}: #{path}"
524
+ raise TranscriptionRuntimeError, "#{message}: #{path}", cause: nil
206
525
  rescue SystemCallError => e
207
526
  raise TranscriptionRuntimeError, "#{message}: #{path}", cause: e
208
527
  end
@@ -52,8 +52,7 @@ module Cohere
52
52
  end
53
53
 
54
54
  def verify_published_outputs(source_snapshot:, output_paths:, state_path:, asr_contract_key:,
55
- render_contract_key:, directory_binding: nil,
56
- guard_bindings: nil)
55
+ render_contract_key:, directory_binding:, guard_bindings: nil)
57
56
  payload, reason = decode_state(
58
57
  state_path,
59
58
  directory_binding: directory_binding,
@@ -72,11 +71,18 @@ module Cohere
72
71
  )
73
72
  return PublicationVerification.new(verified: false, generation_id: nil, reason: mismatch.freeze) if mismatch
74
73
 
74
+ verify_publication_bindings!(directory_binding, guard_bindings)
75
75
  PublicationVerification.new(
76
76
  verified: true,
77
77
  generation_id: payload.fetch("generation_id").dup.freeze,
78
78
  reason: nil
79
79
  )
80
+ rescue PublicationDirectoryChangedError => e
81
+ PublicationVerification.new(
82
+ verified: false,
83
+ generation_id: nil,
84
+ reason: "publication parent changed during verification (#{e.message})".freeze
85
+ )
80
86
  rescue EncodingError, SystemCallError, TypeError, ArgumentError => e
81
87
  PublicationVerification.new(
82
88
  verified: false,
@@ -86,8 +92,7 @@ module Cohere
86
92
  end
87
93
 
88
94
  def publication_mismatch_reason(payload, source_snapshot:, output_paths:, asr_contract_key:,
89
- render_contract_key:, directory_binding: nil,
90
- guard_bindings: nil)
95
+ render_contract_key:, directory_binding:, guard_bindings: nil)
91
96
  return "state is #{payload["kind"].inspect}, not published" unless payload["kind"] == "published"
92
97
  return "state marker ASR contract does not match" unless payload["asr_contract_key"] == asr_contract_key
93
98
  return "state marker render contract does not match" unless payload["render_contract_key"] == render_contract_key
@@ -105,18 +110,11 @@ module Cohere
105
110
  return "state marker path for #{format} does not match" unless record.is_a?(Hash) && record["name"] == path.basename.to_s
106
111
 
107
112
  begin
108
- output_record = if directory_binding
109
- bound_output_record(
110
- path,
111
- directory_binding: directory_binding,
112
- guard_bindings: guard_bindings
113
- )
114
- else
115
- stat = path.lstat
116
- return "#{format} output is missing or not regular" unless stat.file? && !stat.symlink?
117
-
118
- [stat.size, Digest::SHA256.file(path).hexdigest]
119
- end
113
+ output_record = bound_output_record(
114
+ path,
115
+ directory_binding: directory_binding,
116
+ guard_bindings: guard_bindings
117
+ )
120
118
  return "#{format} output changed or is not regular" unless output_record
121
119
 
122
120
  size, sha256 = output_record
@@ -140,18 +138,14 @@ module Cohere
140
138
  Thread.handle_interrupt(DEFERRED_PUBLICATION_EXCEPTIONS) do
141
139
  handle = bound.open_regular(basename)
142
140
  end
143
- rescue TranscriptionRuntimeError
141
+ rescue PublicationEntryError
144
142
  bound.verify!
145
143
  return nil
146
144
  end
147
145
  opened = handle.stat
148
146
  digest = Digest::SHA256.new
149
147
  digest << handle.read(1024 * 1024) until handle.eof?
150
- unchanged = begin
151
- bound.same_regular_entry?(basename, opened)
152
- rescue TranscriptionRuntimeError
153
- false
154
- end
148
+ unchanged = bound.same_regular_entry?(basename, opened)
155
149
  unless unchanged
156
150
  bound.verify!
157
151
  return nil
@@ -164,6 +158,11 @@ module Cohere
164
158
  end
165
159
  end
166
160
  private_class_method :bound_output_record
161
+
162
+ def verify_publication_bindings!(directory_binding, guard_bindings)
163
+ (Array(guard_bindings).compact + [directory_binding].compact).uniq.each(&:verify!)
164
+ end
165
+ private_class_method :verify_publication_bindings!
167
166
  end
168
167
  end
169
168
  end