winhttp 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +291 -0
- data/ext/winhttp/extconf.rb +26 -0
- data/ext/winhttp/winhttp.c +1247 -0
- data/lib/winhttp/version.rb +5 -0
- data/lib/winhttp.rb +562 -0
- metadata +121 -0
data/lib/winhttp.rb
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "winhttp/version"
|
|
4
|
+
require "winhttp/winhttp" # native ext: Session/Request classes, errors, EV_*/ACCESS_*/REDIRECT_* constants
|
|
5
|
+
require "monitor"
|
|
6
|
+
|
|
7
|
+
# winhttp — HTTP for Ruby on the stack Windows already ships: WinHTTP in async
|
|
8
|
+
# mode (system TLS via Schannel, the OS certificate store, the user's
|
|
9
|
+
# proxy/PAC settings, HTTP/2, and transparent gzip), behind a thin client that
|
|
10
|
+
# parks fibers under winloop and blocks plainly without it.
|
|
11
|
+
#
|
|
12
|
+
# require "winhttp"
|
|
13
|
+
#
|
|
14
|
+
# r = Winhttp.get("https://example.com/")
|
|
15
|
+
# r.status # => 200
|
|
16
|
+
# r.headers["content-type"] # => "text/html; charset=UTF-8"
|
|
17
|
+
# r.text[0, 15] # => "<!doctype html>"
|
|
18
|
+
#
|
|
19
|
+
# A Session pools connections; reuse one for many requests. The same code path
|
|
20
|
+
# blocks a plain thread and parks a fiber under a Fiber::Scheduler (winloop) —
|
|
21
|
+
# there are no scheduler branches anywhere in winhttp.
|
|
22
|
+
module Winhttp
|
|
23
|
+
# ---- HTTP token grammar (RFC 7230) for method + header names --------------
|
|
24
|
+
TOKEN_RE = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
|
|
25
|
+
|
|
26
|
+
# Header names WinHTTP manages itself; supplying them is an error.
|
|
27
|
+
MANAGED_HEADERS = %w[host content-length connection].freeze
|
|
28
|
+
|
|
29
|
+
# Revocation policy symbol <-> the C integer the session probe understands.
|
|
30
|
+
REVOCATION_INT = { none: 0, best_effort: 1, strict: 2 }.freeze
|
|
31
|
+
REVOCATION_SYM = { 0 => :none, 1 => :best_effort, 2 => :strict }.freeze
|
|
32
|
+
|
|
33
|
+
# A WinHTTP/Win32 failure carries the originating error code (Integer, or nil
|
|
34
|
+
# for a Ruby-side `timeout:` deadline), set on the exception in C / by the
|
|
35
|
+
# state machine.
|
|
36
|
+
class OSError
|
|
37
|
+
def code
|
|
38
|
+
@code
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Certificate/TLS failures decode the SECURE_FAILURE flag bits captured in the
|
|
43
|
+
# status callback immediately before the failing REQUEST_ERROR.
|
|
44
|
+
class TlsError
|
|
45
|
+
# Array[Symbol] (possibly empty): :cert_rev_failed, :invalid_cert,
|
|
46
|
+
# :cert_revoked, :invalid_ca, :cert_cn_invalid, :cert_date_invalid,
|
|
47
|
+
# :security_channel_error.
|
|
48
|
+
def details
|
|
49
|
+
@details || []
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A plain, immutable HTTP response. Built in Ruby from the values the state
|
|
54
|
+
# machine queried out of WinHTTP.
|
|
55
|
+
class Response
|
|
56
|
+
attr_reader :status, :reason, :headers, :raw_headers, :body, :final_url
|
|
57
|
+
|
|
58
|
+
# status: Integer; raw: the WINHTTP_QUERY_RAW_HEADERS_CRLF blob (status line
|
|
59
|
+
# + header lines, CRLF-separated); final_url: String; http2: bool;
|
|
60
|
+
# body: String (ASCII-8BIT) or nil when streamed.
|
|
61
|
+
def initialize(status, raw, final_url, http2, body)
|
|
62
|
+
@status = status
|
|
63
|
+
@http2 = http2
|
|
64
|
+
@final_url = final_url
|
|
65
|
+
@body = body
|
|
66
|
+
reason, pairs = Winhttp.send(:parse_raw_headers, raw)
|
|
67
|
+
@reason = reason
|
|
68
|
+
@raw_headers = pairs.map { |k, v| [k.dup.freeze, v.dup.freeze].freeze }.freeze
|
|
69
|
+
merged = {}
|
|
70
|
+
pairs.each do |k, v|
|
|
71
|
+
lk = k.downcase
|
|
72
|
+
merged[lk] = merged.key?(lk) ? "#{merged[lk]}, #{v}" : v
|
|
73
|
+
end
|
|
74
|
+
merged.each_value(&:freeze)
|
|
75
|
+
@headers = merged.freeze
|
|
76
|
+
freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Case-insensitive single-header lookup (merged value), or nil.
|
|
80
|
+
def [](name)
|
|
81
|
+
@headers[name.to_s.downcase]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def http2?
|
|
85
|
+
@http2
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def success?
|
|
89
|
+
(200..299).cover?(@status)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A copy of the body tagged with `encoding` (else charset= from
|
|
93
|
+
# Content-Type, else UTF-8). Tags only (force_encoding) — never transcodes;
|
|
94
|
+
# unknown charset names fall back to UTF-8. Raises if the body was streamed.
|
|
95
|
+
def text(encoding = nil)
|
|
96
|
+
raise Error, "winhttp: body was streamed (use the chunk block)" if @body.nil?
|
|
97
|
+
|
|
98
|
+
enc = encoding || charset_encoding || Encoding::UTF_8
|
|
99
|
+
@body.dup.force_encoding(enc)
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
@body.dup.force_encoding(Encoding::UTF_8)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def charset_encoding
|
|
107
|
+
ct = @headers["content-type"]
|
|
108
|
+
return nil unless ct && ct =~ /charset=\s*"?([\w\-]+)"?/i
|
|
109
|
+
|
|
110
|
+
Encoding.find(Regexp.last_match(1))
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class << self
|
|
117
|
+
# The lazily-created, process-lifetime default Session (all-default options).
|
|
118
|
+
# Creation is mutex-guarded. It is never closed; the OS reclaims it at exit.
|
|
119
|
+
def default_session
|
|
120
|
+
@default_session || @default_mutex.synchronize { @default_session ||= Session.new }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Thin delegations to the default session (signatures/defaults identical to
|
|
124
|
+
# the Session instance methods).
|
|
125
|
+
def get(url, headers: nil, timeout: nil, &chunk)
|
|
126
|
+
default_session.get(url, headers: headers, timeout: timeout, &chunk)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def post(url, body: "", headers: nil, timeout: nil, &chunk)
|
|
130
|
+
default_session.post(url, body: body, headers: headers, timeout: timeout, &chunk)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def request(method, url, body: nil, headers: nil, timeout: nil, &chunk)
|
|
134
|
+
default_session.request(method, url, body: body, headers: headers, timeout: timeout, &chunk)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# ---- pump thread (one per process) -------------------------------------
|
|
140
|
+
|
|
141
|
+
def ensure_pump!
|
|
142
|
+
@pump_mutex.synchronize do
|
|
143
|
+
return if @pump&.alive?
|
|
144
|
+
|
|
145
|
+
@pump = Thread.new { pump_loop }
|
|
146
|
+
@pump.report_on_exception = false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def pump_loop
|
|
151
|
+
loop do
|
|
152
|
+
Winhttp._pump_wait
|
|
153
|
+
Winhttp._pump_drain.each do |id, kind, a, b|
|
|
154
|
+
if kind == EV_LOST
|
|
155
|
+
# callback-side OOM: fan out to every registered mailbox
|
|
156
|
+
@mb_lock.synchronize { @mailboxes.values }.each { |q| q.push([EV_LOST, 0, 0]) }
|
|
157
|
+
else
|
|
158
|
+
q = @mb_lock.synchronize { @mailboxes[id] }
|
|
159
|
+
q&.push([kind, a, b])
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
warn "winhttp: pump error: #{e.class}: #{e.message}" if $VERBOSE
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def register_mailbox(id, queue)
|
|
168
|
+
ensure_pump!
|
|
169
|
+
@mb_lock.synchronize { @mailboxes[id] = queue }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def unregister_mailbox(id)
|
|
173
|
+
@mb_lock.synchronize { @mailboxes.delete(id) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ---- shared validation / assembly --------------------------------------
|
|
177
|
+
|
|
178
|
+
MONO = Process::CLOCK_MONOTONIC
|
|
179
|
+
|
|
180
|
+
def monotonic
|
|
181
|
+
Process.clock_gettime(MONO)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Validate a positive Numeric timeout (seconds) -> Float, or raise.
|
|
185
|
+
def check_timeout(timeout)
|
|
186
|
+
return nil if timeout.nil?
|
|
187
|
+
|
|
188
|
+
t = Float(timeout)
|
|
189
|
+
raise ArgumentError, "winhttp: timeout must be positive, got #{timeout.inspect}" unless t.positive?
|
|
190
|
+
|
|
191
|
+
t
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Upcase + token-validate an HTTP method.
|
|
195
|
+
def normalize_method(method)
|
|
196
|
+
m = method.to_s.upcase
|
|
197
|
+
raise ArgumentError, "winhttp: invalid HTTP method #{method.inspect}" unless m.match?(TOKEN_RE)
|
|
198
|
+
|
|
199
|
+
m
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Assemble a CRLF-joined header blob (or nil) with injection + token guards.
|
|
203
|
+
def build_header_blob(headers)
|
|
204
|
+
return nil if headers.nil?
|
|
205
|
+
raise TypeError, "winhttp: headers must be a Hash or nil" unless headers.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
lines = []
|
|
208
|
+
headers.each do |name, value|
|
|
209
|
+
n = name.to_s
|
|
210
|
+
raise ArgumentError, "winhttp: invalid header name #{name.inspect}" unless n.match?(TOKEN_RE)
|
|
211
|
+
if MANAGED_HEADERS.include?(n.downcase)
|
|
212
|
+
raise ArgumentError, "winhttp: #{n} is managed by WinHTTP and cannot be set"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
Array(value).each do |v|
|
|
216
|
+
sv = v.is_a?(Integer) ? v.to_s : v.to_s
|
|
217
|
+
check_header_value!(n, sv)
|
|
218
|
+
lines << "#{n}: #{sv}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
return nil if lines.empty?
|
|
222
|
+
|
|
223
|
+
lines.join("\r\n")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Header values must be printable ASCII + TAB (header-injection guard: any
|
|
227
|
+
# CR/LF, or a byte outside 0x20..0x7E plus TAB, raises).
|
|
228
|
+
def check_header_value!(name, value)
|
|
229
|
+
value.each_byte do |b|
|
|
230
|
+
next if b == 0x09 || (b >= 0x20 && b <= 0x7E)
|
|
231
|
+
|
|
232
|
+
raise ArgumentError, "winhttp: illegal byte in header #{name.inspect} value"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Validate a request body -> String (raw bytes) <= 4 GiB - 1, or nil.
|
|
237
|
+
def check_body(body)
|
|
238
|
+
return nil if body.nil?
|
|
239
|
+
|
|
240
|
+
s = String.try_convert(body)
|
|
241
|
+
raise TypeError, "winhttp: body must be a String" unless s
|
|
242
|
+
if s.bytesize > 0xFFFF_FFFE
|
|
243
|
+
raise ArgumentError, "winhttp: body too large (> 4 GiB - 1)"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
s
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Split the RAW_HEADERS_CRLF blob into [reason, [[name, value], ...]].
|
|
250
|
+
# First line is the status line ("HTTP/1.1 200 OK"); the rest are headers.
|
|
251
|
+
def parse_raw_headers(raw)
|
|
252
|
+
lines = raw.to_s.split("\r\n")
|
|
253
|
+
status_line = lines.shift || ""
|
|
254
|
+
reason = ""
|
|
255
|
+
if status_line =~ %r{\AHTTP/\S+\s+\d+\s+(.*)\z}
|
|
256
|
+
reason = Regexp.last_match(1)
|
|
257
|
+
elsif status_line =~ %r{\AHTTP/\S+\s+\d+\s*\z}
|
|
258
|
+
reason = ""
|
|
259
|
+
end
|
|
260
|
+
pairs = []
|
|
261
|
+
lines.each do |line|
|
|
262
|
+
next if line.empty?
|
|
263
|
+
|
|
264
|
+
k, v = line.split(":", 2)
|
|
265
|
+
next if k.nil?
|
|
266
|
+
|
|
267
|
+
pairs << [k.strip, (v || "").strip]
|
|
268
|
+
end
|
|
269
|
+
[reason, pairs]
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# One async WinHTTP session handle plus its configuration. Thread-safe and
|
|
274
|
+
# fiber-safe: any number of threads/fibers may issue requests concurrently.
|
|
275
|
+
class Session
|
|
276
|
+
# The effective revocation policy, settled at construction.
|
|
277
|
+
attr_reader :revocation
|
|
278
|
+
|
|
279
|
+
# Open a Session. See the gem README for the full option contract.
|
|
280
|
+
def self.new(user_agent: "winhttp-ruby/#{Winhttp::VERSION}",
|
|
281
|
+
proxy: :system, proxy_bypass: nil, http2: true, decompress: true,
|
|
282
|
+
revocation: :best_effort, redirects: 10,
|
|
283
|
+
connect_timeout: nil, send_timeout: nil, receive_timeout: nil)
|
|
284
|
+
access, proxy_str = resolve_proxy(proxy, proxy_bypass)
|
|
285
|
+
rev_int = REVOCATION_INT[revocation]
|
|
286
|
+
raise ArgumentError, "winhttp: revocation must be :best_effort/:strict/:none" unless rev_int
|
|
287
|
+
|
|
288
|
+
unless redirects.is_a?(Integer) && (0..65_535).cover?(redirects)
|
|
289
|
+
raise ArgumentError, "winhttp: redirects must be an Integer in 0..65535"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
redirect_policy = redirects.zero? ? REDIRECT_NEVER : REDIRECT_DISALLOW_HTTPS_TO_HTTP
|
|
293
|
+
|
|
294
|
+
ct = ms_or_unset(connect_timeout, "connect_timeout")
|
|
295
|
+
st = ms_or_unset(send_timeout, "send_timeout")
|
|
296
|
+
rt = ms_or_unset(receive_timeout, "receive_timeout")
|
|
297
|
+
|
|
298
|
+
session, eff = _open(user_agent.to_s, access, proxy_str, proxy_bypass,
|
|
299
|
+
ct, st, rt, http2 ? true : false, decompress ? true : false,
|
|
300
|
+
redirect_policy, redirects, rev_int)
|
|
301
|
+
session.send(:finish_init, REVOCATION_SYM[eff], rev_int)
|
|
302
|
+
session
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Resolve a proxy: kwarg -> [access_type, named_proxy_string_or_nil].
|
|
306
|
+
def self.resolve_proxy(proxy, proxy_bypass)
|
|
307
|
+
case proxy
|
|
308
|
+
when :system
|
|
309
|
+
raise ArgumentError, "winhttp: proxy_bypass requires a String proxy" if proxy_bypass
|
|
310
|
+
[ACCESS_AUTOMATIC, nil]
|
|
311
|
+
when :none
|
|
312
|
+
raise ArgumentError, "winhttp: proxy_bypass requires a String proxy" if proxy_bypass
|
|
313
|
+
[ACCESS_NO_PROXY, nil]
|
|
314
|
+
when String
|
|
315
|
+
validate_proxy_string!(proxy)
|
|
316
|
+
validate_proxy_string!(proxy_bypass) if proxy_bypass
|
|
317
|
+
[ACCESS_NAMED, proxy]
|
|
318
|
+
else
|
|
319
|
+
raise ArgumentError, "winhttp: proxy must be :system, :none, or a String"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
private_class_method :resolve_proxy
|
|
323
|
+
|
|
324
|
+
# Proxy strings must not carry whitespace/CR/LF or userinfo (credentials).
|
|
325
|
+
def self.validate_proxy_string!(str)
|
|
326
|
+
if str =~ /[\s]/ || str.include?("\r") || str.include?("\n")
|
|
327
|
+
raise ArgumentError, "winhttp: proxy string must not contain whitespace or CR/LF"
|
|
328
|
+
end
|
|
329
|
+
if str.include?("@")
|
|
330
|
+
raise ArgumentError, "winhttp: proxy string must not contain credentials (user:pass@)"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
private_class_method :validate_proxy_string!
|
|
334
|
+
|
|
335
|
+
# nil -> -1 (leave WinHTTP default); positive Numeric seconds -> ms Integer.
|
|
336
|
+
def self.ms_or_unset(timeout, name)
|
|
337
|
+
return -1 if timeout.nil?
|
|
338
|
+
|
|
339
|
+
t = Float(timeout)
|
|
340
|
+
raise ArgumentError, "winhttp: #{name} must be non-negative, got #{timeout.inspect}" if t.negative?
|
|
341
|
+
|
|
342
|
+
ms = (t * 1000).round
|
|
343
|
+
ms.zero? && t.positive? ? 1 : ms
|
|
344
|
+
end
|
|
345
|
+
private_class_method :ms_or_unset
|
|
346
|
+
|
|
347
|
+
# Open a session and ensure-close it around the block (block form).
|
|
348
|
+
def self.open(**opts)
|
|
349
|
+
s = new(**opts)
|
|
350
|
+
return s unless block_given?
|
|
351
|
+
|
|
352
|
+
begin
|
|
353
|
+
yield s
|
|
354
|
+
ensure
|
|
355
|
+
s.close
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def get(url, headers: nil, timeout: nil, &chunk)
|
|
360
|
+
perform("GET", url, nil, headers, timeout, &chunk)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def head(url, headers: nil, timeout: nil)
|
|
364
|
+
perform("HEAD", url, nil, headers, timeout)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def post(url, body: "", headers: nil, timeout: nil, &chunk)
|
|
368
|
+
perform("POST", url, body, headers, timeout, &chunk)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def put(url, body:, headers: nil, timeout: nil, &chunk)
|
|
372
|
+
perform("PUT", url, body, headers, timeout, &chunk)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def patch(url, body:, headers: nil, timeout: nil, &chunk)
|
|
376
|
+
perform("PATCH", url, body, headers, timeout, &chunk)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def delete(url, headers: nil, timeout: nil, &chunk)
|
|
380
|
+
perform("DELETE", url, nil, headers, timeout, &chunk)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def request(method, url, body: nil, headers: nil, timeout: nil, &chunk)
|
|
384
|
+
perform(Winhttp.send(:normalize_method, method), url, body, headers, timeout, &chunk)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Close the session: mark closed FIRST (new requests raise Closed), abort
|
|
388
|
+
# every in-flight request (those raise Canceled), wait — bounded, <= 5 s —
|
|
389
|
+
# for the OS to finish teardown, then close the session handle. Idempotent.
|
|
390
|
+
def close
|
|
391
|
+
@lock.synchronize do
|
|
392
|
+
return nil if @closed_flag
|
|
393
|
+
|
|
394
|
+
@closed_flag = true
|
|
395
|
+
_mark_closed
|
|
396
|
+
inflight = @inflight.values
|
|
397
|
+
end
|
|
398
|
+
abort_inflight
|
|
399
|
+
bounded_close_handle
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
private
|
|
404
|
+
|
|
405
|
+
# Called by Session.new after the C handle is created.
|
|
406
|
+
def finish_init(effective_revocation, _rev_int)
|
|
407
|
+
@revocation = effective_revocation
|
|
408
|
+
@lock = Monitor.new
|
|
409
|
+
@inflight = {}
|
|
410
|
+
@closed_flag = false
|
|
411
|
+
self
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def abort_inflight
|
|
415
|
+
reqs = @lock.synchronize { @inflight.values.dup }
|
|
416
|
+
reqs.each do |entry|
|
|
417
|
+
mutex, req = entry
|
|
418
|
+
mutex.synchronize { req._abort }
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def bounded_close_handle
|
|
423
|
+
deadline = Winhttp.send(:monotonic) + 5.0
|
|
424
|
+
loop do
|
|
425
|
+
return if _try_close_handle
|
|
426
|
+
|
|
427
|
+
if Winhttp.send(:monotonic) >= deadline
|
|
428
|
+
warn "winhttp: session handle leaked (teardown did not settle in 5s)" if $VERBOSE
|
|
429
|
+
return
|
|
430
|
+
end
|
|
431
|
+
sleep 0.01
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The per-request state machine. Strictly sequential, so exactly one
|
|
436
|
+
# outstanding WinHTTP operation per request handle by construction.
|
|
437
|
+
def perform(verb, url, body, headers, timeout, &chunk)
|
|
438
|
+
raise Closed, "winhttp: session is closed" if @closed_flag
|
|
439
|
+
|
|
440
|
+
secure, host, port, path = Winhttp._crack(url)
|
|
441
|
+
blob = Winhttp.send(:build_header_blob, headers)
|
|
442
|
+
bytes = Winhttp.send(:check_body, body)
|
|
443
|
+
seconds = Winhttp.send(:check_timeout, timeout)
|
|
444
|
+
deadline = seconds && (Winhttp.send(:monotonic) + seconds)
|
|
445
|
+
|
|
446
|
+
req = Winhttp::Request.allocate
|
|
447
|
+
mutex = Thread::Mutex.new
|
|
448
|
+
mb = Thread::Queue.new
|
|
449
|
+
id = nil
|
|
450
|
+
secure_flags = []
|
|
451
|
+
|
|
452
|
+
begin
|
|
453
|
+
id_num = _start(req, verb, host, port, secure, path, blob, bytes,
|
|
454
|
+
Winhttp::REVOCATION_INT.fetch(@revocation, 0))
|
|
455
|
+
id = id_num
|
|
456
|
+
Winhttp.send(:register_mailbox, id, mb)
|
|
457
|
+
@lock.synchronize do
|
|
458
|
+
raise Closed, "winhttp: session is closed" if @closed_flag
|
|
459
|
+
|
|
460
|
+
@inflight[id] = [mutex, req]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
mutex.synchronize { req._send }
|
|
464
|
+
wait!(mb, deadline, mutex, req, EV_SEND, secure_flags)
|
|
465
|
+
mutex.synchronize { req._receive }
|
|
466
|
+
wait!(mb, deadline, mutex, req, EV_HEADERS, secure_flags)
|
|
467
|
+
status, raw, final_url, proto = mutex.synchronize { req._headers }
|
|
468
|
+
|
|
469
|
+
chunks = chunk ? nil : []
|
|
470
|
+
loop do
|
|
471
|
+
mutex.synchronize { req._read_start }
|
|
472
|
+
n = wait!(mb, deadline, mutex, req, EV_READ, secure_flags)
|
|
473
|
+
break if n.zero?
|
|
474
|
+
|
|
475
|
+
data = mutex.synchronize { req._read_take(n) }
|
|
476
|
+
chunk ? chunk.call(data) : (chunks << data)
|
|
477
|
+
end
|
|
478
|
+
mutex.synchronize { req._finish }
|
|
479
|
+
|
|
480
|
+
Response.new(status, raw, final_url, proto,
|
|
481
|
+
chunks && chunks.join.force_encoding(Encoding::BINARY))
|
|
482
|
+
ensure
|
|
483
|
+
mutex.synchronize { req._abort } rescue nil
|
|
484
|
+
Winhttp.send(:unregister_mailbox, id) if id
|
|
485
|
+
@lock.synchronize { @inflight.delete(id) } if id
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Pop the mailbox until the expected kind arrives. Honors the wall-clock
|
|
490
|
+
# deadline, routes EV_ERROR/EV_SECURE/EV_LOST, returns the payload.
|
|
491
|
+
def wait!(mailbox, deadline, mutex, req, expected, secure_flags)
|
|
492
|
+
loop do
|
|
493
|
+
msg =
|
|
494
|
+
if deadline
|
|
495
|
+
remaining = deadline - Winhttp.send(:monotonic)
|
|
496
|
+
if remaining <= 0
|
|
497
|
+
mutex.synchronize { req._abort }
|
|
498
|
+
raise_timeout
|
|
499
|
+
end
|
|
500
|
+
mailbox.pop(timeout: remaining)
|
|
501
|
+
else
|
|
502
|
+
mailbox.pop
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
if msg.nil?
|
|
506
|
+
mutex.synchronize { req._abort }
|
|
507
|
+
raise_timeout
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
kind, a, b = msg
|
|
511
|
+
case kind
|
|
512
|
+
when expected
|
|
513
|
+
return a
|
|
514
|
+
when EV_SECURE
|
|
515
|
+
secure_flags << a
|
|
516
|
+
next
|
|
517
|
+
when EV_ERROR
|
|
518
|
+
mutex.synchronize { req._abort }
|
|
519
|
+
Winhttp._raise_os(api_name_for(a), b, secure_flags.last)
|
|
520
|
+
when EV_LOST
|
|
521
|
+
mutex.synchronize { req._abort }
|
|
522
|
+
raise Error, "winhttp: completion notification lost (out of memory)"
|
|
523
|
+
else
|
|
524
|
+
warn "winhttp: unexpected event #{kind} (expected #{expected})" if $VERBOSE
|
|
525
|
+
next
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def raise_timeout
|
|
531
|
+
exc = TimeoutError.new("winhttp: request exceeded the timeout deadline")
|
|
532
|
+
exc.instance_variable_set(:@code, nil)
|
|
533
|
+
raise exc
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Map WINHTTP_ASYNC_RESULT.dwResult (which API failed) to a label.
|
|
537
|
+
def api_name_for(which)
|
|
538
|
+
{
|
|
539
|
+
1 => "WinHttpReceiveResponse",
|
|
540
|
+
2 => "WinHttpQueryDataAvailable",
|
|
541
|
+
3 => "WinHttpReadData",
|
|
542
|
+
4 => "WinHttpWriteData",
|
|
543
|
+
5 => "WinHttpSendRequest"
|
|
544
|
+
}.fetch(which, "WinHttp")
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Eagerly initialize the cross-thread routing state at module load — under the
|
|
549
|
+
# GVL, single-threaded, before any request thread can race. Lazy `x ||= alloc`
|
|
550
|
+
# is NOT atomic in MRI (the allocation between ivar-read and ivar-write is a
|
|
551
|
+
# thread-switch/GC point), so two threads' first concurrent use of a shared or
|
|
552
|
+
# the default Session could each install a DIFFERENT @mailboxes Hash / @mb_lock
|
|
553
|
+
# Monitor: register_mailbox writes one, the pump reads the other, the lookup
|
|
554
|
+
# misses, the completion is dropped, and the request hangs to its deadline.
|
|
555
|
+
# Initializing here removes the check-then-act window. (@pump itself stays lazy,
|
|
556
|
+
# guarded by the now-always-present @pump_mutex.) These ivars live on the
|
|
557
|
+
# Winhttp module object, which is `self` for the `class << self` methods above.
|
|
558
|
+
@pump_mutex = Mutex.new
|
|
559
|
+
@mb_lock = Monitor.new
|
|
560
|
+
@mailboxes = {}
|
|
561
|
+
@default_mutex = Mutex.new
|
|
562
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: winhttp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ned
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake-compiler
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.2'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.2'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: vcvars
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.1'
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 0.1.1
|
|
64
|
+
type: :development
|
|
65
|
+
prerelease: false
|
|
66
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - "~>"
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '0.1'
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: 0.1.1
|
|
74
|
+
description: |
|
|
75
|
+
winhttp is a native extension that binds the asynchronous WinHTTP API into a
|
|
76
|
+
thin, hard-to-misuse Ruby HTTP client: system TLS via Schannel with the OS
|
|
77
|
+
certificate store and revocation policy, the user's proxy and PAC settings,
|
|
78
|
+
HTTP/2 negotiation, transparent gzip/deflate, safe redirect defaults, and
|
|
79
|
+
streaming downloads. Requests park the calling fiber under a Fiber scheduler
|
|
80
|
+
(e.g. winloop) and block plainly without one — one code path. Windows MSVC
|
|
81
|
+
(mswin) Ruby only.
|
|
82
|
+
executables: []
|
|
83
|
+
extensions:
|
|
84
|
+
- ext/winhttp/extconf.rb
|
|
85
|
+
extra_rdoc_files: []
|
|
86
|
+
files:
|
|
87
|
+
- CHANGELOG.md
|
|
88
|
+
- LICENSE.txt
|
|
89
|
+
- README.md
|
|
90
|
+
- ext/winhttp/extconf.rb
|
|
91
|
+
- ext/winhttp/winhttp.c
|
|
92
|
+
- lib/winhttp.rb
|
|
93
|
+
- lib/winhttp/version.rb
|
|
94
|
+
homepage: https://github.com/main-path/winhttp
|
|
95
|
+
licenses:
|
|
96
|
+
- MIT
|
|
97
|
+
metadata:
|
|
98
|
+
homepage_uri: https://github.com/main-path/winhttp
|
|
99
|
+
source_code_uri: https://github.com/main-path/winhttp
|
|
100
|
+
changelog_uri: https://github.com/main-path/winhttp/blob/main/CHANGELOG.md
|
|
101
|
+
bug_tracker_uri: https://github.com/main-path/winhttp/issues
|
|
102
|
+
rubygems_mfa_required: 'true'
|
|
103
|
+
rdoc_options: []
|
|
104
|
+
require_paths:
|
|
105
|
+
- lib
|
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '3.2'
|
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0'
|
|
116
|
+
requirements: []
|
|
117
|
+
rubygems_version: 3.6.9
|
|
118
|
+
specification_version: 4
|
|
119
|
+
summary: 'HTTP client for Ruby on the Windows OS stack: WinHTTP with system TLS, cert
|
|
120
|
+
store, and proxy.'
|
|
121
|
+
test_files: []
|