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
data/lib/tep/http.rb
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
# Tep::Http -- a small outbound HTTP client, Faraday-shaped.
|
|
2
|
+
#
|
|
3
|
+
# Why
|
|
4
|
+
# ---
|
|
5
|
+
# Pure-Ruby HTTP clients (net/http, faraday, http.rb, httparty) all
|
|
6
|
+
# pull in things spinel can't lower: `Net::HTTP`'s subclass-per-verb
|
|
7
|
+
# wiring, faraday's middleware DSL (closures + define_method), the
|
|
8
|
+
# `http.rb` gem's MIME parser. tep's runtime already has the socket
|
|
9
|
+
# plumbing (sphttp_connect, sphttp_set_nonblock, sphttp_recv_*); the
|
|
10
|
+
# missing piece is the HTTP/1.0 client on top.
|
|
11
|
+
#
|
|
12
|
+
# Scope (v1)
|
|
13
|
+
# ----------
|
|
14
|
+
# * **HTTP only** -- no TLS. Talk to internal services, the local
|
|
15
|
+
# Ollama API, vLLM, your own tep-backed sidecars.
|
|
16
|
+
# * **HTTP/1.0 + Connection: close** -- one socket per request,
|
|
17
|
+
# read until EOF. No keep-alive, no pipelining.
|
|
18
|
+
# * **No chunked-transfer reading** -- assumes Content-Length or
|
|
19
|
+
# close-delimited body. Most APIs do one or the other.
|
|
20
|
+
# * **No automatic redirects** -- callers inspect `.status` and
|
|
21
|
+
# `.headers["location"]` and re-issue if they want.
|
|
22
|
+
# * **No streaming** -- whole response materialises in memory
|
|
23
|
+
# (sphttp_recv_all caps at ~64 KB).
|
|
24
|
+
#
|
|
25
|
+
# These limits cover the dashboard's needs (talking to local
|
|
26
|
+
# inference backends) and the bulk of "hit an internal API"
|
|
27
|
+
# workloads. HTTPS + keep-alive + chunked land as a v2 surface.
|
|
28
|
+
#
|
|
29
|
+
# API shape
|
|
30
|
+
# ---------
|
|
31
|
+
# Faraday-style class shortcuts:
|
|
32
|
+
#
|
|
33
|
+
# res = Tep::Http.get("http://api.local/users/42")
|
|
34
|
+
# res = Tep::Http.post("http://api.local/users", '{"name":"a"}')
|
|
35
|
+
# res.status # Integer
|
|
36
|
+
# res.headers # Hash<String,String> (downcased keys)
|
|
37
|
+
# res.body # String
|
|
38
|
+
#
|
|
39
|
+
# Reusable client with a base URL + default headers:
|
|
40
|
+
#
|
|
41
|
+
# c = Tep::Http.new("http://api.local")
|
|
42
|
+
# c.set_header("Authorization", "Bearer tok")
|
|
43
|
+
# res = c.do_get("/users/42")
|
|
44
|
+
# res = c.do_post("/users", body)
|
|
45
|
+
#
|
|
46
|
+
# The instance verbs are `do_get` / `do_post` / `do_put` etc. (not
|
|
47
|
+
# the bare Faraday names) so a call like `http.get(path)` doesn't
|
|
48
|
+
# read as a Sinatra route inside an app. The class-level shortcuts
|
|
49
|
+
# (`Tep::Http.get(url)`) keep the Faraday spelling because cmeth
|
|
50
|
+
# names live in a separate namespace.
|
|
51
|
+
#
|
|
52
|
+
# Request-specific headers pass through the lower-level `send_req`:
|
|
53
|
+
#
|
|
54
|
+
# h = Tep.str_hash
|
|
55
|
+
# h["Accept"] = "application/json"
|
|
56
|
+
# res = Tep::Http.send_req("GET", url, "", h)
|
|
57
|
+
module Tep
|
|
58
|
+
class Http
|
|
59
|
+
attr_accessor :base_url, :default_headers
|
|
60
|
+
|
|
61
|
+
def initialize(base_url)
|
|
62
|
+
@base_url = base_url
|
|
63
|
+
@default_headers = Tep.str_hash
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_header(k, v)
|
|
67
|
+
@default_headers[k] = v
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Instance verbs. `path` is appended to `base_url` if it starts
|
|
71
|
+
# with "/", or used as-is if it's a full URL. Prefixed with
|
|
72
|
+
# `do_` to avoid the cmeth / imeth ambiguity at the call site:
|
|
73
|
+
# `http.get(path)` reads like a Sinatra route in apps, whereas
|
|
74
|
+
# `http.do_get(path)` does not.
|
|
75
|
+
def do_get(path); do_req(path, "GET", ""); end
|
|
76
|
+
def do_head(path); do_req(path, "HEAD", ""); end
|
|
77
|
+
def do_delete(path); do_req(path, "DELETE", ""); end
|
|
78
|
+
def do_post(path, body); do_req(path, "POST", body); end
|
|
79
|
+
def do_put(path, body); do_req(path, "PUT", body); end
|
|
80
|
+
def do_patch(path, body); do_req(path, "PATCH", body); end
|
|
81
|
+
|
|
82
|
+
def do_req(path, verb, body)
|
|
83
|
+
url = path
|
|
84
|
+
if path.length > 0 && path[0] == "/"
|
|
85
|
+
url = @base_url + path
|
|
86
|
+
end
|
|
87
|
+
Http.send_req(verb, url, body, @default_headers)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Class-level one-shots. Build a default empty headers hash and
|
|
91
|
+
# dispatch through send_req.
|
|
92
|
+
def self.get(url); Http.send_req("GET", url, "", Http.empty_headers); end
|
|
93
|
+
def self.head(url); Http.send_req("HEAD", url, "", Http.empty_headers); end
|
|
94
|
+
def self.delete(url); Http.send_req("DELETE", url, "", Http.empty_headers); end
|
|
95
|
+
def self.post(url, body); Http.send_req("POST", url, body, Http.empty_headers); end
|
|
96
|
+
def self.put(url, body); Http.send_req("PUT", url, body, Http.empty_headers); end
|
|
97
|
+
def self.patch(url, body); Http.send_req("PATCH", url, body, Http.empty_headers); end
|
|
98
|
+
|
|
99
|
+
def self.empty_headers
|
|
100
|
+
Tep.str_hash
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Per-recv timeout in the cooperative path. Bounds how long a
|
|
104
|
+
# parked fiber will wait for the next chunk from the peer before
|
|
105
|
+
# giving up and returning status=0. 30s matches the scheduled
|
|
106
|
+
# server's KEEPALIVE_TIMEOUT; loud failure beats a wedged fiber.
|
|
107
|
+
COOP_RECV_TIMEOUT = 30
|
|
108
|
+
|
|
109
|
+
# Hard cap on total response bytes accumulated by the cooperative
|
|
110
|
+
# path. Mirrors sphttp_recv_all's static-buffer cap (~64 KiB) so
|
|
111
|
+
# the two paths impose the same upper bound. Bigger responses
|
|
112
|
+
# need streaming, which v1 doesn't ship.
|
|
113
|
+
COOP_RESPONSE_MAX = 65535
|
|
114
|
+
|
|
115
|
+
# Recv timeout (ms) on pooled keep-alive sockets. Bounds a response
|
|
116
|
+
# read so a no-Content-Length / chunked keep-alive upstream can't
|
|
117
|
+
# hang the worker waiting for an EOF that never comes (the recv
|
|
118
|
+
# returns and we bail with what we have, un-pooled). 30s matches
|
|
119
|
+
# COOP_RECV_TIMEOUT.
|
|
120
|
+
POOL_RECV_TIMEOUT_MS = 30000
|
|
121
|
+
|
|
122
|
+
# The workhorse. Returns a Tep::Http::Response in all cases --
|
|
123
|
+
# on connect or send failure, `.status` is 0 and `.body` is "".
|
|
124
|
+
#
|
|
125
|
+
# When called from inside a Tep::Scheduler fiber (i.e. running
|
|
126
|
+
# under Tep::Server::Scheduled), routes through `send_req_coop`,
|
|
127
|
+
# which parks on `Tep::Scheduler.io_wait` between recv calls so
|
|
128
|
+
# the worker fiber doesn't hog the scheduler while waiting for
|
|
129
|
+
# peer bytes. Outside scheduler context the call falls through to
|
|
130
|
+
# `send_req_blocking`, which is the original sphttp_recv_all path.
|
|
131
|
+
#
|
|
132
|
+
# Why split rather than always-async: outside a scheduled context
|
|
133
|
+
# (the default Tep::Server prefork model, scripts, REPL), io_wait
|
|
134
|
+
# falls back to a single-shot poll per call which would add an
|
|
135
|
+
# extra poll(2) round per chunk for no benefit. Keeping the
|
|
136
|
+
# blocking path keeps the cheap case cheap.
|
|
137
|
+
def self.send_req(verb, url, body, headers)
|
|
138
|
+
# Under Tep::Server::Scheduled, route BOTH http and https through
|
|
139
|
+
# the cooperative path. Plaintext parks on io_wait between recvs;
|
|
140
|
+
# https (tep#150) additionally drives a non-blocking TLS handshake
|
|
141
|
+
# (sphttp_tls_connect_start + handshake_step) and a want-aware
|
|
142
|
+
# SSL_read loop, so an outbound HTTPS call no longer blocks the
|
|
143
|
+
# whole worker. Outside a scheduled context the blocking path stays
|
|
144
|
+
# cheap (no extra poll(2) per chunk).
|
|
145
|
+
if Tep::Scheduler.scheduled_context?
|
|
146
|
+
Http.send_req_coop(verb, url, body, headers)
|
|
147
|
+
else
|
|
148
|
+
Http.send_req_blocking(verb, url, body, headers)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.send_req_blocking(verb, url, body, headers)
|
|
153
|
+
out = Tep::Http::Response.new
|
|
154
|
+
parts = Tep::Url.split_url(url)
|
|
155
|
+
scheme = parts["scheme"]
|
|
156
|
+
if scheme != "http" && scheme != "https"
|
|
157
|
+
# Unknown scheme.
|
|
158
|
+
return out
|
|
159
|
+
end
|
|
160
|
+
host = parts["host"]
|
|
161
|
+
port = parts["port"].to_i
|
|
162
|
+
path = parts["path"]
|
|
163
|
+
if parts["query"].length > 0
|
|
164
|
+
path = path + "?" + parts["query"]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# HTTPS: no pooling. The fd carries an SSL* in sphttp's registry;
|
|
168
|
+
# pooling TLS sockets is out of scope for 6.7b (#126). HTTP/1.0 +
|
|
169
|
+
# Connection: close + recv-until-EOF over a fresh verified socket.
|
|
170
|
+
if scheme == "https"
|
|
171
|
+
fd = Sock.sphttp_connect_tls(host, port) # port 443 via Tep::Url
|
|
172
|
+
if fd < 0
|
|
173
|
+
return out
|
|
174
|
+
end
|
|
175
|
+
# Head inlined (not a helper): spinel picks one type per param
|
|
176
|
+
# name file-wide, and path/host collide with int uses elsewhere.
|
|
177
|
+
head = verb + " " + path + " HTTP/1.0\r\n" +
|
|
178
|
+
"Host: " + host + "\r\n" +
|
|
179
|
+
"Connection: close\r\n"
|
|
180
|
+
headers.each do |k, v|
|
|
181
|
+
head = head + k + ": " + v + "\r\n"
|
|
182
|
+
end
|
|
183
|
+
if body.length > 0
|
|
184
|
+
head = head + "Content-Length: " + body.length.to_s + "\r\n"
|
|
185
|
+
end
|
|
186
|
+
head = head + "\r\n"
|
|
187
|
+
if Sock.sphttp_write_str(fd, head) < 0
|
|
188
|
+
Sock.sphttp_close(fd)
|
|
189
|
+
return out
|
|
190
|
+
end
|
|
191
|
+
if body.length > 0
|
|
192
|
+
if Sock.sphttp_write_str(fd, body) < 0
|
|
193
|
+
Sock.sphttp_close(fd)
|
|
194
|
+
return out
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
raw = Sock.sphttp_recv_all(fd, 0)
|
|
198
|
+
Sock.sphttp_close(fd)
|
|
199
|
+
return Http.parse_response(raw)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# HTTP: HTTP/1.1 keep-alive over a pooled (reused) socket (6.7b).
|
|
203
|
+
# Claim an idle fd for (host, port) or connect fresh; frame the
|
|
204
|
+
# response by Content-Length; reuse the socket (return it to the
|
|
205
|
+
# pool) only when it's cleanly framed, the peer didn't ask to
|
|
206
|
+
# close, and the status isn't a retry-worthy 5xx. A pool HIT that
|
|
207
|
+
# fails (stale socket the upstream already closed) is retried once
|
|
208
|
+
# on a fresh connection.
|
|
209
|
+
attempt = 0
|
|
210
|
+
while attempt < 2
|
|
211
|
+
from_pool = 0
|
|
212
|
+
fd = Tep::Http::Pool.claim(host, port)
|
|
213
|
+
if fd >= 0
|
|
214
|
+
from_pool = 1
|
|
215
|
+
else
|
|
216
|
+
fd = Sock.sphttp_connect(host, port)
|
|
217
|
+
end
|
|
218
|
+
if fd < 0
|
|
219
|
+
return out
|
|
220
|
+
end
|
|
221
|
+
Sock.sphttp_set_recv_timeout(fd, Http::POOL_RECV_TIMEOUT_MS)
|
|
222
|
+
|
|
223
|
+
head = verb + " " + path + " HTTP/1.1\r\n" +
|
|
224
|
+
"Host: " + host + "\r\n" +
|
|
225
|
+
"Connection: keep-alive\r\n"
|
|
226
|
+
headers.each do |k, v|
|
|
227
|
+
head = head + k + ": " + v + "\r\n"
|
|
228
|
+
end
|
|
229
|
+
if body.length > 0
|
|
230
|
+
head = head + "Content-Length: " + body.length.to_s + "\r\n"
|
|
231
|
+
end
|
|
232
|
+
head = head + "\r\n"
|
|
233
|
+
|
|
234
|
+
wrote = Sock.sphttp_write_str(fd, head)
|
|
235
|
+
if wrote >= 0 && body.length > 0
|
|
236
|
+
wrote = Sock.sphttp_write_str(fd, body)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
if wrote < 0
|
|
240
|
+
Sock.sphttp_close(fd)
|
|
241
|
+
if from_pool == 0
|
|
242
|
+
return out
|
|
243
|
+
end
|
|
244
|
+
attempt = attempt + 1 # stale pooled socket -- retry fresh
|
|
245
|
+
else
|
|
246
|
+
fr = Http.recv_framed(fd)
|
|
247
|
+
if fr.raw.length == 0 && from_pool == 1
|
|
248
|
+
Sock.sphttp_close(fd)
|
|
249
|
+
attempt = attempt + 1 # stale pooled socket gave nothing -- retry fresh
|
|
250
|
+
else
|
|
251
|
+
resp = Http.parse_response(fr.raw)
|
|
252
|
+
reuse = fr.framed_clean && !fr.conn_close && resp.status > 0 && resp.status < 500
|
|
253
|
+
if reuse
|
|
254
|
+
Tep::Http::Pool.release(fd, host, port)
|
|
255
|
+
else
|
|
256
|
+
Sock.sphttp_close(fd)
|
|
257
|
+
end
|
|
258
|
+
return resp
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
return out
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Cooperative variant. Same wire shape, same parse, but:
|
|
266
|
+
# * flips the fd to non-blocking after connect, and
|
|
267
|
+
# * replaces the synchronous sphttp_recv_all with a parked
|
|
268
|
+
# io_wait(READ) + sphttp_recv_some loop that yields the
|
|
269
|
+
# worker fiber back to the scheduler between recvs.
|
|
270
|
+
#
|
|
271
|
+
# This is what closes the macOS self-call deadlock: while the
|
|
272
|
+
# outer handler fiber is parked here, the worker's accept fiber
|
|
273
|
+
# can run, accept the inner request, dispatch its handler, and
|
|
274
|
+
# write the response -- which unblocks our io_wait. See
|
|
275
|
+
# docs/MACOS-CONCURRENCY.md for the why.
|
|
276
|
+
def self.send_req_coop(verb, url, body, headers)
|
|
277
|
+
out = Tep::Http::Response.new
|
|
278
|
+
parts = Tep::Url.split_url(url)
|
|
279
|
+
scheme = parts["scheme"]
|
|
280
|
+
if scheme != "http" && scheme != "https"
|
|
281
|
+
return out
|
|
282
|
+
end
|
|
283
|
+
host = parts["host"]
|
|
284
|
+
port = parts["port"].to_i
|
|
285
|
+
path = parts["path"]
|
|
286
|
+
if parts["query"].length > 0
|
|
287
|
+
path = path + "?" + parts["query"]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Connect. https (tep#150): non-blocking TLS -- set up the SSL then
|
|
291
|
+
# drive SSL_do_handshake, parking on io_wait for the direction
|
|
292
|
+
# OpenSSL asks for until the handshake completes. http: a plain
|
|
293
|
+
# non-blocking connect. Either way `fd` ends up non-blocking with
|
|
294
|
+
# write/recv routed appropriately (SSL_* for https via the registry).
|
|
295
|
+
if scheme == "https"
|
|
296
|
+
fd = Sock.sphttp_tls_connect_start(host, port)
|
|
297
|
+
if fd < 0
|
|
298
|
+
return out
|
|
299
|
+
end
|
|
300
|
+
hs = Sock.sphttp_tls_handshake_step(fd)
|
|
301
|
+
while hs == 1 || hs == 2
|
|
302
|
+
mode = Tep::Scheduler::READ
|
|
303
|
+
if hs == 2
|
|
304
|
+
mode = Tep::Scheduler::WRITE
|
|
305
|
+
end
|
|
306
|
+
ready = Tep::Scheduler.io_wait(fd, mode, COOP_RECV_TIMEOUT)
|
|
307
|
+
if ready == 0
|
|
308
|
+
Sock.sphttp_close(fd)
|
|
309
|
+
return out
|
|
310
|
+
end
|
|
311
|
+
hs = Sock.sphttp_tls_handshake_step(fd)
|
|
312
|
+
end
|
|
313
|
+
if hs < 0
|
|
314
|
+
# Handshake/verify failure -- handshake_step already freed the
|
|
315
|
+
# SSL*; close the fd. (cert/hostname mismatch lands here.)
|
|
316
|
+
Sock.sphttp_close(fd)
|
|
317
|
+
return out
|
|
318
|
+
end
|
|
319
|
+
else
|
|
320
|
+
fd = Sock.sphttp_connect(host, port)
|
|
321
|
+
if fd < 0
|
|
322
|
+
return out
|
|
323
|
+
end
|
|
324
|
+
Sock.sphttp_set_nonblock(fd)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Same head shape as send_req_blocking; inlined for the same
|
|
328
|
+
# spinel-type-inference reason (see that path's comment).
|
|
329
|
+
head = verb + " " + path + " HTTP/1.0\r\n" +
|
|
330
|
+
"Host: " + host + "\r\n" +
|
|
331
|
+
"Connection: close\r\n"
|
|
332
|
+
headers.each do |k, v|
|
|
333
|
+
head = head + k + ": " + v + "\r\n"
|
|
334
|
+
end
|
|
335
|
+
if body.length > 0
|
|
336
|
+
head = head + "Content-Length: " + body.length.to_s + "\r\n"
|
|
337
|
+
end
|
|
338
|
+
head = head + "\r\n"
|
|
339
|
+
|
|
340
|
+
# send(2) on a non-blocking localhost socket with a small
|
|
341
|
+
# request (start line + few headers, well under the kernel's
|
|
342
|
+
# ~16 KiB socket buffer) returns immediately. If it ever
|
|
343
|
+
# surfaces EAGAIN we'll need a write-side park; for v1 the
|
|
344
|
+
# bounded request size makes that path dead code.
|
|
345
|
+
if Sock.sphttp_write_str(fd, head) < 0
|
|
346
|
+
Sock.sphttp_close(fd)
|
|
347
|
+
return out
|
|
348
|
+
end
|
|
349
|
+
if body.length > 0
|
|
350
|
+
if Sock.sphttp_write_str(fd, body) < 0
|
|
351
|
+
Sock.sphttp_close(fd)
|
|
352
|
+
return out
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
raw = ""
|
|
357
|
+
while raw.length < COOP_RESPONSE_MAX
|
|
358
|
+
ready = Tep::Scheduler.io_wait(fd, Tep::Scheduler::READ, COOP_RECV_TIMEOUT)
|
|
359
|
+
if ready == 0
|
|
360
|
+
# Timeout -- bail with whatever we have so far. An
|
|
361
|
+
# incomplete response will surface as status=0 from
|
|
362
|
+
# parse_response if the status line never arrived.
|
|
363
|
+
break
|
|
364
|
+
end
|
|
365
|
+
chunk = Sock.sphttp_recv_some(fd, 4096)
|
|
366
|
+
if chunk.length == 0
|
|
367
|
+
# An empty read is ambiguous: for TLS it may mean "no full
|
|
368
|
+
# record decoded yet" (SSL_read WANT_READ/WANT_WRITE), not EOF.
|
|
369
|
+
# Consult the want-status: 1 = want-read (re-park on READ at the
|
|
370
|
+
# loop top), 2 = want-write (TLS renegotiation -- park on WRITE
|
|
371
|
+
# then retry), anything else (3 EOF / -1 error, or a plaintext
|
|
372
|
+
# peer close) is the end of the HTTP/1.0 + Connection: close
|
|
373
|
+
# response.
|
|
374
|
+
st = Sock.sphttp_io_status
|
|
375
|
+
if st == 1
|
|
376
|
+
next
|
|
377
|
+
end
|
|
378
|
+
if st == 2
|
|
379
|
+
Tep::Scheduler.io_wait(fd, Tep::Scheduler::WRITE, COOP_RECV_TIMEOUT)
|
|
380
|
+
next
|
|
381
|
+
end
|
|
382
|
+
break
|
|
383
|
+
end
|
|
384
|
+
raw = raw + chunk
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
Sock.sphttp_close(fd)
|
|
388
|
+
Http.parse_response(raw)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Parse a raw HTTP/1.0 or 1.1 response. Status line + headers
|
|
392
|
+
# (terminated by \r\n\r\n) + body. Header names are downcased
|
|
393
|
+
# so callers don't have to worry about case. Allocates and
|
|
394
|
+
# returns a fresh Response (rather than mutating one passed in
|
|
395
|
+
# by reference -- the mutation pattern widens the param to
|
|
396
|
+
# poly in spinel's analyzer).
|
|
397
|
+
def self.parse_response(raw)
|
|
398
|
+
out = Tep::Http::Response.new
|
|
399
|
+
if raw.length < 12
|
|
400
|
+
return out
|
|
401
|
+
end
|
|
402
|
+
# Status line: "HTTP/1.x SSS reason\r\n"
|
|
403
|
+
eol = Tep.str_find(raw, "\r\n", 0)
|
|
404
|
+
if eol < 0
|
|
405
|
+
return out
|
|
406
|
+
end
|
|
407
|
+
line = raw[0, eol]
|
|
408
|
+
sp1 = Tep.str_find(line, " ", 0)
|
|
409
|
+
if sp1 < 0
|
|
410
|
+
return out
|
|
411
|
+
end
|
|
412
|
+
rest = line[sp1 + 1, line.length - sp1 - 1]
|
|
413
|
+
sp2 = Tep.str_find(rest, " ", 0)
|
|
414
|
+
code_str = ""
|
|
415
|
+
if sp2 >= 0
|
|
416
|
+
code_str = rest[0, sp2]
|
|
417
|
+
else
|
|
418
|
+
code_str = rest
|
|
419
|
+
end
|
|
420
|
+
out.status = code_str.to_i
|
|
421
|
+
|
|
422
|
+
# Walk header lines until empty line.
|
|
423
|
+
pos = eol + 2
|
|
424
|
+
while pos < raw.length
|
|
425
|
+
next_eol = Tep.str_find(raw, "\r\n", pos)
|
|
426
|
+
if next_eol < 0
|
|
427
|
+
return out
|
|
428
|
+
end
|
|
429
|
+
if next_eol == pos
|
|
430
|
+
# blank line -- body starts at pos+2
|
|
431
|
+
body_start = pos + 2
|
|
432
|
+
if body_start < raw.length
|
|
433
|
+
out.body = raw[body_start, raw.length - body_start]
|
|
434
|
+
end
|
|
435
|
+
return out
|
|
436
|
+
end
|
|
437
|
+
line2 = raw[pos, next_eol - pos]
|
|
438
|
+
ci = Tep.str_find(line2, ":", 0)
|
|
439
|
+
if ci > 0
|
|
440
|
+
k = line2[0, ci].downcase
|
|
441
|
+
# Skip leading space after the colon.
|
|
442
|
+
v_start = ci + 1
|
|
443
|
+
if v_start < line2.length && line2[v_start] == " "
|
|
444
|
+
v_start += 1
|
|
445
|
+
end
|
|
446
|
+
v = line2[v_start, line2.length - v_start]
|
|
447
|
+
out.headers[k] = v
|
|
448
|
+
end
|
|
449
|
+
pos = next_eol + 2
|
|
450
|
+
end
|
|
451
|
+
out
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Read a full HTTP response, framing the body by Content-Length when
|
|
455
|
+
# present so a kept-alive socket stops at the message boundary and
|
|
456
|
+
# stays reusable. Without Content-Length we read until EOF or the
|
|
457
|
+
# recv timeout (socket not reusable). Returns a FramedResp.
|
|
458
|
+
# Bounded at 4 MiB (matches sphttp's SPHTTP_RESP_MAX) so a runaway
|
|
459
|
+
# upstream can't grow buf unboundedly.
|
|
460
|
+
def self.recv_framed(fd)
|
|
461
|
+
out = Tep::Http::FramedResp.new
|
|
462
|
+
buf = ""
|
|
463
|
+
hdr_end = -1
|
|
464
|
+
clen = -1
|
|
465
|
+
conn_close = false
|
|
466
|
+
while buf.length < 4194304
|
|
467
|
+
if hdr_end < 0
|
|
468
|
+
idx = Tep.str_find(buf, "\r\n\r\n", 0)
|
|
469
|
+
if idx >= 0
|
|
470
|
+
hdr_end = idx + 4
|
|
471
|
+
# Header-block scanning is inlined (not extracted to helper
|
|
472
|
+
# methods): spinel types a param by name file-wide, so a
|
|
473
|
+
# String param that isn't forced String at the boundary
|
|
474
|
+
# defaults to mrb_int and the call mismatches. Operating on
|
|
475
|
+
# `buf` slices here keeps everything unambiguously String.
|
|
476
|
+
lowh = buf[0, hdr_end].downcase
|
|
477
|
+
# Content-Length (to_i tolerates the leading space, stops at CR).
|
|
478
|
+
ci = Tep.str_find(lowh, "content-length:", 0)
|
|
479
|
+
if ci >= 0
|
|
480
|
+
crest = lowh[ci + 15, lowh.length]
|
|
481
|
+
ceol = Tep.str_find(crest, "\r\n", 0)
|
|
482
|
+
cline = crest
|
|
483
|
+
if ceol >= 0
|
|
484
|
+
cline = crest[0, ceol]
|
|
485
|
+
end
|
|
486
|
+
clen = cline.to_i
|
|
487
|
+
end
|
|
488
|
+
# Connection: close
|
|
489
|
+
ki = Tep.str_find(lowh, "connection:", 0)
|
|
490
|
+
if ki >= 0
|
|
491
|
+
krest = lowh[ki + 11, lowh.length]
|
|
492
|
+
keol = Tep.str_find(krest, "\r\n", 0)
|
|
493
|
+
kline = krest
|
|
494
|
+
if keol >= 0
|
|
495
|
+
kline = krest[0, keol]
|
|
496
|
+
end
|
|
497
|
+
if Tep.str_find(kline, "close", 0) >= 0
|
|
498
|
+
conn_close = true
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
if hdr_end >= 0 && clen >= 0 && (buf.length - hdr_end) >= clen
|
|
504
|
+
break
|
|
505
|
+
end
|
|
506
|
+
chunk = Sock.sphttp_recv_some(fd, 65536)
|
|
507
|
+
if chunk.length == 0
|
|
508
|
+
break
|
|
509
|
+
end
|
|
510
|
+
buf = buf + chunk
|
|
511
|
+
end
|
|
512
|
+
framed = false
|
|
513
|
+
if hdr_end >= 0 && clen >= 0 && (buf.length - hdr_end) == clen
|
|
514
|
+
framed = true
|
|
515
|
+
end
|
|
516
|
+
out.raw = buf
|
|
517
|
+
out.framed_clean = framed
|
|
518
|
+
out.conn_close = conn_close
|
|
519
|
+
out
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
class Response
|
|
523
|
+
attr_accessor :status, :headers, :body
|
|
524
|
+
def initialize
|
|
525
|
+
@status = 0
|
|
526
|
+
@headers = Tep.str_hash
|
|
527
|
+
@body = ""
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Result of recv_framed: the raw bytes, whether the body was cleanly
|
|
532
|
+
# Content-Length-framed (so the socket sits at a clean boundary and
|
|
533
|
+
# can be pooled), and whether the peer asked to close.
|
|
534
|
+
class FramedResp
|
|
535
|
+
attr_accessor :raw, :framed_clean, :conn_close
|
|
536
|
+
def initialize
|
|
537
|
+
@raw = ""
|
|
538
|
+
@framed_clean = false
|
|
539
|
+
@conn_close = false
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# HTTP/1.1 outbound connection pool (chunk 6.7a -- design lands
|
|
544
|
+
# ahead of Tep::Http.send_req integration in 6.7b). Per-process,
|
|
545
|
+
# keyed by (host, port). Thin Ruby surface over the C primitives
|
|
546
|
+
# in sphttp.c.
|
|
547
|
+
#
|
|
548
|
+
# Method names are `claim` / `release` (NOT `checkout` / `checkin`)
|
|
549
|
+
# to avoid colliding with `PG::Pool#checkin` -- spinel unifies
|
|
550
|
+
# parameter types across same-named methods, so reusing the names
|
|
551
|
+
# widens PG::Pool's `c` (a PG::Connection) and our `fd` (an int)
|
|
552
|
+
# into a poly type and breaks the codegen.
|
|
553
|
+
#
|
|
554
|
+
# In 6.7a, this module is callable but NOT YET wired into
|
|
555
|
+
# send_req -- Tep::Http still opens a fresh socket per request +
|
|
556
|
+
# closes it. The 6.7b chunk lands the integration once the
|
|
557
|
+
# HTTP/1.1 keep-alive recv-N-bytes path is in place. Apps can
|
|
558
|
+
# already use Pool directly for their own outbound clients.
|
|
559
|
+
class Pool
|
|
560
|
+
# Try to claim an idle keep-alive fd for (host, port). Returns
|
|
561
|
+
# the fd (>=0) on hit, -1 on miss. The caller owns the fd on
|
|
562
|
+
# hit -- close it explicitly if the request fails, or
|
|
563
|
+
# `release` it for reuse.
|
|
564
|
+
def self.claim(host, port)
|
|
565
|
+
Sock.sphttp_pool_checkout(host, port)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Register `fd` as an idle keep-alive socket for (host, port).
|
|
569
|
+
# Returns 0 on success, -1 on failure (pool full -- the LRU
|
|
570
|
+
# gets evicted internally, so failures are rare). Don't release
|
|
571
|
+
# after a 5xx that triggered retries (the half-broken socket
|
|
572
|
+
# would poison the pool) -- close directly via Sock.sphttp_close.
|
|
573
|
+
def self.release(fd, host, port)
|
|
574
|
+
Sock.sphttp_pool_checkin(fd, host, port)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Close idle fds older than `idle_seconds`. Returns the count
|
|
578
|
+
# closed. Call periodically from the server's idle path; not
|
|
579
|
+
# called automatically yet.
|
|
580
|
+
def self.close_idle(idle_seconds)
|
|
581
|
+
Sock.sphttp_pool_close_idle(idle_seconds)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Stats snapshot -- returns a Tep.str_hash with the four
|
|
585
|
+
# counters. checkouts/checkins are total calls; hits/misses
|
|
586
|
+
# are subsets of checkouts (hit + miss = checkouts). The
|
|
587
|
+
# C-side primitives keep the underlying counter names; this
|
|
588
|
+
# surface uses the same names for clarity.
|
|
589
|
+
def self.stats
|
|
590
|
+
h = Tep.str_hash
|
|
591
|
+
h["checkouts"] = Sock.sphttp_pool_stat_checkouts.to_s
|
|
592
|
+
h["checkins"] = Sock.sphttp_pool_stat_checkins.to_s
|
|
593
|
+
h["hits"] = Sock.sphttp_pool_stat_hits.to_s
|
|
594
|
+
h["misses"] = Sock.sphttp_pool_stat_misses.to_s
|
|
595
|
+
h
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
data/lib/tep/identity.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Tep::Identity -- principal + delegate identity. Identifies who
|
|
2
|
+
# made a request and, optionally, the agent acting on their behalf.
|
|
3
|
+
#
|
|
4
|
+
# Set on `req.identity` by the Tep::Auth provider chain (see
|
|
5
|
+
# docs/BATTERIES-DESIGN.md for the full Auth surface). Consumers
|
|
6
|
+
# (Broadcast, Presence, LiveView) read req.identity and key authz
|
|
7
|
+
# decisions off #may?(cap) plus the human?/agent? split.
|
|
8
|
+
#
|
|
9
|
+
# Agentic shape: a "delegated" identity is one where a non-human
|
|
10
|
+
# agent (LLM-driven bot, scheduled worker, automation client)
|
|
11
|
+
# acts on behalf of a human principal. Both layers are visible:
|
|
12
|
+
# - principal_id is the human (e.g. "user:42").
|
|
13
|
+
# - acting_via.agent_id is the agent ("summarizer-bot").
|
|
14
|
+
# Authz checks via #may? gate on the granted capability set, which
|
|
15
|
+
# the issuer is expected to keep as a subset of the principal's
|
|
16
|
+
# own caps -- never a superset. The split lets app code branch
|
|
17
|
+
# tighter on req.identity (e.g. "only humans may revoke other
|
|
18
|
+
# agents") rather than treating every authenticated caller alike.
|
|
19
|
+
module Tep
|
|
20
|
+
class Identity
|
|
21
|
+
attr_reader :principal_id # String, opaque to tep (apps own the format)
|
|
22
|
+
attr_reader :acting_via # Tep::AgentDelegation or nil
|
|
23
|
+
attr_reader :capabilities # Array of symbols
|
|
24
|
+
|
|
25
|
+
def initialize(principal_id, acting_via, capabilities)
|
|
26
|
+
@principal_id = principal_id
|
|
27
|
+
@acting_via = acting_via
|
|
28
|
+
@capabilities = capabilities
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The unauthenticated identity. Used by the Tep::Auth before-
|
|
32
|
+
# filter when no provider sniffed a credential off the request.
|
|
33
|
+
# Apps that gate routes on identity check the principal_id ==
|
|
34
|
+
# "" shape; #may? returns false for everything since the cap
|
|
35
|
+
# array is empty.
|
|
36
|
+
def self.anonymous
|
|
37
|
+
seed = [:_seed]
|
|
38
|
+
seed.delete_at(0)
|
|
39
|
+
Identity.new("", nil, seed)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def human?
|
|
43
|
+
@acting_via == nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def agent?
|
|
47
|
+
@acting_via != nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def may?(cap)
|
|
51
|
+
@capabilities.include?(cap)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Audit-friendly string. Humans render as "user:<principal>";
|
|
55
|
+
# agents render as "agent:<agent_id>/<principal>" -- the slash
|
|
56
|
+
# makes the principal-of-record visible at a glance and is the
|
|
57
|
+
# standard shape every log line and Broadcast `from` field
|
|
58
|
+
# should carry.
|
|
59
|
+
def subject
|
|
60
|
+
if @acting_via == nil
|
|
61
|
+
"user:" + @principal_id
|
|
62
|
+
else
|
|
63
|
+
"agent:" + @acting_via.agent_id + "/" + @principal_id
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|