monkrb 0.22.0 → 0.22.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa788432dda8104f066b4230e406e690df36c72528f57de126001519576fa0e6
4
- data.tar.gz: 36352b5fa73ff475ca37141e8d5aeca9f0cd487845f1521f62bc3048650a57a2
3
+ metadata.gz: ae98dd74608d293e7fdae3c428abae8d851f25e4a52d02d9d92aa499588d5182
4
+ data.tar.gz: ff9fb430178e748ea859c9c6ae8ef72b8d35851b297a7d38f39b9f36a8597f02
5
5
  SHA512:
6
- metadata.gz: 03ecd1b575dcb40a8a1a35c4b5b0a2b1c8f867369ffda064d6138fbde7f8db8c586d9d4e302d2ee471a71a29b9c50133ee8a91eaf4c49dec88ca64e0158aecab
7
- data.tar.gz: c14485432530c8e4f14ee1ff630208a191ebffb4ec6c1c1fd6326562aba05555a4173e865fe73b913027f896a530166ab0d115468d28dc15efdade92fc49d0c5
6
+ metadata.gz: ffa8fe3639cca148a43d9bd308d2e5b7fe761d391d3687a709e82c2747ec0825184c2b3f10bafe3630a6f568733545ff8879ab0768f9f555256e83c0d0cc5f7e
7
+ data.tar.gz: 1a7ec63d1a87f9f31d931022889a1630dcd4b8c4e02316992650a72e2bc5114fe49c280ace2a638b43d9425a4460283fe295f10b6f27ce499649db8e4156ebe9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,68 @@ All notable changes to this project are documented here. Format is loosely
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are as released
5
5
  in `lib/monk/version.rb`.
6
6
 
7
+ ## 0.22.1 - 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - **Several WebSocket processes on one port.** `Server.new(reuse_port:
12
+ true)` binds with `SO_REUSEPORT`, and the scaffold's
13
+ `bin/websocket_server` runs `WS_PROCESSES` processes (1 by default),
14
+ starting that many copies of itself through the new
15
+ `Monk::Bin.spawn_processes!` and passing `TERM`/`INT` on. Each has its
16
+ own heap and its own GC pauses over its share of the sockets, so on
17
+ Ruby 4.0 K processes take about K² times the connects a second
18
+ (`docs/guides/websocket.md`, "Several processes"; plan item 8). An
19
+ existing app copies the new script (`docs/guides/upgrading.md`,
20
+ 0.22.1).
21
+
22
+ ### Fixed
23
+
24
+ - **A WebSocket connect burst queues instead of collapsing.** The server
25
+ caps the handshakes in flight (`Server.new(max_handshakes:)`, 128 by
26
+ default); past the cap, connects wait in the kernel's listen backlog.
27
+ Before, every accepted socket started its handshake at once, and a
28
+ burst past what the process could verify (a restart's reconnect wave)
29
+ filled the `db_pool:` queue and timed most of them out
30
+ (`docs/plan-ws-connect-ceiling.md`, item 1).
31
+ - **A WebSocket handshake has a deadline and a size limit.** A handshake
32
+ not over within `handshake_timeout:` (10 s by default) is dropped, and a request head past 16 KiB gets a
33
+ `431`. Before, a socket that connected and sent nothing held its
34
+ connection Ractor forever, and a request with no blank line grew
35
+ without limit (item 2).
36
+ - **A WebSocket handshake whose session check fails is refused with
37
+ `503`**, and one line on stderr. Before, a `verify` that raised (the
38
+ `db_pool:` full or timed out, the database down) killed the
39
+ connection's Ractor with a backtrace and sent no response. Under
40
+ `authenticate: :optional` it is a `503` too, not an anonymous
41
+ connection. Clients without an `Origin` also get `Retry-After`
42
+ (item 3).
43
+
44
+ ### Changed
45
+
46
+ - **A WebSocket handshake allocates less.** The request is read with one
47
+ `gets` instead of a String a byte, parsed once instead of twice, and
48
+ its credential worked out once instead of up to three times. Fewer
49
+ objects a connect means fewer GCs, which is what limits connects a
50
+ second once thousands of sockets are open (item 4).
51
+ - **A Live subscribe is one registry ask, not one a topic.**
52
+ `Registry#register_many(keys, port)` and `#unregister_many` (on
53
+ `RedisFanout` and `PgFanout` too) take every key at once;
54
+ `Monk::Live`'s subscribe, unsubscribe and cleanup use them. Each fiber
55
+ also reuses one reply port for its asks instead of making one an ask,
56
+ and the registry survives answering an ask whose caller gave up
57
+ (item 5).
58
+ - **`bin/websocket_server` runs with GC settings for many open sockets.**
59
+ It starts itself again, once at boot, with
60
+ `RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO=0.6`, `_GOAL_RATIO=0.8` and
61
+ `_MAX_RATIO=0.9` (`Monk::Bin::WEBSOCKET_GC_ENV`, through the new
62
+ `Monk::Bin.boot_with_env!`), unless the environment sets them. On Ruby
63
+ 4.0, with 15K sockets held, 6–30× fewer GCs and p99 to ready at
64
+ 300 connects/s from 6.3 s to 1.2 s, for ~30% more memory. An existing
65
+ app adds one line (`docs/guides/upgrading.md`, 0.22.1) (item 7).
66
+ Restarting with the settings carries the warning level (`-W0`) over;
67
+ other flags given to `ruby` itself aren't (`RUBYOPT` is).
68
+
7
69
  ## 0.22.0 - 2026-10-13
8
70
 
9
71
  ### Added
data/README.md CHANGED
@@ -88,6 +88,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
88
88
 
89
89
  ## Status
90
90
 
