Skip to content

Commit 9239a07

Browse files
authored
Merge branch 'master' into codex/coerce-file-path
2 parents a937e6c + a3492e7 commit 9239a07

3 files changed

Lines changed: 133 additions & 23 deletions

File tree

.github/workflows/push_gem.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
ruby-version: ruby
3737

3838
- name: Publish to RubyGems
39-
uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
39+
uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
4040

4141
- name: Create GitHub release
4242
run: |

lib/pstore.rb

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ def initialize(file, thread_safe = false)
389389

390390
# Raises PStore::Error if the calling code is not in a PStore#transaction.
391391
def in_transaction
392-
raise PStore::Error, "not in transaction" unless @lock.locked?
392+
raise PStore::Error, "not in transaction" unless @lock.owned?
393393
end
394394
#
395395
# Raises PStore::Error if the calling code is not in a PStore#transaction or
@@ -439,7 +439,7 @@ def [](key)
439439
def fetch(key, default=PStore::Error)
440440
in_transaction
441441
unless @table.key? key
442-
if default == PStore::Error
442+
if PStore::Error.equal?(default)
443443
raise PStore::Error, format("undefined key '%s'", key)
444444
else
445445
return default
@@ -527,7 +527,7 @@ def path
527527
def commit
528528
in_transaction
529529
@abort = false
530-
throw :pstore_abort_transaction
530+
throw self
531531
end
532532

533533
# Exits the current transaction block, discarding any changes
@@ -538,7 +538,7 @@ def commit
538538
def abort
539539
in_transaction
540540
@abort = true
541-
throw :pstore_abort_transaction
541+
throw self
542542
end
543543

544544
# Opens a transaction block for the store.
@@ -570,7 +570,7 @@ def transaction(read_only = false) # :yields: pstore
570570
begin
571571
@table, checksum, original_data_size = load_data(file, read_only)
572572

573-
catch(:pstore_abort_transaction) do
573+
catch(self) do
574574
value = yield(self)
575575
end
576576

@@ -583,7 +583,7 @@ def transaction(read_only = false) # :yields: pstore
583583
else
584584
# This can only occur if read_only == true.
585585
@table = {}
586-
catch(:pstore_abort_transaction) do
586+
catch(self) do
587587
value = yield(self)
588588
end
589589
end
@@ -621,23 +621,26 @@ def transaction(read_only = false) # :yields: pstore
621621
# All exceptions are propagated.
622622
#
623623
def open_and_lock_file(filename, read_only)
624-
if read_only
625-
begin
626-
file = File.new(filename, **RD_ACCESS)
624+
filename = File.path(filename)
625+
loop do
626+
if read_only
627627
begin
628-
file.flock(File::LOCK_SH)
629-
return file
630-
rescue
631-
file.close
632-
raise
628+
file = File.new(filename, **RD_ACCESS)
629+
rescue Errno::ENOENT
630+
return nil
633631
end
634-
rescue Errno::ENOENT
635-
return nil
632+
else
633+
file = File.new(filename, **RDWR_ACCESS)
634+
end
635+
current = false
636+
begin
637+
file.flock(read_only ? File::LOCK_SH : File::LOCK_EX)
638+
# An atomic save may have replaced the file while this lock was pending.
639+
current = File.identical?(file, filename)
640+
return file if current
641+
ensure
642+
file.close unless current
636643
end
637-
else
638-
file = File.new(filename, **RDWR_ACCESS)
639-
file.flock(File::LOCK_EX)
640-
return file
641644
end
642645
end
643646

@@ -651,6 +654,7 @@ def load_data(file, read_only)
651654
table = load(file)
652655
raise Error, "PStore file seems to be corrupted." unless table.is_a?(Hash)
653656
rescue EOFError
657+
raise unless file.size == 0
654658
# This seems to be a newly-created file.
655659
table = {}
656660
end
@@ -700,7 +704,7 @@ def save_data_with_atomic_file_rename_strategy(data, file)
700704
temp_file.write(data)
701705
temp_file.flush
702706
File.rename(temp_filename, @filename)
703-
rescue
707+
rescue Exception
704708
File.unlink(temp_file) rescue nil
705709
raise
706710
ensure

