Skip to content

Commit 774bd08

Browse files
hsbtclaude
andcommitted
Flush atomic write temp files before renaming over the destination
The temporary file was renamed into place before being closed, so buffered data had not reached the filesystem yet and a concurrent reader could observe an empty destination. A flush failure such as ENOSPC would surface only after the destination had already been replaced. Cleanup also moved to an ensure block, so an interrupt no longer leaks the temporary file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a7dc564 commit 774bd08

2 files changed

Lines changed: 199 additions & 24 deletions

File tree

lib/rubygems/util/atomic_file_writer.rb

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -45,47 +45,50 @@ def self.open(file_name)
4545
flags = File::RDWR | File::CREAT | File::EXCL | File::BINARY
4646
flags |= File::SHARE_DELETE if defined?(File::SHARE_DELETE)
4747

48-
File.open(tmp_path, flags) do |temp_file|
48+
renamed = false
49+
temp_file = File.open(tmp_path, flags)
50+
51+
begin
4952
temp_file.binmode
5053
if old_stat
5154
# Set correct permissions on new file
5255
begin
53-
File.chown(old_stat.uid, old_stat.gid, temp_file.path)
56+
File.chown(old_stat.uid, old_stat.gid, tmp_path)
5457
# This operation will affect filesystem ACL's
55-
File.chmod(old_stat.mode, temp_file.path)
58+
File.chmod(old_stat.mode, tmp_path)
5659
rescue Errno::EPERM, Errno::EACCES
5760
# Changing file ownership failed, moving on.
5861
end
5962
end
6063

6164
return_val = yield temp_file
62-
rescue StandardError => error
63-
begin
64-
temp_file.close
65-
rescue StandardError
66-
nil
67-
end
6865

69-
begin
70-
File.unlink(temp_file.path)
71-
rescue StandardError
72-
nil
73-
end
66+
# Any data still buffered is handed to the filesystem on close, so a
67+
# failing flush must surface while the destination is still intact.
68+
# That means closing the temporary file before the rename. Note this
69+
# does not fsync, so the write is atomic but not crash durable.
70+
temp_file.close
71+
File.rename(tmp_path, file_name)
72+
renamed = true
7473

75-
raise error
76-
else
77-
begin
78-
File.rename(temp_file.path, file_name)
79-
rescue StandardError
74+
return_val
75+
ensure
76+
unless renamed
8077
begin
81-
File.unlink(temp_file.path)
78+
temp_file.close
8279
rescue StandardError
80+
nil
81+
ensure
82+
# The unlink runs from an ensure so that a non-StandardError raised by
83+
# the close above, a second Ctrl-C for instance, still reaches it. An
84+
# interrupt landing on the unlink itself is not covered.
85+
begin
86+
File.unlink(tmp_path)
87+
rescue StandardError
88+
nil
89+
end
8390
end
84-
85-
raise
8691
end
87-
88-
return_val
8992
end
9093
end
9194

test/rubygems/test_gem_util_atomic_file_writer.rb

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,151 @@ def test_external_encoding
1818
end
1919
end
2020

