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 +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +1 -1
- data/lib/monk/bin.rb +94 -0
- data/lib/monk/live/session.rb +16 -9
- data/lib/monk/templates/websocket/bin/websocket_server +14 -0
- data/lib/monk/templates/websocket/setup.md +2 -1
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/handshake.rb +32 -9
- data/lib/monk/websocket/pg_fanout.rb +16 -7
- data/lib/monk/websocket/redis_fanout.rb +16 -7
- data/lib/monk/websocket/registry.rb +40 -5
- data/lib/monk/websocket/server.rb +211 -47
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae98dd74608d293e7fdae3c428abae8d851f25e4a52d02d9d92aa499588d5182
|
|
4
|
+
data.tar.gz: ff9fb430178e748ea859c9c6ae8ef72b8d35851b297a7d38f39b9f36a8597f02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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")
|
data/lib/monk/live/session.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
@
|
|
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.
|
|
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 =
|
|
18
|
-
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
79
|
+
token = nil
|
|
80
|
+
headers["cookie"]&.split(";")&.each do |pair|
|
|
60
81
|
name, value = pair.strip.split("=", 2)
|
|
61
|
-
|
|
62
|
-
end
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
12
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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 &&
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
credential &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|