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
@@ -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