91
- Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). Features to add are listed in [`docs/roadmap.md`](docs/roadmap.md). Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`). In 0.21 (2026-10-09): scaffolding by module. `monk new` makes the core app, and `monk add` adds a module (Postgres, Redis, mail, auth, jobs, WebSocket, Live) to any app, new or existing, with its wiring, commented examples, a test, and its sections of `SETUP.md` and an `AGENTS.md` for coding agents; `--json` for agents and scripts. Redis and WebSocket become modules of their own, the WebSocket transport is chosen once per app, and `monk new`'s module flags are gone (ADR 0017, `docs/guides/scaffolding.md`, `docs/history/plan-scaffold.md`). In 0.22 (2026-10-13): recurring jobs on a cron schedule, `Monk::Jobs.recurring` in `config/recurring.rb`, enqueued once per occurrence however many `bin/jobs` run (ADR 0016, `docs/guides/jobs.md`); auth housekeeping, with a login-email rate limit in the database, an index on `sessions (subject)` and a nightly cleanup job (`docs/guides/auth.md`); and an upgrade guide for apps made with an earlier Monk (`docs/guides/upgrading.md`).
91
+ Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). Features to add are listed in [`docs/roadmap.md`](docs/roadmap.md). Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`). In 0.21 (2026-10-09): scaffolding by module. `monk new` makes the core app, and `monk add` adds a module (Postgres, Redis, mail, auth, jobs, WebSocket, Live) to any app, new or existing, with its wiring, commented examples, a test, and its sections of `SETUP.md` and an `AGENTS.md` for coding agents; `--json` for agents and scripts. Redis and WebSocket become modules of their own, the WebSocket transport is chosen once per app, and `monk new`'s module flags are gone (ADR 0017, `docs/guides/scaffolding.md`, `docs/history/plan-scaffold.md`). In 0.22 (2026-10-13): recurring jobs on a cron schedule, `Monk::Jobs.recurring` in `config/recurring.rb`, enqueued once per occurrence however many `bin/jobs` run (ADR 0016, `docs/guides/jobs.md`); auth housekeeping, with a login-email rate limit in the database, an index on `sessions (subject)` and a nightly cleanup job (`docs/guides/auth.md`); and an upgrade guide for apps made with an earlier Monk (`docs/guides/upgrading.md`). In 0.22.1 (2026-10-20): the WebSocket server queues a connect burst instead of collapsing under it, refuses a handshake it can't check with a `503`, and takes more connects a second with thousands of sockets open: fewer allocations a connect, GC settings in `bin/websocket_server`, and `WS_PROCESSES` for several server processes on one port (`docs/guides/websocket.md`, `docs/plan-ws-connect-ceiling.md`).
92
92
 
93
93
  [Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.md)
data/lib/monk/bin.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require "rbconfig"
2
+
1
3
  module Monk
2
4
  # What a scaffolded app's bin/ scripts run their work in:
3
5
  #
@@ -13,7 +15,60 @@ module Monk
13
15
  # is. Errors are recognized by class name, so this file loads neither pg
14
16
  # nor redis (docs/adr/0017).
15
17
  module Bin
18
+ # bin/websocket_server's GC settings: keep the heap 60-90% free. On Ruby
19
+ # 4.0 a GC runs whenever new connection Ractors have used up the free
20
+ # pages of the smallest heap (each takes one, however little it
21
+ # allocates), and every GC stops and marks every open socket: at 15K
22
+ # sockets held, these run 6-30x fewer GCs, for ~30% more memory
23
+ # (docs/plan-ws-connect-ceiling.md, "After items 1-5"). Measured to
24
+ # change nothing on 4.1, whose Ractors have heaps of their own.
25
+ WEBSOCKET_GC_ENV = {
26
+ "RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO" => "0.6",
27
+ "RUBY_GC_HEAP_FREE_SLOTS_GOAL_RATIO" => "0.8",
28
+ "RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO" => "0.9",
29
+ }.freeze
30
+
16
31
  class << self
32
+ # Ruby reads settings like these only when it boots, so a script
33
+ # that needs them starts itself again, once, with the ones the
34
+ # environment doesn't set; what it does set wins. Call it first,
35
+ # before the script loads the app. The same PID throughout. Of the
36
+ # flags given to ruby itself, only the warning level is carried over
37
+ # (RUBYOPT is, whole).
38
+ def boot_with_env!(script, env)
39
+ missing = env.reject { |name, _| ENV.key?(name) }
40
+ return if missing.empty?
41
+
42
+ # exec drops what's still buffered.
43
+ $stdout.flush
44
+ $stderr.flush
45
+ exec(missing, RbConfig.ruby, *warning_flags, script, *ARGV)
46
+ end
47
+
48
+ # Runs the script in `count` processes: this one starts that many
49
+ # copies of it, then only waits on them, passing INT and TERM on; in
50
+ # each copy (MONK_BIN_PROCESS set) it returns at once. Copies, not
51
+ # forks: on macOS a forked child that opens a Postgres connection
52
+ # crashes in the Objective-C runtime, and a fresh process inherits
53
+ # nothing. If one ends on its own (a crash, a service down at boot),
54
+ # the others are stopped too, and this process exits with its status,
55
+ # so whatever restarts the script restarts them all. With 1, returns
56
+ # at once, in this process.
57
+ def spawn_processes!(script, count)
58
+ unless count.is_a?(Integer) && count.positive?
59
+ raise ArgumentError, "the process count must be a positive Integer, got #{count.inspect}"
60
+ end
61
+ return if count == 1 || ENV.key?("MONK_BIN_PROCESS")
62
+
63
+ # Each in its own process group, so a Ctrl-C at the terminal
64
+ # reaches it once, passed on from here, and not twice.
65
+ pids = Array.new(count) do |index|
66
+ env = { "MONK_BIN_PROCESS" => (index + 1).to_s }
67
+ Process.spawn(env, RbConfig.ruby, *warning_flags, script, *ARGV, pgroup: true)
68
+ end
69
+ exit supervise(pids)
70
+ end
71
+
17
72
  def run(script)
18
73
  yield
19
74
  rescue StandardError => e
@@ -41,6 +96,45 @@ module Monk
41
96
 
42
97
  private
43
98
 
