curb 1.3.7 → 1.4.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.
@@ -0,0 +1,223 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+
3
+ # Coverage for the blocking (non-scheduler) Curl::Multi#perform wait loop:
4
+ # descriptors above FD_SETSIZE, long waits, and interrupting those waits.
5
+ class TestCurbCurlMultiPoll < Test::Unit::TestCase
6
+ FD_SETSIZE = 1024
7
+ HIGH_FD_TARGET = FD_SETSIZE + 76
8
+
9
+ class PollInterrupt < StandardError; end
10
+
11
+ def setup
12
+ super
13
+ @server = CurbTinyHTTPServer.new
14
+ @original_default_timeout = Curl::Multi.default_timeout
15
+ end
16
+
17
+ def teardown
18
+ Curl::Multi.default_timeout = @original_default_timeout
19
+ @server.close if @server
20
+ super
21
+ end
22
+
23
+ def monotonic
24
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ end
26
+
27
+ # Under valgrind these bounds can no longer tell the old fixed-sleep loop
28
+ # from ordinary overhead. Skip only the bounds; the requests and their
29
+ # assertions still run under memcheck.
30
+ def assert_faster_than(limit, elapsed, message)
31
+ return if curb_under_valgrind?
32
+ assert_operator elapsed, :<, limit, message
33
+ end
34
+
35
+ # Occupy low descriptor numbers so every socket libcurl opens afterwards is
36
+ # numbered above FD_SETSIZE and cannot be represented in an fd_set.
37
+ def with_fds_above_fd_setsize
38
+ omit('fd numbering differs on Windows') if WINDOWS
39
+ # Before 7.68 there is no curl_multi_poll and perform falls back to
40
+ # select(), which cannot represent descriptors above FD_SETSIZE.
41
+ omit('libcurl < 7.68 has no curl_multi_poll') if Gem::Version.new(Curl::CURL_VERSION[/\A\d+(\.\d+)*/]) < Gem::Version.new('7.68.0')
42
+
43
+ soft, hard = Process.getrlimit(:NOFILE)
44
+ needed = HIGH_FD_TARGET + 256
45
+ if soft < needed
46
+ omit("RLIMIT_NOFILE hard limit #{hard} is too low") if hard != Process::RLIM_INFINITY && hard < needed
47
+ Process.setrlimit(:NOFILE, needed, hard)
48
+ end
49
+
50
+ hog = []
51
+ hog << File.open(File::NULL) while hog.empty? || hog.last.fileno < HIGH_FD_TARGET
52
+
53
+ probe = Socket.new(:INET, :STREAM)
54
+ assert_operator probe.fileno, :>, FD_SETSIZE, "new sockets should be numbered above FD_SETSIZE"
55
+ probe.close
56
+
57
+ yield
58
+ ensure
59
+ hog.each(&:close) if hog
60
+ Process.setrlimit(:NOFILE, soft, hard) if soft && Process.getrlimit(:NOFILE)[0] != soft
61
+ end
62
+
63
+ # A request whose socket is above FD_SETSIZE used to be invisible to the
64
+ # curl_multi_fdset/select loop, which then fell back to fixed 100ms sleeps,
65
+ # so each request took at least 100ms. Ten sequential local requests would
66
+ # need a full second.
67
+ def test_easy_perform_with_socket_fds_above_fd_setsize
68
+ with_fds_above_fd_setsize do
69
+ started = monotonic
70
+ 10.times do
71
+ assert_equal "fast", Curl.get(@server.url('/fast')).body_str
72
+ end
73
+ elapsed = monotonic - started
74
+
75
+ assert_faster_than 0.6, elapsed, "10 local requests took #{elapsed.round(3)}s with high-numbered sockets"
76
+ end
77
+ end
78
+
79
+ # Batches of concurrent requests share each 100ms sleep of the old loop, so
80
+ # run several batches to make that fixed floor (>= 1s total) visible.
81
+ def test_multi_concurrent_requests_with_socket_fds_above_fd_setsize
82
+ with_fds_above_fd_setsize do
83
+ started = monotonic
84
+ 5.times do |batch|
85
+ multi = Curl::Multi.new
86
+ easies = 20.times.map do |i|
87
+ easy = Curl::Easy.new(@server.url("/fast?#{batch}-#{i}"))
88
+ multi.add(easy)
89
+ easy
90
+ end
91
+
92
+ multi.perform
93
+
94
+ easies.each do |easy|
95
+ assert_equal 200, easy.response_code
96
+ assert_equal "fast", easy.body_str
97
+ end
98
+ ensure
99
+ multi.close if multi
100
+ end
101
+ elapsed = monotonic - started
102
+
103
+ assert_faster_than 0.8, elapsed, "5 batches of 20 concurrent local requests took #{elapsed.round(3)}s with high-numbered sockets"
104
+ end
105
+ end
106
+
107
+ # With a large default_timeout, readiness on the transfer's socket must still
108
+ # wake the wait immediately rather than waiting out the timeout.
109
+ def test_long_default_timeout_still_wakes_on_socket_activity
110
+ Curl::Multi.default_timeout = 5_000
111
+
112
+ multi = Curl::Multi.new
113
+ fast = Curl::Easy.new(@server.url('/fast'))
114
+ delayed = Curl::Easy.new(@server.url('/delay/0.3'))
115
+ multi.add(fast)
116
+ multi.add(delayed)
117
+
118
+ started = monotonic
119
+ multi.perform
120
+ elapsed = monotonic - started
121
+
122
+ assert_equal "fast", fast.body_str
123
+ assert_equal "delayed", delayed.body_str
124
+ assert_operator elapsed, :<, 2.0, "perform waited #{elapsed.round(3)}s; the 5s default_timeout should not delay completion"
125
+ ensure
126
+ multi.close if multi
127
+ end
128
+
129
+ # The block passed to perform is yielded each time the wait times out, so a
130
+ # small default_timeout keeps it running while a transfer is idle.
131
+ def test_perform_block_yields_while_waiting
132
+ Curl::Multi.default_timeout = 20
133
+
134
+ multi = Curl::Multi.new
135
+ easy = Curl::Easy.new(@server.url('/delay/0.4'))
136
+ multi.add(easy)
137
+
138
+ yields = 0
139
+ multi.perform { yields += 1 }
140
+
141
+ assert_equal "delayed", easy.body_str
142
+ assert_operator yields, :>=, 5, "perform block ran #{yields} times during a 400ms idle transfer"
143
+ ensure
144
+ multi.close if multi
145
+ end
146
+
147
+ def run_interruptible_hang
148
+ Curl::Multi.default_timeout = 30_000
149
+ errors = Queue.new
150
+
151
+ worker = Thread.new do
152
+ Thread.current.report_on_exception = false
153
+ begin
154
+ Curl.get(@server.url('/hang'))
155
+ errors << nil
156
+ rescue Exception => e
157
+ errors << e
158
+ raise
159
+ end
160
+ end
161
+
162
+ assert_equal '/hang', curb_pop_within(@server.requests, 5), "server never received the request"
163
+ # Give the worker a moment to settle into the blocking wait.
164
+ sleep 0.1
165
+
166
+ started = monotonic
167
+ yield worker
168
+ joined = worker.join(3) rescue worker
169
+ elapsed = monotonic - started
170
+
171
+ assert_not_nil joined, "worker was still blocked 3s after being interrupted"
172
+ assert_operator elapsed, :<, 2.0, "interrupt took #{elapsed.round(3)}s to stop the wait"
173
+ errors.empty? ? nil : errors.pop
174
+ ensure
175
+ if worker&.alive?
176
+ worker.kill
177
+ worker.join(1)
178
+ end
179
+ end
180
+
181
+ def test_thread_raise_interrupts_long_wait
182
+ error = run_interruptible_hang { |worker| worker.raise(PollInterrupt, "stop waiting") }
183
+ assert_kind_of PollInterrupt, error
184
+ end
185
+
186
+ def test_thread_kill_interrupts_long_wait
187
+ run_interruptible_hang { |worker| worker.kill }
188
+ end
189
+
190
+ # A trapped signal must run its handler promptly during a long wait, and the
191
+ # wait must then resume so the transfer still completes. The signal can
192
+ # interrupt the poll with EINTR directly, so this guards against treating
193
+ # that as a failed wait rather than exercising the unblocking function
194
+ # (the Thread#raise/#kill tests cover that).
195
+ def test_signal_trap_runs_during_long_wait_and_transfer_completes
196
+ omit('POSIX signals are not available on Windows') if WINDOWS
197
+ Curl::Multi.default_timeout = 30_000
198
+
199
+ handled_at = nil
200
+ previous = trap('USR2') { handled_at = monotonic }
201
+ trapped = true
202
+ sent_at = nil
203
+
204
+ signaller = Thread.new do
205
+ curb_pop_within(@server.requests, 5)
206
+ sleep 0.1
207
+ sent_at = monotonic
208
+ Process.kill('USR2', Process.pid)
209
+ sleep 0.3
210
+ @server.release_hung(1)
211
+ end
212
+
213
+ easy = Curl.get(@server.url('/hang'))
214
+ signaller.join(5)
215
+
216
+ assert_equal "released", easy.body_str
217
+ assert_not_nil handled_at, "USR2 handler never ran"
218
+ assert_operator handled_at - sent_at, :<, 0.25, "signal handler was delayed #{(handled_at - sent_at).round(3)}s by the wait"
219
+ ensure
220
+ trap('USR2', previous || 'DEFAULT') if trapped
221
+ signaller&.kill
222
+ end
223
+ end
@@ -0,0 +1,374 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+
3
+ begin
4
+ require 'async'
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ # Coverage for the socket-action loop's kernel event queue (epoll/kqueue):
10
+ # under a fiber scheduler every libcurl socket is waited on through a single
11
+ # poller descriptor with io_wait, instead of the optional io_select hook.
12
+ class TestCurbCurlMultiSocketPoller < Test::Unit::TestCase
13
+ class PollerAbort < StandardError; end
14
+
15
+ class RecordingScheduler
16
+ attr_reader :io_wait_calls, :io_select_calls
17
+
18
+ def initialize
19
+ @io_wait_calls = []
20
+ @io_select_calls = 0
21
+ end
22
+
23
+ def fiber(&block)
24
+ Fiber.new(blocking: false, &block)
25
+ end
26
+
27
+ def io_wait(io, events, timeout = nil)
28
+ @io_wait_calls << [io.fileno, events]
29
+ readers = (events & IO::READABLE) != 0 ? [io] : nil
30
+ writers = (events & IO::WRITABLE) != 0 ? [io] : nil
31
+ readable, writable = blocking_io { IO.select(readers, writers, nil, timeout) }
32
+
33
+ ready = 0
34
+ ready |= IO::READABLE if readable && !readable.empty?
35
+ ready |= IO::WRITABLE if writable && !writable.empty?
36
+ ready.zero? ? false : ready
37
+ end
38
+
39
+ def io_select(readers, writers, excepts, timeout = nil)
40
+ @io_select_calls += 1
41
+ blocking_io { IO.select(readers, writers, excepts, timeout) }
42
+ end
43
+
44
+ def kernel_sleep(duration = nil)
45
+ blocking_io { sleep(duration || 0) }
46
+ end
47
+
48
+ def block(_blocker, timeout = nil)
49
+ blocking_io { sleep(timeout || 0) }
50
+ false
51
+ end
52
+
53
+ def unblock(*)
54
+ end
55
+
56
+ def close
57
+ end
58
+
59
+ def fiber_interrupt(*)
60
+ end
61
+
62
+ private
63
+
64
+ def blocking_io(&block)
65
+ if Fiber.respond_to?(:blocking)
66
+ Fiber.blocking(&block)
67
+ else
68
+ Fiber.new(blocking: true, &block).resume
69
+ end
70
+ end
71
+ end
72
+
73
+ # io_select is an optional hook; many schedulers only implement io_wait.
74
+ class IoWaitOnlyScheduler < RecordingScheduler
75
+ undef_method :io_select
76
+ end
77
+
78
+ # Counts Async's io_select calls, each of which starts a Thread. Prepended
79
+ # to a single scheduler's singleton class so other tests are unaffected.
80
+ module AsyncIoSelectCounter
81
+ attr_reader :curb_io_select_calls
82
+
83
+ def io_select(*args)
84
+ @curb_io_select_calls = (@curb_io_select_calls || 0) + 1
85
+ super
86
+ end
87
+ end
88
+
89
+ STREAM_CHUNKS = 32
90
+ STREAM_BYTES = 8192
91
+
92
+ def setup
93
+ super
94
+ omit('The socket-action loop is not built on Windows') if WINDOWS
95
+ omit('Fiber scheduler API unavailable on this Ruby') unless Fiber.respond_to?(:set_scheduler) && Fiber.respond_to?(:schedule)
96
+ omit('socket-action perform path is not available in this build') unless Curl::Multi.private_method_defined?(:_socket_perform)
97
+
98
+ @server = CurbTinyHTTPServer.new
99
+ @original_default_timeout = Curl::Multi.default_timeout
100
+ end
101
+
102
+ def teardown
103
+ Curl::Multi.default_timeout = @original_default_timeout if @original_default_timeout
104
+ # Easy#perform under a scheduler keeps a per-thread shared multi; drop it so
105
+ # later tests do not inherit state from this file's Async runs.
106
+ state = Thread.current.thread_variable_get(:curb_scheduler_state)
107
+ state[:multi].close if state && state[:multi]
108
+ Thread.current.thread_variable_set(:curb_scheduler_state, nil)
109
+ @server.close if @server
110
+ super
111
+ end
112
+
113
+ def poller
114
+ Curl::Multi.send(:_socket_poller)
115
+ end
116
+
117
+ def monotonic
118
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
+ end
120
+
121
+ def with_scheduler(scheduler)
122
+ previous_scheduler = Fiber.scheduler if Fiber.respond_to?(:scheduler)
123
+ Fiber.set_scheduler(scheduler)
124
+ fiber = Fiber.schedule { yield }
125
+ fiber.resume while fiber.alive?
126
+ ensure
127
+ Fiber.set_scheduler(previous_scheduler)
128
+ end
129
+
130
+ def stream_path
131
+ "/stream/#{STREAM_CHUNKS}/#{STREAM_BYTES}"
132
+ end
133
+
134
+ def expected_stream_body
135
+ CurbTinyHTTPServer.stream_body(STREAM_CHUNKS, STREAM_BYTES)
136
+ end
137
+
138
+ def perform_streams(count)
139
+ multi = Curl::Multi.new
140
+ easies = count.times.map do
141
+ easy = Curl::Easy.new(@server.url(stream_path))
142
+ multi.add(easy)
143
+ easy
144
+ end
145
+ multi.perform
146
+ easies
147
+ ensure
148
+ multi.close if multi
149
+ end
150
+
151
+ def eventpoll_fd_count
152
+ Dir.children('/proc/self/fd').count do |fd|
153
+ begin
154
+ File.readlink("/proc/self/fd/#{fd}") == 'anon_inode:[eventpoll]'
155
+ rescue SystemCallError
156
+ false
157
+ end
158
+ end
159
+ end
160
+
161
+ def test_socket_poller_reports_backend
162
+ assert_include [nil, 'epoll', 'kqueue'], poller
163
+ if RUBY_PLATFORM =~ /linux/
164
+ assert_equal 'epoll', poller
165
+ elsif RUBY_PLATFORM =~ /darwin|bsd/
166
+ assert_equal 'kqueue', poller
167
+ end
168
+ end
169
+
170
+ # With several sockets in flight, every wait goes through io_wait on one
171
+ # descriptor (the poller), and the io_select hook is never used.
172
+ def test_concurrent_streams_wait_on_single_poller_descriptor
173
+ omit('no kernel event queue in this build') unless poller
174
+ scheduler = RecordingScheduler.new
175
+ easies = nil
176
+
177
+ with_scheduler(scheduler) { easies = perform_streams(8) }
178
+
179
+ easies.each do |easy|
180
+ assert_equal 200, easy.response_code
181
+ assert_equal expected_stream_body, easy.body_str
182
+ end
183
+ assert_equal 0, scheduler.io_select_calls, 'io_select should not be used when a poller is available'
184
+ assert_operator scheduler.io_wait_calls.length, :>=, 1
185
+ assert_equal 1, scheduler.io_wait_calls.map(&:first).uniq.length,
186
+ "expected every wait on one poller descriptor, saw #{scheduler.io_wait_calls.map(&:first).uniq.inspect}"
187
+ assert(scheduler.io_wait_calls.all? { |_fd, events| events == IO::READABLE },
188
+ 'the poller descriptor should only be waited on for readability')
189
+ end
190
+
191
+ # Without io_select the old loop waited on one arbitrary socket for up to
192
+ # default_timeout, so an idle socket first in line delayed every other
193
+ # transfer. The poller wakes for whichever socket becomes ready.
194
+ def test_scheduler_without_io_select_is_not_delayed_by_idle_socket
195
+ omit('no kernel event queue in this build') unless poller
196
+ Curl::Multi.default_timeout = 2_000
197
+ scheduler = IoWaitOnlyScheduler.new
198
+ fast_elapsed = nil
199
+ hung = Curl::Easy.new(@server.url('/hang'))
200
+ fast = Curl::Easy.new(@server.url('/fast'))
201
+
202
+ started = monotonic
203
+ fast.on_complete do
204
+ fast_elapsed = monotonic - started
205
+ @server.release_hung(1)
206
+ end
207
+
208
+ with_scheduler(scheduler) do
209
+ multi = Curl::Multi.new
210
+ begin
211
+ multi.add(hung)
212
+ multi.add(fast)
213
+ multi.perform
214
+ ensure
215
+ multi.close
216
+ end
217
+ end
218
+
219
+ assert_equal "fast", fast.body_str
220
+ assert_equal "released", hung.body_str
221
+ assert_not_nil fast_elapsed
222
+ assert_operator fast_elapsed, :<, 1.0, "fast transfer waited #{fast_elapsed.round(3)}s behind an idle socket"
223
+ end
224
+
225
+ def test_async_concurrent_streams_do_not_use_io_select
226
+ omit('Async gem not available') unless defined?(Async::Scheduler)
227
+ omit('no kernel event queue in this build') unless poller
228
+ bodies = []
229
+ scheduler = nil
230
+
231
+ Async do |task|
232
+ scheduler = Fiber.scheduler
233
+ scheduler.singleton_class.prepend(AsyncIoSelectCounter)
234
+ 12.times.map do
235
+ task.async { bodies << Curl.get(@server.url(stream_path)).body_str }
236
+ end.each(&:wait)
237
+ end
238
+
239
+ assert_equal 12, bodies.length
240
+ assert(bodies.all? { |body| body == expected_stream_body }, 'streamed bodies were not reassembled intact')
241
+ assert_equal 0, scheduler.curb_io_select_calls.to_i, 'Async#io_select starts a thread per call and should not be used'
242
+ end
243
+
244
+ # Completions that queue new requests close and open sockets within one
245
+ # perform, so descriptor numbers are reused while the poller stays open.
246
+ def test_socket_churn_within_one_perform
247
+ omit('Async gem not available') unless defined?(Async::Scheduler)
248
+ total = 30
249
+ bodies = []
250
+ started = monotonic
251
+
252
+ Async do
253
+ multi = Curl::Multi.new
254
+ remaining = total
255
+ add_next = lambda do
256
+ next if remaining.zero?
257
+ remaining -= 1
258
+ easy = Curl::Easy.new(@server.url('/fast'))
259
+ easy.on_complete do |completed|
260
+ bodies << completed.body_str
261
+ add_next.call
262
+ end
263
+ multi.add(easy)
264
+ end
265
+
266
+ begin
267
+ 2.times { add_next.call }
268
+ multi.perform
269
+ ensure
270
+ multi.close
271
+ end
272
+ end
273
+
274
+ assert_equal Array.new(total, "fast"), bodies
275
+ assert_operator monotonic - started, :<, 10.0
276
+ end
277
+
278
+ # A peer that disconnects early or resets the connection must fail only its
279
+ # own transfer: readiness is reported, libcurl sees the error on read, and
280
+ # the loop neither spins nor stalls the healthy transfer.
281
+ def test_truncated_and_reset_transfers_fail_without_stalling
282
+ scheduler = RecordingScheduler.new
283
+ truncated = Curl::Easy.new(@server.url('/truncate'))
284
+ reset = Curl::Easy.new(@server.url('/reset'))
285
+ healthy = Curl::Easy.new(@server.url(stream_path))
286
+ started = monotonic
287
+
288
+ with_scheduler(scheduler) do
289
+ multi = Curl::Multi.new
290
+ begin
291
+ [truncated, reset, healthy].each { |easy| multi.add(easy) }
292
+ multi.perform
293
+ ensure
294
+ multi.close
295
+ end
296
+ end
297
+
298
+ assert_operator monotonic - started, :<, 5.0
299
+ assert_equal expected_stream_body, healthy.body_str
300
+ assert_not_equal 0, truncated.last_result, 'a truncated body should fail the transfer'
301
+ assert_not_equal 0, reset.last_result, 'a reset connection should fail the transfer'
302
+ end
303
+
304
+ def test_poller_descriptor_closed_after_perform
305
+ omit('eventpoll descriptors are only visible on Linux') unless poller == 'epoll' && File.directory?('/proc/self/fd')
306
+ before = eventpoll_fd_count
307
+
308
+ with_scheduler(RecordingScheduler.new) { 3.times { perform_streams(2) } }
309
+
310
+ assert_equal before, eventpoll_fd_count
311
+ end
312
+
313
+ def test_poller_descriptor_closed_when_perform_raises
314
+ omit('eventpoll descriptors are only visible on Linux') unless poller == 'epoll' && File.directory?('/proc/self/fd')
315
+ before = eventpoll_fd_count
316
+
317
+ assert_raise(PollerAbort) do
318
+ with_scheduler(RecordingScheduler.new) do
319
+ multi = Curl::Multi.new
320
+ begin
321
+ 2.times { multi.add(Curl::Easy.new(@server.url('/delay/0.2'))) }
322
+ yields = 0
323
+ # Raise mid-transfer, after the loop has waited on the poller.
324
+ multi.perform do
325
+ yields += 1
326
+ raise PollerAbort, 'stop the drive loop' if yields >= 3
327
+ end
328
+ ensure
329
+ multi.close
330
+ end
331
+ end
332
+ end
333
+
334
+ assert_equal before, eventpoll_fd_count
335
+ end
336
+
337
+ # If wrapping the poller descriptor in an IO fails, the error propagates and
338
+ # the descriptor is still closed.
339
+ def test_poller_descriptor_closed_when_io_wrapper_fails
340
+ omit('eventpoll descriptors are only visible on Linux') unless poller == 'epoll' && File.directory?('/proc/self/fd')
341
+ wrapper_error = Class.new(StandardError)
342
+ original_for_fd = IO.method(:for_fd)
343
+ before = eventpoll_fd_count
344
+
345
+ begin
346
+ redefine_io_for_fd { |*| raise wrapper_error, 'IO wrapper failed' }
347
+ assert_raise(wrapper_error) do
348
+ with_scheduler(RecordingScheduler.new) do
349
+ multi = Curl::Multi.new
350
+ begin
351
+ 2.times { multi.add(Curl::Easy.new(@server.url('/fast'))) }
352
+ multi.perform
353
+ ensure
354
+ multi.close
355
+ end
356
+ end
357
+ end
358
+ ensure
359
+ redefine_io_for_fd(original_for_fd)
360
+ end
361
+
362
+ assert_equal before, eventpoll_fd_count
363
+ end
364
+
365
+ private
366
+
367
+ def redefine_io_for_fd(callable = nil, &block)
368
+ previous_verbose = $VERBOSE
369
+ $VERBOSE = nil
370
+ IO.define_singleton_method(:for_fd, callable || block)
371
+ ensure
372
+ $VERBOSE = previous_verbose
373
+ end
374
+ end
@@ -318,3 +318,42 @@ class TestCurbCurlPostfieldMultipartCoverage < Test::Unit::TestCase
318
318
  assert_match(/Content-Type: text\/plain/, body)
