puma-plus 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/exe/puma-plus +12 -0
- data/exe/puma-plus-worker +79 -0
- data/ext/puma_plus_gvl/extconf.rb +16 -0
- data/ext/puma_plus_gvl/puma_plus_gvl.c +160 -0
- data/lib/puma_plus/address.rb +33 -0
- data/lib/puma_plus/app_loader.rb +66 -0
- data/lib/puma_plus/config_file.rb +267 -0
- data/lib/puma_plus/control_channel.rb +138 -0
- data/lib/puma_plus/gvl.rb +76 -0
- data/lib/puma_plus/hooks.rb +88 -0
- data/lib/puma_plus/input.rb +216 -0
- data/lib/puma_plus/launcher.rb +347 -0
- data/lib/puma_plus/rack_env.rb +92 -0
- data/lib/puma_plus/ractor_worker.rb +227 -0
- data/lib/puma_plus/shepherd.rb +234 -0
- data/lib/puma_plus/version.rb +5 -0
- data/lib/puma_plus/wire.rb +236 -0
- data/lib/puma_plus/worker.rb +90 -0
- data/lib/puma_plus/worker_thread.rb +331 -0
- data/lib/puma_plus/ws.rb +233 -0
- data/lib/puma_plus/ws_event.rb +78 -0
- metadata +85 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "puma_plus/wire"
|
|
5
|
+
require "puma_plus/rack_env"
|
|
6
|
+
require "puma_plus/input"
|
|
7
|
+
require "puma_plus/gvl"
|
|
8
|
+
|
|
9
|
+
module PumaPlus
|
|
10
|
+
# One Ruby thread serving one connection to the Go server.
|
|
11
|
+
#
|
|
12
|
+
# This replaces the seam puma occupies with Server#process_client plus
|
|
13
|
+
# Response#handle_request. There is no thread pool and no reactor: the thread
|
|
14
|
+
# dials in, blocks on read, serves one request, and blocks again. Because the
|
|
15
|
+
# connection carries exactly one request at a time, nothing here needs a mutex
|
|
16
|
+
# or a demultiplexer.
|
|
17
|
+
#
|
|
18
|
+
# An idle connection *is* the capacity signal the Go dispatcher counts, so
|
|
19
|
+
# "blocked in read" and "available" are the same state -- there is no separate
|
|
20
|
+
# bookkeeping to drift out of sync.
|
|
21
|
+
class WorkerThread
|
|
22
|
+
MONOTONIC = Process::CLOCK_MONOTONIC
|
|
23
|
+
THREAD_CPU = Process::CLOCK_THREAD_CPUTIME_ID
|
|
24
|
+
|
|
25
|
+
def initialize(socket_path:, app:, worker_id:, thread_index:,
|
|
26
|
+
multithread: true, multiprocess: false, logger: $stderr)
|
|
27
|
+
@socket_path = socket_path
|
|
28
|
+
@app = app
|
|
29
|
+
@worker_id = worker_id
|
|
30
|
+
@thread_index = thread_index
|
|
31
|
+
@logger = logger
|
|
32
|
+
@env_builder = RackEnv.new(multithread: multithread, multiprocess: multiprocess)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
conn = UNIXSocket.new(@socket_path)
|
|
37
|
+
conn.sync = true
|
|
38
|
+
hello(conn)
|
|
39
|
+
|
|
40
|
+
loop do
|
|
41
|
+
begin
|
|
42
|
+
type, payload = Wire.read_frame(conn)
|
|
43
|
+
rescue Wire::Closed
|
|
44
|
+
break
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
case type
|
|
48
|
+
when Wire::REQUEST
|
|
49
|
+
break if serve(conn, payload) == :hijacked
|
|
50
|
+
when Wire::GOAWAY
|
|
51
|
+
# Sent only to an idle conn, so there is never in-flight work to lose.
|
|
52
|
+
break
|
|
53
|
+
else
|
|
54
|
+
@logger.puts "[puma-plus] unexpected frame #{Wire.type_name(type)}; closing"
|
|
55
|
+
break
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
rescue Errno::ECONNRESET, Errno::EPIPE => e
|
|
59
|
+
@logger.puts "[puma-plus] worker #{@worker_id}/#{@thread_index} lost the server: #{e.class}"
|
|
60
|
+
ensure
|
|
61
|
+
conn&.close
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# The readiness signal. Sent only once the Rack app is loaded and callable,
|
|
67
|
+
# so the Go side never has to poll for readiness.
|
|
68
|
+
def hello(conn)
|
|
69
|
+
conn.write Wire.frame(Wire::HELLO, Wire.encode_kv([
|
|
70
|
+
["version", Wire::VERSION],
|
|
71
|
+
["role", "data"],
|
|
72
|
+
["pid", Process.pid],
|
|
73
|
+
["worker_id", @worker_id],
|
|
74
|
+
["thread_index", @thread_index]
|
|
75
|
+
]))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def serve(conn, payload)
|
|
79
|
+
meta, offset = Wire.decode_request_meta(payload)
|
|
80
|
+
pairs, offset = Wire.decode_kv(payload, offset)
|
|
81
|
+
body = payload.byteslice(offset, payload.bytesize - offset)
|
|
82
|
+
|
|
83
|
+
env = @env_builder.build(pairs, meta)
|
|
84
|
+
input = Input.for(meta[:body_mode], body, conn)
|
|
85
|
+
env["rack.input"] = input
|
|
86
|
+
|
|
87
|
+
# Rack full hijack. Calling env['rack.hijack'] hands the application the
|
|
88
|
+
# raw socket; from that moment Go stops framing and splices bytes straight
|
|
89
|
+
# to the client, so the app owns the whole conversation including writing
|
|
90
|
+
# its own status line. That is what makes WebSockets work without
|
|
91
|
+
# puma-plus knowing anything about WebSockets.
|
|
92
|
+
hijack = Hijack.new(conn, env)
|
|
93
|
+
env["rack.hijack"] = hijack.to_proc
|
|
94
|
+
|
|
95
|
+
status = headers = app_body = nil
|
|
96
|
+
error = nil
|
|
97
|
+
|
|
98
|
+
wall0 = Process.clock_gettime(MONOTONIC, :nanosecond)
|
|
99
|
+
cpu0 = Process.clock_gettime(THREAD_CPU, :nanosecond)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
status, headers, app_body = @app.call(env)
|
|
104
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
105
|
+
error = e
|
|
106
|
+
log_error(e, env)
|
|
107
|
+
status, headers, app_body = lowlevel_error(e)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A hijacked connection is no longer ours: no drain, no response, no
|
|
111
|
+
# RESP_END. The socket is the tunnel and the app has finished with it.
|
|
112
|
+
return :hijacked if hijack.hijacked?
|
|
113
|
+
|
|
114
|
+
# Consume anything the app left unread BEFORE writing the response. Go
|
|
115
|
+
# streams body frames concurrently with the app running, so an early
|
|
116
|
+
# return leaves frames in flight; sending RESP_END on top of them would
|
|
117
|
+
# desynchronise the connection and the next request would read a body
|
|
118
|
+
# chunk where it expected a REQUEST. Protocol invariant, not politeness.
|
|
119
|
+
input.drain if input.respond_to?(:drain)
|
|
120
|
+
|
|
121
|
+
service_ns = Process.clock_gettime(MONOTONIC, :nanosecond) - wall0
|
|
122
|
+
# The controller derives io_fraction = 1 - cpu_ns/service_ns from this pair.
|
|
123
|
+
# Two clock reads per request, and it is the cheapest honest signal for
|
|
124
|
+
# "would another thread actually help, or is the GVL the bottleneck".
|
|
125
|
+
cpu_ns = Process.clock_gettime(THREAD_CPU, :nanosecond) - cpu0
|
|
126
|
+
|
|
127
|
+
# Time blocked waiting on the uploader is not the application being slow.
|
|
128
|
+
# Reporting it separately lets the controller subtract it, so a slow client
|
|
129
|
+
# cannot masquerade as a workload that needs more capacity.
|
|
130
|
+
body_read_ns = input.respond_to?(:read_ns) ? input.read_ns : 0
|
|
131
|
+
|
|
132
|
+
# This PROCESS's cumulative GVL wait, not a per-request delta. Go
|
|
133
|
+
# differences it between observations and divides by available
|
|
134
|
+
# thread-time.
|
|
135
|
+
#
|
|
136
|
+
# A per-request delta would measure the wrong window: a worker thread does
|
|
137
|
+
# most of its waiting before the handler starts, while waking from the
|
|
138
|
+
# socket read and queueing for the GVL. Measured under pure CPU load the
|
|
139
|
+
# delta reported 0.03 where the truth was near 0.80.
|
|
140
|
+
gvl_wait_ns = GVL.wait_ns
|
|
141
|
+
|
|
142
|
+
write_response(conn, status, headers, app_body, service_ns, cpu_ns,
|
|
143
|
+
body_read_ns, gvl_wait_ns)
|
|
144
|
+
:ok
|
|
145
|
+
ensure
|
|
146
|
+
# Ordering transcribed from puma/lib/puma/response.rb:109-131: close the
|
|
147
|
+
# body, then rack.after_reply in order, then rack.response_finished in
|
|
148
|
+
# reverse with (env, status, headers, error). Each callback is rescued
|
|
149
|
+
# individually so one bad hook cannot poison the rest or kill the thread.
|
|
150
|
+
app_body.close if app_body.respond_to?(:close)
|
|
151
|
+
run_hooks(env&.[]("rack.after_reply")) { |o| o.call }
|
|
152
|
+
run_hooks(env&.[]("rack.response_finished")&.reverse) do |o|
|
|
153
|
+
o.call(env, status, headers, error)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def write_response(conn, status, headers, body, service_ns, cpu_ns,
|
|
158
|
+
body_read_ns, gvl_wait_ns = 0)
|
|
159
|
+
pairs = []
|
|
160
|
+
headers&.each do |key, value|
|
|
161
|
+
# Rack 3 permits array-valued headers, and a String value may still carry
|
|
162
|
+
# embedded newlines from Rack 2-era middleware. Both become repeated kv
|
|
163
|
+
# entries, which Go turns back into separate header lines -- this is what
|
|
164
|
+
# keeps multiple Set-Cookie headers intact.
|
|
165
|
+
if value.is_a?(Array)
|
|
166
|
+
value.each { |v| pairs << [key, v] }
|
|
167
|
+
elsif value.is_a?(String) && value.include?("\n")
|
|
168
|
+
value.split("\n").each { |v| pairs << [key, v] }
|
|
169
|
+
else
|
|
170
|
+
pairs << [key, value]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
conn.write Wire.frame(Wire::RESPONSE, [status.to_i].pack("n") << Wire.encode_kv(pairs))
|
|
175
|
+
write_body(conn, body)
|
|
176
|
+
|
|
177
|
+
conn.write Wire.frame(Wire::RESP_END,
|
|
178
|
+
Wire.encode_resp_end_meta(
|
|
179
|
+
service_ns: service_ns,
|
|
180
|
+
cpu_ns: cpu_ns,
|
|
181
|
+
body_read_ns: body_read_ns,
|
|
182
|
+
gvl_wait_ns: gvl_wait_ns
|
|
183
|
+
) << Wire.encode_kv([]))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Emit the response body as RESP_CHUNK frames.
|
|
187
|
+
#
|
|
188
|
+
# Each yielded chunk becomes one frame, and Go flushes each frame straight to
|
|
189
|
+
# the client, so an SSE or long-poll body reaches the browser as the app
|
|
190
|
+
# produces it. This is the puma-dev bug we are deliberately not repeating:
|
|
191
|
+
# its reverse proxy sets FlushInterval to 1 second (puma-dev/dev/http.go:63),
|
|
192
|
+
# which batches event streams into one-second hiccups.
|
|
193
|
+
def write_body(conn, body)
|
|
194
|
+
return if body.nil?
|
|
195
|
+
|
|
196
|
+
# Rack 3 streaming body: an object responding to #call(stream) rather than
|
|
197
|
+
# #each. This is how Rack 3 apps do SSE without hijacking.
|
|
198
|
+
if !body.respond_to?(:each) && body.respond_to?(:call)
|
|
199
|
+
body.call(ChunkWriter.new(conn))
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if body.respond_to?(:each)
|
|
204
|
+
body.each do |chunk|
|
|
205
|
+
next if chunk.nil? || chunk.empty?
|
|
206
|
+
|
|
207
|
+
conn.write Wire.frame(Wire::RESP_CHUNK, chunk)
|
|
208
|
+
end
|
|
209
|
+
else
|
|
210
|
+
conn.write Wire.frame(Wire::RESP_CHUNK, body.to_s)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The `stream` object handed to a Rack 3 streaming body.
|
|
215
|
+
#
|
|
216
|
+
# The Rack SPEC requires read, write, <<, flush, close, close_read,
|
|
217
|
+
# close_write and closed?, and says their semantics "must be a best effort
|
|
218
|
+
# match to those of a normal Ruby IO or Socket object, using standard
|
|
219
|
+
# arguments and raising standard exceptions" (rack SPEC.rdoc:252). So this
|
|
220
|
+
# behaves like a Ruby IO opened for writing only -- including raising the
|
|
221
|
+
# same errors that one does, rather than inventing gentler ones.
|
|
222
|
+
#
|
|
223
|
+
# Rack::Lint enforces only that the methods exist, which is how an earlier
|
|
224
|
+
# version passed every other check while omitting three of them and failing
|
|
225
|
+
# the moment a streaming body was actually used.
|
|
226
|
+
class ChunkWriter
|
|
227
|
+
def initialize(conn)
|
|
228
|
+
@conn = conn
|
|
229
|
+
@closed = false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# IO#write takes any number of arguments and returns the total bytes.
|
|
233
|
+
def write(*chunks)
|
|
234
|
+
raise IOError, "closed stream" if @closed
|
|
235
|
+
|
|
236
|
+
total = 0
|
|
237
|
+
chunks.each do |chunk|
|
|
238
|
+
data = chunk.to_s
|
|
239
|
+
next if data.empty?
|
|
240
|
+
|
|
241
|
+
@conn.write Wire.frame(Wire::RESP_CHUNK, data)
|
|
242
|
+
total += data.bytesize
|
|
243
|
+
end
|
|
244
|
+
total
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# IO#<< returns self so it can be chained, where #write returns a count.
|
|
248
|
+
# Aliasing the two would break `stream << a << b`.
|
|
249
|
+
def <<(chunk)
|
|
250
|
+
write(chunk)
|
|
251
|
+
self
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Nothing is buffered on this side: every write is already a frame on the
|
|
255
|
+
# wire, which is the point (puma-dev batches SSE into one-second hiccups
|
|
256
|
+
# with a 1s FlushInterval). IO#flush returns self.
|
|
257
|
+
def flush = self
|
|
258
|
+
|
|
259
|
+
# Write-only, so reading raises exactly what a Ruby IO opened "w" raises.
|
|
260
|
+
# Full-duplex streaming would mean interleaving BODY_CHUNK reads with
|
|
261
|
+
# RESP_CHUNK writes on one connection, which the protocol's drain
|
|
262
|
+
# invariant (consume the body to BODY_END before RESP_END) forbids.
|
|
263
|
+
def read(*)
|
|
264
|
+
raise IOError, "not opened for reading"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def close_read
|
|
268
|
+
raise IOError, "closing non-duplex IO for reading"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Closing the stream ends the body but must NOT close the connection: the
|
|
272
|
+
# connection outlives the request and goes back to the idle pool. RESP_END
|
|
273
|
+
# is what terminates the body, and write_response sends it.
|
|
274
|
+
def close
|
|
275
|
+
@closed = true
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
alias close_write close
|
|
279
|
+
|
|
280
|
+
def closed? = @closed
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def run_hooks(hooks)
|
|
284
|
+
return unless hooks
|
|
285
|
+
|
|
286
|
+
hooks.each do |hook|
|
|
287
|
+
yield hook
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
@logger.puts "[puma-plus] hook error: #{e.class}: #{e.message}"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The object behind env['rack.hijack'].
|
|
294
|
+
#
|
|
295
|
+
# Per the Rack SPEC, calling it returns the IO and also sets
|
|
296
|
+
# env['rack.hijack_io']. Sending the HIJACK frame first is what tells Go to
|
|
297
|
+
# stop expecting frames -- if the app wrote to the socket before that frame
|
|
298
|
+
# went out, Go would try to parse the app's bytes as a response frame.
|
|
299
|
+
class Hijack
|
|
300
|
+
def initialize(conn, env)
|
|
301
|
+
@conn = conn
|
|
302
|
+
@env = env
|
|
303
|
+
@hijacked = false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def hijacked? = @hijacked
|
|
307
|
+
|
|
308
|
+
def to_proc
|
|
309
|
+
method(:call).to_proc
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def call
|
|
313
|
+
return @conn if @hijacked
|
|
314
|
+
|
|
315
|
+
@conn.write Wire.frame(Wire::HIJACK)
|
|
316
|
+
@hijacked = true
|
|
317
|
+
@env["rack.hijack_io"] = @conn
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def lowlevel_error(_error)
|
|
322
|
+
[500, { "content-type" => "text/plain" }, ["Internal Server Error\n"]]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def log_error(error, env)
|
|
326
|
+
@logger.puts "[puma-plus] #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}: " \
|
|
327
|
+
"#{error.class}: #{error.message}"
|
|
328
|
+
@logger.puts(error.backtrace&.first(10)&.map { |l| " #{l}" }&.join("\n"))
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
data/lib/puma_plus/ws.rb
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "puma_plus/wire"
|
|
6
|
+
|
|
7
|
+
module PumaPlus
|
|
8
|
+
# Outbound WebSocket API.
|
|
9
|
+
#
|
|
10
|
+
# The application never touches a socket. Go owns every WebSocket connection;
|
|
11
|
+
# this module sends it commands over a per-process control channel, which means
|
|
12
|
+
# the app can address a connection at any time from any thread -- not only
|
|
13
|
+
# while handling a message from it.
|
|
14
|
+
#
|
|
15
|
+
# PumaPlus::WS.send(conn_id, "pong")
|
|
16
|
+
# PumaPlus::WS.subscribe(conn_id, "room:42")
|
|
17
|
+
# PumaPlus::WS.publish("room:42", payload) # one call -> N sockets
|
|
18
|
+
# PumaPlus::WS.close(conn_id, code: 1000)
|
|
19
|
+
#
|
|
20
|
+
# #publish is the reason this exists. Go does the fan-out on goroutines, so a
|
|
21
|
+
# broadcast to ten thousand subscribers costs the application exactly one frame
|
|
22
|
+
# instead of ten thousand. Doing that in Ruby is what currently pushes
|
|
23
|
+
# ActionCable into a separate process with Redis behind it.
|
|
24
|
+
module WS
|
|
25
|
+
class NotConnected < StandardError; end
|
|
26
|
+
|
|
27
|
+
TEXT = "text"
|
|
28
|
+
BINARY = "binary"
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Called once per worker process, before any threads start.
|
|
32
|
+
def connect!(socket_path, logger: $stderr)
|
|
33
|
+
@logger = logger
|
|
34
|
+
@socket_path = socket_path
|
|
35
|
+
@monitor = Monitor.new
|
|
36
|
+
@pending = {}
|
|
37
|
+
@seq = 0
|
|
38
|
+
@conn = UNIXSocket.new(socket_path)
|
|
39
|
+
@conn.sync = true
|
|
40
|
+
@conn.write Wire.frame(Wire::HELLO, Wire.encode_kv([
|
|
41
|
+
["version", Wire::VERSION],
|
|
42
|
+
["role", "ws"],
|
|
43
|
+
["pid", Process.pid]
|
|
44
|
+
]))
|
|
45
|
+
start_reader
|
|
46
|
+
true
|
|
47
|
+
rescue SystemCallError => e
|
|
48
|
+
@conn = nil
|
|
49
|
+
logger.puts "[puma-plus] ws control channel unavailable: #{e.class}: #{e.message}"
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Is the control channel usable?
|
|
54
|
+
def connected? = !@conn.nil?
|
|
55
|
+
|
|
56
|
+
# Send a reliable, ordered message to one connection.
|
|
57
|
+
#
|
|
58
|
+
# +stream:+ targets one stream of a WebTransport session. Without it the
|
|
59
|
+
# message goes to the connection's default channel, which is all a
|
|
60
|
+
# WebSocket has anyway -- so code that ignores streams works on both.
|
|
61
|
+
def send(conn_id, payload, opcode: TEXT, stream: nil)
|
|
62
|
+
args = { "conn_id" => conn_id, "opcode" => opcode }
|
|
63
|
+
args["stream_id"] = stream if stream
|
|
64
|
+
command(Wire::WS_SEND, args, payload)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Send a best-effort datagram to one connection. WebTransport only.
|
|
68
|
+
#
|
|
69
|
+
# Deliberately a separate method from #send rather than a keyword on it.
|
|
70
|
+
# A datagram may be dropped, duplicated or delivered out of order; that is
|
|
71
|
+
# a fundamentally different contract, and a one-word argument is far too
|
|
72
|
+
# easy to copy between call sites without noticing the guarantee changed.
|
|
73
|
+
#
|
|
74
|
+
# Silently does nothing for a WebSocket client, which has no unreliable
|
|
75
|
+
# channel. Check PumaPlus::WSEvent#transport if you need to know.
|
|
76
|
+
def datagram(conn_id, payload)
|
|
77
|
+
command(Wire::WS_DATAGRAM, { "conn_id" => conn_id }, payload)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Open a server-initiated stream on a WebTransport session.
|
|
81
|
+
# Returns the new stream id, or nil if the transport has no streams.
|
|
82
|
+
def open_stream(conn_id)
|
|
83
|
+
id = query_raw(Wire::WS_OPEN_STREAM, "conn_id" => conn_id)
|
|
84
|
+
id.nil? || id.empty? ? nil : id
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Broadcast to every subscriber of a topic.
|
|
88
|
+
#
|
|
89
|
+
# One frame regardless of how many subscribers there are; Go performs the
|
|
90
|
+
# writes. Returns nil rather than a delivery count because waiting for one
|
|
91
|
+
# would serialise the caller behind every recipient's socket -- use
|
|
92
|
+
# #subscribers if you need the number.
|
|
93
|
+
def publish(topic, payload, opcode: TEXT)
|
|
94
|
+
command(Wire::WS_PUBLISH, { "topic" => topic, "opcode" => opcode }, payload)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Broadcast a best-effort datagram to a topic. WebTransport subscribers
|
|
98
|
+
# receive it; WebSocket subscribers are skipped, since they have no
|
|
99
|
+
# unreliable channel.
|
|
100
|
+
#
|
|
101
|
+
# WS.publish("room:42", chat) # reliable -- everyone
|
|
102
|
+
# WS.publish_datagram("game:7", pos) # lossy -- WebTransport only
|
|
103
|
+
#
|
|
104
|
+
# The asymmetry is the point: one of those is safe for chat, the other is
|
|
105
|
+
# not.
|
|
106
|
+
def publish_datagram(topic, payload)
|
|
107
|
+
command(Wire::WS_PUBLISH_DATAGRAM, { "topic" => topic }, payload)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Which transport a connection is using: "websocket" or "webtransport".
|
|
111
|
+
def transport(conn_id)
|
|
112
|
+
query("transport", "conn_id" => conn_id)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def subscribe(conn_id, topic)
|
|
116
|
+
command(Wire::WS_SUBSCRIBE, { "conn_id" => conn_id, "topic" => topic })
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def unsubscribe(conn_id, topic)
|
|
120
|
+
command(Wire::WS_UNSUBSCRIBE, { "conn_id" => conn_id, "topic" => topic })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def close(conn_id, code: 1000, reason: "")
|
|
124
|
+
command(Wire::WS_CLOSE,
|
|
125
|
+
{ "conn_id" => conn_id, "code" => code.to_s, "reason" => reason })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# How many connections are subscribed to a topic. Synchronous.
|
|
129
|
+
def subscribers(topic)
|
|
130
|
+
query("subscribers", "topic" => topic).to_i
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Total live WebSocket connections across the whole server, not just this
|
|
134
|
+
# worker -- Go holds them all.
|
|
135
|
+
def connections
|
|
136
|
+
query("connections").to_i
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Is this connection still open?
|
|
140
|
+
def open?(conn_id)
|
|
141
|
+
query("exists", "conn_id" => conn_id) == "true"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def command(type, args, payload = nil)
|
|
147
|
+
raise NotConnected, "ws control channel is not connected" unless @conn
|
|
148
|
+
|
|
149
|
+
frame = Wire.frame(type, Wire.encode_kv(args.to_a) + (payload ? payload.to_s.b : ""))
|
|
150
|
+
# One writer at a time: frames must not interleave on the wire, and any
|
|
151
|
+
# thread may be publishing.
|
|
152
|
+
@monitor.synchronize { @conn.write(frame) }
|
|
153
|
+
nil
|
|
154
|
+
rescue IOError, SystemCallError => e
|
|
155
|
+
@conn = nil
|
|
156
|
+
raise NotConnected, "ws control channel lost: #{e.class}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Synchronous request/reply on a command frame that answers directly,
|
|
160
|
+
# rather than through the generic query path.
|
|
161
|
+
def query_raw(type, extra = {})
|
|
162
|
+
raise NotConnected, "ws control channel is not connected" unless @conn
|
|
163
|
+
|
|
164
|
+
seq = nil
|
|
165
|
+
q = Queue.new
|
|
166
|
+
@monitor.synchronize do
|
|
167
|
+
@seq += 1
|
|
168
|
+
seq = @seq.to_s
|
|
169
|
+
@pending[seq] = q
|
|
170
|
+
@conn.write Wire.frame(type, Wire.encode_kv([["seq", seq]] + extra.to_a))
|
|
171
|
+
end
|
|
172
|
+
begin
|
|
173
|
+
Timeout.timeout(5) { q.pop }
|
|
174
|
+
rescue Timeout::Error
|
|
175
|
+
@monitor.synchronize { @pending.delete(seq) }
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Synchronous request/reply, correlated by sequence number so many threads
|
|
181
|
+
# can have questions outstanding on the one channel.
|
|
182
|
+
def query(kind, extra = {})
|
|
183
|
+
raise NotConnected, "ws control channel is not connected" unless @conn
|
|
184
|
+
|
|
185
|
+
seq = nil
|
|
186
|
+
q = Queue.new
|
|
187
|
+
@monitor.synchronize do
|
|
188
|
+
@seq += 1
|
|
189
|
+
seq = @seq.to_s
|
|
190
|
+
@pending[seq] = q
|
|
191
|
+
@conn.write Wire.frame(Wire::WS_QUERY,
|
|
192
|
+
Wire.encode_kv([["seq", seq], ["kind", kind]] + extra.to_a))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Bounded wait: a lost reply must not park an application thread forever.
|
|
196
|
+
result = nil
|
|
197
|
+
begin
|
|
198
|
+
Timeout.timeout(5) { result = q.pop }
|
|
199
|
+
rescue Timeout::Error
|
|
200
|
+
@monitor.synchronize { @pending.delete(seq) }
|
|
201
|
+
raise NotConnected, "ws query #{kind} timed out"
|
|
202
|
+
end
|
|
203
|
+
result
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def start_reader
|
|
207
|
+
Thread.new do
|
|
208
|
+
Thread.current.name = "puma-plus ws-control"
|
|
209
|
+
loop do
|
|
210
|
+
type, payload = Wire.read_frame(@conn)
|
|
211
|
+
next unless type == Wire::WS_QUERY_REPLY
|
|
212
|
+
|
|
213
|
+
pairs, = Wire.decode_kv(payload)
|
|
214
|
+
fields = pairs.to_h
|
|
215
|
+
seq = fields["seq"]
|
|
216
|
+
waiter = @monitor.synchronize { @pending.delete(seq) }
|
|
217
|
+
waiter&.push(fields["result"])
|
|
218
|
+
rescue Wire::Closed, IOError, SystemCallError
|
|
219
|
+
break
|
|
220
|
+
end
|
|
221
|
+
# Wake anything still waiting rather than leaving it parked forever.
|
|
222
|
+
@monitor.synchronize do
|
|
223
|
+
@pending.each_value { |w| w.push(nil) }
|
|
224
|
+
@pending.clear
|
|
225
|
+
@conn = nil
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
require "timeout"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaPlus
|
|
4
|
+
# Convenience wrapper over the WebSocket keys in a Rack env.
|
|
5
|
+
#
|
|
6
|
+
# A WebSocket event arrives as an ordinary Rack call with REQUEST_METHOD set to
|
|
7
|
+
# WEBSOCKET, so existing routing and middleware work unchanged. This just saves
|
|
8
|
+
# the app from string-keyed env lookups.
|
|
9
|
+
class WSEvent
|
|
10
|
+
def self.websocket?(env) = env["REQUEST_METHOD"] == "WEBSOCKET"
|
|
11
|
+
|
|
12
|
+
def initialize(env)
|
|
13
|
+
@env = env
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# open | message | datagram | stream_open | stream_close | close
|
|
17
|
+
def kind = @env["puma_plus.ws.event"]
|
|
18
|
+
def open? = kind == "open"
|
|
19
|
+
def message? = kind == "message"
|
|
20
|
+
def close? = kind == "close"
|
|
21
|
+
|
|
22
|
+
# An unreliable datagram. A distinct event kind from #message? on purpose:
|
|
23
|
+
# this payload may have been dropped, duplicated or reordered, and handling
|
|
24
|
+
# it identically to a guaranteed message is usually a bug.
|
|
25
|
+
def datagram? = kind == "datagram"
|
|
26
|
+
|
|
27
|
+
def stream_open? = kind == "stream_open"
|
|
28
|
+
def stream_close? = kind == "stream_close"
|
|
29
|
+
|
|
30
|
+
# "websocket" or "webtransport". Usually irrelevant -- that is the point of
|
|
31
|
+
# a shared hub -- but needed before reaching for a datagram.
|
|
32
|
+
def transport = @env["puma_plus.ws.transport"]
|
|
33
|
+
def webtransport? = transport == "webtransport"
|
|
34
|
+
|
|
35
|
+
# Which stream a WebTransport message arrived on. nil for WebSocket, whose
|
|
36
|
+
# single channel needs no name.
|
|
37
|
+
def stream_id = @env["puma_plus.ws.stream_id"]
|
|
38
|
+
|
|
39
|
+
def conn_id = @env["puma_plus.ws.conn_id"]
|
|
40
|
+
def mode = @env["puma_plus.ws.mode"]
|
|
41
|
+
def sticky? = mode == "sticky"
|
|
42
|
+
def binary? = @env["puma_plus.ws.opcode"] == "binary"
|
|
43
|
+
|
|
44
|
+
def close_code = @env["puma_plus.ws.close_code"]&.to_i
|
|
45
|
+
def close_reason = @env["puma_plus.ws.close_reason"]
|
|
46
|
+
|
|
47
|
+
# The message payload.
|
|
48
|
+
def data
|
|
49
|
+
@data ||= (@env["rack.input"]&.read || "")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Metadata captured at upgrade time and echoed on every event, so a flexible
|
|
53
|
+
# handler knows who it is talking to without a lookup.
|
|
54
|
+
def meta
|
|
55
|
+
@meta ||= @env.each_with_object({}) do |(k, v), h|
|
|
56
|
+
h[k.delete_prefix("puma_plus.ws.meta.")] = v if k.start_with?("puma_plus.ws.meta.")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Queue time for this event, in seconds. WebSocket messages are queued and
|
|
61
|
+
# measured exactly like HTTP requests, which is what lets them drive
|
|
62
|
+
# autoscaling.
|
|
63
|
+
def queue_time = @env["puma.request_queue_time"]
|
|
64
|
+
|
|
65
|
+
# Build the 101 response that asks Go to take over a connection.
|
|
66
|
+
#
|
|
67
|
+
# PumaPlus::WSEvent.upgrade(mode: :sticky, topics: ["room:42"])
|
|
68
|
+
def self.upgrade(mode: :flexible, topics: [], meta: {}, transport: nil)
|
|
69
|
+
headers = { "puma-plus-websocket" => mode.to_s }
|
|
70
|
+
headers["puma-plus-transport"] = transport.to_s if transport
|
|
71
|
+
headers["puma-plus-websocket-topic"] = Array(topics).map(&:to_s) unless Array(topics).empty?
|
|
72
|
+
unless meta.empty?
|
|
73
|
+
headers["puma-plus-websocket-meta"] = meta.map { |k, v| "#{k}=#{v}" }
|
|
74
|
+
end
|
|
75
|
+
[101, headers, []]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|