99
+ # The warning level this Ruby runs at, as flags for another: a script
100
+ # started with -W0 stays quiet when it starts again.
101
+ def warning_flags
102
+ flags = []
103
+ flags << "-W0" if $VERBOSE.nil?
104
+ flags << "-W:no-experimental" unless Warning[:experimental]
105
+ flags
106
+ end
107
+
108
+ # Waits until every pid has ended, and returns the first nonzero
109
+ # status among them, or 0.
110
+ def supervise(pids)
111
+ stop = -> { pids.each { |pid| terminate(pid) } }
112
+ stopping = false
113
+ %w[INT TERM].each do |name|
114
+ trap(name) do
115
+ stopping = true
116
+ stop.call
117
+ end
118
+ end
119
+ status = 0
120
+ until pids.empty?
121
+ pid, process_status = Process.wait2
122
+ pids.delete(pid)
123
+ status = process_status.exitstatus || 1 if status.zero? && !process_status.success?
124
+ next if stopping
125
+
126
+ stopping = true
127
+ stop.call
128
+ end
129
+ status
130
+ end
131
+
132
+ def terminate(pid)
133
+ Process.kill("TERM", pid)
134
+ rescue Errno::ESRCH
135
+ nil # already gone
136
+ end
137
+
44
138
  def postgres(error, context)
45
139
  host = error.message[/server at "([^"]+)"/, 1] || ENV.fetch("DB_HOST", "127.0.0.1")
46
140
  port = error.message[/port (\d+)/, 1] || ENV.fetch("DB_PORT", "5432")
@@ -58,18 +58,24 @@ module Monk
58
58
  topics.all? { |topic| topic.is_a?(String) && topic.valid_encoding? }
59
59
  end
60
60
 
61
+ # The new topics go to the registry in one ask, not one each: at a
62
+ # reconnect wave's rate, one ask per topic is most of the registry
63
+ # Ractor's work (docs/plan-ws-connect-ceiling.md, item 5).
61
64
  def subscribe(topics)
62
- allowed, denied = topics.partition { |topic| try_subscribe?(topic) }
65
+ added = {}
66
+ allowed, denied = topics.partition { |topic| try_subscribe?(topic, added) }
67
+ unless added.empty?
68
+ @registry.register_many(added.values, @port)
69
+ @topics.merge!(added)
70
+ end
63
71
  reply(op: "subscribed", topics: allowed, denied: denied)
64
72
  end
65
73
 
66
- def try_subscribe?(topic)
67
- return true if @topics.key?(topic)
68
- return false unless permitted?(topic) && @topics.size < @max_topics
74
+ def try_subscribe?(topic, added)
75
+ return true if @topics.key?(topic) || added.key?(topic)
76
+ return false unless permitted?(topic) && @topics.size + added.size < @max_topics
69
77
 
70
- key = topic.to_sym
71
- @registry.register(key, @port)
72
- @topics[topic] = key
78
+ added[topic] = topic.to_sym
73
79
  true
74
80
  end
75
81
 
@@ -79,7 +85,8 @@ module Monk
79
85
 
80
86
  def unsubscribe(topics)
81
87
  removed = topics.select { |topic| @topics.key?(topic) }
82
- removed.each { |topic| @registry.unregister(@topics.delete(topic), @port) }
88
+ keys = removed.filter_map { |topic| @topics.delete(topic) }
89
+ @registry.unregister_many(keys, @port) unless keys.empty?
83
90
  reply(op: "unsubscribed", topics: removed)
84
91
  end
85
92
 
@@ -119,7 +126,7 @@ module Monk
119
126
  # read -- so a connection never leaves a stale registry entry.
120
127
  def cleanup
121
128
  @relay&.kill
122
- @topics.each_value { |key| @registry.unregister(key, @port) }
129
+ @registry.unregister_many(@topics.values, @port) unless @topics.empty?
123
130
  @topics.clear
124
131
  @port&.close
125
132
  end
@@ -4,6 +4,19 @@ require "bundler/setup"
4
4
  # with what to fix (SETUP.md's commands), not a backtrace.
5
5
  require "monk/bin"
6
6
 
7
+ # Fewer GC pauses while thousands of sockets are open, set the only way
8
+ # Ruby takes them: at boot. Any already in the environment win.
9
+ Monk::Bin.boot_with_env!(__FILE__, Monk::Bin::WEBSOCKET_GC_ENV)
10
+
11
+ # WS_PROCESSES (1): this many server processes on WS_PORT, each with its own
12
+ # heap and its own GC pauses over a share of the sockets
13
+ # (docs/guides/websocket.md, "Several processes").
14
+ processes = Integer(ENV.fetch("WS_PROCESSES", "1"), exception: false)
15
+ unless processes&.positive?
16
+ abort "bin/websocket_server: WS_PROCESSES must be a whole number, 1 or more, got #{ENV["WS_PROCESSES"].inspect}."
17
+ end
18
+ Monk::Bin.spawn_processes!(__FILE__, processes)
19
+
7
20
  Monk::Bin.run(__FILE__) do
8
21
  require_relative "../config/settings"
9
22
  require_relative "../config/websocket"
@@ -55,6 +68,7 @@ Monk::Bin.run(__FILE__) do
55
68
 
56
69
  server = Monk::WebSocket::Server.new(
57
70
  port: port, authenticate: authenticate, allowed_origins: allowed_origins, db_pool: db_pool,
71
+ reuse_port: processes > 1,
58
72
  )
59
73
  puts "Monk::WebSocket::Server listening on ws://0.0.0.0:#{port} " \
60
74
  "(#{live ? "live" : "app/sockets"}, transport: #{AppWebSocket::TRANSPORT}, authenticate: #{authenticate})"
@@ -18,7 +18,8 @@ exits. In production it's one more process from the same image, with
18
18
 
19
19
  `WS_ALLOWED_ORIGINS` (default: `PUBLIC_URL`) must list every origin your
20
20
  pages are served from, or browsers' sockets are refused. Behind TLS, pages
