tep 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# Tep::Scheduler -- a tiny fiber-based cooperative scheduler.
|
|
2
|
+
#
|
|
3
|
+
# Spinel ships Fiber today (ucontext-based, GC-aware, ivars persist
|
|
4
|
+
# across yields). What was missing was the layer above: a way to run
|
|
5
|
+
# multiple cooperating fibers within a single worker process so a
|
|
6
|
+
# long-running response (SSE stream, long-poll, slow batch) doesn't
|
|
7
|
+
# pin the worker for the whole connection lifetime.
|
|
8
|
+
#
|
|
9
|
+
# This covers two parking modes:
|
|
10
|
+
#
|
|
11
|
+
# * **Time**: register a fiber to be resumed at-or-after `wake_at`
|
|
12
|
+
# via `Tep::Scheduler.pause(seconds)`.
|
|
13
|
+
# * **I/O**: park a fiber on (fd, mode) via `Tep::Scheduler.io_wait`.
|
|
14
|
+
# tick() runs a poll(2) round, marks ready fibers, and resumes them
|
|
15
|
+
# (along with any time-ready ones) on the same pass.
|
|
16
|
+
#
|
|
17
|
+
# Storage shape
|
|
18
|
+
# -------------
|
|
19
|
+
# Parallel arrays on the Tep::APP singleton -- one entry per fiber:
|
|
20
|
+
# sched_fibers PtrArray<FiberSlot> the Fiber itself
|
|
21
|
+
# sched_wake_at IntArray unix-seconds; -1 = ready now
|
|
22
|
+
# sched_io_fd IntArray fd parked on; -1 = no I/O wait
|
|
23
|
+
# sched_io_mode IntArray requested mode bits (1=R, 2=W)
|
|
24
|
+
# sched_io_ready IntArray observed-ready bits (0=not yet)
|
|
25
|
+
#
|
|
26
|
+
# Spinel handles same-shaped typed arrays cleanly; using a single
|
|
27
|
+
# array of structs would force a poly_array. Same App-instance
|
|
28
|
+
# pattern as Tep::Assets.
|
|
29
|
+
#
|
|
30
|
+
# What it doesn't do (yet)
|
|
31
|
+
# ------------------------
|
|
32
|
+
# **Implicit yield on blocking calls.** Ruby 3.0's
|
|
33
|
+
# `Fiber::SchedulerInterface` makes every blocking I/O auto-yield
|
|
34
|
+
# to a registered scheduler. Spinel doesn't recognise that hook;
|
|
35
|
+
# fibers yield explicitly via `Tep::Scheduler.pause / io_wait`.
|
|
36
|
+
#
|
|
37
|
+
# **Non-blocking accept on the listening socket.** The Server's
|
|
38
|
+
# worker_loop still does a blocking accept(); fibers cooperate
|
|
39
|
+
# *within* a single request lifetime, not across requests. Adding
|
|
40
|
+
# poll-on-accept needs the worker_loop to opt into the scheduler.
|
|
41
|
+
module Tep
|
|
42
|
+
class Scheduler
|
|
43
|
+
# Mode bits for io_wait. Mirror sphttp's wire encoding so the
|
|
44
|
+
# C side and Ruby side stay aligned.
|
|
45
|
+
READ = 1
|
|
46
|
+
WRITE = 2
|
|
47
|
+
|
|
48
|
+
def self.spawn_fiber(f)
|
|
49
|
+
Tep::APP.sched_fibers.push(Tep::FiberSlot.new(f))
|
|
50
|
+
Tep::APP.sched_wake_at.push(-1)
|
|
51
|
+
Tep::APP.sched_io_fd.push(-1)
|
|
52
|
+
Tep::APP.sched_io_mode.push(0)
|
|
53
|
+
Tep::APP.sched_io_ready.push(0)
|
|
54
|
+
f
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# One scheduler pass. If any fibers are parked on I/O, build a
|
|
58
|
+
# poll set, run poll(2) for up to `poll_timeout_ms`, and mark
|
|
59
|
+
# ready ones. Then resume the soonest-due fiber whose wake_at
|
|
60
|
+
# is <= now. Returns true if it resumed something.
|
|
61
|
+
#
|
|
62
|
+
# If a fiber is already time-due (wake_at <= now -- e.g. a newly
|
|
63
|
+
# spawned fiber with wake_at=-1, or a fiber that just called
|
|
64
|
+
# pause(0)), poll() must NOT block: we have runnable work and
|
|
65
|
+
# any wait is wasted wall time. This matters for the cooperative
|
|
66
|
+
# request path -- when an outer handler parks on io_wait and
|
|
67
|
+
# the accept fiber spawns an inner connection-fiber, the next
|
|
68
|
+
# tick has a wake_at=-1 fiber ready; without this short-circuit
|
|
69
|
+
# each "hand off to the freshly-spawned fiber" step costs a full
|
|
70
|
+
# poll-timeout's worth of latency.
|
|
71
|
+
def self.tick(poll_timeout_ms)
|
|
72
|
+
# Reclaim trailing dead slots. Without this, the parallel
|
|
73
|
+
# arrays grow once per accepted connection and never shrink --
|
|
74
|
+
# a slow leak and per-tick iteration tax in a long-running
|
|
75
|
+
# Scheduled server. Tail-only (stop at first alive) is
|
|
76
|
+
# deliberate: it keeps every surviving slot's index stable,
|
|
77
|
+
# so external captures of sched_current held across Fiber.yield
|
|
78
|
+
# (e.g. pg.rb's PG::Pool @waiter_idxs) stay valid. Middle
|
|
79
|
+
# dead slots aren't reclaimed until the tail catches up; for
|
|
80
|
+
# FIFO request lifecycles that's the common case.
|
|
81
|
+
i = Tep::APP.sched_fibers.length - 1
|
|
82
|
+
while i >= 0 && !Tep::APP.sched_fibers[i].f.alive?
|
|
83
|
+
Tep::APP.sched_fibers.delete_at(i)
|
|
84
|
+
Tep::APP.sched_wake_at.delete_at(i)
|
|
85
|
+
Tep::APP.sched_io_fd.delete_at(i)
|
|
86
|
+
Tep::APP.sched_io_mode.delete_at(i)
|
|
87
|
+
Tep::APP.sched_io_ready.delete_at(i)
|
|
88
|
+
i -= 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
ms = poll_timeout_ms
|
|
92
|
+
if Scheduler.any_time_ready
|
|
93
|
+
ms = 0
|
|
94
|
+
end
|
|
95
|
+
Scheduler.poll_round(ms)
|
|
96
|
+
|
|
97
|
+
now = Time.now.to_i
|
|
98
|
+
best = -1
|
|
99
|
+
i = 0
|
|
100
|
+
n = Tep::APP.sched_fibers.length
|
|
101
|
+
while i < n
|
|
102
|
+
if Tep::APP.sched_fibers[i].f.alive? && Tep::APP.sched_wake_at[i] <= now
|
|
103
|
+
if best < 0 || Tep::APP.sched_wake_at[i] < Tep::APP.sched_wake_at[best]
|
|
104
|
+
best = i
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
i += 1
|
|
108
|
+
end
|
|
109
|
+
if best < 0
|
|
110
|
+
return false
|
|
111
|
+
end
|
|
112
|
+
Tep::APP.sched_current = best
|
|
113
|
+
Tep::APP.sched_wake_at[best] = -1
|
|
114
|
+
Tep::APP.sched_fibers[best].f.resume
|
|
115
|
+
Tep::APP.sched_current = -1
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Build poll set from parked-on-I/O fibers, call poll(2), and
|
|
120
|
+
# write observed-ready bits back into the parallel arrays.
|
|
121
|
+
# `timeout_ms` is the poll() timeout (-1 = block forever,
|
|
122
|
+
# 0 = non-blocking peek). Idempotent for an empty set.
|
|
123
|
+
def self.poll_round(timeout_ms)
|
|
124
|
+
Sock.sphttp_poll_reset
|
|
125
|
+
slots = [-1] # slot index parallel to sched_fibers; -1 = not polled
|
|
126
|
+
slots.delete_at(0)
|
|
127
|
+
added = 0
|
|
128
|
+
i = 0
|
|
129
|
+
n = Tep::APP.sched_fibers.length
|
|
130
|
+
while i < n
|
|
131
|
+
slot = -1
|
|
132
|
+
if Tep::APP.sched_fibers[i].f.alive? &&
|
|
133
|
+
Tep::APP.sched_io_fd[i] >= 0 &&
|
|
134
|
+
Tep::APP.sched_io_ready[i] == 0
|
|
135
|
+
slot = Sock.sphttp_poll_add(Tep::APP.sched_io_fd[i],
|
|
136
|
+
Tep::APP.sched_io_mode[i])
|
|
137
|
+
added += 1
|
|
138
|
+
end
|
|
139
|
+
slots.push(slot)
|
|
140
|
+
i += 1
|
|
141
|
+
end
|
|
142
|
+
if added == 0
|
|
143
|
+
return 0
|
|
144
|
+
end
|
|
145
|
+
Sock.sphttp_poll_run(timeout_ms)
|
|
146
|
+
now = Time.now.to_i
|
|
147
|
+
i = 0
|
|
148
|
+
while i < n
|
|
149
|
+
if slots[i] >= 0
|
|
150
|
+
ready = Sock.sphttp_poll_ready(slots[i])
|
|
151
|
+
if ready > 0
|
|
152
|
+
Tep::APP.sched_io_ready[i] = ready
|
|
153
|
+
Tep::APP.sched_wake_at[i] = now
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
i += 1
|
|
157
|
+
end
|
|
158
|
+
added
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Drain. Resumes everything ready until the schedulable set
|
|
162
|
+
# is empty (every fiber finished or all are waiting for a
|
|
163
|
+
# future wake_at / I/O). Returns the number of resumes performed.
|
|
164
|
+
# Pure non-blocking; no poll() wait between passes.
|
|
165
|
+
def self.run_until_empty
|
|
166
|
+
n = 0
|
|
167
|
+
while Scheduler.tick(0)
|
|
168
|
+
n += 1
|
|
169
|
+
end
|
|
170
|
+
n
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Drain until `seconds` has elapsed OR every fiber's done.
|
|
174
|
+
# Between empty passes, blocks in poll(2) (or sleep, if no
|
|
175
|
+
# I/O waits) until the next wake-up.
|
|
176
|
+
def self.run_for(seconds)
|
|
177
|
+
deadline = Time.now.to_i + seconds
|
|
178
|
+
while Time.now.to_i < deadline
|
|
179
|
+
if !Scheduler.tick(0)
|
|
180
|
+
# Nothing ready this pass. Compute the next deadline:
|
|
181
|
+
# min(next_wake, overall_deadline). If any fiber is
|
|
182
|
+
# parked on I/O, block in poll() until that or the
|
|
183
|
+
# timer hits.
|
|
184
|
+
next_at = Scheduler.next_wake
|
|
185
|
+
gap = deadline - Time.now.to_i
|
|
186
|
+
if next_at >= 0
|
|
187
|
+
tgap = next_at - Time.now.to_i
|
|
188
|
+
if tgap < gap
|
|
189
|
+
gap = tgap
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
if gap < 0
|
|
193
|
+
gap = 0
|
|
194
|
+
end
|
|
195
|
+
if Scheduler.any_io_waiter
|
|
196
|
+
# Park in poll for up to `gap` seconds.
|
|
197
|
+
Scheduler.poll_round(gap * 1000)
|
|
198
|
+
elsif next_at < 0
|
|
199
|
+
return 0
|
|
200
|
+
elsif gap > 0
|
|
201
|
+
sleep gap
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
0
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.next_wake
|
|
209
|
+
best = -1
|
|
210
|
+
i = 0
|
|
211
|
+
n = Tep::APP.sched_fibers.length
|
|
212
|
+
while i < n
|
|
213
|
+
if Tep::APP.sched_fibers[i].f.alive?
|
|
214
|
+
if best < 0 || Tep::APP.sched_wake_at[i] < Tep::APP.sched_wake_at[best]
|
|
215
|
+
best = i
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
i += 1
|
|
219
|
+
end
|
|
220
|
+
if best < 0
|
|
221
|
+
return -1
|
|
222
|
+
end
|
|
223
|
+
Tep::APP.sched_wake_at[best]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.any_io_waiter
|
|
227
|
+
i = 0
|
|
228
|
+
n = Tep::APP.sched_fibers.length
|
|
229
|
+
while i < n
|
|
230
|
+
if Tep::APP.sched_fibers[i].f.alive? &&
|
|
231
|
+
Tep::APP.sched_io_fd[i] >= 0 &&
|
|
232
|
+
Tep::APP.sched_io_ready[i] == 0
|
|
233
|
+
return true
|
|
234
|
+
end
|
|
235
|
+
i += 1
|
|
236
|
+
end
|
|
237
|
+
false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Is any alive fiber's wake_at already <= now? Used by tick() to
|
|
241
|
+
# decide whether poll() can block: if anyone is time-due, the
|
|
242
|
+
# poll timeout collapses to 0 (non-blocking peek) so we don't
|
|
243
|
+
# waste wall time idling when there's runnable work.
|
|
244
|
+
def self.any_time_ready
|
|
245
|
+
now = Time.now.to_i
|
|
246
|
+
i = 0
|
|
247
|
+
n = Tep::APP.sched_fibers.length
|
|
248
|
+
while i < n
|
|
249
|
+
if Tep::APP.sched_fibers[i].f.alive? && Tep::APP.sched_wake_at[i] <= now
|
|
250
|
+
return true
|
|
251
|
+
end
|
|
252
|
+
i += 1
|
|
253
|
+
end
|
|
254
|
+
false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Called from within a fiber's body to suspend until at-or-
|
|
258
|
+
# after `seconds` from now. Named `pause` rather than `sleep`
|
|
259
|
+
# to keep the semantics distinct from `Kernel#sleep`: this is
|
|
260
|
+
# a fiber-aware yield that returns the cooperative scheduler to
|
|
261
|
+
# the dispatch loop, not an OS-level sleep. Outside a fiber it
|
|
262
|
+
# falls through to bare `sleep(seconds)`.
|
|
263
|
+
def self.pause(seconds)
|
|
264
|
+
idx = Tep::APP.sched_current
|
|
265
|
+
if idx < 0
|
|
266
|
+
# Called from outside any fiber -- fall back to POSIX sleep.
|
|
267
|
+
sleep(seconds)
|
|
268
|
+
return 0
|
|
269
|
+
end
|
|
270
|
+
Tep::APP.sched_wake_at[idx] = Time.now.to_i + seconds
|
|
271
|
+
Fiber.yield
|
|
272
|
+
0
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Park the current fiber until `fd` is ready for the given
|
|
276
|
+
# `mode` bits (1=READ, 2=WRITE, 3=both) OR `timeout_seconds`
|
|
277
|
+
# elapses. Returns the observed-ready bits (0 on timeout).
|
|
278
|
+
# When called from outside a fiber, falls back to a single
|
|
279
|
+
# poll() call so the same code works at top level.
|
|
280
|
+
def self.io_wait(fd, mode, timeout_seconds)
|
|
281
|
+
idx = Tep::APP.sched_current
|
|
282
|
+
if idx < 0
|
|
283
|
+
# No fiber context -- single-shot poll inline.
|
|
284
|
+
Sock.sphttp_poll_reset
|
|
285
|
+
slot = Sock.sphttp_poll_add(fd, mode)
|
|
286
|
+
Sock.sphttp_poll_run(timeout_seconds * 1000)
|
|
287
|
+
return Sock.sphttp_poll_ready(slot)
|
|
288
|
+
end
|
|
289
|
+
Tep::APP.sched_io_fd[idx] = fd
|
|
290
|
+
Tep::APP.sched_io_mode[idx] = mode
|
|
291
|
+
Tep::APP.sched_io_ready[idx] = 0
|
|
292
|
+
if timeout_seconds < 0
|
|
293
|
+
# "Wait forever for I/O": -1 would mean "ready now" to the
|
|
294
|
+
# tick picker, so use a far-future wake_at as the sentinel.
|
|
295
|
+
Tep::APP.sched_wake_at[idx] = Time.now.to_i + 86400
|
|
296
|
+
else
|
|
297
|
+
Tep::APP.sched_wake_at[idx] = Time.now.to_i + timeout_seconds
|
|
298
|
+
end
|
|
299
|
+
Fiber.yield
|
|
300
|
+
ready = Tep::APP.sched_io_ready[idx]
|
|
301
|
+
Tep::APP.sched_io_fd[idx] = -1
|
|
302
|
+
Tep::APP.sched_io_mode[idx] = 0
|
|
303
|
+
Tep::APP.sched_io_ready[idx] = 0
|
|
304
|
+
ready
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Reset the schedulable set. Useful between worker-loop
|
|
308
|
+
# iterations or between tests.
|
|
309
|
+
def self.clear
|
|
310
|
+
while Tep::APP.sched_fibers.length > 0
|
|
311
|
+
Tep::APP.sched_fibers.delete_at(0)
|
|
312
|
+
Tep::APP.sched_wake_at.delete_at(0)
|
|
313
|
+
Tep::APP.sched_io_fd.delete_at(0)
|
|
314
|
+
Tep::APP.sched_io_mode.delete_at(0)
|
|
315
|
+
Tep::APP.sched_io_ready.delete_at(0)
|
|
316
|
+
end
|
|
317
|
+
0
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def self.alive_count
|
|
321
|
+
n = 0
|
|
322
|
+
i = 0
|
|
323
|
+
total = Tep::APP.sched_fibers.length
|
|
324
|
+
while i < total
|
|
325
|
+
if Tep::APP.sched_fibers[i].f.alive?
|
|
326
|
+
n += 1
|
|
327
|
+
end
|
|
328
|
+
i += 1
|
|
329
|
+
end
|
|
330
|
+
n
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# True iff a Tep::Scheduler-managed fiber is currently executing.
|
|
334
|
+
# Set by tick() right before f.resume and reset right after, so
|
|
335
|
+
# this is the canonical "am I in cooperative context?" check for
|
|
336
|
+
# callers that want to pick a blocking vs. fiber-yielding path
|
|
337
|
+
# (e.g. Tep::Http -- see lib/tep/http.rb#send_req).
|
|
338
|
+
def self.scheduled_context?
|
|
339
|
+
Tep::APP.sched_current >= 0
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
data/lib/tep/security.rb
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Tep::Security -- before-filter helpers for the two middleware
|
|
2
|
+
# patterns Sinatra apps almost always reach for: CORS and a
|
|
3
|
+
# default-secure header bundle. The Rack::Cors / rack-protection
|
|
4
|
+
# gems do the same things via runtime middleware registration
|
|
5
|
+
# (`use Rack::Cors`); spinel can't do dynamic dispatch into a Rack
|
|
6
|
+
# stack, so we expose the behaviour as small filter classes the
|
|
7
|
+
# user wires with `Tep.before(...)`.
|
|
8
|
+
#
|
|
9
|
+
# Usage
|
|
10
|
+
# =====
|
|
11
|
+
#
|
|
12
|
+
# # CORS, allowing one origin.
|
|
13
|
+
# cors = Tep::Security::Cors.new
|
|
14
|
+
# cors.set_origin("https://app.example.com")
|
|
15
|
+
# cors.set_allowed_verbs("GET,POST,DELETE,OPTIONS")
|
|
16
|
+
# Tep.before cors
|
|
17
|
+
#
|
|
18
|
+
# # Default-secure headers on every response. Apps can still
|
|
19
|
+
# # override individual headers in handlers.
|
|
20
|
+
# Tep.after Tep::Security::Headers.new
|
|
21
|
+
#
|
|
22
|
+
# Both classes are explicit Filter subclasses so they slot into
|
|
23
|
+
# tep's existing single-before / single-after slots cleanly.
|
|
24
|
+
# Multi-filter chains stack via `Tep.before` setting one chain
|
|
25
|
+
# class (the bin/tep translator already composes multiple
|
|
26
|
+
# `before do ... end` blocks; library-side filters can be added
|
|
27
|
+
# alongside via subclassing).
|
|
28
|
+
module Tep
|
|
29
|
+
module Security
|
|
30
|
+
|
|
31
|
+
# CORS preflight + same-origin response decoration.
|
|
32
|
+
#
|
|
33
|
+
# Configurable bits:
|
|
34
|
+
# - origin: a single allowed origin URL ("*" allowed for
|
|
35
|
+
# fully open APIs; not recommended for any endpoint that
|
|
36
|
+
# uses cookies / Authorization headers).
|
|
37
|
+
# - methods: comma-separated. Default "GET,POST,OPTIONS".
|
|
38
|
+
# - headers: comma-separated. Default "Content-Type,Authorization".
|
|
39
|
+
# - max_age: number of seconds the browser caches the
|
|
40
|
+
# preflight result. Default 3600.
|
|
41
|
+
#
|
|
42
|
+
# Behaviour:
|
|
43
|
+
# - On any request: emits `Access-Control-Allow-Origin` plus
|
|
44
|
+
# credential / vary headers.
|
|
45
|
+
# - On `OPTIONS` preflight: short-circuits to a 204 with
|
|
46
|
+
# `Access-Control-Allow-Methods` / `-Headers` / `-Max-Age`.
|
|
47
|
+
class Cors < Tep::Filter
|
|
48
|
+
# Field names are deliberately distinctive (not `methods` /
|
|
49
|
+
# `headers`) -- spinel's per-method type inference unifies
|
|
50
|
+
# method names across classes, and `Object#methods` /
|
|
51
|
+
# `Tep::Response#headers` would widen the dispatch return
|
|
52
|
+
# to poly and break res.headers writes downstream.
|
|
53
|
+
attr_accessor :origin, :allowed_verbs, :allowed_headers, :max_age
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
@origin = "*"
|
|
57
|
+
@allowed_verbs = "GET,POST,OPTIONS"
|
|
58
|
+
@allowed_headers = "Content-Type,Authorization"
|
|
59
|
+
@max_age = 3600
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_origin(o); @origin = o; end
|
|
63
|
+
def set_allowed_verbs(m); @allowed_verbs = m; end
|
|
64
|
+
def set_allowed_headers(h); @allowed_headers = h; end
|
|
65
|
+
def set_max_age(n); @max_age = n; end
|
|
66
|
+
|
|
67
|
+
def before(req, res)
|
|
68
|
+
res.headers["Access-Control-Allow-Origin"] = @origin
|
|
69
|
+
res.headers["Vary"] = "Origin"
|
|
70
|
+
if req.verb == "OPTIONS"
|
|
71
|
+
res.headers["Access-Control-Allow-Methods"] = @allowed_verbs
|
|
72
|
+
res.headers["Access-Control-Allow-Headers"] = @allowed_headers
|
|
73
|
+
res.headers["Access-Control-Max-Age"] = @max_age.to_s
|
|
74
|
+
res.set_status(204)
|
|
75
|
+
res.set_body_if_empty("")
|
|
76
|
+
# `res.halted = true` short-circuits the dispatch loop
|
|
77
|
+
# (see App#dispatch) so the no-route fallthrough doesn't
|
|
78
|
+
# overwrite our 204 with a 404.
|
|
79
|
+
res.halted = true
|
|
80
|
+
end
|
|
81
|
+
0
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Default-secure response headers. Mirrors what
|
|
86
|
+
# rack-protection sets out of the box, minus the parts that
|
|
87
|
+
# need stateful middleware (CSRF token threading is its own
|
|
88
|
+
# feature; tep handlers can opt in with `<form><input type=
|
|
89
|
+
# "hidden" name="_csrf" value="..."></form>` + a session
|
|
90
|
+
# check on POST routes).
|
|
91
|
+
#
|
|
92
|
+
# Headers set:
|
|
93
|
+
# X-Content-Type-Options: nosniff
|
|
94
|
+
# X-Frame-Options: SAMEORIGIN
|
|
95
|
+
# Referrer-Policy: strict-origin-when-cross-origin
|
|
96
|
+
# X-XSS-Protection: 0 (modern browsers ignore; "0"
|
|
97
|
+
# is current OWASP guidance over
|
|
98
|
+
# "1; mode=block" which causes
|
|
99
|
+
# reflected XSS injection bugs)
|
|
100
|
+
#
|
|
101
|
+
# Optional, off by default:
|
|
102
|
+
# Strict-Transport-Security
|
|
103
|
+
# -- enable via `set_hsts(seconds)`. Setting on plain HTTP
|
|
104
|
+
# is ineffective; only emit when you've actually got
|
|
105
|
+
# TLS termination upstream.
|
|
106
|
+
#
|
|
107
|
+
# Wiring: register as an `after` filter so it runs after the
|
|
108
|
+
# handler can override Content-Type etc.
|
|
109
|
+
class Headers < Tep::Filter
|
|
110
|
+
attr_accessor :hsts_seconds
|
|
111
|
+
|
|
112
|
+
def initialize
|
|
113
|
+
@hsts_seconds = 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def set_hsts(seconds); @hsts_seconds = seconds; end
|
|
117
|
+
|
|
118
|
+
def after(req, res)
|
|
119
|
+
if !res.headers.key?("X-Content-Type-Options")
|
|
120
|
+
res.headers["X-Content-Type-Options"] = "nosniff"
|
|
121
|
+
end
|
|
122
|
+
if !res.headers.key?("X-Frame-Options")
|
|
123
|
+
res.headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
124
|
+
end
|
|
125
|
+
if !res.headers.key?("Referrer-Policy")
|
|
126
|
+
res.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
127
|
+
end
|
|
128
|
+
if !res.headers.key?("X-XSS-Protection")
|
|
129
|
+
res.headers["X-XSS-Protection"] = "0"
|
|
130
|
+
end
|
|
131
|
+
if @hsts_seconds > 0 && !res.headers.key?("Strict-Transport-Security")
|
|
132
|
+
res.headers["Strict-Transport-Security"] =
|
|
133
|
+
"max-age=" + @hsts_seconds.to_s + "; includeSubDomains"
|
|
134
|
+
end
|
|
135
|
+
0
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
end
|
|
140
|
+
end
|