21+
def test_returns_block_value_and_leaves_no_temp_file
22+
result = Gem::AtomicFileWriter.open(@path) do |file|
23+
file.write "hello"
24+
:done
25+
end
26+
27+
assert_equal :done, result
28+
assert_equal "hello", File.binread(@path)
29+
assert_equal ["out.txt"], Dir.children(@dir)
30+
end
31+
32+
def test_content_is_flushed_before_rename
33+
original_rename = File.method(:rename)
34+
size_at_rename = nil
35+
36+
rename_spy = lambda do |src, dest|
37+
size_at_rename = File.size(src)
38+
original_rename.call(src, dest)
39+
end
40+
41+
File.stub(:rename, rename_spy) do
42+
Gem::AtomicFileWriter.open(@path) do |file|
43+
file.write "hello"
44+
end
45+
end
46+
47+
assert_equal 5, size_at_rename
48+
assert_equal "hello", File.binread(@path)
49+
end
50+
51+
def test_keeps_destination_and_removes_temp_file_on_error
52+
File.binwrite @path, "old"
53+
54+
error = assert_raise(RuntimeError) do
55+
Gem::AtomicFileWriter.open(@path) do |file|
56+
file.write "new"
57+
raise "boom"
58+
end
59+
end
60+
61+
assert_equal "boom", error.message
62+
assert_equal "old", File.binread(@path)
63+
assert_equal ["out.txt"], Dir.children(@dir)
64+
end
65+
66+
def test_keeps_destination_and_removes_temp_file_on_interrupt
67+
File.binwrite @path, "old"
68+
69+
assert_raise(Interrupt) do
70+
Gem::AtomicFileWriter.open(@path) do |file|
71+
file.write "new"
72+
raise Interrupt
73+
end
74+
end
75+
76+
assert_equal "old", File.binread(@path)
77+
assert_equal ["out.txt"], Dir.children(@dir)
78+
end
79+
80+
def test_removes_temp_file_when_closing_it_keeps_raising
81+
File.binwrite @path, "old"
82+
83+
temp_file = nil
84+
closes = 0
85+
raising = true
86+
# A real file is closed even when its close raises, so only a stub can keep
87+
# raising like this. The writer leaves that file to the garbage collector,
88+
# which is why this test closes it itself.
89+
failing_open = temp_file_open_stub do |file|
90+
temp_file = file
91+
file.define_singleton_method(:close) do
92+
closes += 1
93+
raise Interrupt if raising
94+
95+
super()
96+
end
97+
end
98+
99+
File.stub(:open, failing_open) do
100+
assert_raise(Interrupt) do
101+
Gem::AtomicFileWriter.open(@path) do |file|
102+
file.write "new"
103+
end
104+
end
105+
end
106+
107+
assert_equal 2, closes
108+
assert_equal "old", File.binread(@path)
109+
assert_equal ["out.txt"], Dir.children(@dir)
110+
ensure
111+
raising = false
112+
temp_file&.close
113+
end
114+
115+
def test_keeps_destination_when_closing_the_temp_file_fails
116+
File.binwrite @path, "old"
117+
118+
failing_open = temp_file_open_stub do |file|
119+
file.define_singleton_method(:close) do
120+
super()
121+
raise Errno::ENOSPC
122+
end
123+
end
124+
125+
File.stub(:open, failing_open) do
126+
assert_raise(Errno::ENOSPC) do
127+
Gem::AtomicFileWriter.open(@path) do |file|
128+
file.write "new"
129+
end
130+
end
131+
end
132+
133+
assert_equal "old", File.binread(@path)
134+
assert_equal ["out.txt"], Dir.children(@dir)
135+
end
136+
137+
def test_keeps_destination_when_renaming_fails
138+
File.binwrite @path, "old"
139+
140+
File.stub(:rename, ->(_src, _dest) { raise Errno::EXDEV }) do
141+
assert_raise(Errno::EXDEV) do
142+
Gem::AtomicFileWriter.open(@path) do |file|
143+
file.write "new"
144+
end
145+
end
146+
end
147+
148+
assert_equal "old", File.binread(@path)
149+
assert_equal ["out.txt"], Dir.children(@dir)
150+
end
151+
152+
def test_preserves_destination_permissions
153+
pend "Windows cannot round-trip the POSIX permission bits" if Gem.win_platform?
154+
155+
File.binwrite @path, "old"
156+
File.chmod 0o604, @path
157+
158+
Gem::AtomicFileWriter.open(@path) do |file|
159+
file.write "new"
160+
end
161+
162+
assert_equal "new", File.binread(@path)
163+
assert_equal 0o604, File.stat(@path).mode & 0o777
164+
end
165+
21166
def test_multibyte_basename_is_truncated_at_char_boundary
22167
pend "long file names easily exceed MAX_PATH on Windows" if Gem.win_platform?
23168

@@ -82,4 +227,31 @@ def test_byteslice_at_char_boundary_with_invalid_encoding
82227

83228
assert_equal "\xFFa".dup.force_encoding(Encoding::UTF_8), sliced
84229
end
230+
231+
private
232+
233+
# Returns a File.open replacement that hands the writer's temporary file to
234+
# the given block, which installs whatever close behaviour a test needs.
235+
# Every other path is opened normally. Call this before installing the stub,
236+
# since it captures the current File.open.
237+
def temp_file_open_stub(&injection)
238+
original_open = File.method(:open)
239+
prefix = ".#{File.basename(@path)}.tmp."
240+
241+
lambda do |name, *args, **kwargs, &block|
242+
unless File.basename(name.to_s).start_with?(prefix)
243+
next original_open.call(name, *args, **kwargs, &block)
244+
end
245+
raise ArgumentError, "this stub cannot replace close on a File.open with a block" if block
246+
247+
file = original_open.call(name, *args, **kwargs)
248+
begin
249+
injection.call(file)
250+
rescue StandardError
251+
file.close
252+
raise
253+
end
254+
file
255+
end
256+
end
85257
end

0 commit comments

Comments
 (0)