21
- connect with `wss://`.
21
+ connect with `wss://`. Past ~5K open sockets, `WS_PROCESSES` (default 1)
22
+ runs several server processes on the port, on Linux.
22
23
 
23
24
  ### Switching transports
24
25
 
data/lib/monk/version.rb CHANGED
@@ -5,5 +5,5 @@ module Monk
5
5
  # class of bug .freeze! guards against for routes/error handlers/models,
6
6
  # just on a top-level constant that every worker Ractor reads on boot
7
7
  # (found live under kino: GET /hello 500'd until this was frozen).
8
- VERSION = "0.22.0".freeze
8
+ VERSION = "0.22.1".freeze
9
9
  end
@@ -9,16 +9,35 @@ module Monk
9
9
  # read from a connection Ractor. .freeze is not optional.
10
10
  MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
11
11
 
12
+ # The most a handshake request's head may be (Server.serve answers
13
+ # 431 past it): room for a large cookie, nowhere near a memory risk.
14
+ MAX_REQUEST_SIZE = 16 * 1024
15
+
12
16
  def self.accept_key(client_key)
13
17
  Base64.strict_encode64(Digest::SHA1.digest(client_key + MAGIC))
14
18
  end
15
19
 
20
+ # Two Strings a header line (its name and its value), plus the line:
21
+ # this runs once a connect, and every object it makes is one more
22
+ # towards the next GC (docs/plan-ws-connect-ceiling.md, item 4).
16
23
  def self.parse_request(raw_request)
17
- headers = raw_request.split("\r\n").drop(1).each_with_object({}) do |line, h|
18
- next if line.empty?
24
+ headers = {}
25
+ request_line = true
26
+ raw_request.each_line("\r\n", chomp: true) do |line|
27
+ if request_line
28
+ request_line = false
29
+ next
30
+ end
31
+
32
+ colon = line.index(":")
33
+ next unless colon
19
34
 
20
- name, value = line.split(":", 2)
21
- h[name.strip.downcase] = value.strip if name && value
35
+ name = line.byteslice(0, colon)
36
+ name.strip!
37
+ name.downcase!
38
+ value = line.byteslice(colon + 1, line.bytesize)
39
+ value.strip!
40
+ headers[name] = value
22
41
  end
23
42
 
24
43
  unless headers["upgrade"]&.downcase == "websocket"
@@ -55,17 +74,21 @@ module Monk
55
74
  end
56
75
  private_class_method :bearer_token
57
76
 
77
+ # The last session_token in the header wins, as when it was a Hash.
58
78
  def self.session_cookie_token(headers)
59
- headers["cookie"].to_s.split(";").each_with_object({}) do |pair, cookies|
79
+ token = nil
80
+ headers["cookie"]&.split(";")&.each do |pair|
60
81
  name, value = pair.strip.split("=", 2)
61
- cookies[name] = value if name
62
- end["session_token"]
82
+ token = value if name == "session_token"
83
+ end
84
+ token
63
85
  end
64
86
  private_class_method :session_cookie_token
65
87
 
66
- def self.response_for(raw_request)
67
- headers = parse_request(raw_request)
88
+ def self.response_for(raw_request) = response(parse_request(raw_request))
68
89
 
90
+ # The 101 for headers parse_request already returned.
91
+ def self.response(headers)
69
92
  "HTTP/1.1 101 Switching Protocols\r\n" \
70
93
  "Upgrade: websocket\r\n" \
71
94
  "Connection: Upgrade\r\n" \
@@ -225,17 +225,17 @@ module Monk
225
225
  end
226
226
 
227
227
  def register(key, port)
228
- unless Listeners.listening?(@origin)
229
- raise NotListeningError,
230
- "Monk::WebSocket::PgFanout#register before #listen!: this socket would never get " \
231
- "other processes' broadcasts -- call listen! once at boot in the process that holds " \
232
- "sockets (bin/websocket_server: Monk::Live.listen!)"
233
- end
234
-
228
+ ensure_listening!(:register)
235
229
  @registry.register(key, port)
236
230
  end
237
231
 
232
+ def register_many(keys, port)
233
+ ensure_listening!(:register_many)
234
+ @registry.register_many(keys, port)
235
+ end
236
+
238
237
  def unregister(key, port) = @registry.unregister(key, port)
238
+ def unregister_many(keys, port) = @registry.unregister_many(keys, port)
239
239
  def count(key) = @registry.count(key)
240
240
 
241
241
  def broadcast(key, payload)
@@ -266,6 +266,15 @@ module Monk
266
266
 
267
267
  private
268
268
 
269
+ def ensure_listening!(method)
270
+ return if Listeners.listening?(@origin)
271
+
272
+ raise NotListeningError,
273
+ "Monk::WebSocket::PgFanout##{method} before #listen!: this socket would never get " \
274
+ "other processes' broadcasts -- call listen! once at boot in the process that holds " \
275
+ "sockets (bin/websocket_server: Monk::Live.listen!)"
276
+ end
277
+
269
278
  # The pool's database's connection options, for #listen!.
270
279
  def pooled_pg_opts(db_pool)
271
280
  config = Monk::Persistence::Pg.pool_config(db_pool)
@@ -161,17 +161,17 @@ module Monk
161
161
  end
162
162
 
163
163
  def register(key, port)
164
- unless Listeners.listening?(@origin)
165
- raise NotListeningError,
166
- "Monk::WebSocket::RedisFanout#register before #listen!: this socket would never get " \
167
- "other processes' broadcasts -- call listen! once at boot in the process that holds " \
168
- "sockets (bin/websocket_server: Monk::Live.listen!)"
169
- end
170
-
164
+ ensure_listening!(:register)
171
165
  @registry.register(key, port)
172
166
  end
173
167
 
168
+ def register_many(keys, port)
169
+ ensure_listening!(:register_many)
170
+ @registry.register_many(keys, port)
171
+ end
172
+
174
173
  def unregister(key, port) = @registry.unregister(key, port)
