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/test/test_events.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
# Tep::Events -- toy/v1 JSONL emitter. The app emits a full
|
|
5
|
+
# run_start -> inference x2 -> run_end scenario into a file and dumps
|
|
6
|
+
# it back over HTTP, so the test can parse + assert the envelope.
|
|
7
|
+
class TestEvents < TepTest
|
|
8
|
+
EV_PATH = "/tmp/tep_events_test.jsonl"
|
|
9
|
+
|
|
10
|
+
app_source <<~RB
|
|
11
|
+
require 'sinatra'
|
|
12
|
+
|
|
13
|
+
PATH = "#{EV_PATH}"
|
|
14
|
+
|
|
15
|
+
# One self-contained scenario: reset the file, emit the full
|
|
16
|
+
# event sequence, return the raw JSONL.
|
|
17
|
+
get '/scenario' do
|
|
18
|
+
File.write(PATH, "")
|
|
19
|
+
ev = Tep::Events.new(PATH)
|
|
20
|
+
ev.run_start("testhost", "cpu", "smollm2-135m", "/m.gguf",
|
|
21
|
+
"{\\"server\\":\\"tep\\",\\"cap\\":\\"infer\\"}")
|
|
22
|
+
ev.inference("smollm2-135m", 12, 8, 87000,
|
|
23
|
+
"{\\"request_id\\":\\"cmpl-abc\\",\\"principal_id\\":\\"user:42\\"}")
|
|
24
|
+
ev.inference("smollm2-135m", 5, 3, 40000,
|
|
25
|
+
"{\\"request_id\\":\\"cmpl-def\\"}")
|
|
26
|
+
ev.run_end("ok")
|
|
27
|
+
File.read(PATH)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Disabled emitter ("" path): enabled? false + no file written.
|
|
31
|
+
get '/disabled' do
|
|
32
|
+
File.write(PATH, "SENTINEL")
|
|
33
|
+
d = Tep::Events.new("")
|
|
34
|
+
d.run_start("h", "cpu", "m", "/p", "{}")
|
|
35
|
+
d.inference("m", 1, 1, 1, "{}")
|
|
36
|
+
d.run_end("ok")
|
|
37
|
+
# File must still hold the sentinel (disabled wrote nothing).
|
|
38
|
+
"enabled=" + d.enabled?.to_s + " file=" + File.read(PATH)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ISO-8601 helper directly.
|
|
42
|
+
get '/iso' do
|
|
43
|
+
Sock.sphttp_iso8601_utc(0)
|
|
44
|
+
end
|
|
45
|
+
RB
|
|
46
|
+
|
|
47
|
+
def scenario_lines
|
|
48
|
+
body = get("/scenario").body
|
|
49
|
+
body.split("\n").reject(&:empty?).map { |l| JSON.parse(l) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_emits_four_events_in_order
|
|
53
|
+
lines = scenario_lines
|
|
54
|
+
assert_equal 4, lines.length
|
|
55
|
+
assert_equal "run_start", lines[0]["kind"]
|
|
56
|
+
# #136: inference events are kind:"eval"+name:"request".
|
|
57
|
+
assert_equal "eval", lines[1]["kind"]
|
|
58
|
+
assert_equal "request", lines[1]["name"]
|
|
59
|
+
assert_equal "eval", lines[2]["kind"]
|
|
60
|
+
assert_equal "request", lines[2]["name"]
|
|
61
|
+
assert_equal "run_end", lines[3]["kind"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_run_start_envelope
|
|
65
|
+
rs = scenario_lines[0]
|
|
66
|
+
assert_equal "toy/v1", rs["schema"]
|
|
67
|
+
assert_equal 0, rs["t"]
|
|
68
|
+
# host is {name, os, arch} per toy/v1 (#115).
|
|
69
|
+
assert_equal "testhost", rs["host"]["name"]
|
|
70
|
+
assert_kind_of String, rs["host"]["os"]
|
|
71
|
+
assert_kind_of String, rs["host"]["arch"]
|
|
72
|
+
refute_empty rs["host"]["os"], "os field should be populated via uname()"
|
|
73
|
+
refute_empty rs["host"]["arch"], "arch field should be populated via uname()"
|
|
74
|
+
assert_equal "cpu", rs["backend"]["kind"]
|
|
75
|
+
assert_equal "smollm2-135m", rs["model"]["name"]
|
|
76
|
+
assert_equal "/m.gguf", rs["model"]["path"]
|
|
77
|
+
assert_equal "infer", rs["config"]["cap"]
|
|
78
|
+
assert_match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/, rs["started_at"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_inference_event_fields
|
|
82
|
+
ev = scenario_lines[1]
|
|
83
|
+
# #136 spec shape: kind:"eval" + phase:"serve" + name:"request",
|
|
84
|
+
# with model + tokens + latency_us nested under extra.
|
|
85
|
+
assert_equal "eval", ev["kind"]
|
|
86
|
+
assert_equal "serve", ev["phase"]
|
|
87
|
+
assert_equal "request", ev["name"]
|
|
88
|
+
assert_kind_of Integer, ev["t"]
|
|
89
|
+
assert_equal "smollm2-135m", ev["extra"]["model"]
|
|
90
|
+
assert_equal 12, ev["extra"]["prompt_tokens"]
|
|
91
|
+
assert_equal 8, ev["extra"]["completion_tokens"]
|
|
92
|
+
assert_equal 87000, ev["extra"]["latency_us"]
|
|
93
|
+
assert_equal "cmpl-abc", ev["extra"]["request_id"]
|
|
94
|
+
assert_equal "user:42", ev["extra"]["principal_id"]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_run_end_stats_accumulate
|
|
98
|
+
re = scenario_lines[3]
|
|
99
|
+
assert_equal "ok", re["reason"]
|
|
100
|
+
assert_equal 2, re["stats"]["requests"] # two inference calls
|
|
101
|
+
assert_equal 11, re["stats"]["tokens_out"] # 8 + 3
|
|
102
|
+
assert_equal 0, re["stats"]["errors"]
|
|
103
|
+
assert_match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/, re["ended_at"])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_disabled_emitter_writes_nothing
|
|
107
|
+
res = get("/disabled")
|
|
108
|
+
assert_equal "enabled=false file=SENTINEL", res.body
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_iso8601_epoch_zero
|
|
112
|
+
assert_equal "1970-01-01T00:00:00Z", get("/iso").body
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
# before / after filters. The translator wraps blocks into Tep::Filter
|
|
4
|
+
# subclasses; spinel restricts a single filter slot per kind, so
|
|
5
|
+
# multi-filter chaining is composed by the user, not registered N
|
|
6
|
+
# times.
|
|
7
|
+
class TestFilters < TepTest
|
|
8
|
+
app_source <<~RB
|
|
9
|
+
before do
|
|
10
|
+
response.headers["X-Before"] = "1"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
after do
|
|
14
|
+
response.headers["X-After"] = "2"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
get '/' do
|
|
18
|
+
"ok"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
get '/echo-before' do
|
|
22
|
+
response.headers["X-Before"]
|
|
23
|
+
end
|
|
24
|
+
RB
|
|
25
|
+
|
|
26
|
+
def test_before_runs
|
|
27
|
+
res = get("/")
|
|
28
|
+
assert_equal "1", res["x-before"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_after_runs
|
|
32
|
+
res = get("/")
|
|
33
|
+
assert_equal "2", res["x-after"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_before_runs_before_handler
|
|
37
|
+
# The handler can read the X-Before header that the before filter set.
|
|
38
|
+
res = get("/echo-before")
|
|
39
|
+
assert_equal "1", res.body
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "minitest/autorun"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "socket"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
# Proves tep can compile + run an app that uses a REAL, unmodified
|
|
8
|
+
# published Ruby gem (pr_geohash 1.0.0, MIT) vendored next to the app and
|
|
9
|
+
# pulled in with `require_relative`. `bin/tep build` inlines app-local
|
|
10
|
+
# require_relative targets into the AOT binary (no runtime gem loader),
|
|
11
|
+
# so GeoHash.encode runs as native compiled code. Reference outputs below
|
|
12
|
+
# are CRuby's (ruby -I lib + GeoHash.encode) -- the spinel-compiled binary
|
|
13
|
+
# must match them byte-for-byte.
|
|
14
|
+
#
|
|
15
|
+
# Unlike the other suites this builds the real examples/geohash/app.rb in
|
|
16
|
+
# place (not an app_source heredoc in a tmpdir) so the relative
|
|
17
|
+
# require_relative resolves against the vendored gem.
|
|
18
|
+
class TestGeohashExample < Minitest::Test
|
|
19
|
+
TEP_BIN = File.expand_path("../bin/tep", __dir__)
|
|
20
|
+
APP = File.expand_path("../examples/geohash/app.rb", __dir__)
|
|
21
|
+
EX_DIR = File.dirname(APP)
|
|
22
|
+
|
|
23
|
+
# vendor/spinel is generated from Gemfile.lock by bundler-spinel
|
|
24
|
+
# (`spinel-compat vendor`), not committed. Regenerate before building;
|
|
25
|
+
# skip if spinelgems isn't reachable (suite run outside the dev
|
|
26
|
+
# container, which mounts /spinelgems).
|
|
27
|
+
def ensure_vendored
|
|
28
|
+
deps = File.join(EX_DIR, "vendor", "spinel", "deps.rb")
|
|
29
|
+
return if File.exist?(deps)
|
|
30
|
+
sg = ENV["SPINELGEMS"] || "/spinelgems"
|
|
31
|
+
skip "spinelgems not at #{sg}; run `make vendor-examples`" unless File.directory?(File.join(sg, "exe"))
|
|
32
|
+
out = `cd #{EX_DIR} && ruby -I #{sg}/lib #{sg}/exe/spinel-compat vendor 2>&1`
|
|
33
|
+
skip "spinel-compat vendor failed (offline?):\n#{out}" unless $?.success? && File.exist?(deps)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def setup
|
|
37
|
+
ensure_vendored
|
|
38
|
+
@tmp = Dir.mktmpdir("tep-geohash")
|
|
39
|
+
@bin = File.join(@tmp, "geohash")
|
|
40
|
+
out = `#{TEP_BIN} build #{APP} -o #{@bin} 2>&1`
|
|
41
|
+
raise "geohash example build failed:\n#{out}" unless $?.success? && File.executable?(@bin)
|
|
42
|
+
@port = 4970 + (Process.pid % 80)
|
|
43
|
+
@log = File.join(@tmp, "app.log")
|
|
44
|
+
@pid = Process.spawn(@bin, "-p", @port.to_s, out: @log, err: [:child, :out], pgroup: true)
|
|
45
|
+
wait_for_port(@port)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def teardown
|
|
49
|
+
if @pid
|
|
50
|
+
Process.kill("TERM", -@pid) rescue nil
|
|
51
|
+
Process.wait(@pid) rescue nil
|
|
52
|
+
end
|
|
53
|
+
FileUtils.remove_entry(@tmp) if @tmp && File.directory?(@tmp)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wait_for_port(port, timeout: 10.0)
|
|
57
|
+
deadline = Time.now + timeout
|
|
58
|
+
while Time.now < deadline
|
|
59
|
+
begin
|
|
60
|
+
TCPSocket.new("127.0.0.1", port).close
|
|
61
|
+
return
|
|
62
|
+
rescue
|
|
63
|
+
sleep 0.05
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
raise "geohash app never bound :#{port}\n#{File.read(@log) rescue ''}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get(path)
|
|
70
|
+
Net::HTTP.get_response(URI("http://127.0.0.1:#{@port}#{path}")).body
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# GeoHash.encode reference values (computed under CRuby).
|
|
74
|
+
def test_encode_paris_precision_8
|
|
75
|
+
assert_equal "u09tunqu", get("/geohash?lat=48.8584&lon=2.2945&precision=8")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_encode_tokyo_default_precision
|
|
79
|
+
assert_equal "xn76urx0zhkz", get("/geohash?lat=35.681&lon=139.767")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_encode_sydney_precision_6
|
|
83
|
+
assert_equal "r3gx2f", get("/geohash?lat=-33.8688&lon=151.2093&precision=6")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_index_mentions_the_gem
|
|
87
|
+
assert_match(/pr_geohash 1\.0\.0/, get("/"))
|
|
88
|
+
end
|
|
89
|
+
end
|
data/test/test_http.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
# Tep::Http -- outbound HTTP client tests. Boots a tep app with both
|
|
4
|
+
# "target" endpoints (/ping, /echo, /headers, /404) and a "client"
|
|
5
|
+
# endpoint (/selfcall/:port) that uses Tep::Http to call its own
|
|
6
|
+
# server. The test passes its bound port to the handler via path
|
|
7
|
+
# capture, which side-steps the test harness's lack of "tell the
|
|
8
|
+
# handler its own port" plumbing.
|
|
9
|
+
#
|
|
10
|
+
# Runs under Tep::Server::Scheduled with workers=1. The handlers do
|
|
11
|
+
# outbound HTTP back to the same server -- under cooperative I/O the
|
|
12
|
+
# outer fiber parks on io_wait while the accept fiber accepts the
|
|
13
|
+
# inner connection, which is the only shape that works on macOS
|
|
14
|
+
# (SO_REUSEPORT doesn't load-balance on Darwin). See
|
|
15
|
+
# docs/MACOS-CONCURRENCY.md.
|
|
16
|
+
class TestHttp < TepTest
|
|
17
|
+
app_source <<~RB
|
|
18
|
+
require 'sinatra'
|
|
19
|
+
|
|
20
|
+
# Cooperative server + single worker. The two-fiber dance is
|
|
21
|
+
# what unblocks self-calling handlers; see
|
|
22
|
+
# docs/MACOS-CONCURRENCY.md for the full path.
|
|
23
|
+
set :scheduler, :scheduled
|
|
24
|
+
set :workers, 1
|
|
25
|
+
|
|
26
|
+
get '/ping' do
|
|
27
|
+
"pong"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
get '/echo/:msg' do
|
|
31
|
+
res.headers["X-Tep-Echo"] = params[:msg]
|
|
32
|
+
params[:msg]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
post '/echo_body' do
|
|
36
|
+
res.headers["Content-Type"] = "text/plain"
|
|
37
|
+
req.raw_body
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
get '/headers_back' do
|
|
41
|
+
h = req.req_headers["x-custom"]
|
|
42
|
+
"x-custom=" + h
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
get '/teapot' do
|
|
46
|
+
res.set_status(418)
|
|
47
|
+
"i'm a teapot"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The "client" endpoint -- uses Tep::Http against itself.
|
|
51
|
+
get '/selfcall/:port' do
|
|
52
|
+
r = Tep::Http.get("http://127.0.0.1:" + params[:port] + "/ping")
|
|
53
|
+
"status=" + r.status.to_s + " body=" + r.body
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reusable client with base URL and a default header.
|
|
57
|
+
get '/instance/:port' do
|
|
58
|
+
c = Tep::Http.new("http://127.0.0.1:" + params[:port])
|
|
59
|
+
c.set_header("X-Custom", "hello-from-tep")
|
|
60
|
+
r = c.do_get("/headers_back")
|
|
61
|
+
"status=" + r.status.to_s + " body=" + r.body
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# POST with a body, read it back from the echo endpoint.
|
|
65
|
+
get '/post_echo/:port' do
|
|
66
|
+
c = Tep::Http.new("http://127.0.0.1:" + params[:port])
|
|
67
|
+
r = c.do_post("/echo_body", "round trip body")
|
|
68
|
+
"status=" + r.status.to_s + " body=" + r.body
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Non-2xx round trip.
|
|
72
|
+
get '/teapot_from/:port' do
|
|
73
|
+
r = Tep::Http.get("http://127.0.0.1:" + params[:port] + "/teapot")
|
|
74
|
+
"status=" + r.status.to_s + " body=" + r.body
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Verify header parsing on the inbound side: hit /echo/<msg>
|
|
78
|
+
# which sets an X-Tep-Echo response header, then read it back.
|
|
79
|
+
get '/header_parse/:port' do
|
|
80
|
+
r = Tep::Http.get("http://127.0.0.1:" + params[:port] + "/echo/hi")
|
|
81
|
+
"echo_header=" + r.headers["x-tep-echo"]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Bad URL: scheme not http -- send_req returns Response with status=0.
|
|
85
|
+
get '/bad_scheme' do
|
|
86
|
+
r = Tep::Http.get("ftp://127.0.0.1/")
|
|
87
|
+
"status=" + r.status.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Connect failure: nothing's listening on this port.
|
|
91
|
+
get '/connect_fail' do
|
|
92
|
+
r = Tep::Http.get("http://127.0.0.1:1/ping")
|
|
93
|
+
"status=" + r.status.to_s
|
|
94
|
+
end
|
|
95
|
+
RB
|
|
96
|
+
|
|
97
|
+
def test_selfcall_returns_pong
|
|
98
|
+
res = get("/selfcall/#{@port}")
|
|
99
|
+
assert_equal "200", res.code
|
|
100
|
+
assert_equal "status=200 body=pong", res.body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_instance_sends_default_header
|
|
104
|
+
res = get("/instance/#{@port}")
|
|
105
|
+
assert_equal "200", res.code
|
|
106
|
+
# The target's /headers_back echoes the X-Custom header it saw.
|
|
107
|
+
assert_equal "status=200 body=x-custom=hello-from-tep", res.body
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_post_body_round_trips
|
|
111
|
+
res = get("/post_echo/#{@port}")
|
|
112
|
+
assert_equal "200", res.code
|
|
113
|
+
assert_equal "status=200 body=round trip body", res.body
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def test_non_2xx_status_propagates
|
|
117
|
+
res = get("/teapot_from/#{@port}")
|
|
118
|
+
assert_equal "200", res.code
|
|
119
|
+
assert_equal "status=418 body=i'm a teapot", res.body
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_response_headers_parsed
|
|
123
|
+
res = get("/header_parse/#{@port}")
|
|
124
|
+
assert_equal "200", res.code
|
|
125
|
+
assert_equal "echo_header=hi", res.body
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_https_or_unknown_scheme_returns_zero
|
|
129
|
+
res = get("/bad_scheme")
|
|
130
|
+
assert_equal "status=0", res.body
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def test_connect_failure_returns_zero
|
|
134
|
+
res = get("/connect_fail")
|
|
135
|
+
assert_equal "status=0", res.body
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
# Tep::Http::Pool (chunk 6.7a). The C-side pool primitives + Ruby
|
|
5
|
+
# wrapper, exercised directly. No Tep::Http.send_req integration
|
|
6
|
+
# yet -- that's 6.7b (needs the HTTP/1.1 keep-alive recv-N-bytes
|
|
7
|
+
# path). Tests assert the pool keys, evicts LRU, and surfaces the
|
|
8
|
+
# right stats.
|
|
9
|
+
class TestHttpPool < TepTest
|
|
10
|
+
app_source <<~RB
|
|
11
|
+
require 'sinatra'
|
|
12
|
+
|
|
13
|
+
# Scheduled server so self-calls (sphttp_connect to our own port
|
|
14
|
+
# for the /pool/register fd) don't deadlock the single worker.
|
|
15
|
+
set :scheduler, :scheduled
|
|
16
|
+
set :workers, 1
|
|
17
|
+
|
|
18
|
+
# Reset by closing all idle fds (well past their use). Used to
|
|
19
|
+
# make per-test state deterministic.
|
|
20
|
+
post '/pool/reset' do
|
|
21
|
+
res.headers["Content-Type"] = "text/plain"
|
|
22
|
+
Tep::Http::Pool.close_idle(-1).to_s # idle > -1s -> closes all
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Open a real socket to ourselves + checkin to the pool. Returns
|
|
26
|
+
# the fd we just registered. The test treats the fd as opaque +
|
|
27
|
+
# only asserts on the pool's behaviour.
|
|
28
|
+
post '/pool/register' do
|
|
29
|
+
res.headers["Content-Type"] = "text/plain"
|
|
30
|
+
fd = Sock.sphttp_connect("127.0.0.1", params[:port].to_i)
|
|
31
|
+
if fd < 0
|
|
32
|
+
return "connect_failed"
|
|
33
|
+
end
|
|
34
|
+
Tep::Http::Pool.release(fd, "127.0.0.1", params[:port].to_i).to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Claim and report the fd (>=0 hit, -1 miss). Close on hit so
|
|
38
|
+
# the test doesn't leak.
|
|
39
|
+
get '/pool/claim/:port' do
|
|
40
|
+
res.headers["Content-Type"] = "text/plain"
|
|
41
|
+
fd = Tep::Http::Pool.claim("127.0.0.1", params[:port].to_i)
|
|
42
|
+
if fd >= 0
|
|
43
|
+
Sock.sphttp_close(fd)
|
|
44
|
+
return "hit"
|
|
45
|
+
end
|
|
46
|
+
"miss"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Stats snapshot as JSON.
|
|
50
|
+
get '/pool/stats' do
|
|
51
|
+
res.headers["Content-Type"] = "application/json"
|
|
52
|
+
s = Tep::Http::Pool.stats
|
|
53
|
+
"{" +
|
|
54
|
+
Tep::Json.encode_pair_int("checkouts", s["checkouts"].to_i) + "," +
|
|
55
|
+
Tep::Json.encode_pair_int("checkins", s["checkins"].to_i) + "," +
|
|
56
|
+
Tep::Json.encode_pair_int("hits", s["hits"].to_i) + "," +
|
|
57
|
+
Tep::Json.encode_pair_int("misses", s["misses"].to_i) +
|
|
58
|
+
"}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Trivial route the pool's TCP open targets -- the connect
|
|
62
|
+
# itself is what we care about; the response shape is unused.
|
|
63
|
+
get '/ping' do
|
|
64
|
+
"pong"
|
|
65
|
+
end
|
|
66
|
+
RB
|
|
67
|
+
|
|
68
|
+
def setup
|
|
69
|
+
super
|
|
70
|
+
# Drain the pool + reset counters via "miss" cycles isn't possible
|
|
71
|
+
# (stats are monotonic process-wide). Tests assert DELTAS, not
|
|
72
|
+
# absolute counts.
|
|
73
|
+
post("/pool/reset", "")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stats_now
|
|
77
|
+
JSON.parse(get("/pool/stats").body)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_release_then_claim_is_a_hit
|
|
81
|
+
s0 = stats_now
|
|
82
|
+
# Register an fd in the pool.
|
|
83
|
+
res = post("/pool/register?port=#{@port}", "")
|
|
84
|
+
assert_equal "200", res.code
|
|
85
|
+
assert_equal "0", res.body, "release should succeed (returned 0)"
|
|
86
|
+
|
|
87
|
+
# First claim should HIT.
|
|
88
|
+
res = get("/pool/claim/#{@port}")
|
|
89
|
+
assert_equal "hit", res.body, "expected pool hit for the released fd"
|
|
90
|
+
|
|
91
|
+
s1 = stats_now
|
|
92
|
+
assert_equal s0["hits"] + 1, s1["hits"]
|
|
93
|
+
assert_equal s0["checkins"] + 1, s1["checkins"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_claim_on_empty_pool_is_a_miss
|
|
97
|
+
s0 = stats_now
|
|
98
|
+
res = get("/pool/claim/#{@port}")
|
|
99
|
+
assert_equal "miss", res.body
|
|
100
|
+
s1 = stats_now
|
|
101
|
+
assert_equal s0["misses"] + 1, s1["misses"]
|
|
102
|
+
assert_equal s0["hits"], s1["hits"]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_second_claim_after_one_release_misses
|
|
106
|
+
# Register one fd; claim it; second claim should miss.
|
|
107
|
+
post("/pool/register?port=#{@port}", "")
|
|
108
|
+
res = get("/pool/claim/#{@port}")
|
|
109
|
+
assert_equal "hit", res.body
|
|
110
|
+
res = get("/pool/claim/#{@port}")
|
|
111
|
+
assert_equal "miss", res.body
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_close_idle_removes_pooled_fds
|
|
115
|
+
# Register an fd then sweep with idle_seconds=-1 (every fd is
|
|
116
|
+
# older than -1s from "now") -- subsequent claim should miss.
|
|
117
|
+
post("/pool/register?port=#{@port}", "")
|
|
118
|
+
post("/pool/reset", "")
|
|
119
|
+
res = get("/pool/claim/#{@port}")
|
|
120
|
+
assert_equal "miss", res.body
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
# 6.7b: Tep::Http.send_req reuses pooled keep-alive connections.
|
|
5
|
+
#
|
|
6
|
+
# Pooling lives in the BLOCKING send path (prefork), and pool state is
|
|
7
|
+
# per-worker -- so the two send_req calls must share one worker. We
|
|
8
|
+
# can't self-call here (blocking + workers=1 would deadlock; multi-
|
|
9
|
+
# worker makes pool hits nondeterministic), so the client makes both
|
|
10
|
+
# calls to a SEPARATE upstream process within one handler. One worker,
|
|
11
|
+
# one pool: the second call reuses the connection the first released.
|
|
12
|
+
class TestHttpPoolSend < TepTest
|
|
13
|
+
UPSTREAM = <<~UP
|
|
14
|
+
require 'sinatra'
|
|
15
|
+
set :workers, 1
|
|
16
|
+
get '/ping' do
|
|
17
|
+
"pong"
|
|
18
|
+
end
|
|
19
|
+
UP
|
|
20
|
+
|
|
21
|
+
app_source <<~RB
|
|
22
|
+
require 'sinatra'
|
|
23
|
+
set :workers, 1
|
|
24
|
+
|
|
25
|
+
# Two sequential GETs to the same upstream in ONE handler (one
|
|
26
|
+
# worker -> one pool). Reports the hits delta + both bodies so the
|
|
27
|
+
# test asserts both reuse and correctness.
|
|
28
|
+
get '/twocalls/:uport' do
|
|
29
|
+
res.headers["Content-Type"] = "application/json"
|
|
30
|
+
base = "http://127.0.0.1:" + params[:uport]
|
|
31
|
+
h0 = Tep::Http::Pool.stats["hits"].to_i
|
|
32
|
+
r1 = Tep::Http.get(base + "/ping")
|
|
33
|
+
r2 = Tep::Http.get(base + "/ping")
|
|
34
|
+
h1 = Tep::Http::Pool.stats["hits"].to_i
|
|
35
|
+
"{" +
|
|
36
|
+
Tep::Json.encode_pair_int("hits_delta", h1 - h0) + "," +
|
|
37
|
+
Tep::Json.encode_pair_str("b1", r1.body) + "," +
|
|
38
|
+
Tep::Json.encode_pair_str("b2", r2.body) +
|
|
39
|
+
"}"
|
|
40
|
+
end
|
|
41
|
+
RB
|
|
42
|
+
|
|
43
|
+
def setup
|
|
44
|
+
super
|
|
45
|
+
@up_port = TepHarness.spawn_app(UPSTREAM, mode: :sinatra, workers: 1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_send_req_reuses_pooled_connection
|
|
49
|
+
res = get("/twocalls/#{@up_port}")
|
|
50
|
+
assert_equal "200", res.code
|
|
51
|
+
j = JSON.parse(res.body)
|
|
52
|
+
assert_equal "pong", j["b1"]
|
|
53
|
+
assert_equal "pong", j["b2"]
|
|
54
|
+
assert_equal 1, j["hits_delta"],
|
|
55
|
+
"second send_req to the same upstream should reuse the pooled connection"
|
|
56
|
+
end
|
|
57
|
+
end
|