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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
data/lib/tep/server.rb ADDED
@@ -0,0 +1,276 @@
1
+ # Tep::Server -- accept loop. Single-threaded inside one process;
2
+ # the perf model is "fork N workers, each runs its own Server"
3
+ # (-w workers) using SO_REUSEPORT so the kernel load-balances.
4
+ module Tep
5
+ def self.reason(status)
6
+ if status == 200; return "OK"; end
7
+ if status == 201; return "Created"; end
8
+ if status == 204; return "No Content"; end
9
+ if status == 301; return "Moved Permanently"; end
10
+ if status == 302; return "Found"; end
11
+ if status == 304; return "Not Modified"; end
12
+ if status == 400; return "Bad Request"; end
13
+ if status == 401; return "Unauthorized"; end
14
+ if status == 403; return "Forbidden"; end
15
+ if status == 404; return "Not Found"; end
16
+ if status == 500; return "Internal Server Error"; end
17
+ "OK"
18
+ end
19
+
20
+ class Server
21
+ attr_accessor :app
22
+
23
+ def initialize(app)
24
+ @app = app
25
+ end
26
+
27
+ def run(port, workers, quiet)
28
+ scheme = "http"
29
+ if Tep::APP.tls_cert.length > 0
30
+ scheme = "https"
31
+ end
32
+ if !quiet
33
+ puts "[tep " + VERSION + "] listening on " + scheme + "://0.0.0.0:" + port.to_s +
34
+ " (workers=" + workers.to_s + ")"
35
+ end
36
+
37
+ # Install SIGTERM/SIGINT handlers BEFORE fork so children inherit
38
+ # them; on signal, sphttp_accept returns -1 and the worker loop
39
+ # runs Tep.on_shutdown (flushes events.run_end + future hooks).
40
+ Sock.sphttp_install_term_handlers
41
+
42
+ # Inbound TLS (tep#148 phase 2): load the server cert/key once
43
+ # before forking so every worker inherits the SSL_CTX. A bad
44
+ # cert/key is fatal -- fail loud rather than serve plaintext on a
45
+ # port the operator believes is TLS.
46
+ if Tep::APP.tls_cert.length > 0 && Tep::APP.tls_key.length > 0
47
+ if Sock.sphttp_tls_server_init(Tep::APP.tls_cert, Tep::APP.tls_key) < 0
48
+ puts "tep: TLS init failed (cert=" + Tep::APP.tls_cert + ", key=" + Tep::APP.tls_key + ")"
49
+ exit(1)
50
+ end
51
+ end
52
+
53
+ if workers <= 1
54
+ sfd = Sock.sphttp_listen(port, 0)
55
+ if sfd < 0
56
+ puts "tep: bind failed on :" + port.to_s
57
+ exit(1)
58
+ end
59
+ worker_loop(sfd)
60
+ # Single-process is its own "parent" -- emit run_end here.
61
+ if Sock.sphttp_shutdown_requested != 0
62
+ Tep.on_shutdown
63
+ end
64
+ return
65
+ end
66
+
67
+ # Pre-fork. Each child opens its own SO_REUSEPORT listener so
68
+ # the kernel load-balances accept() across workers.
69
+ i = 0
70
+ while i < workers
71
+ pid = Sock.sphttp_fork
72
+ if pid == 0
73
+ sfd = Sock.sphttp_listen(port, 1)
74
+ if sfd < 0
75
+ puts "tep: worker " + Sock.sphttp_getpid.to_s + " bind failed"
76
+ exit(1)
77
+ end
78
+ worker_loop(sfd)
79
+ exit(0)
80
+ end
81
+ i += 1
82
+ end
83
+ # Parent: reap children until none remain (wait returns -1).
84
+ # On SIGTERM-to-the-pgroup, children break their accept loops and
85
+ # exit; parent's wait_any reaps them in order. Once all workers
86
+ # are gone, emit the single aggregated run_end (re-reading the
87
+ # JSONL for cross-worker stats; see Tep::Events#run_end_aggregated).
88
+ loop do
89
+ gone = Sock.sphttp_wait_any
90
+ if gone < 0
91
+ break
92
+ end
93
+ end
94
+ if Sock.sphttp_shutdown_requested != 0
95
+ Tep.on_shutdown
96
+ end
97
+ end
98
+
99
+ def worker_loop(sfd)
100
+ loop do
101
+ client = Sock.sphttp_accept(sfd)
102
+ if client < 0
103
+ # accept returns -1 with the term flag set after the first
104
+ # SIGTERM/SIGINT (sphttp_accept retries past unrelated
105
+ # signals). Break here; the parent (or this same process for
106
+ # workers=1) emits the aggregated run_end. Workers used to
107
+ # call Tep.on_shutdown here too, which emitted N run_ends
108
+ # in the JSONL for an N-worker deployment (#128).
109
+ if Sock.sphttp_shutdown_requested != 0
110
+ break
111
+ end
112
+ next
113
+ end
114
+ # Inbound TLS: complete the server-side handshake on the freshly
115
+ # accepted fd before reading the request. Bound the handshake
116
+ # with a recv timeout first -- otherwise a connection that opens
117
+ # but never sends a ClientHello (a port probe, a slowloris, a
118
+ # plain-HTTP client) blocks SSL_accept and wedges this worker.
119
+ # Clear the timeout after a successful handshake so normal
120
+ # (incl. keep-alive) request reads aren't bounded. On failure
121
+ # drop the connection.
122
+ if Tep::APP.tls_cert.length > 0
123
+ Sock.sphttp_set_recv_timeout(client, 5000)
124
+ if Sock.sphttp_accept_tls(client) < 0
125
+ Sock.sphttp_close(client)
126
+ next
127
+ end
128
+ Sock.sphttp_set_recv_timeout(client, 0)
129
+ end
130
+ handle_connection(client)
131
+ end
132
+ end
133
+
134
+ # Keep-alive loop on a single accepted connection.
135
+ def handle_connection(client)
136
+ keep_going = true
137
+ while keep_going
138
+ n = Sock.sphttp_read_request(client)
139
+ if n <= 0
140
+ break
141
+ end
142
+ blob = Sock.sphttp_request_buf
143
+ req = Parser.parse(blob)
144
+ if req == nil
145
+ send_simple(client, 400, "bad request")
146
+ break
147
+ end
148
+
149
+ req.consume_body(client)
150
+
151
+ res = Response.new
152
+ @app.dispatch(req, res)
153
+
154
+ keep_alive = req.keep_alive? && !res.halted_close?
155
+ write_response(client, req, res, keep_alive)
156
+ keep_going = keep_alive
157
+ end
158
+ Sock.sphttp_close(client)
159
+ end
160
+
161
+ def write_response(client, req, res, keep_alive)
162
+ # WebSocket upgrade is only supported under Tep::Server::Scheduled
163
+ # (the recv loop needs cooperative I/O via Tep::Scheduler.io_wait).
164
+ # Under the prefork-blocking server, fail with 501 so the client
165
+ # sees a clean refusal instead of a half-installed handshake.
166
+ if res.upgrading_ws
167
+ send_simple(client, 501, "WebSocket requires the scheduled server: set :scheduler, :scheduled")
168
+ return
169
+ end
170
+ if res.streaming
171
+ # Chunked-encoding stream. Send headers immediately, hand a
172
+ # Stream writer to the user's Streamer.pump, emit terminator.
173
+ # Connection is closed afterwards (keep-alive + chunked is
174
+ # technically legal but we keep things simple).
175
+ res.headers["Transfer-Encoding"] = "chunked"
176
+ res.headers["Connection"] = "close"
177
+ if !res.headers.key?("Content-Type")
178
+ res.headers["Content-Type"] = "text/event-stream"
179
+ end
180
+ head = build_head(req, res)
181
+ Sock.sphttp_write_str(client, head)
182
+ out = Stream.new(client)
183
+ res.streamer.pump(out)
184
+ Sock.sphttp_write_chunk_end(client)
185
+ return
186
+ end
187
+
188
+ if res.file_path.length > 0
189
+ # send_file path -- compute size, emit headers, then stream.
190
+ sz = Sock.sphttp_filesize(res.file_path)
191
+ if sz < 0
192
+ send_simple(client, 404, "file not found")
193
+ return
194
+ end
195
+ # Cache validators for revalidation (#152): a size-mtime ETag +
196
+ # Last-Modified, set before the conditional check.
197
+ mt = Sock.sphttp_file_mtime(res.file_path)
198
+ if mt >= 0
199
+ res.etag(sz.to_s + "-" + mt.to_s)
200
+ res.last_modified(mt)
201
+ end
202
+ if !res.headers.key?("Content-Type")
203
+ res.headers["Content-Type"] = "application/octet-stream"
204
+ end
205
+ if keep_alive
206
+ res.headers["Connection"] = "keep-alive"
207
+ else
208
+ res.headers["Connection"] = "close"
209
+ end
210
+ # Conditional GET: 304 + no body (no sendfile) when the client's
211
+ # cached copy is still fresh.
212
+ if Tep::Cache.not_modified?(req, res)
213
+ res.set_status(304)
214
+ res.headers["Content-Length"] = "0"
215
+ Sock.sphttp_write_str(client, build_head(req, res))
216
+ return
217
+ end
218
+ res.headers["Content-Length"] = sz.to_s
219
+ head = build_head(req, res)
220
+ Sock.sphttp_write_str(client, head)
221
+ Sock.sphttp_sendfile(client, res.file_path)
222
+ return
223
+ end
224
+
225
+ # Conditional GET: if the handler set a validator (ETag /
226
+ # Last-Modified) that the request's precondition satisfies, drop to
227
+ # 304 with no body. The validator + cache headers already on res
228
+ # are still emitted; Content-Length becomes 0 below. (Issue #152.)
229
+ if Tep::Cache.not_modified?(req, res)
230
+ res.set_status(304)
231
+ res.set_body("")
232
+ end
233
+
234
+ if res.body.length > 0 && !res.headers.key?("Content-Type")
235
+ res.headers["Content-Type"] = "text/html; charset=utf-8"
236
+ end
237
+ res.headers["Content-Length"] = res.body.length.to_s
238
+ if keep_alive
239
+ res.headers["Connection"] = "keep-alive"
240
+ else
241
+ res.headers["Connection"] = "close"
242
+ end
243
+
244
+ head = build_head(req, res)
245
+ Sock.sphttp_write_str(client, head)
246
+ if res.body.length > 0
247
+ Sock.sphttp_write_str(client, res.body)
248
+ end
249
+ end
250
+
251
+ def build_head(req, res)
252
+ reason = Tep.reason(res.status)
253
+ head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n"
254
+ res.headers.each do |k, v|
255
+ head = head + k + ": " + v + "\r\n"
256
+ end
257
+ # Set-Cookie can repeat; emit each on its own line.
258
+ ci = 0
259
+ while ci < res.set_cookies.length
260
+ head = head + "Set-Cookie: " + res.set_cookies[ci] + "\r\n"
261
+ ci += 1
262
+ end
263
+ head + "\r\n"
264
+ end
265
+
266
+ def send_simple(client, status, msg)
267
+ reason = Tep.reason(status)
268
+ body = "<h1>" + status.to_s + " " + reason + "</h1><p>" + msg + "</p>\n"
269
+ head = "HTTP/1.0 " + status.to_s + " " + reason + "\r\n" +
270
+ "Content-Type: text/html; charset=utf-8\r\n" +
271
+ "Content-Length: " + body.length.to_s + "\r\n" +
272
+ "Connection: close\r\n\r\n"
273
+ Sock.sphttp_write_str(client, head + body)
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,375 @@
1
+ # Tep::Server::Scheduled -- Falcon-shape fiber-per-connection HTTP
2
+ # server, built on Tep::Scheduler + sphttp non-blocking accept/recv.
3
+ #
4
+ # Why this exists
5
+ # ---------------
6
+ # The default Tep::Server (in server.rb) is prefork + blocking per
7
+ # worker -- N workers <=> N concurrent connections. WebSockets and
8
+ # slow keep-alive clients tie up a worker for the full connection
9
+ # duration, so the prefork pool's effective concurrency degrades
10
+ # to N regardless of actual CPU work. The scheduled variant accepts
11
+ # in a fiber, spawns one fiber per accepted connection, and parks
12
+ # all I/O on Tep::Scheduler.io_wait -- N workers serve M >> N
13
+ # concurrent connections, bounded only by per-fiber memory.
14
+ #
15
+ # Fiber bodies use ordinary closure capture for sfd / client now
16
+ # (matz/spinel#564 + #1007 both closed; the heap-cell-reset fix
17
+ # in spinel commit 48594d6 lets multi-method capture chains lower
18
+ # correctly). cmeths still preferred for accept_loop /
19
+ # handle_connection so the bodies read cleanly without per-instance
20
+ # state, but the per-connection fd flows through closure capture,
21
+ # not the earlier `Tep::APP.pending_*` stash + pause(0) handoff.
22
+ module Tep
23
+ class Server
24
+ class Scheduled
25
+ # Max bytes accepted from a single request's start-line +
26
+ # headers. Bigger requests get 413; matches the blocking
27
+ # server's SPHTTP_BUFSIZE cap (64 KiB).
28
+ MAX_REQUEST_BYTES = 65535
29
+
30
+ # Idle keep-alive timeout between requests on the same
31
+ # connection. 30s matches nginx; bump from app code as needed.
32
+ KEEPALIVE_TIMEOUT = 30
33
+
34
+ # Slow-headers DoS guard.
35
+ HEADER_READ_TIMEOUT = 10
36
+
37
+ attr_accessor :app
38
+
39
+ def initialize(app)
40
+ @app = app
41
+ end
42
+
43
+ def run(port, workers, quiet)
44
+ sfd = Sock.sphttp_listen(port, workers > 1 ? 1 : 0)
45
+ if sfd < 0
46
+ return 1
47
+ end
48
+ Sock.sphttp_set_nonblock(sfd)
49
+
50
+ # Install SIGTERM/SIGINT handlers BEFORE fork so children
51
+ # inherit them; accept_loop checks the term flag once per
52
+ # second and runs Tep.on_shutdown (run_end + future hooks).
53
+ Sock.sphttp_install_term_handlers
54
+
55
+ # Inbound TLS (tep#148 phase 2, scheduled variant): load the
56
+ # server cert/key once before forking so every worker inherits
57
+ # the SSL_CTX. A bad cert/key is fatal -- never silently serve
58
+ # plaintext on a port the operator believes is TLS. The handshake
59
+ # itself runs non-blocking per-connection (handle_connection).
60
+ if Tep::APP.tls_cert.length > 0 && Tep::APP.tls_key.length > 0
61
+ if Sock.sphttp_tls_server_init(Tep::APP.tls_cert, Tep::APP.tls_key) < 0
62
+ return 1
63
+ end
64
+ end
65
+
66
+ if workers > 1
67
+ i = 0
68
+ while i < workers
69
+ pid = Sock.sphttp_fork
70
+ if pid == 0
71
+ Tep::Server::Scheduled.run_worker(sfd)
72
+ Sock.sphttp_exit(0)
73
+ end
74
+ i += 1
75
+ end
76
+ # Reap children until none remain. After all workers exit,
77
+ # emit the single aggregated run_end (see #128 / Tep::Events
78
+ # #run_end_aggregated).
79
+ loop do
80
+ gone = Sock.sphttp_wait_any
81
+ if gone < 0
82
+ break
83
+ end
84
+ end
85
+ if Sock.sphttp_shutdown_requested != 0
86
+ Tep.on_shutdown
87
+ end
88
+ else
89
+ Tep::Server::Scheduled.run_worker(sfd)
90
+ # Single-process: this IS the parent; emit run_end here.
91
+ if Sock.sphttp_shutdown_requested != 0
92
+ Tep.on_shutdown
93
+ end
94
+ end
95
+ 0
96
+ end
97
+
98
+ # Spawn the accept fiber + pump the scheduler. Called inside
99
+ # each prefork child. Loops directly on `tick` rather than
100
+ # `run_until_empty` because the accept fiber parks on io_wait
101
+ # indefinitely -- run_until_empty bails when no fiber is ready
102
+ # to resume THIS pass; we need to keep polling so parked
103
+ # accept-on-sfd fibers get woken when a connection arrives.
104
+ def self.run_worker(sfd)
105
+ f = Fiber.new { Tep::Server::Scheduled.accept_loop(sfd) }
106
+ Tep::Scheduler.spawn_fiber(f)
107
+ while Tep::Scheduler.alive_count > 0
108
+ Tep::Scheduler.tick(1000)
109
+ end
110
+ 0
111
+ end
112
+
113
+ # Accept loop. Each accepted connection becomes its own fiber
114
+ # that closes over the just-accepted `client` fd.
115
+ def self.accept_loop(sfd)
116
+ while true
117
+ # SIGTERM/SIGINT: sphttp's term flag is set by the signal
118
+ # handler; check before parking on io_wait so we don't sleep
119
+ # past a shutdown request. The 1s io_wait timeout below
120
+ # bounds the sleep-side latency. The parent (or this same
121
+ # process for workers=1) emits the aggregated run_end after
122
+ # all workers exit (#128).
123
+ if Sock.sphttp_shutdown_requested != 0
124
+ break
125
+ end
126
+ # Bounded wait so the flag check above runs once per second
127
+ # even when traffic is idle (was -1 = wait forever).
128
+ ready = Tep::Scheduler.io_wait(sfd, Tep::Scheduler::READ, 1)
129
+ if ready == 0
130
+ next
131
+ end
132
+ client = Sock.sphttp_accept_nb(sfd)
133
+ if client < 0
134
+ next
135
+ end
136
+ Sock.sphttp_set_nonblock(client)
137
+ conn = Fiber.new { Tep::Server::Scheduled.handle_connection(client) }
138
+ Tep::Scheduler.spawn_fiber(conn)
139
+ end
140
+ end
141
+
142
+ # Non-blocking server-side TLS handshake on an accepted fd.
143
+ # Returns 1 on success (SSL* registered -- reads/writes are now
144
+ # transparent), 0 on failure. Drives SSL_do_handshake, parking on
145
+ # io_wait for the direction OpenSSL asks for, bounded by
146
+ # HEADER_READ_TIMEOUT so a connection that opens but never
147
+ # completes the handshake (port probe, slowloris, plain-HTTP
148
+ # client) can't pin the fiber.
149
+ def self.tls_handshake(client)
150
+ if Sock.sphttp_tls_accept_start(client) < 0
151
+ return 0
152
+ end
153
+ deadline = Time.now.to_i + HEADER_READ_TIMEOUT
154
+ hs = Sock.sphttp_tls_handshake_step(client)
155
+ while hs == 1 || hs == 2
156
+ remaining = deadline - Time.now.to_i
157
+ if remaining <= 0
158
+ return 0
159
+ end
160
+ mode = Tep::Scheduler::READ
161
+ if hs == 2
162
+ mode = Tep::Scheduler::WRITE
163
+ end
164
+ ready = Tep::Scheduler.io_wait(client, mode, remaining)
165
+ if ready == 0
166
+ return 0
167
+ end
168
+ hs = Sock.sphttp_tls_handshake_step(client)
169
+ end
170
+ if hs < 0
171
+ return 0
172
+ end
173
+ 1
174
+ end
175
+
176
+ # Per-connection lifecycle.
177
+ def self.handle_connection(client)
178
+ # Inbound TLS: complete a non-blocking server handshake before
179
+ # reading anything. Runs inside this per-connection fiber so a
180
+ # slow handshake parks cooperatively instead of blocking the
181
+ # accept loop. On failure (incl. a plaintext client hitting the
182
+ # TLS port) drop the connection.
183
+ if Tep::APP.tls_cert.length > 0
184
+ if Tep::Server::Scheduled.tls_handshake(client) == 0
185
+ Sock.sphttp_close(client)
186
+ return 0
187
+ end
188
+ end
189
+ keep_going = true
190
+ while keep_going
191
+ blob = Tep::Server::Scheduled.read_request_blob(client, KEEPALIVE_TIMEOUT)
192
+ if blob.length == 0
193
+ break
194
+ end
195
+ req = Parser.parse(blob)
196
+ if req == nil
197
+ Tep::Server::Scheduled.send_simple(client, 400, "bad request")
198
+ break
199
+ end
200
+
201
+ req.consume_body_via_scheduler(client)
202
+
203
+ res = Response.new
204
+ Tep::APP.dispatch(req, res)
205
+
206
+ # Streaming responses use chunked Connection: close (same
207
+ # simplification as the prefork server) -- force the
208
+ # keep-alive loop to end after this response so the stream's
209
+ # terminator isn't followed by a stale read on the same fd.
210
+ keep_alive = req.keep_alive? && !res.halted_close? && !res.streaming
211
+ Tep::Server::Scheduled.write_response(client, req, res, keep_alive)
212
+ keep_going = keep_alive
213
+ end
214
+ Sock.sphttp_close(client)
215
+ 0
216
+ end
217
+
218
+ # Non-blocking request reader. Returns the accumulated blob
219
+ # once "\r\n\r\n" is seen, or "" on timeout / EOF / oversize.
220
+ def self.read_request_blob(fd, timeout_seconds)
221
+ buf = ""
222
+ deadline = Time.now.to_i + timeout_seconds
223
+ while buf.length < MAX_REQUEST_BYTES
224
+ remaining = deadline - Time.now.to_i
225
+ if remaining <= 0
226
+ return ""
227
+ end
228
+ ready = Tep::Scheduler.io_wait(fd, Tep::Scheduler::READ, remaining)
229
+ if ready == 0
230
+ return ""
231
+ end
232
+ chunk = Sock.sphttp_recv_some(fd, 4096)
233
+ if chunk.length == 0
234
+ # Over TLS an empty read can be a partial record (SSL_read
235
+ # WANT_READ/WANT_WRITE), not EOF -- re-park on the indicated
236
+ # direction and retry rather than dropping the request. The
237
+ # loop top re-applies the deadline on the want-read path.
238
+ st = Sock.sphttp_io_status
239
+ if st == 1
240
+ next
241
+ end
242
+ if st == 2
243
+ Tep::Scheduler.io_wait(fd, Tep::Scheduler::WRITE, remaining)
244
+ next
245
+ end
246
+ return ""
247
+ end
248
+ buf = buf + chunk
249
+ if buf.length >= 4 && buf.include?("\r\n\r\n")
250
+ return buf
251
+ end
252
+ end
253
+ ""
254
+ end
255
+
256
+ # Body-shape mirror of Tep::Server#write_response. Lifted into
257
+ # a cmeth so the connection fiber can call it without a captured
258
+ # `self`.
259
+ def self.write_response(client, req, res, keep_alive)
260
+ # WebSocket upgrade branch. Set by res.start_websocket in the
261
+ # user's handler after a successful Handshake.check. Writes
262
+ # the 101 Switching Protocols head, then assigns the client
263
+ # fd onto the driver and runs the recv loop. The recv loop
264
+ # returns when the connection closes (peer EOF, idle timeout,
265
+ # or a CLOSE frame round-trip). After return, the caller's
266
+ # handle_connection closes the fd as usual.
267
+ if res.upgrading_ws
268
+ head = Tep::WebSocket::Handshake.build_response(
269
+ res.ws_accept_key, res.ws_driver.subprotocol)
270
+ Sock.sphttp_write_str(client, head)
271
+ res.ws_driver.set_fd(client)
272
+ conn = Tep::WebSocket::Connection.new(res.ws_driver)
273
+ conn.run
274
+ return 0
275
+ end
276
+
277
+ # Streaming branch -- cooperative mirror of Tep::Server's
278
+ # streaming path (server.rb). Set by res.start_stream(streamer)
279
+ # in the handler. Writes a chunked-encoding head immediately,
280
+ # hands a Tep::Stream writer to the user's Streamer#pump, then
281
+ # emits the end-of-stream terminator. pump runs cooperatively:
282
+ # it parks on Tep::Scheduler.io_wait between writes (e.g. the
283
+ # proxy streamer waits on the upstream fd), so other fibers keep
284
+ # running while this stream is in flight. Connection: close --
285
+ # chunked keep-alive is legal but we keep it simple, matching
286
+ # the prefork server.
287
+ if res.streaming
288
+ res.headers["Transfer-Encoding"] = "chunked"
289
+ if !res.headers.key?("Content-Type")
290
+ res.headers["Content-Type"] = "text/event-stream"
291
+ end
292
+ reason = Tep.reason(res.status)
293
+ head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n"
294
+ res.headers.each do |k, v|
295
+ head = head + k + ": " + v + "\r\n"
296
+ end
297
+ res.set_cookies.each do |line|
298
+ head = head + "Set-Cookie: " + line + "\r\n"
299
+ end
300
+ head = head + "Connection: close\r\n\r\n"
301
+ Sock.sphttp_write_str(client, head)
302
+ out = Tep::Stream.new(client)
303
+ res.streamer.pump(out)
304
+ Sock.sphttp_write_chunk_end(client)
305
+ return 0
306
+ end
307
+
308
+ # File validators for cache revalidation (#152): a size-mtime
309
+ # ETag + Last-Modified, set before headers are serialized below.
310
+ if res.file_path.length > 0
311
+ fsz = Sock.sphttp_filesize(res.file_path)
312
+ fmt = Sock.sphttp_file_mtime(res.file_path)
313
+ if fsz >= 0 && fmt >= 0
314
+ res.etag(fsz.to_s + "-" + fmt.to_s)
315
+ res.last_modified(fmt)
316
+ end
317
+ end
318
+
319
+ # Conditional GET (issue #152): 304 + no body when the request's
320
+ # precondition matches the response's validator (ETag /
321
+ # Last-Modified, whether set by the handler or for a file above).
322
+ # For a file we also clear file_path so the sendfile branch below
323
+ # is skipped and the empty 304 goes out the inline-body path.
324
+ if Tep::Cache.not_modified?(req, res)
325
+ res.set_status(304)
326
+ res.set_body("")
327
+ res.file_path = ""
328
+ end
329
+
330
+ # Default Content-Type for inline-body responses. Matches
331
+ # Tep::Server#send; without it, the Security::Headers nosniff
332
+ # default leaves the browser refusing to interpret an erb
333
+ # response as HTML.
334
+ if res.file_path.length == 0 && res.body.length > 0 && !res.headers.key?("Content-Type")
335
+ res.headers["Content-Type"] = "text/html; charset=utf-8"
336
+ end
337
+ reason = Tep.reason(res.status)
338
+ head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n"
339
+ res.headers.each do |k, v|
340
+ head = head + k + ": " + v + "\r\n"
341
+ end
342
+ res.set_cookies.each do |line|
343
+ head = head + "Set-Cookie: " + line + "\r\n"
344
+ end
345
+ if keep_alive
346
+ head = head + "Connection: keep-alive\r\n"
347
+ else
348
+ head = head + "Connection: close\r\n"
349
+ end
350
+ if res.file_path.length > 0
351
+ fs = Sock.sphttp_filesize(res.file_path)
352
+ head = head + "Content-Length: " + fs.to_s + "\r\n\r\n"
353
+ Sock.sphttp_write_str(client, head)
354
+ Sock.sphttp_sendfile(client, res.file_path)
355
+ else
356
+ head = head + "Content-Length: " + res.body.length.to_s + "\r\n\r\n"
357
+ Sock.sphttp_write_str(client, head)
358
+ if res.body.length > 0
359
+ Sock.sphttp_write_str(client, res.body)
360
+ end
361
+ end
362
+ 0
363
+ end
364
+
365
+ def self.send_simple(client, status, msg)
366
+ reason = Tep.reason(status)
367
+ head = "HTTP/1.0 " + status.to_s + " " + reason + "\r\n" +
368
+ "Content-Length: " + msg.length.to_s + "\r\n" +
369
+ "Connection: close\r\n\r\n" + msg
370
+ Sock.sphttp_write_str(client, head)
371
+ 0
372
+ end
373
+ end
374
+ end
375
+ end