Skip to content

Commit d8b95a2

Browse files
committed
Fix: closing fd is thread unsafe on UNIX targets (crystal-lang#16209)
1 parent 0cd7262 commit d8b95a2

File tree

12 files changed

+675
-47
lines changed

12 files changed

+675
-47
lines changed

spec/std/crystal/fd_lock_spec.cr

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
require "spec"
2+
require "crystal/fd_lock"
3+
require "wait_group"
4+
5+
describe Crystal::FdLock do
6+
describe "#read" do
7+
it "acquires read lock" do
8+
lock = Crystal::FdLock.new
9+
called = 0
10+
11+
lock.read { called += 1 }
12+
lock.read { called += 1 }
13+
14+
called.should eq(2)
15+
end
16+
17+
it "acquires exclusive lock" do
18+
lock = Crystal::FdLock.new
19+
increment = 0
20+
21+
WaitGroup.wait do |wg|
22+
10.times do
23+
wg.spawn do
24+
100_000.times do |i|
25+
lock.read do
26+
increment += 1
27+
# Fiber.yield if i % 100 == 1
28+
end
29+
end
30+
end
31+
end
32+
end
33+
34+
increment.should eq(1_000_000)
35+
end
36+
37+
it "raises when closed" do
38+
lock = Crystal::FdLock.new
39+
called = false
40+
41+
lock.try_close? { }
42+
expect_raises(IO::Error, "Closed") { lock.read { called = true; Fiber.yield } }
43+
44+
called.should eq(false)
45+
end
46+
end
47+
48+
describe "#write" do
49+
it "acquires write lock" do
50+
lock = Crystal::FdLock.new
51+
called = 0
52+
53+
lock.write { called += 1 }
54+
lock.write { called += 1 }
55+
56+
called.should eq(2)
57+
end
58+
59+
it "acquires exclusive lock" do
60+
lock = Crystal::FdLock.new
61+
increment = 0
62+
63+
WaitGroup.wait do |wg|
64+
10.times do
65+
wg.spawn do
66+
100_000.times do |i|
67+
lock.write do
68+
increment += 1
69+
# Fiber.yield if i % 100 == 1
70+
end
71+
end
72+
end
73+
end
74+
end
75+
76+
increment.should eq(1_000_000)
77+
end
78+
79+
it "raises when closed" do
80+
lock = Crystal::FdLock.new
81+
called = false
82+
83+
lock.try_close? { }
84+
expect_raises(IO::Error, "Closed") { lock.read { called = true } }
85+
86+
called.should eq(false)
87+
end
88+
end
89+
90+
describe "#reference" do
91+
it "acquires" do
92+
lock = Crystal::FdLock.new
93+
called = 0
94+
95+
lock.reference { called += 1 }
96+
lock.reference { called += 1 }
97+
98+
called.should eq(2)
99+
end
100+
101+
it "allows reentrancy (side effect)" do
102+
lock = Crystal::FdLock.new
103+
called = 0
104+
105+
lock.reference { called += 1 }
106+
lock.reference do
107+
lock.reference { called += 1 }
108+
end
109+
110+
called.should eq(2)
111+
end
112+
113+
it "acquires shared reference" do
114+
lock = Crystal::FdLock.new
115+
116+
ready = WaitGroup.new(1)
117+
release = Channel(String).new
118+
119+
spawn do
120+
lock.reference do
121+
ready.done
122+
123+
select
124+
when release.send("ok")
125+
when timeout(1.second)
126+
release.send("timeout")
127+
end
128+
end
129+
end
130+
131+
ready.wait
132+
lock.reference { }
133+
134+
release.receive.should eq("ok")
135+
end
136+
137+
it "raises when closed" do
138+
lock = Crystal::FdLock.new
139+
lock.try_close? { }
140+
141+
called = false
142+
expect_raises(IO::Error, "Closed") do
143+
lock.reference { called = true }
144+
end
145+
146+
called.should be_false
147+
end
148+
end
149+
150+
describe "#try_close?" do
151+
it "closes" do
152+
lock = Crystal::FdLock.new
153+
lock.closed?.should be_false
154+
155+
called = false
156+
lock.try_close? { called = true }.should be_true
157+
lock.closed?.should be_true
158+
called.should be_true
159+
end
160+
161+
it "closes once" do
162+
lock = Crystal::FdLock.new
163+
164+
called = 0
165+
166+
WaitGroup.wait do |wg|
167+
10.times do
168+
wg.spawn do
169+
lock.try_close? { called += 1 }
170+
lock.try_close? { called += 1 }
171+
end
172+
end
173+
end
174+
175+
called.should eq(1)
176+
end
177+
178+
it "waits for all references to return" do
179+
lock = Crystal::FdLock.new
180+
181+
ready = WaitGroup.new(10)
182+
exceptions = Channel(Exception).new(10)
183+
184+
WaitGroup.wait do |wg|
185+
10.times do
186+
wg.spawn do
187+
begin
188+
lock.reference do
189+
ready.done
190+
Fiber.yield
191+
end
192+
rescue ex
193+
exceptions.send(ex)
194+
end
195+
end
196+
end
197+
198+
ready.wait
199+
200+
called = false
201+
lock.try_close? { called = true }.should be_true
202+
lock.closed?.should be_true
203+
called.should be_true
204+
end
205+
exceptions.close
206+
207+
if ex = exceptions.receive?
208+
raise ex
209+
end
210+
end
211+
212+
it "resumes waiters" do
213+
lock = Crystal::FdLock.new
214+
215+
ready = WaitGroup.new(8)
216+
running = WaitGroup.new
217+
exceptions = Channel(Exception).new(8)
218+
219+
# acquire locks
220+
lock.read do
221+
lock.write do
222+
# spawn concurrent fibers
223+
4.times do |i|
224+
running.spawn do
225+
ready.done
226+
lock.read { }
227+
rescue ex
228+
exceptions.send(ex)
229+
end
230+
231+
running.spawn do
232+
ready.done
233+
lock.write { }
234+
rescue ex
235+
exceptions.send(ex)
236+
end
237+
end
238+
239+
# wait for all the concurrent fibers to be trying to lock
240+
ready.wait
241+
end
242+
end
243+
244+
# close, then wait for the fibers to be resumed (and fail)
245+
lock.try_close? { }.should eq(true)
246+
running.wait
247+
exceptions.close
248+
249+
# fibers should have failed (unlikely: one may succeed to lock)
250+
failed = 0
251+
while ex = exceptions.receive?
252+
failed += 1
253+
ex.should be_a(IO::Error)
254+
ex.message.should eq("Closed")
255+
end
256+
failed.should be > 0
257+
end
258+
end
259+
260+
it "locks read + write + shared reference" do
261+
lock = Crystal::FdLock.new
262+
called = 0
263+
264+
lock.read do
265+
lock.write do
266+
lock.reference do
267+
called += 1
268+
end
269+
end
270+
end
271+
272+
called.should eq(1)
273+
end
274+
275+
it "#reset" do
276+
lock = Crystal::FdLock.new
277+
lock.try_close? { }
278+
lock.reset
279+
lock.try_close? { }.should eq(true)
280+
end
281+
end

src/crystal/event_loop/file_descriptor.cr

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,16 @@ abstract class Crystal::EventLoop
4646
# file descriptor.
4747
abstract def reopened(file_descriptor : Crystal::System::FileDescriptor) : Nil
4848

49-
# Closes the file descriptor resource.
49+
# Hook to react on the file descriptor after the IO object has been marked
50+
# closed but before calling `#close` to actually close the system file
51+
# descriptor or handle.
52+
#
53+
# On UNIX targets the event loop must resume any pending waiter in order to
54+
# decrement the `Crystal::FdLock` counter.
55+
def before_close(file_descriptor : Crystal::System::FileDescriptor) : Nil
56+
end
57+
58+
# Closes the system fd or handle.
5059
abstract def close(file_descriptor : Crystal::System::FileDescriptor) : Nil
5160
end
5261

src/crystal/event_loop/libevent.cr

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,13 @@ class Crystal::EventLoop::LibEvent < Crystal::EventLoop
181181
file_descriptor.evented_close
182182
end
183183

184-
def close(file_descriptor : Crystal::System::FileDescriptor) : Nil
184+
def before_close(file_descriptor : Crystal::System::FileDescriptor) : Nil
185185
# perform cleanup before LibC.close. Using a file descriptor after it has
186186
# been closed is never defined and can always lead to undefined results
187187
file_descriptor.evented_close
188+
end
189+
190+
def close(file_descriptor : Crystal::System::FileDescriptor) : Nil
188191
file_descriptor.file_descriptor_close
189192
end
190193

@@ -302,10 +305,13 @@ class Crystal::EventLoop::LibEvent < Crystal::EventLoop
302305
end
303306
end
304307

305-
def close(socket : ::Socket) : Nil
308+
def before_close(socket : ::Socket) : Nil
306309
# perform cleanup before LibC.close. Using a file descriptor after it has
307310
# been closed is never defined and can always lead to undefined results
308311
socket.evented_close
312+
end
313+
314+
def close(socket : ::Socket) : Nil
309315
socket.socket_close
310316
end
311317

src/crystal/event_loop/polling.cr

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,13 @@ abstract class Crystal::EventLoop::Polling < Crystal::EventLoop
232232
resume_all(file_descriptor)
233233
end
234234

235-
def close(file_descriptor : System::FileDescriptor) : Nil
235+
def before_close(file_descriptor : System::FileDescriptor) : Nil
236236
# perform cleanup before LibC.close. Using a file descriptor after it has
237237
# been closed is never defined and can always lead to undefined results
238238
resume_all(file_descriptor)
239+
end
240+
241+
def close(file_descriptor : System::FileDescriptor) : Nil
239242
file_descriptor.file_descriptor_close
240243
end
241244

@@ -363,10 +366,13 @@ abstract class Crystal::EventLoop::Polling < Crystal::EventLoop
363366
end
364367
end
365368

366-
def close(socket : ::Socket) : Nil
369+
def before_close(socket : ::Socket) : Nil
367370
# perform cleanup before LibC.close. Using a file descriptor after it has
368371
# been closed is never defined and can always lead to undefined results
369372
resume_all(socket)
373+
end
374+
375+
def close(socket : ::Socket) : Nil
370376
socket.socket_close
371377
end
372378

src/crystal/event_loop/socket.cr

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,16 @@ abstract class Crystal::EventLoop
7474
# and the source address.
7575
abstract def receive_from(socket : ::Socket, slice : Bytes) : Tuple(Int32, ::Socket::Address)
7676

77-
# Closes the socket.
77+
# Hook to react on the socket after the IO object has been marked closed but
78+
# before calling `#close` to actually close the system file descriptor or
79+
# handle.
80+
#
81+
# On UNIX targets the event loop must resume any pending waiter in order to
82+
# decrement the `Crystal::FdLock` counter.
83+
def before_close(socket : ::Socket) : Nil
84+
end
85+
86+
# Closes the system socket fd or handle.
7887
abstract def close(socket : ::Socket) : Nil
7988
end
8089

0 commit comments

Comments
 (0)