test/test_pstore.rb

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,113 @@ def clear_store
191191
def second_file
192192
File.join(Dir.tmpdir, "pstore.tmp2.#{Process.pid}")
193193
end
194+
195+
def test_truncated_read_only_store_is_not_empty
196+
@pstore.transaction { @pstore[:foo] = "bar" }
197+
data = File.binread(@pstore_file)
198+
File.binwrite(@pstore_file, data.byteslice(0, data.bytesize - 1))
199+
200+
assert_raise(EOFError) { @pstore.transaction(true) { @pstore[:foo] } }
201+
end
202+
203+
def test_store_operations_require_transaction_owner
204+
ready = Thread::Queue.new
205+
release = Thread::Queue.new
206+
owner = Thread.new do
207+
@pstore.transaction do
208+
@pstore[:foo] = "bar"
209+
ready << true
210+
release.pop
211+
end
212+
end
213+
214+
ready.pop
215+
assert_raise(PStore::Error) { @pstore[:foo] }
216+
assert_raise(PStore::Error) { @pstore[:foo] = "other" }
217+
ensure
218+
release << true if release
219+
owner.join if owner
220+
end
221+
222+
def test_commit_targets_the_owning_store
223+
inner = PStore.new(second_file)
224+
@pstore.transaction do
225+
@pstore[:outer] = true
226+
inner.transaction do
227+
inner[:inner] = true
228+
@pstore.commit
229+
flunk("outer commit should exit its own transaction")
230+
end
231+
flunk("outer commit should exit its own transaction")
232+
end
233+
234+
assert_equal(true, @pstore.transaction(true) { @pstore[:outer] })
235+
assert_nil(inner.transaction(true) { inner[:inner] })
236+
ensure
237+
File.unlink(second_file) rescue nil
238+
end
239+
240+
def test_fetch_compares_missing_default_by_identity
241+
default = Object.new
242+
def default.==(_other)
243+
true
244+
end
245+
246+
assert_same(default, @pstore.transaction(true) { @pstore.fetch(:missing, default) })
247+
end
248+
249+
def test_lock_retries_when_atomic_save_replaces_file
250+
fake_file = Struct.new(:closed) do
251+
def flock(_mode)
252+
true
253+
end
254+
255+
def close
256+
self.closed = true
257+
end
258+
end
259+
first = fake_file.new(false)
260+
second = fake_file.new(false)
261+
files = [first, second]
262+
original_new = File.method(:new)
263+
original_identical = File.method(:identical?)
264+
File.define_singleton_method(:new) { |*_args, **_kwargs| files.shift }
265+
File.define_singleton_method(:identical?) { |file, _path| file.equal?(second) }
266+
267+
result = @pstore.send(:open_and_lock_file, @pstore_file, false)
268+
269+
assert_same(second, result)
270+
assert_equal(true, first.closed)
271+
assert_equal(false, second.closed)
272+
ensure
273+
result.close if result && !result.closed
274+
File.define_singleton_method(:new, original_new) if original_new
275+
File.define_singleton_method(:identical?, original_identical) if original_identical
276+
end
277+
278+
def test_interrupted_atomic_save_removes_temporary_file
279+
return if /mswin|mingw|bccwin|wince/ =~ RUBY_PLATFORM
280+
281+
@pstore.transaction { @pstore[:foo] = "old" }
282+
@pstore.ultra_safe = true
283+
original_new = File.method(:new)
284+
store_path = @pstore_file
285+
File.define_singleton_method(:new) do |path, **options|
286+
file = original_new.call(path, **options)
287+
if path.start_with?("#{store_path}.tmp.")
288+
file.define_singleton_method(:write) { |_data| raise Interrupt }
289+
end
290+
file
291+
end
292+
293+
assert_raise(Interrupt) { @pstore.transaction { @pstore[:foo] = "new" } }
294+
assert_equal([], Dir.glob("#{@pstore_file}.tmp.*"))
295+
assert_equal("old", @pstore.transaction(true) { @pstore[:foo] })
296+
ensure
297+
File.define_singleton_method(:new, original_new) if original_new
298+
Dir.glob("#{@pstore_file}.tmp.*").each { |path| File.unlink(path) rescue nil }
299+
end
300+
194301
def test_path_like_filename_is_normalized
195302
store = PStore.new(Pathname(@pstore_file))
196303
store.ultra_safe = true
@@ -200,5 +307,4 @@ def test_path_like_filename_is_normalized
200307
assert_equal(@pstore_file, store.path)
201308
assert_equal("bar", store.transaction(true) { store[:foo] })
202309
end
203-
204310
end

0 commit comments

Comments
 (0)