174
+ def unregister_many(keys, port) = @registry.unregister_many(keys, port)
175
175
  def count(key) = @registry.count(key)
176
176
 
177
177
  def broadcast(key, payload)
@@ -181,6 +181,15 @@ module Monk
181
181
 
182
182
  private
183
183
 
184
+ def ensure_listening!(method)
185
+ return if Listeners.listening?(@origin)
186
+
187
+ raise NotListeningError,
188
+ "Monk::WebSocket::RedisFanout##{method} before #listen!: this socket would never get " \
189
+ "other processes' broadcasts -- call listen! once at boot in the process that holds " \
190
+ "sockets (bin/websocket_server: Monk::Live.listen!)"
191
+ end
192
+
184
193
  # A single shared Redis client called concurrently from every
185
194
  # connection Ractor's #broadcast wouldn't be safe -- redis-rb clients
186
195
  # aren't documented safe for concurrent commands from multiple
@@ -8,8 +8,9 @@ module Monk
8
8
  # A dedicated Ractor holding the live set of connection handles keyed by
9
9
  # an app-assigned channel/subject -- mirrors StateRactor's shape
10
10
  # (docs/design/ractor.md): the mutable Hash stays hidden inside this one
11
- # Ractor, and every call is a synchronous "ask" via a fresh
12
- # Ractor::Port, so registration/broadcast can never race each other.
11
+ # Ractor, and every call is a synchronous "ask" answered on the
12
+ # caller's reply port, so registration/broadcast can never race each
13
+ # other.
13
14
  class Registry
14
15
  def initialize
15
16
  @ractor = Ractor.new do
@@ -25,6 +26,14 @@ module Monk
25
26
  when :unregister
26
27
  keys[key].delete(arg)
27
28
  true
29
+ when :register_many
30
+ # key is the Array of keys: a Live subscribe's topics in
31
+ # one ask, not one each.
32
+ key.each { |k| keys[k] << arg unless keys[k].include?(arg) }
33
+ true
34
+ when :unregister_many
35
+ key.each { |k| keys[k].delete(arg) }
36
+ true
28
37
  when :broadcast
29
38
  # A port can be closed out from under this loop without
30
39
  # ever going through #unregister -- e.g. the owning
@@ -58,7 +67,13 @@ module Monk
58
67
  end
59
68
  ports.size
60
69
  end
61
- reply_port.send(result)
70
+ begin
71
+ reply_port.send(result)
72
+ rescue Ractor::ClosedError
73
+ # The caller gave up on this ask (its thread was killed while
74
+ # it waited) and closed its port: nobody to answer, and no
75
+ # reason to end the registry for every other caller.
76
+ end
62
77
  end
63
78
  end
64
79
  freeze
@@ -72,6 +87,16 @@ module Monk
72
87
  ask(:unregister, key, port)
73
88
  end
74
89
 
90
+ # register for each of keys, in one ask: one message and one reply
91
+ # instead of one each (docs/plan-ws-connect-ceiling.md, item 5).
92
+ def register_many(keys, port)
93
+ ask(:register_many, keys, port)
94
+ end
95
+
96
+ def unregister_many(keys, port)
97
+ ask(:unregister_many, keys, port)
98
+ end
99
+
75
100
  def broadcast(key, payload)
76
101
  ask(:broadcast, key, payload)
77
102
  end
@@ -99,10 +124,20 @@ module Monk
99
124
 
100
125
  private
101
126
 
127
+ # An ask is synchronous, so a fiber has at most one outstanding: its
128
+ # reply port is made once and reused (a Port is an allocation and a
129
+ # message each), taken out of fiber storage while the ask is in
130
+ # flight. An ask cut short (the thread killed while it waits) closes
131
+ # the port instead of putting it back, so its late reply can't be
132
+ # read as the next ask's.
102
133
  def ask(op, key, arg)
103
- reply_port = Ractor::Port.new
134
+ reply_port = Thread.current[:monk_registry_reply_port] || Ractor::Port.new
135
+ Thread.current[:monk_registry_reply_port] = nil
104
136
  @ractor.send([op, key, arg, reply_port])
105
- reply_port.receive
137
+ result = reply_port.receive
138
+ Thread.current[:monk_registry_reply_port] = reply_port
139
+ reply_port = nil
140
+ result
106
141
  rescue Ractor::ClosedError
107
142
  # The registry Ractor itself can be gone by the time this runs --
108
143
  # e.g. process-wide shutdown (Ctrl+C) tearing down Ractors in
@@ -55,14 +55,49 @@ module Monk
55
55
  # hostile client on a public endpoint, not an opt-in behavior
56
56
  # change, so a server that doesn't mention it still gets the
57
57
  # default cap rather than no cap at all.
58
+ #
59
+ # max_handshakes: caps the connections still in their handshake (the
60
+ # HTTP upgrade and, with authenticate:, verifying the session). At the
61
+ # cap the accept loop stops accepting until one finishes, and new
62
+ # connects wait in the kernel's listen backlog. Without it, a burst
63
+ # past what the process can verify (a restart's reconnect wave) starts
64
+ # every handshake at once, fills db_pool:'s queue, and ends with most
65
+ # of them timing out instead of the rest queueing
66
+ # (docs/plan-ws-connect-ceiling.md, item 1). Kept well under the
67
+ # pool's queue (1,000 by default), so a full cap can't overfill it.
68
+ # Always on, like max_payload_size:.
69
+ #
70
+ # handshake_timeout: (seconds) bounds the whole handshake, from
71
+ # accept to the 101, and Handshake::MAX_REQUEST_SIZE its request; a
72
+ # client past the deadline is dropped, one past the size gets a 431.
73
+ # Without them, a socket that connects and sends nothing holds a
74
+ # max_handshakes: slot forever, so a few hundred of them would shut
75
+ # the server, and a request that never ends grows without limit
76
+ # (item 2). Off once the 101 is written: an idle socket is the
77
+ # handler's business, not the handshake's.
78
+ #
79
+ # reuse_port: true lets several processes listen on the one port, and
80
+ # on Linux the kernel spreads connects across them. Each has its own
81
+ # heap, so its own GC pauses, over a share of the sockets: on Ruby 4.0
82
+ # K processes cut what GC costs a connect by about K squared
83
+ # (item 8). A broadcast reaches all of them through the fan-out.
58
84
  AUTHENTICATE_MODES = [false, true, :optional].freeze
