tina4ruby 3.13.137 → 3.13.138
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 +4 -4
- data/CHANGELOG.md +36 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +57 -7
- data/lib/tina4/auth.rb +18 -12
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +6 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +6 -0
- data/lib/tina4/cli.rb +22 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +215 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +196 -56
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +28 -27
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +6 -0
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +6 -0
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +85 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +6 -0
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +25 -8
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- metadata +43 -140
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
require "socket"
|
|
10
|
+
require "io/wait"
|
|
11
|
+
require "stringio"
|
|
12
|
+
require "json"
|
|
13
|
+
|
|
14
|
+
module Tina4
|
|
15
|
+
# Tina4's own HTTP/1.1 server, on stdlib `socket` only.
|
|
16
|
+
#
|
|
17
|
+
# It replaced WEBrick (development) and Puma (production) as the default, the
|
|
18
|
+
# way Python's built-in asyncio server and PHP's Tina4/Server.php are their
|
|
19
|
+
# frameworks' defaults; Puma is still used when the APPLICATION bundles it
|
|
20
|
+
# (ADR-0067). The app is any Rack-style object answering call(env) with
|
|
21
|
+
# [status, headers, body], so Tina4::RackApp runs here unchanged and any Rack
|
|
22
|
+
# server can still run Tina4.
|
|
23
|
+
#
|
|
24
|
+
# The wire rules are ADR-0068's, the same in every built-in Tina4 server:
|
|
25
|
+
#
|
|
26
|
+
# TINA4_MAX_REQUEST_HEADER head bytes before 431 (65536)
|
|
27
|
+
# TINA4_MAX_UPLOAD_SIZE declared AND running body cap (10485760)
|
|
28
|
+
# TINA4_REQUEST_TIMEOUT silent seconds before 408 (30, 0 disables)
|
|
29
|
+
#
|
|
30
|
+
# A declared Content-Length over the cap is refused BEFORE any body byte is
|
|
31
|
+
# read, the body is read in bounded chunks under a running count (chunked
|
|
32
|
+
# included), malformed framing is 400, and every rejection has one shape:
|
|
33
|
+
# a JSON {"error": ...} body with the canonical security headers and
|
|
34
|
+
# Connection: close.
|
|
35
|
+
#
|
|
36
|
+
# tina4: one thread per connection, capped at MAX_CONNECTIONS (the accept
|
|
37
|
+
# loop waits for a free slot). Swap for a reactor + worker pool if idle
|
|
38
|
+
# keep-alive connections ever need to outnumber threads.
|
|
39
|
+
class HttpServer
|
|
40
|
+
DEFAULT_REQUEST_TIMEOUT = 30
|
|
41
|
+
DEFAULT_MAX_REQUEST_HEADER = 65_536
|
|
42
|
+
MAX_CONNECTIONS = 1024
|
|
43
|
+
READ_SIZE = 16_384
|
|
44
|
+
SOFTWARE = "tina4-server"
|
|
45
|
+
|
|
46
|
+
# RFC 9110 token: what a header field name (and a method) may contain.
|
|
47
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
48
|
+
# A bare CR, LF or NUL anywhere in a request head is a smuggling attempt.
|
|
49
|
+
ILLEGAL_HEAD_BYTE = /[\r\n\0]/
|
|
50
|
+
# CR, LF or NUL in a RESPONSE header would split it into attacker-chosen
|
|
51
|
+
# headers or a second response (ADR-0068). Such a header is never written.
|
|
52
|
+
ILLEGAL_RESPONSE_VALUE = /[\r\n\0]/
|
|
53
|
+
MALFORMED_HEAD = "Malformed request head"
|
|
54
|
+
INVALID_LENGTH = "Invalid Content-Length"
|
|
55
|
+
INVALID_ENCODING = "Invalid Transfer-Encoding"
|
|
56
|
+
TIMED_OUT = "Request timed out before it was complete"
|
|
57
|
+
# After a rejection, what the client is still sending is read and thrown
|
|
58
|
+
# away for this long, so it sees the answer instead of a reset.
|
|
59
|
+
LINGER_SECONDS = 2
|
|
60
|
+
STATUSES_WITHOUT_BODY = [204, 304].freeze
|
|
61
|
+
|
|
62
|
+
Listener = Struct.new(:socket, :app, :port)
|
|
63
|
+
|
|
64
|
+
# An answer the server gives itself, before (or instead of) the app.
|
|
65
|
+
class RequestRefused < StandardError
|
|
66
|
+
attr_reader :status
|
|
67
|
+
|
|
68
|
+
def initialize(status, message)
|
|
69
|
+
super(message)
|
|
70
|
+
@status = status
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The peer went away; nothing to answer.
|
|
75
|
+
class ClientGone < StandardError; end
|
|
76
|
+
|
|
77
|
+
# The app handed the writer a header it must not write (ADR-0068 s2).
|
|
78
|
+
class UnsafeHeader < StandardError; end
|
|
79
|
+
|
|
80
|
+
attr_reader :request_timeout, :max_request_header, :max_upload_size
|
|
81
|
+
|
|
82
|
+
def initialize(server_name:)
|
|
83
|
+
@server_name = server_name
|
|
84
|
+
@listeners = []
|
|
85
|
+
@connections = {}
|
|
86
|
+
@lock = Mutex.new
|
|
87
|
+
@slot_freed = ConditionVariable.new
|
|
88
|
+
@running = false
|
|
89
|
+
@request_timeout = self.class.resolve_limit("TINA4_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT, zero_allowed: true)
|
|
90
|
+
@max_request_header = self.class.resolve_limit("TINA4_MAX_REQUEST_HEADER", DEFAULT_MAX_REQUEST_HEADER)
|
|
91
|
+
@max_upload_size = Tina4::Request.max_upload_size
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A numeric limit from the environment. A non-number or a negative value (or
|
|
95
|
+
# 0 where 0 would switch the guard off) warns and falls back, because a typo
|
|
96
|
+
# must never be the thing that disables a DoS guard. PHP Server::resolveLimit.
|
|
97
|
+
def self.resolve_limit(name, default, zero_allowed: false)
|
|
98
|
+
raw = ENV[name]
|
|
99
|
+
return default if raw.nil? || raw.strip.empty?
|
|
100
|
+
|
|
101
|
+
unless raw.strip.match?(/\A-?\d+\z/)
|
|
102
|
+
Tina4::Log.warning("#{name}=#{raw} is not a number - using #{default}")
|
|
103
|
+
return default
|
|
104
|
+
end
|
|
105
|
+
value = raw.to_i
|
|
106
|
+
if value.negative? || (value.zero? && !zero_allowed)
|
|
107
|
+
Tina4::Log.warning("#{name}=#{raw} is not a usable limit - using #{default}")
|
|
108
|
+
return default
|
|
109
|
+
end
|
|
110
|
+
value
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Bind a listener. Raises on a failed bind (the caller decides whether that
|
|
114
|
+
# is fatal - the main port is, a loopback sibling or the AI port is not).
|
|
115
|
+
def listen(host, port, app)
|
|
116
|
+
socket = TCPServer.new(host, port)
|
|
117
|
+
@listeners << Listener.new(socket, app, port.to_s)
|
|
118
|
+
socket
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Serve until #shutdown. Blocks on the FIRST listener's accept loop.
|
|
122
|
+
def start
|
|
123
|
+
@running = true
|
|
124
|
+
others = @listeners.drop(1).map { |listener| Thread.new { accept_loop(listener) } }
|
|
125
|
+
accept_loop(@listeners.first) if @listeners.first
|
|
126
|
+
others.each { |thread| thread.join(1) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Stop accepting: close every listener (a new connection is then REFUSED by
|
|
130
|
+
# the kernel) and close keep-alive connections waiting for their next
|
|
131
|
+
# request. A request already being served runs to completion; draining it is
|
|
132
|
+
# Tina4::Shutdown's job (TINA4_SHUTDOWN_TIMEOUT).
|
|
133
|
+
def shutdown
|
|
134
|
+
@running = false
|
|
135
|
+
@listeners.each do |listener|
|
|
136
|
+
listener.socket.close
|
|
137
|
+
rescue IOError, SystemCallError
|
|
138
|
+
# already closed
|
|
139
|
+
end
|
|
140
|
+
@lock.synchronize do
|
|
141
|
+
@connections.each do |socket, state|
|
|
142
|
+
next unless state == :idle
|
|
143
|
+
|
|
144
|
+
@connections[socket] = :closed
|
|
145
|
+
begin
|
|
146
|
+
socket.close
|
|
147
|
+
rescue IOError, SystemCallError
|
|
148
|
+
# already closed
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
@slot_freed.broadcast
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def running?
|
|
156
|
+
@running
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def accept_loop(listener)
|
|
162
|
+
while @running
|
|
163
|
+
wait_for_slot
|
|
164
|
+
break unless @running
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
socket = listener.socket.accept
|
|
168
|
+
rescue IOError, Errno::EBADF, Errno::EINVAL
|
|
169
|
+
break # closed by #shutdown
|
|
170
|
+
rescue Errno::ECONNABORTED, Errno::EPROTO, Errno::ECONNRESET
|
|
171
|
+
next
|
|
172
|
+
rescue Errno::EMFILE, Errno::ENFILE, Errno::ENOBUFS, Errno::ENOMEM => e
|
|
173
|
+
Tina4::Log.warning("accept failed (#{e.class}), backing off")
|
|
174
|
+
sleep 0.1
|
|
175
|
+
next
|
|
176
|
+
end
|
|
177
|
+
@lock.synchronize { @connections[socket] = :idle }
|
|
178
|
+
thread = Thread.new(socket, listener) { |client, owner| serve_connection(client, owner) }
|
|
179
|
+
thread.report_on_exception = false
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def wait_for_slot
|
|
184
|
+
@lock.synchronize do
|
|
185
|
+
@slot_freed.wait(@lock, 1) while @running && @connections.size >= MAX_CONNECTIONS
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def serve_connection(socket, listener)
|
|
190
|
+
hijacked = false
|
|
191
|
+
begin
|
|
192
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
193
|
+
rescue SystemCallError, IOError
|
|
194
|
+
# not fatal - a unix-domain or odd socket
|
|
195
|
+
end
|
|
196
|
+
peer = peer_address(socket)
|
|
197
|
+
buffer = +"".b
|
|
198
|
+
loop do
|
|
199
|
+
outcome = serve_request(socket, buffer, listener, peer)
|
|
200
|
+
hijacked = outcome == :hijacked
|
|
201
|
+
break unless outcome == :keep_alive && @running
|
|
202
|
+
end
|
|
203
|
+
rescue ClientGone, IOError, SystemCallError
|
|
204
|
+
# the peer vanished, or #shutdown closed an idle keep-alive connection
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
Tina4::Log.error("HTTP connection error: #{e.class}: #{e.message}")
|
|
207
|
+
ensure
|
|
208
|
+
unless hijacked
|
|
209
|
+
begin
|
|
210
|
+
socket.close
|
|
211
|
+
rescue IOError, SystemCallError
|
|
212
|
+
# already closed
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
@lock.synchronize do
|
|
216
|
+
@connections.delete(socket)
|
|
217
|
+
@slot_freed.signal
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The raw TCP peer. Never empty: an empty REMOTE_ADDR would count as loopback
|
|
222
|
+
# in the dev gates, so a socket that cannot name its peer is not served.
|
|
223
|
+
def peer_address(socket)
|
|
224
|
+
address = socket.remote_address.ip_address.to_s
|
|
225
|
+
raise ClientGone if address.empty?
|
|
226
|
+
|
|
227
|
+
address.start_with?("::ffff:") && address.include?(".") ? address.delete_prefix("::ffff:") : address
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Serve ONE request off the connection. Returns :keep_alive, :close or
|
|
231
|
+
# :hijacked.
|
|
232
|
+
def serve_request(socket, buffer, listener, peer)
|
|
233
|
+
head = read_head(socket, buffer)
|
|
234
|
+
return :close if head.nil?
|
|
235
|
+
|
|
236
|
+
method, target, version, headers = parse_head(head)
|
|
237
|
+
body_bytes = read_body(socket, buffer, headers, version)
|
|
238
|
+
env = build_env(method, target, version, headers, body_bytes, listener, peer)
|
|
239
|
+
keep_alive = keep_alive?(version, headers)
|
|
240
|
+
|
|
241
|
+
if Tina4::Shutdown.shutting_down?
|
|
242
|
+
send_error(socket, 503, "Service shutting down")
|
|
243
|
+
return :close
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
hijacked = false
|
|
247
|
+
env["rack.hijack?"] = true
|
|
248
|
+
env["rack.hijack"] = lambda do
|
|
249
|
+
hijacked = true
|
|
250
|
+
env["rack.hijack_io"] = socket
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
Tina4::Shutdown.track_request do
|
|
254
|
+
status, response_headers, body = call_app(listener.app, env)
|
|
255
|
+
if hijacked
|
|
256
|
+
@lock.synchronize { @connections.delete(socket) }
|
|
257
|
+
next
|
|
258
|
+
end
|
|
259
|
+
keep_alive = write_response(socket, status, response_headers, body,
|
|
260
|
+
head_request: method == "HEAD", keep_alive: keep_alive && @running,
|
|
261
|
+
http10: version == "HTTP/1.0")
|
|
262
|
+
end
|
|
263
|
+
return :hijacked if hijacked
|
|
264
|
+
|
|
265
|
+
keep_alive ? :keep_alive : :close
|
|
266
|
+
rescue RequestRefused => e
|
|
267
|
+
send_error(socket, e.status, e.message)
|
|
268
|
+
:close
|
|
269
|
+
rescue UnsafeHeader => e
|
|
270
|
+
# Nothing of that response has been written; it is replaced whole.
|
|
271
|
+
Tina4::Log.error("Refused to write response header #{e.message}: " \
|
|
272
|
+
"a header name must be a token and a value may not contain CR, LF or NUL")
|
|
273
|
+
send_error(socket, 500, "Invalid response header")
|
|
274
|
+
:close
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# A Rack app that raises gets a 500 rather than a dropped connection.
|
|
278
|
+
# (Tina4::RackApp rescues its own errors; this is for any other app.)
|
|
279
|
+
def call_app(app, env)
|
|
280
|
+
app.call(env)
|
|
281
|
+
rescue StandardError => e
|
|
282
|
+
Tina4::Log.error("Unhandled application error: #{e.class}: #{e.message}")
|
|
283
|
+
body = JSON.generate({ "error" => "Internal Server Error" })
|
|
284
|
+
[500, { "content-type" => "application/json" }, [body]]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ── reading ─────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
# Read up to the blank line that ends the head. nil when the client closed
|
|
290
|
+
# (or went idle) before sending anything - that is not a request, so it
|
|
291
|
+
# gets no answer. A client that STARTS a request and stalls gets 408.
|
|
292
|
+
def read_head(socket, buffer)
|
|
293
|
+
# RFC 9112 s2.2: ignore empty lines before a request line.
|
|
294
|
+
buffer.slice!(0, 2) while buffer.start_with?("\r\n")
|
|
295
|
+
|
|
296
|
+
if buffer.empty?
|
|
297
|
+
mark(socket, :idle)
|
|
298
|
+
return nil unless wait_for_bytes(socket, buffer, idle: true)
|
|
299
|
+
|
|
300
|
+
buffer.slice!(0, 2) while buffer.start_with?("\r\n")
|
|
301
|
+
end
|
|
302
|
+
mark(socket, :busy)
|
|
303
|
+
|
|
304
|
+
deadline = @request_timeout.positive? ? monotonic + @request_timeout : nil
|
|
305
|
+
until (head_end = buffer.index("\r\n\r\n"))
|
|
306
|
+
if buffer.bytesize > @max_request_header
|
|
307
|
+
raise RequestRefused.new(431, header_cap_message)
|
|
308
|
+
end
|
|
309
|
+
raise RequestRefused.new(408, TIMED_OUT) unless
|
|
310
|
+
wait_for_bytes(socket, buffer, deadline: deadline)
|
|
311
|
+
end
|
|
312
|
+
raise RequestRefused.new(431, header_cap_message) if head_end > @max_request_header
|
|
313
|
+
|
|
314
|
+
buffer.slice!(0, head_end + 4).byteslice(0, head_end)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Append whatever the socket has. false on timeout; ClientGone on EOF
|
|
318
|
+
# mid-request; nil-safe EOF on an idle connection returns false.
|
|
319
|
+
def wait_for_bytes(socket, buffer, idle: false, deadline: nil)
|
|
320
|
+
timeout = if idle
|
|
321
|
+
@request_timeout.positive? ? @request_timeout : nil
|
|
322
|
+
elsif deadline
|
|
323
|
+
[deadline - monotonic, 0].max
|
|
324
|
+
end
|
|
325
|
+
return false unless socket.wait_readable(timeout)
|
|
326
|
+
|
|
327
|
+
buffer << socket.read_nonblock(READ_SIZE)
|
|
328
|
+
true
|
|
329
|
+
rescue IO::WaitReadable
|
|
330
|
+
true
|
|
331
|
+
rescue EOFError, Errno::ECONNRESET
|
|
332
|
+
raise ClientGone unless idle
|
|
333
|
+
|
|
334
|
+
false
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def header_cap_message
|
|
338
|
+
"Request header fields exceed TINA4_MAX_REQUEST_HEADER (#{@max_request_header} bytes)"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The head is split on CRLF, so any CR, LF or NUL left inside a line is a
|
|
342
|
+
# bare one. A name must be a token, which also refuses obs-fold (a
|
|
343
|
+
# continuation line starting with SP/HTAB, RFC 9112 s5.2).
|
|
344
|
+
def parse_head(head)
|
|
345
|
+
lines = head.split("\r\n", -1)
|
|
346
|
+
raise RequestRefused.new(400, MALFORMED_HEAD) if lines.any? { |line| line.match?(ILLEGAL_HEAD_BYTE) }
|
|
347
|
+
|
|
348
|
+
method, target, version, extra = lines.shift.to_s.split(" ", -1)
|
|
349
|
+
unless extra.nil? && method.to_s.match?(TOKEN) && target && !target.empty? &&
|
|
350
|
+
!target.match?(/[\x00-\x20\x7F]/) && %w[HTTP/1.1 HTTP/1.0].include?(version)
|
|
351
|
+
raise RequestRefused.new(400, MALFORMED_HEAD)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
headers = lines.map do |line|
|
|
355
|
+
name, value = line.split(":", 2)
|
|
356
|
+
raise RequestRefused.new(400, MALFORMED_HEAD) if value.nil? || !name.match?(TOKEN)
|
|
357
|
+
|
|
358
|
+
[name.downcase, value.strip]
|
|
359
|
+
end
|
|
360
|
+
[method, target, version, headers]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def header_values(headers, name)
|
|
364
|
+
headers.filter_map { |key, value| value if key == name }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def read_body(socket, buffer, headers, version)
|
|
368
|
+
lengths = header_values(headers, "content-length")
|
|
369
|
+
encodings = header_values(headers, "transfer-encoding")
|
|
370
|
+
|
|
371
|
+
unless encodings.empty?
|
|
372
|
+
# Exactly "chunked", and never alongside Content-Length (smuggling).
|
|
373
|
+
unless lengths.empty? && encodings.length == 1 && encodings.first.casecmp?("chunked")
|
|
374
|
+
raise RequestRefused.new(400, INVALID_ENCODING)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
send_continue(socket, headers, version)
|
|
378
|
+
return read_chunked(socket, buffer)
|
|
379
|
+
end
|
|
380
|
+
return +"".b if lengths.empty?
|
|
381
|
+
|
|
382
|
+
# Exactly one Content-Length, all digits. Two are refused even when they
|
|
383
|
+
# agree (ADR-0068): a proxy in front may pick the other one.
|
|
384
|
+
raise RequestRefused.new(400, INVALID_LENGTH) unless lengths.length == 1 && lengths.first.match?(/\A\d+\z/)
|
|
385
|
+
|
|
386
|
+
declared = lengths.first.to_i
|
|
387
|
+
# Refused on the DECLARED length, before one body byte is read.
|
|
388
|
+
raise RequestRefused.new(413, upload_cap_message(declared)) if declared > @max_upload_size
|
|
389
|
+
|
|
390
|
+
send_continue(socket, headers, version)
|
|
391
|
+
read_exactly(socket, buffer, declared)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def upload_cap_message(bytes)
|
|
395
|
+
"Request body (#{bytes} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{@max_upload_size} bytes)"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Expect: 100-continue - the client is waiting for permission before it
|
|
399
|
+
# sends the body. Only reached when the declared size already passed.
|
|
400
|
+
def send_continue(socket, headers, version)
|
|
401
|
+
return unless version == "HTTP/1.1"
|
|
402
|
+
return unless header_values(headers, "expect").any? { |value| value.casecmp?("100-continue") }
|
|
403
|
+
|
|
404
|
+
write_fully(socket, "HTTP/1.1 100 Continue\r\n\r\n")
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Body reads use an INACTIVITY timeout: a large upload on a slow link keeps
|
|
408
|
+
# going while bytes arrive; a peer that stops sending gets 408.
|
|
409
|
+
def read_exactly(socket, buffer, length)
|
|
410
|
+
fill(socket, buffer) while buffer.bytesize < length
|
|
411
|
+
buffer.slice!(0, length)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def read_line(socket, buffer, limit)
|
|
415
|
+
until (line_end = buffer.index("\r\n"))
|
|
416
|
+
raise RequestRefused.new(400, INVALID_ENCODING) if buffer.bytesize > limit
|
|
417
|
+
|
|
418
|
+
fill(socket, buffer)
|
|
419
|
+
end
|
|
420
|
+
buffer.slice!(0, line_end + 2).byteslice(0, line_end)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def fill(socket, buffer)
|
|
424
|
+
deadline = @request_timeout.positive? ? monotonic + @request_timeout : nil
|
|
425
|
+
return if wait_for_bytes(socket, buffer, deadline: deadline)
|
|
426
|
+
|
|
427
|
+
raise RequestRefused.new(408, TIMED_OUT)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def read_chunked(socket, buffer)
|
|
431
|
+
body = +"".b
|
|
432
|
+
loop do
|
|
433
|
+
size_line = read_line(socket, buffer, 1024)
|
|
434
|
+
size_text = size_line.split(";", 2).first.to_s.strip
|
|
435
|
+
raise RequestRefused.new(400, INVALID_ENCODING) unless size_text.match?(/\A\h{1,16}\z/)
|
|
436
|
+
|
|
437
|
+
size = size_text.to_i(16)
|
|
438
|
+
break if size.zero?
|
|
439
|
+
|
|
440
|
+
# The running counter: refuse the moment the ACTUAL bytes pass the cap.
|
|
441
|
+
if body.bytesize + size > @max_upload_size
|
|
442
|
+
raise RequestRefused.new(413, upload_cap_message(body.bytesize + size))
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
body << read_exactly(socket, buffer, size)
|
|
446
|
+
raise RequestRefused.new(400, INVALID_ENCODING) unless read_exactly(socket, buffer, 2) == "\r\n"
|
|
447
|
+
end
|
|
448
|
+
# Trailer section: header lines up to an empty line, bounded like headers.
|
|
449
|
+
trailer_bytes = 0
|
|
450
|
+
loop do
|
|
451
|
+
line = read_line(socket, buffer, @max_request_header)
|
|
452
|
+
break if line.empty?
|
|
453
|
+
|
|
454
|
+
trailer_bytes += line.bytesize
|
|
455
|
+
raise RequestRefused.new(431, header_cap_message) if trailer_bytes > @max_request_header
|
|
456
|
+
end
|
|
457
|
+
body
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def keep_alive?(version, headers)
|
|
461
|
+
tokens = header_values(headers, "connection").join(",").downcase.split(",").map(&:strip)
|
|
462
|
+
return false if tokens.include?("close")
|
|
463
|
+
|
|
464
|
+
version == "HTTP/1.1" || tokens.include?("keep-alive")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# ── the Rack env ────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
def build_env(method, target, version, headers, body_bytes, listener, peer)
|
|
470
|
+
target = absolute_form_path(target)
|
|
471
|
+
raw_path, query = target.split("?", 2)
|
|
472
|
+
path_info = normalize_path(percent_decode(raw_path.to_s))
|
|
473
|
+
|
|
474
|
+
env = {
|
|
475
|
+
"REQUEST_METHOD" => method,
|
|
476
|
+
"SCRIPT_NAME" => "",
|
|
477
|
+
"PATH_INFO" => path_info,
|
|
478
|
+
"REQUEST_PATH" => raw_path.to_s,
|
|
479
|
+
"REQUEST_URI" => target,
|
|
480
|
+
"QUERY_STRING" => query.to_s,
|
|
481
|
+
"SERVER_NAME" => @server_name,
|
|
482
|
+
"SERVER_PORT" => listener.port,
|
|
483
|
+
"SERVER_PROTOCOL" => version,
|
|
484
|
+
"HTTP_VERSION" => version,
|
|
485
|
+
"SERVER_SOFTWARE" => SOFTWARE,
|
|
486
|
+
"REMOTE_ADDR" => peer,
|
|
487
|
+
"CONTENT_TYPE" => header_values(headers, "content-type").last.to_s,
|
|
488
|
+
"CONTENT_LENGTH" => body_bytes.bytesize.to_s,
|
|
489
|
+
"rack.input" => StringIO.new(body_bytes),
|
|
490
|
+
"rack.errors" => $stderr,
|
|
491
|
+
"rack.url_scheme" => "http",
|
|
492
|
+
"rack.version" => [1, 3],
|
|
493
|
+
"rack.multithread" => true,
|
|
494
|
+
"rack.multiprocess" => false,
|
|
495
|
+
"rack.run_once" => false
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
# A dashed name wins over an underscored one that maps to the same env
|
|
499
|
+
# key, whatever the order: "X_Forwarded_For" must not clobber a proxy's
|
|
500
|
+
# X-Forwarded-For.
|
|
501
|
+
dashed, underscored = headers.partition { |name, _| !name.include?("_") }
|
|
502
|
+
(dashed + underscored).each do |name, value|
|
|
503
|
+
next if name == "content-type" || name == "content-length" || name == "transfer-encoding"
|
|
504
|
+
|
|
505
|
+
key = "HTTP_#{name.upcase.tr('-', '_')}"
|
|
506
|
+
if name.include?("_") && dashed.any? { |other, _| "HTTP_#{other.upcase.tr('-', '_')}" == key }
|
|
507
|
+
next
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
env[key] = env.key?(key) ? "#{env[key]}#{name == 'cookie' ? '; ' : ', '}#{value}" : value
|
|
511
|
+
end
|
|
512
|
+
env
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def absolute_form_path(target)
|
|
516
|
+
return target unless target.match?(%r{\Ahttps?://}i)
|
|
517
|
+
|
|
518
|
+
path = target.sub(%r{\Ahttps?://[^/?]*}i, "")
|
|
519
|
+
path.start_with?("/") ? path : "/#{path}"
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def percent_decode(path)
|
|
523
|
+
path.b.gsub(/%\h\h/) { |escape| escape[1, 2].hex.chr }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# The same normalisation the previous built-in server (WEBrick) applied
|
|
527
|
+
# before routing: collapse "//", resolve "." and ".." segments, and refuse a
|
|
528
|
+
# path that climbs above the root or carries a NUL.
|
|
529
|
+
def normalize_path(path)
|
|
530
|
+
raise RequestRefused.new(400, MALFORMED_HEAD) if path.include?("\0")
|
|
531
|
+
return path if path == "*"
|
|
532
|
+
raise RequestRefused.new(400, MALFORMED_HEAD) unless path.start_with?("/")
|
|
533
|
+
|
|
534
|
+
normalized = path.gsub(%r{/+}, "/")
|
|
535
|
+
nil while normalized.sub!(%r{/\.(?:/|\z)}, "/")
|
|
536
|
+
nil while normalized.sub!(%r{/(?!\.\./)[^/]+/\.\.(?:/|\z)}, "/")
|
|
537
|
+
raise RequestRefused.new(400, MALFORMED_HEAD) if normalized.match?(%r{/\.\.(?:/|\z)})
|
|
538
|
+
|
|
539
|
+
normalized
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# ── writing ─────────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
# Returns whether the connection may be reused.
|
|
545
|
+
def write_response(socket, status, headers, body, head_request:, keep_alive:, http10:)
|
|
546
|
+
status = status.to_i
|
|
547
|
+
buffered = body.is_a?(Array)
|
|
548
|
+
no_body = head_request || STATUSES_WITHOUT_BODY.include?(status) || status < 200
|
|
549
|
+
lines = ["HTTP/1.1 #{status} #{Tina4.http_reason(status)}"]
|
|
550
|
+
declared_length = nil
|
|
551
|
+
|
|
552
|
+
header_lines(headers).each do |name, value|
|
|
553
|
+
lowered = name.downcase
|
|
554
|
+
case lowered
|
|
555
|
+
when "connection"
|
|
556
|
+
keep_alive = false if value.downcase.include?("close")
|
|
557
|
+
next
|
|
558
|
+
when "transfer-encoding"
|
|
559
|
+
next # framing is the server's, never the app's
|
|
560
|
+
when "content-length"
|
|
561
|
+
declared_length = value
|
|
562
|
+
next
|
|
563
|
+
end
|
|
564
|
+
lines << "#{name}: #{value}"
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
chunks = buffered ? body.map(&:to_s) : nil
|
|
568
|
+
if status < 200 || STATUSES_WITHOUT_BODY.include?(status)
|
|
569
|
+
# 304 may state the representation's length; 1xx and 204 never do.
|
|
570
|
+
lines << "content-length: #{declared_length}" if declared_length && status == 304
|
|
571
|
+
elsif buffered
|
|
572
|
+
# HEAD reports the length the GET would have sent (the app computed it).
|
|
573
|
+
length_value = head_request && declared_length ? declared_length : chunks.sum(&:bytesize).to_s
|
|
574
|
+
lines << "content-length: #{length_value}"
|
|
575
|
+
elsif declared_length
|
|
576
|
+
lines << "content-length: #{declared_length}"
|
|
577
|
+
else
|
|
578
|
+
# A stream of unknown length is delimited by closing the connection
|
|
579
|
+
# (PHP streamToClient / Python's built-in server do the same).
|
|
580
|
+
keep_alive = false unless no_body
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
if keep_alive
|
|
584
|
+
lines << "connection: keep-alive" if http10
|
|
585
|
+
else
|
|
586
|
+
lines << "connection: close"
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
head = "#{lines.join("\r\n")}\r\n\r\n".b
|
|
590
|
+
if no_body
|
|
591
|
+
write_fully(socket, head)
|
|
592
|
+
elsif buffered
|
|
593
|
+
payload = chunks.sum(&:bytesize) <= 65_536 ? head + chunks.join.b : nil
|
|
594
|
+
if payload
|
|
595
|
+
write_fully(socket, payload)
|
|
596
|
+
else
|
|
597
|
+
write_fully(socket, head)
|
|
598
|
+
chunks.each { |chunk| write_fully(socket, chunk.b) }
|
|
599
|
+
end
|
|
600
|
+
else
|
|
601
|
+
write_fully(socket, head)
|
|
602
|
+
body.each { |chunk| write_fully(socket, chunk.to_s.b) unless chunk.to_s.empty? }
|
|
603
|
+
end
|
|
604
|
+
keep_alive
|
|
605
|
+
ensure
|
|
606
|
+
body.close if body.respond_to?(:close)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Every [name, value] line to write, checked BEFORE any byte goes out. An
|
|
610
|
+
# Array value (Rack 3) or a "\n"-joined Set-Cookie (Rack 2, and
|
|
611
|
+
# Tina4::Response#to_rack) is one line per element. A name that is not a
|
|
612
|
+
# token, or a value carrying CR, LF or NUL, raises UnsafeHeader (ADR-0068):
|
|
613
|
+
# it would let the value choose its own headers - or a second response.
|
|
614
|
+
# Response#header refuses these at the call site already; this catches a
|
|
615
|
+
# header appended to the hash directly.
|
|
616
|
+
def header_lines(headers)
|
|
617
|
+
headers.flat_map do |name, value|
|
|
618
|
+
name = name.to_s
|
|
619
|
+
next [] if name.start_with?("rack.")
|
|
620
|
+
|
|
621
|
+
values = if value.is_a?(Array)
|
|
622
|
+
value.map(&:to_s)
|
|
623
|
+
elsif name.casecmp?("set-cookie")
|
|
624
|
+
value.to_s.split("\n")
|
|
625
|
+
else
|
|
626
|
+
[value.to_s]
|
|
627
|
+
end
|
|
628
|
+
values.map do |single|
|
|
629
|
+
raise UnsafeHeader, JSON.generate(name) if !name.match?(TOKEN) || single.match?(ILLEGAL_RESPONSE_VALUE)
|
|
630
|
+
|
|
631
|
+
[name, single]
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Write every byte, or give up when the peer stops reading for
|
|
637
|
+
# TINA4_REQUEST_TIMEOUT (a slow reader must not hold a thread forever).
|
|
638
|
+
def write_fully(socket, data)
|
|
639
|
+
data = data.b
|
|
640
|
+
offset = 0
|
|
641
|
+
while offset < data.bytesize
|
|
642
|
+
begin
|
|
643
|
+
offset += socket.write_nonblock(data.byteslice(offset, data.bytesize - offset))
|
|
644
|
+
rescue IO::WaitWritable
|
|
645
|
+
timeout = @request_timeout.positive? ? @request_timeout : nil
|
|
646
|
+
raise ClientGone unless socket.wait_writable(timeout)
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
650
|
+
raise ClientGone
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# A transport rejection, in ADR-0068's one shape: the JSON {"error": ...}
|
|
654
|
+
# body, the canonical security headers (no HSTS - the scheme is not known
|
|
655
|
+
# yet) and Connection: close. The close is a lingering one - stop writing,
|
|
656
|
+
# throw away what the client is still sending for up to LINGER_SECONDS -
|
|
657
|
+
# so a client mid-upload reads the answer instead of a reset.
|
|
658
|
+
def send_error(socket, status, message)
|
|
659
|
+
body = JSON.generate({ "error" => message })
|
|
660
|
+
lines = ["HTTP/1.1 #{status} #{Tina4.http_reason(status)}",
|
|
661
|
+
"Content-Type: application/json",
|
|
662
|
+
"Content-Length: #{body.bytesize}",
|
|
663
|
+
"Connection: close"]
|
|
664
|
+
Tina4::SecurityHeadersMiddleware.canonical_headers.each do |name, value|
|
|
665
|
+
lines << "#{name}: #{value}" unless value.to_s.match?(ILLEGAL_RESPONSE_VALUE)
|
|
666
|
+
end
|
|
667
|
+
write_fully(socket, "#{lines.join("\r\n")}\r\n\r\n#{body}")
|
|
668
|
+
socket.close_write
|
|
669
|
+
deadline = monotonic + LINGER_SECONDS
|
|
670
|
+
discard = +"".b # one reused buffer: draining must not allocate per read
|
|
671
|
+
while (remaining = deadline - monotonic).positive? && socket.wait_readable(remaining)
|
|
672
|
+
socket.read_nonblock(READ_SIZE, discard)
|
|
673
|
+
end
|
|
674
|
+
rescue ClientGone, IOError, SystemCallError, IO::WaitReadable
|
|
675
|
+
# the peer is gone - nothing more to say
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def mark(socket, state)
|
|
679
|
+
@lock.synchronize do
|
|
680
|
+
raise ClientGone if @connections[socket] == :closed
|
|
681
|
+
|
|
682
|
+
@connections[socket] = state
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def monotonic
|
|
687
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|