319
319
  end
320
320
  end
321
+
322
+ class TestCurbCurlPostfieldInvalidToS < Test::Unit::TestCase
323
+ class BadToS
324
+ def to_s
325
+ 42
326
+ end
327
+ end
328
+
329
+ def test_to_s_with_non_string_content_to_s_result_does_not_crash
330
+ pf = Curl::PostField.content('name', BadToS.new)
331
+ body = nil
332
+ assert_nothing_raised { body = pf.to_s }
333
+ assert_kind_of String, body
334
+ assert body.start_with?('name=')
335
+ end
336
+
337
+ def test_to_s_with_non_string_name_to_s_result_does_not_crash
338
+ pf = Curl::PostField.content(BadToS.new, 'value')
339
+ body = nil
340
+ assert_nothing_raised { body = pf.to_s }
341
+ assert_kind_of String, body
342
+ end
343
+
344
+ def test_to_s_with_content_proc_returning_non_string_does_not_crash
345
+ pf = Curl::PostField.content('name') { BadToS.new }
346
+ assert_nothing_raised { pf.to_s }
347
+ end
348
+
349
+ def test_to_s_content_proc_raising_error_still_propagates
350
+ pf = Curl::PostField.content('name') { raise 'boom' }
351
+ error = assert_raise(RuntimeError) { pf.to_s }
352
+ assert_equal 'boom', error.message
353
+ end
354
+
355
+ def test_to_s_still_coerces_objects_with_valid_to_s
356
+ pf = Curl::PostField.content('name', 42)
357
+ assert_equal 'name=42', pf.to_s
358
+ end
359
+ end