59
85
 
86
+ DEFAULT_MAX_HANDSHAKES = 128
87
+ DEFAULT_HANDSHAKE_TIMEOUT = 10
88
+
89
+ # What the handshake's verify returns when it raised instead of
90
+ # answering (the pool full or timed out, the database down): neither
91
+ # a subject nor "no session", so the connection gets a 503.
92
+ VERIFY_FAILED = Object.new.freeze
93
+
60
94
  # The signals #run treats as "stop": Ctrl+C and TERM.
61
95
  STOP_SIGNALS = [Signal.list.fetch("INT"), Signal.list.fetch("TERM")].freeze
62
96
 
63
97
  def initialize(
64
98
  port:, bind: "0.0.0.0", allowed_origins: nil, authenticate: false, ping_interval: nil,
65
- reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
99
+ reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil,
100
+ max_handshakes: DEFAULT_MAX_HANDSHAKES, handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT, reuse_port: false
66
101
  )
67
102
  if ping_interval && !ping_interval.positive?
68
103
  raise ArgumentError, "ping_interval must be positive, got #{ping_interval.inspect}"
@@ -85,6 +120,14 @@ module Monk
85
120
  raise ArgumentError, "max_payload_size must be positive, got #{max_payload_size.inspect}"
86
121
  end
87
122
 
123
+ unless max_handshakes.is_a?(Integer) && max_handshakes.positive?
124
+ raise ArgumentError, "max_handshakes must be a positive Integer, got #{max_handshakes.inspect}"
125
+ end
126
+
127
+ unless handshake_timeout.is_a?(Numeric) && handshake_timeout.positive?
128
+ raise ArgumentError, "handshake_timeout must be positive, got #{handshake_timeout.inspect}"
129
+ end
130
+
88
131
  if authenticate
89
132
  unless defined?(Monk::Auth) && Monk::Auth.config
90
133
  raise Monk::AuthNotConfiguredError,
@@ -106,19 +149,46 @@ module Monk
106
149
  # declared or isn't started in this process.
107
150
  Monk::Persistence::Pg.pool(db_pool) if db_pool
108
151
 
109
- @tcp_server = TCPServer.new(bind, port)
152
+ @tcp_server = reuse_port ? Server.listen_reusing_port(bind, port) : TCPServer.new(bind, port)
110
153
  @allowed_origins = allowed_origins ? Ractor.make_shareable(allowed_origins.dup) : nil
111
154
  @authenticate = authenticate
112
155
  @ping_interval = ping_interval
113
156
  @reverify_interval = reverify_interval
114
157
  @max_payload_size = max_payload_size
115
158
  @db_pool = db_pool
159
+ @max_handshakes = max_handshakes
160
+ @handshake_timeout = handshake_timeout
116
161
  end
117
162
 
118
163
  def port
119
164
  @tcp_server.addr[1]
120
165
  end
121
166
 
167
+ # reuse_port:, for several processes on one port (bin/websocket_server's
168
+ # WS_PROCESSES): SO_REUSEPORT has to be set before the bind, which
169
+ # TCPServer.new does at once. On Linux the kernel spreads connects
170
+ # across the processes listening.
171
+ def self.listen_reusing_port(bind, port)
172
+ unless defined?(Socket::SO_REUSEPORT)
173
+ raise ArgumentError, "reuse_port: true needs SO_REUSEPORT, which this platform doesn't have"
174
+ end
175
+
176
+ address = Addrinfo.tcp(bind, port)
177
+ socket = Socket.new(address.afamily, :STREAM)
178
+ socket.setsockopt(:SOCKET, :REUSEADDR, true)
179
+ socket.setsockopt(:SOCKET, :REUSEPORT, true)
180
+ socket.bind(address)
181
+ socket.listen(Socket::SOMAXCONN)
182
+ # A TCPServer on the same descriptor, so accept returns a TCPSocket
183
+ # as without reuse_port:.
184
+ socket.autoclose = false
185
+ TCPServer.for_fd(socket.fileno)
186
+ rescue StandardError
187
+ socket&.autoclose = true
188
+ socket&.close
189
+ raise
190
+ end
191
+
122
192
  def run(&block)
123
193
  shareable_block =
124
194
  begin
@@ -130,16 +200,30 @@ module Monk
130
200
  "top-level block -- mirrors StateRactor#update's own constraint)"
131
201
  end
132
202
 
203
+ # One slot a handshake in flight (max_handshakes:). Each connection
204
+ # Ractor sends to handshake_done once its handshake is over, however
205
+ # it ended, and the releaser thread frees a slot for it.
206
+ slots = Thread::SizedQueue.new(@max_handshakes)
207
+ handshake_done = Ractor::Port.new
208
+ releaser = Thread.new do
209
+ loop do
210
+ handshake_done.receive
211
+ slots.pop
212
+ end
213
+ end
214
+
133
215
  loop do
216
+ slots.push(true)
134
217
  socket = @tcp_server.accept
135
218
  ractor = Ractor.new(
136
219
  shareable_block, @authenticate, @allowed_origins, @ping_interval, @reverify_interval, @max_payload_size,
137
- @db_pool
138
- ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size, db_pool|
220
+ @db_pool, handshake_done, @handshake_timeout
221
+ ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size, db_pool, done, timeout|
139
222
  Monk::WebSocket::Server.serve(
140
223
  Ractor.receive, blk,
141
224
  authenticate: authenticate, allowed_origins: origins, ping_interval: ping_interval,
142
- reverify_interval: reverify_interval, max_payload_size: max_payload_size, db_pool: db_pool
225
+ reverify_interval: reverify_interval, max_payload_size: max_payload_size, db_pool: db_pool,
226
+ handshake_done: done, handshake_timeout: timeout
143
227
  )
144
228
  end
145
229
  ractor.send(socket, move: true)
@@ -161,45 +245,63 @@ module Monk
161
245
  # different failure point in this same shutdown -- a live
162
246
  # connection's cleanup racing the registry Ractor's teardown.
163
247
  @tcp_server.close
248
+ ensure
249
+ releaser&.kill
250
+ handshake_done&.close
164
251
  end
165
252
 
166
253
  # Runs entirely inside the connection's own dedicated Ractor
167
254
  # (docs/history/plan-websocket.md Decision 3) -- a class method, not an instance
168
255
  # method, since the Server instance itself (holding a live
169
256
  # TCPServer) is never Ractor-shareable and can't cross into here.
257
+ #
258
+ # handshake_done: is #run's port for max_handshakes:, told once the
259
+ # handshake is over: the 101 written, or the connection refused or
260
+ # dropped before it.
170
261
  def self.serve(
171
262
  socket, block, authenticate: false, allowed_origins: nil, ping_interval: nil, reverify_interval: nil,
172
- max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
263
+ max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil, handshake_done: nil,
264
+ handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT
173
265
  )
174
- request = read_handshake_request(socket)
175
- return socket.close unless request
176
-
177
- headers =
178
- begin
179
- Handshake.parse_request(request)
180
- rescue Monk::WebSocket::HandshakeError
181
- return socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
266
+ # A `return` in here (a refused handshake) ends serve, and its
267
+ # ensure frees the slot.
268
+ credential, subject = with_deadline(socket, handshake_timeout) do
269
+ request = read_handshake_request(socket)
270
+ return unless request
271
+ return socket.write("HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n") if request == :too_large
272
+
273
+ headers =
274
+ begin
275
+ Handshake.parse_request(request)
276
+ rescue Monk::WebSocket::HandshakeError
277
+ return socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
278
+ end
279
+
280
+ subject = nil
281
+ credential = Handshake.credential_from(headers) if authenticate
282
+ case authenticate
283
+ when true
284
+ subject = authenticate!(socket, headers, credential, allowed_origins, db_pool)
285
+ return unless subject
286
+ when :optional
287
+ return unless origin_allowed_for_optional?(socket, headers, credential, allowed_origins)
288
+
289
+ subject = identify(credential, db_pool)
290
+ # Not anonymous: that would quietly sign the user out of their
291
+ # topics until the next reconnect.
292
+ return unavailable!(socket, headers) if subject.equal?(VERIFY_FAILED)
182
293
  end
183
294
 
184
- subject = nil
185
- case authenticate
186
- when true
187
- subject = authenticate!(socket, headers, allowed_origins, db_pool)
188
- return unless subject
189
- when :optional
190
- return unless origin_allowed_for_optional?(socket, headers, allowed_origins)
191
-
192
- subject = identify(headers, db_pool)
295
+ socket.write(Handshake.response(headers))
296
+ [credential, subject]
193
297
  end
194
-
195
- socket.write(Handshake.response_for(request))
298
+ handshake_done = handshake_over(handshake_done)
196
299
 
197
300
  connection = Connection.new(socket, subject: subject, max_payload_size: max_payload_size)
198
301
  connection.start_heartbeat(ping_interval) if ping_interval
199
- reverify_thread =
200
- if reverify_interval && subject
201
- start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval, db_pool)
202
- end
302
+ if reverify_interval && subject
303
+ reverify_thread = start_reverify(connection, credential[:token], reverify_interval, db_pool)
304
+ end
203
305
  begin
204
306
  block.call(connection)
205
307
  rescue StandardError
@@ -209,11 +311,16 @@ module Monk
209
311
  # not re-raised -- there's no caller left to hand it to once
210
312
  # we're inside this connection's own dedicated Ractor.
211
313
  end
314
+ rescue IOError, SystemCallError
315
+ # The client went away, or was shut out by the deadline, before
316
+ # the handshake was over: nothing to answer.
212
317
  ensure
213
318
  # Runs on every exit path -- normal completion, the close
214
319
  # handshake, and a crashed handler alike -- so a subscribed
215
320
  # connection never leaks a stale registry entry (step 17), a
216
321
  # heartbeat thread, or a reverify thread outliving its socket.
322
+ # A handshake that never reached its 101 still frees its slot.
323
+ handshake_over(handshake_done)
217
324
  reverify_thread&.kill
218
325
  connection&.unsubscribe!
219
326
  connection&.stop_heartbeat!
@@ -221,6 +328,35 @@ module Monk
221
328
  disconnect_all
222
329
  end
223
330
 
331
+ # Runs the block with a watchdog that shuts the socket down once
332
+ # `seconds` have passed, which ends a read or write blocked on it (a
333
+ # read returns nil, a write raises). Not IO#timeout=: on Ruby 4.0,
334
+ # once an IO#timeout has fired in one Ractor, reads in Ractors made
335
+ # after it can raise IO::TimeoutError on sockets with no timeout set.
336
+ def self.with_deadline(socket, seconds)
337
+ watchdog = Thread.new do
338
+ sleep seconds
339
+ socket.shutdown(:RDWR)
340
+ rescue IOError, SystemCallError
341
+ # Already closed or gone.
342
+ end
343
+ yield
344
+ ensure
345
+ watchdog&.kill
346
+ end
347
+ private_class_method :with_deadline
348
+
349
+ # Frees this connection's max_handshakes: slot in #run; returns nil,
350
+ # so serve can mark it freed. The port is gone once #run has
351
+ # returned, and then there's no slot to free.
352
+ def self.handshake_over(handshake_done)
353
+ handshake_done&.send(true)
354
+ nil
355
+ rescue Ractor::ClosedError
356
+ nil
357
+ end
358
+ private_class_method :handshake_over
359
+
224
360
  # Closes the connections this socket's Ractor opened: its database
225
361
  # connection (verifying the session without db_pool:, or a block
226
362
  # that queries) and a fan-out's publisher (a block that broadcasts).
@@ -239,10 +375,9 @@ module Monk
239
375
  # credential -- a Bearer connection has no Origin header to check
240
376
  # by construction (mirrors require_csrf!'s own Bearer exemption in
241
377
  # docs/history/plan-auth.md). Returns the verified subject, or nil after writing
242
- # the appropriate 403/401 response itself.
243
- def self.authenticate!(socket, headers, allowed_origins, db_pool)
244
- credential = Handshake.credential_from(headers)
245
-
378
+ # the appropriate 403/401 response itself -- or 503, when verify
379
+ # couldn't answer (docs/plan-ws-connect-ceiling.md, item 3).
380
+ def self.authenticate!(socket, headers, credential, allowed_origins, db_pool)
246
381
  if credential&.fetch(:via) == :cookie && allowed_origins
247
382
  origin = headers["origin"]
248
383
  unless origin && allowed_origins.include?(origin)
@@ -251,12 +386,26 @@ module Monk
251
386
  end
252
387
  end
253
388
 
254
- subject = credential && verify(credential[:token], db_pool)
389
+ subject = credential && handshake_verify(credential[:token], db_pool)
390
+ return unavailable!(socket, headers) if subject.equal?(VERIFY_FAILED)
391
+
255
392
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") unless subject
256
393
  subject
257
394
  end
258
395
  private_class_method :authenticate!
259
396
 
397
+ # A handshake whose verify couldn't answer: 503, and nil. Browser JS
398
+ # never sees a failed handshake's status or headers (only onclose,
399
+ # 1006), so Retry-After is for the other clients, the ones without
400
+ # an Origin, and jittered, so a wave of them doesn't come back as
401
+ # one.
402
+ def self.unavailable!(socket, headers)
403
+ retry_after = headers["origin"] ? "" : "Retry-After: #{rand(1..10)}\r\n"
404
+ socket.write("HTTP/1.1 503 Service Unavailable\r\n#{retry_after}\r\n")
405
+ nil
406
+ end
407
+ private_class_method :unavailable!
408
+
260
409
  # authenticate: :optional's Origin check, before anything is verified.
261
410
  # A Bearer connection is exempt, as under true. A cookie needs an
262
411
  # allowed Origin, as under true. An anonymous connection that sends an
@@ -264,10 +413,9 @@ module Monk
264
413
  # stops another site's page from opening sockets here on its
265
414
  # visitors' behalf; one that sends none (not a browser) gets in.
266
415
  # Writes the 403 itself and returns false when refused.
267
- def self.origin_allowed_for_optional?(socket, headers, allowed_origins)
416
+ def self.origin_allowed_for_optional?(socket, headers, credential, allowed_origins)
268
417
  return true unless allowed_origins
269
418
 
270
- credential = Handshake.credential_from(headers)
271
419
  return true if credential&.fetch(:via) == :bearer
272
420
 
273
421
  origin = headers["origin"]
@@ -280,13 +428,26 @@ module Monk
280
428
  private_class_method :origin_allowed_for_optional?
281
429
 
282
430
  # The subject of a valid session, or nil -- no credential, or one
283
- # Monk::Auth no longer accepts, both mean anonymous under :optional.
284
- def self.identify(headers, db_pool)
285
- credential = Handshake.credential_from(headers)
286
- credential && verify(credential[:token], db_pool)
431
+ # Monk::Auth no longer accepts, both mean anonymous under :optional --
432
+ # or VERIFY_FAILED.
433
+ def self.identify(credential, db_pool)
434
+ credential && handshake_verify(credential[:token], db_pool)
287
435
  end
288
436
  private_class_method :identify
289
437
 
438
+ # verify, or VERIFY_FAILED with one line on stderr if it raised:
439
+ # under a burst, a backtrace per refused connection buries the log.
440
+ # The message's first line only (a PG error adds the query and a
441
+ # caret), and not Kernel#warn, which -W0 silences.
442
+ def self.handshake_verify(raw_token, db_pool)
443
+ verify(raw_token, db_pool)
444
+ rescue StandardError => e
445
+ reason = e.message.lines.first&.chomp
446
+ $stderr.puts "monk-websocket: handshake refused with 503, verify failed: #{e.class}: #{reason}" # rubocop:disable Style/StderrPuts
447
+ VERIFY_FAILED
448
+ end
449
+ private_class_method :handshake_verify
450
+
290
451
  # Sleeps in `interval`-sized steps, re-running Monk::Auth.verify
291
452
  # against the same raw credential each time, until it comes back
292
453
  # nil (revoked or expired) -- then closes the connection with the
@@ -319,15 +480,18 @@ module Monk
319
480
  end
320
481
  private_class_method :verify
321
482
 
483
+ # The request head, nil if the client went away or was too slow, or
484
+ # :too_large past Handshake::MAX_REQUEST_SIZE.
485
+ #
486
+ # One String, read through the socket's own buffer: Connection's
487
+ # reads use that buffer after it, so a frame the client sent right
488
+ # behind its request isn't lost (a readpartial would take it into
489
+ # the request and drop it). A read(1) loop made a String a byte.
322
490
  def self.read_handshake_request(socket)
323
- request = +""
324
- until request.end_with?("\r\n\r\n")
325
- chunk = socket.read(1)
326
- return nil if chunk.nil?
491
+ request = socket.gets("\r\n\r\n", Handshake::MAX_REQUEST_SIZE)
492
+ return request if request&.end_with?("\r\n\r\n")
327
493
 
328
- request << chunk
329
- end
330
- request
494
+ :too_large if request && request.bytesize >= Handshake::MAX_REQUEST_SIZE
331
495
  end
332
496
  private_class_method :read_handshake_request
333
497
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: monkrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.0
4
+ version: 0.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matteo Folin