tep 0.11.5 → 0.11.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0f6e0ace2299c85ad62734b3af3b5b5a704994d2c0769f3151f1741b2c7ec26
4
- data.tar.gz: 0d57c798029526125f7698d20c9f6419c557381b0d99ebb55be2ca35a8e819d4
3
+ metadata.gz: f69c2e25a18f0ad96cc6a765e9655cc130c35ecc90fe006715409868c8e69e72
4
+ data.tar.gz: bfe36187e4d9565b03c58b37ac1e0243f96dbe3b888752983acc6d6f87595fc7
5
5
  SHA512:
6
- metadata.gz: c5bc56857336297807d32345e8957dfafb714c6f15445da6b69a833b2d41090a4c3a8918ca89f5ec02bdbb593db1ed79996fb3ef5a271aec768358fe26604e67
7
- data.tar.gz: 8fadaa359cdee7cd715e61e89c83ce29e82d471cfb2f00489c365f98078f7d50368aba77542fbe5e3fa6873898803a499c77913fac2dd32e01914cf3bdb7d5d0
6
+ metadata.gz: 65c8b19acd15c4548a98a3db081e5f0c9d5b73068b8e99744e9064a35f1a4cdc920aa0a447061d708cd889155c5034efc6303532abee38869b193b5a1d10241d
7
+ data.tar.gz: 6307c6fefde2da3b7a3e67480d1c59ab051527c62796d03971fe25e1596c0fbee7271ea9d9d531b458ec310b99758e9d81b075d1f4c139d9500c25afc677a71a
data/README.md CHANGED
@@ -4,37 +4,41 @@
4
4
 
5
5
  # Tep
6
6
 
7
- A Sinatra-flavoured web framework that compiles to a native binary
8
- via [Spinel][spinel].
7
+ A Sinatra-flavoured web framework that compiles to a **native binary**
8
+ via [Spinel][spinel]. You write a Sinatra-style `app.rb`:
9
9
 
10
- > **Current release:** [v0.11.0](https://github.com/OriPekelman/tep/releases/tag/v0.11.0)
11
- > — now on [RubyGems](https://rubygems.org/gems/tep) (`gem install tep`):
12
- > TLS, HTTP caching, connection pooling, PG raise-on-error + embeddings,
13
- > on top of the agentic surface (Auth, Broadcast, Presence, LiveView, MCP).
10
+ ```ruby
11
+ require 'sinatra'
12
+
13
+ get '/hi/:name' do
14
+ "hi, " + params[:name] + "!"
15
+ end
16
+ ```
17
+
18
+ …and get `./app` — a single static executable, ~80 KB, **no Ruby
19
+ runtime**, doing ~150k req/s. Beyond routing / sessions / templates, the
20
+ [batteries ↓](#whats-in-the-box) cover auth (bearer / cookie / OAuth2),
21
+ SQLite, opt-in PostgreSQL, WebSockets, Broadcast + Presence + LiveView,
22
+ an MCP tool catalog, TLS, a pooled HTTP client, and an OpenAI-compatible
23
+ LLM client + server.
24
+
25
+ > **Current release:** [v0.11.7](https://github.com/OriPekelman/tep/releases/tag/v0.11.7)
26
+ > on [RubyGems](https://rubygems.org/gems/tep) — `gem install tep`.
14
27
  > Pre-alpha; API still in motion.
15
28
 
16
- > **Why Tep exists.** Two complementary goals:
17
- >
18
- > 1. **Exercise Spinel against real-world Ruby code.** Tep is the
19
- > largest pure-Ruby application Spinel compiles end-to-end. Every
20
- > Sinatra idiom, every battery, every demo doubles as a torture
21
- > test for the AOT compiler's codegen + analyzer. Bugs surface
22
- > here, get reduced to minimal repros, and land upstream as PRs
23
- > or issues against [matz/spinel](https://github.com/matz/spinel).
24
- > If something doesn't work in Tep, the bug is usually in Spinel,
25
- > and that's the point.
26
- >
27
- > 2. **Be the harness for [toy][toy].** Toy is Tep's sibling
28
- > project: a machine-learning framework written in pure Ruby and
29
- > compiled by Spinel. Toy needs an HTTP/MCP layer for serving
30
- > models, exposing training tools to agents (Claude Code et al.),
31
- > streaming inference results, and wiring presence into
32
- > collaborative training sessions. Tep is that layer. Every
33
- > battery in Tep is shaped by what Toy actually needs to ship.
34
- >
35
- > Tep happens to be useful as a general web framework too — fast,
36
- > single-binary, Sinatra-shaped — but the design choices are made
37
- > through these two lenses.
29
+ <details>
30
+ <summary><b>Why Tep exists</b> — it's a deliberate Spinel torture test + toy's serving layer</summary>
31
+
32
+ Tep is the largest pure-Ruby application Spinel compiles end-to-end:
33
+ every Sinatra idiom and battery doubles as a torture test for the AOT
34
+ compiler's codegen + analyzer, and bugs found here get reduced to
35
+ minimal repros and land upstream as
36
+ [matz/spinel](https://github.com/matz/spinel) PRs. It's also the
37
+ HTTP / MCP / serving layer for its sibling [toy][toy] a pure-Ruby ML
38
+ framework Spinel compiles — so every battery is shaped by what toy
39
+ needs to ship. Tep is a genuinely useful general web framework too, but
40
+ the design choices are made through those two lenses.
41
+ </details>
38
42
 
39
43
  [toy]: https://github.com/OriPekelman/toy
40
44
 
@@ -107,7 +111,7 @@ gem install. Recommended Ruby manager:
107
111
  [`rv`](https://github.com/spinel-coop/rv) — fast version+gem
108
112
  manager from the Spinel Cooperative (separate project from the
109
113
  matz/spinel AOT compiler Tep compiles through; same Ruby
110
- neighbourhood). `.ruby-version` in this repo pins 3.4.0; `rv
114
+ neighbourhood). `.ruby-version` in this repo pins 4.0.0; `rv
111
115
  shell` makes `rv run rake test` just work. Build deps on Linux:
112
116
  `build-essential`, `libsqlite3-dev`. macOS: Xcode CLI tools.
113
117
 
@@ -174,7 +178,7 @@ through Spinel.
174
178
  | `Tep::WebSocket` | RFC 6455 server-side WebSocket. `websocket '/chat' do \|ws\| ... end` DSL lowers to Frame + Handshake + Driver + Connection. Requires `set :scheduler, :scheduled`. |
175
179
  | `Tep::Parallel` | grosser/parallel-shaped fork fan-out. |
176
180
  | `Tep::Job` | sidekiq-shaped queue over SQLite. |
177
- | `PG` | ruby-pg-shape libpq client: `PG::Connection`, `PG::Result`, `PG::Error`; surface mirrors the `pg` gem (`exec` / `exec_params` / `escape_*` / `fields` / `values` / `getvalue` / `sql_state`). Designed so an eventual ActiveRecord-on-spinel port reuses the existing AR adapter with minimal divergence — see `docs/PG-BATTERY.md`. |
181
+ | `PG` | **Opt-in** ruby-pg-shape libpq client `require "tep/pg"` (so non-PG apps don't link libpq). `PG::Connection`, `PG::Result`, `PG::Error`; surface mirrors the `pg` gem (`exec` / `exec_params` / `escape_*` / `fields` / `values` / `getvalue` / `sql_state`). Designed so an eventual ActiveRecord-on-spinel port reuses the existing AR adapter with minimal divergence — see `docs/PG-BATTERY.md`. |
178
182
  | `Tep::Auth` | Principal+delegate identity (`Tep::Identity` / `Tep::AgentDelegation`) + provider chain. Three providers shipped: `Tep::AuthBearerToken` (JWT-HS256), `Tep::AuthSessionCookie` (signed cookie), `Tep::AuthOAuth2` (authorization-code grant issuance for bots/agents). Same `req.identity` surface regardless of provider; agents are first-class (`identity.agent?`, `identity.acting_via.agent_id`, capability subsetting). |
179
183
  | `Tep::Broadcast` | In-process pub-sub + cross-worker via PG LISTEN/NOTIFY. Subscribe an fd to a topic (`subscribe` raw, `subscribe_ws` WS-frame-wrapped); publish writes to every matching subscriber. The seam Presence and LiveView build on. |
180
184
  | `Tep::Presence` | Topic-keyed who's-here registry, agent-aware. `Tep::Presence.track(req, topic, fd)` records a (principal, session, topic) tuple with a 3-state structured status (`:available | :busy | :blocked` + free-text note + expiry). Diffs broadcast on join/leave/status; PG-mirror for cross-worker `list_global` snapshots. |
data/bin/tep CHANGED
@@ -86,7 +86,14 @@ def fatal(msg)
86
86
  end
87
87
 
88
88
  # Build the translated Ruby source from a Sinatra-style file.
89
- def translate(input_path)
89
+ #
90
+ # Strict by default (mirror condition 3, docs/mirrors/sinatra.md):
91
+ # any construct the translator would drop -- Rack `use`, `helpers`,
92
+ # unknown `set` keys, on_stop, malformed DSL blocks, missing views --
93
+ # is a fatal error, because building anyway produces a binary that
94
+ # silently diverges from what the same source does under real
95
+ # sinatra. `--lax` (or TEP_LAX=1) restores warn-and-continue.
96
+ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
90
97
  raw_source = File.read(input_path)
91
98
 
92
99
  # Split off `__END__` inline templates. Sinatra's convention is
@@ -909,7 +916,19 @@ def translate(input_path)
909
916
  RB
910
917
  out << ""
911
918
 
912
- warnings.each { |w| warn "tep: #{w}" }
919
+ if warnings.any?
920
+ if lax
921
+ warnings.each { |w| warn "tep: #{w}" }
922
+ warn "tep: --lax: built with #{warnings.length} dropped construct(s); " \
923
+ "behaviour diverges from real sinatra where they applied"
924
+ else
925
+ warnings.each { |w| warn "tep: error: #{w}" }
926
+ fatal "#{warnings.length} unsupported construct(s) -- refusing to build " \
927
+ "a binary that silently diverges (docs/mirrors/sinatra.md, " \
928
+ "condition 3). Re-run with --lax (or TEP_LAX=1) to drop them " \
929
+ "with a warning instead."
930
+ end
931
+ end
913
932
 
914
933
  out.join("\n")
915
934
  end
@@ -1596,8 +1615,12 @@ def rewrite_block(src, force_string: true)
1596
1615
  # `headers["X"] = "y"` -> `res.headers[...]` when not already qualified
1597
1616
  s = s.gsub(/(?<![\w.])headers\[/, "res.headers[")
1598
1617
 
1599
- # `cookies["k"]` -> `req.cookies["k"]` when not already qualified
1600
- s = s.gsub(/(?<![\w.])cookies\[/, "req.cookies[")
1618
+ # `cookies["k"] = v` (write) -> direct hash write; `cookies["k"]`
1619
+ # (read) -> Tep.cookie(req, "k"), which nil-guards the miss: the
1620
+ # documented cookies[] contract is ""-on-miss (test_cookies pins
1621
+ # it), but the underlying hash reads nil for a missing key.
1622
+ s = s.gsub(/(?<![\w.])cookies\[([^\]]*)\]\s*=(?![=~])/, 'req.cookies[\1] =')
1623
+ s = s.gsub(/(?<![\w.])cookies\[([^\]]*)\]/, 'Tep.cookie(req, \1)')
1601
1624
 
1602
1625
  # `erb :name` -> `tep_view_name(Tep.str_hash, req.ivars)`
1603
1626
  # `erb :name, locals: {a: "b", c: "d"}` -> build a String=>String
@@ -1610,7 +1633,14 @@ def rewrite_block(src, force_string: true)
1610
1633
  # are handled separately so they can contain commas or spaces.
1611
1634
  pairs = $2.scan(/(\w+):\s*("[^"]*"|'[^']*'|[^,}]+?)\s*(?=,|\z)/)
1612
1635
  setters = pairs.map { |k, v| %{__l["#{k}"] = (#{v.strip}).to_s} }.join("; ")
1613
- "(__l = Tep.str_hash; #{setters}; tep_view_#{view}(__l, req.ivars))"
1636
+ # Build the locals hash INSIDE the first argument (a sequence
1637
+ # expression that returns __l), rather than `(__l = ...; render(__l))`
1638
+ # with __l a bare arg. In the latter shape spinel (recent master)
1639
+ # hoists the `render`'s arg temp -- `_t = __l` -- above the in-sequence
1640
+ # `__l = str_hash()` assignment, so render receives the pre-assignment
1641
+ # (empty) hash and every interpolated value blanks (matz/spinel#1478).
1642
+ # Keeping the assignment inside the arg evaluation defeats the hoist.
1643
+ "tep_view_#{view}((__l = Tep.str_hash; #{setters}; __l), req.ivars)"
1614
1644
  end
1615
1645
  s = s.gsub(/(?<![\w.])erb\s+:(\w+)/, 'tep_view_\1(Tep.str_hash, req.ivars)')
1616
1646
 
@@ -1619,7 +1649,8 @@ def rewrite_block(src, force_string: true)
1619
1649
  view = $1
1620
1650
  pairs = $2.scan(/(\w+):\s*("[^"]*"|'[^']*'|[^,}]+?)\s*(?=,|\z)/)
1621
1651
  setters = pairs.map { |k, v| %{__l["#{k}"] = (#{v.strip}).to_s} }.join("; ")
1622
- "(__l = Tep.str_hash; #{setters}; tep_mustache_#{view}(__l, req.ivars))"
1652
+ # Same arg-hoist avoidance as the erb form above (matz/spinel#1478).
1653
+ "tep_mustache_#{view}((__l = Tep.str_hash; #{setters}; __l), req.ivars)"
1623
1654
  end
1624
1655
  s = s.gsub(/(?<![\w.])mustache\s+:(\w+)/, 'tep_mustache_\1(Tep.str_hash, req.ivars)')
1625
1656
 
@@ -1689,6 +1720,12 @@ def rewrite_block(src, force_string: true)
1689
1720
  # to string keys.
1690
1721
  s = s.gsub(/(req\.params)\[:(\w+)\]/, '\1["\2"]')
1691
1722
 
1723
+ # Read form -> Tep.param(req, k), which nil-guards the miss: tep's
1724
+ # documented params[] contract is ""-on-miss (test_params pins it;
1725
+ # ledgered as a sinatra divergence -- sinatra reads nil). Writes
1726
+ # (assignment) stay direct hash writes.
1727
+ s = s.gsub(/req\.params\[([^\]]*)\](?!\s*=[^=~])/, 'Tep.param(req, \1)')
1728
+
1692
1729
  # `"#{params['x']}"` interpolation works fine after the params rewrite.
1693
1730
 
1694
1731
  # String-coerce the last expression of the block body. Spinel's
@@ -2168,23 +2205,25 @@ def cmd_build(args)
2168
2205
  input = nil
2169
2206
  out_path = nil
2170
2207
  c_only = false
2208
+ lax = ENV["TEP_LAX"] == "1"
2171
2209
  i = 0
2172
2210
  while i < args.length
2173
2211
  a = args[i]
2174
2212
  case a
2175
2213
  when "-o" then out_path = args[i + 1]; i += 2
2176
2214
  when "-c" then c_only = true; i += 1
2215
+ when "--lax" then lax = true; i += 1
2177
2216
  when /^-/ then fatal "unknown option: #{a}"
2178
2217
  else
2179
2218
  input ||= a
2180
2219
  i += 1
2181
2220
  end
2182
2221
  end
2183
- fatal "usage: tep build app.rb [-o out] [-c]" unless input
2222
+ fatal "usage: tep build app.rb [-o out] [-c] [--lax]" unless input
2184
2223
 
2185
2224
  base = File.basename(input, ".rb")
2186
2225
  out_path ||= File.join(File.dirname(input), base)
2187
- translated = translate(input)
2226
+ translated = translate(input, lax: lax)
2188
2227
 
2189
2228
  # Spinel's require_relative resolves against the source file's
2190
2229
  # directory and chokes on absolute paths. Drop the generated file
@@ -2222,8 +2261,10 @@ def cmd_run(args)
2222
2261
  end
2223
2262
 
2224
2263
  def cmd_translate(args)
2225
- input = args.first or fatal "usage: tep translate app.rb"
2226
- puts translate(input)
2264
+ lax = ENV["TEP_LAX"] == "1"
2265
+ lax = true if args.delete("--lax")
2266
+ input = args.first or fatal "usage: tep translate app.rb [--lax]"
2267
+ puts translate(input, lax: lax)
2227
2268
  end
2228
2269
 
2229
2270
  case ARGV.shift
@@ -2235,9 +2276,14 @@ when nil, "-h", "--help"
2235
2276
  tep -- Sinatra-flavoured framework that compiles to a native binary.
2236
2277
 
2237
2278
  Usage:
2238
- tep build app.rb [-o out] [-c] translate + spinel-compile
2239
- tep run app.rb [-p PORT] [-w N] build then exec
2240
- tep translate app.rb print the translated Ruby
2279
+ tep build app.rb [-o out] [-c] [--lax] translate + spinel-compile
2280
+ tep run app.rb [-p PORT] [-w N] build then exec
2281
+ tep translate app.rb [--lax] print the translated Ruby
2282
+
2283
+ Strict by default: source constructs tep would have to drop
2284
+ (Rack `use`, `helpers do`, unknown `set` keys, ...) fail the
2285
+ build rather than silently diverging from real sinatra.
2286
+ `--lax` (or TEP_LAX=1) downgrades them to warnings.
2241
2287
 
2242
2288
  Source compatibility: top-level `get '/path' do ... end`,
2243
2289
  `post`, `put`, `patch`, `delete`, `before`, `after`, `not_found`,
data/examples/blog/app.rb CHANGED
@@ -332,6 +332,7 @@ end
332
332
  post '/api/posts' do
333
333
  res.headers["Content-Type"] = "application/json"
334
334
  auth = req.req_headers["authorization"]
335
+ auth = "" if auth.nil? # missing header reads as nil (tep#235)
335
336
  bearer = ""
336
337
  if auth.length > 7 && auth[0, 7] == "Bearer "
337
338
  bearer = auth[7, auth.length - 7]
@@ -218,7 +218,9 @@ end
218
218
  # baked in. Here, accept an X-Demo-Cap-Run header as the
219
219
  # capability source so humans can drive the demo with curl.
220
220
  before do
221
- if req.req_headers["x-demo-cap-run"].length > 0
221
+ cap = req.req_headers["x-demo-cap-run"]
222
+ cap = "" if cap.nil? # missing header reads as nil (tep#235)
223
+ if cap.length > 0
222
224
  req.identity = Tep::Identity.new(
223
225
  "user:demo", nil, [:run_experiments])
224
226
  end
data/examples/pg_hello.rb CHANGED
@@ -68,15 +68,16 @@ get '/error' do
68
68
  out = "rescued PG::UndefinedTable\n" +
69
69
  "sqlstate: " + c.last_sqlstate + "\n" +
70
70
  "is undefined-table? " + (c.last_sqlstate == "42P01" ? "yes" : "no") + "\n" +
71
- # WORKAROUND -- REMOVE WHEN UPSTREAM LANDS: `e.is_a?(PG::Error)`
72
- # miscompiles at spinel master (whole-program is_a? on a deep
73
- # exception subclass vs an ancestor -- e here is rescued as
74
- # PG::UndefinedTable, a PG::Error subclass, so this is always
75
- # "yes"). Minimal `rescue Sub => e; e.is_a?(Super)` repros
76
- # compile fine; it only trips in the full program. Hardcoded
77
- # to "yes" to keep the example building for the re-pin. See
78
- # matz/spinel#1434, tep#196. Original:
71
+ # WORKAROUND -- still open at SPINEL_PIN (re-checked at the
72
+ # ad2b71ad re-pin: `e.is_a?(PG::Error)` here is rejected as
73
+ # `unsupported call: is_a? recv=LocalVariableRead argc=1`).
74
+ # `e` is the rescued exception, typed PG::UndefinedTable -- a
75
+ # whole-program is_a? against the namespaced ancestor PG::Error
76
+ # isn't lowered yet. Minimal `rescue Sub => e; e.is_a?(Super)`
77
+ # compiles fine; only the full program trips it. Since `e` is
78
+ # always a PG::Error subclass here, hardcode "yes". Restore
79
79
  # (e.is_a?(PG::Error) ? "yes" : "no")
80
+ # once is_a?-on-rescued-namespaced-ancestor lowers. See tep#196.
80
81
  "is PG::Error? " + "yes" + "\n" +
81
82
  "message: " + e.message
82
83
  end
data/lib/tep/app.rb CHANGED
@@ -187,6 +187,7 @@ module Tep
187
187
  secret = Tep.session_secret
188
188
  if secret.length > 0
189
189
  cv = req.cookies[Tep::COOKIE_NAME]
190
+ cv = "" if cv.nil?
190
191
  if cv.length > 0
191
192
  req.session.load_from(cv, secret)
192
193
  end
@@ -47,6 +47,7 @@ module Tep
47
47
  # signature / expired / malformed payload.
48
48
  def self.try(req)
49
49
  header = req.req_headers["authorization"]
50
+ header = "" if header.nil?
50
51
  if header.length < 8 || header[0, 7] != "Bearer "
51
52
  return nil
52
53
  end
@@ -84,12 +84,16 @@ module Tep
84
84
  # if the session has no identity (no prior #set call, or after
85
85
  # #clear) or the stored identity is expired.
86
86
  def self.try(req)
87
+ # Session#get on a missing key reads as nil (sinatra-parity Hash
88
+ # semantics; same class as tep#235) -- guard before String calls.
87
89
  sub = req.session.get("identity_sub")
90
+ sub = "" if sub.nil?
88
91
  if sub.length == 0
89
92
  return nil
90
93
  end
91
94
 
92
95
  exp_str = req.session.get("identity_exp")
96
+ exp_str = "" if exp_str.nil?
93
97
  if exp_str.length > 0
94
98
  exp = exp_str.to_i
95
99
  if exp > 0 && Time.now.to_i >= exp
@@ -98,9 +102,11 @@ module Tep
98
102
  end
99
103
 
100
104
  caps_str = req.session.get("identity_caps")
105
+ caps_str = "" if caps_str.nil?
101
106
  caps = Tep::AuthBearerToken.parse_caps(caps_str)
102
107
 
103
108
  delegate_str = req.session.get("identity_delegate")
109
+ delegate_str = "" if delegate_str.nil?
104
110
  delegation = Tep::AuthBearerToken.parse_delegate(delegate_str)
105
111
 
106
112
  Tep::Identity.new(sub, delegation, caps)
data/lib/tep/cache.rb CHANGED
@@ -18,8 +18,10 @@ module Tep
18
18
  # tag is matched as a substring so a comma-separated list of tags
19
19
  # in If-None-Match works.
20
20
  etag = res.headers["ETag"]
21
+ etag = "" if etag.nil?
21
22
  if etag.length > 0
22
23
  inm = req.headers["if-none-match"]
24
+ inm = "" if inm.nil?
23
25
  if inm.length > 0
24
26
  if inm == "*"
25
27
  return true
@@ -35,6 +37,7 @@ module Tep
35
37
  lm = res.lastmod_epoch
36
38
  if lm > 0
37
39
  ims = req.headers["if-modified-since"]
40
+ ims = "" if ims.nil?
38
41
  if ims.length > 0
39
42
  ims_epoch = Sock.sphttp_parse_http_date(ims)
40
43
  if ims_epoch >= 0 && lm <= ims_epoch
data/lib/tep/http.rb CHANGED
@@ -9,10 +9,12 @@
9
9
  # plumbing (sphttp_connect, sphttp_set_nonblock, sphttp_recv_*); the
10
10
  # missing piece is the HTTP/1.0 client on top.
11
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.
12
+ # Scope
13
+ # -----
14
+ # * **http + https** -- outbound TLS landed in #150
15
+ # (Sock.sphttp_connect_tls; non-blocking handshake under the
16
+ # scheduled server). Talk to internal services, the local Ollama
17
+ # API, vLLM, your own tep-backed sidecars, or external https APIs.
16
18
  # * **HTTP/1.0 + Connection: close** -- one socket per request,
17
19
  # read until EOF. No keep-alive, no pipelining.
18
20
  # * **No chunked-transfer reading** -- assumes Content-Length or
@@ -24,7 +26,7 @@
24
26
  #
25
27
  # These limits cover the dashboard's needs (talking to local
26
28
  # inference backends) and the bulk of "hit an internal API"
27
- # workloads. HTTPS + keep-alive + chunked land as a v2 surface.
29
+ # workloads. Keep-alive + chunked reading land as a v2 surface.
28
30
  #
29
31
  # API shape
30
32
  # ---------
@@ -141,7 +141,10 @@ module Tep
141
141
  def self.serve!(events_jsonl = "")
142
142
  events = Tep::Events.new(events_jsonl)
143
143
  Tep::APP.set_openai_events(events)
144
+ # ENV[] reads nil for an unset variable (CRuby semantics) --
145
+ # guard before String calls (same class as tep#235).
144
146
  host = ENV["HOSTNAME"]
147
+ host = "" if host.nil?
145
148
  if host.length == 0
146
149
  host = "tep"
147
150
  end
data/lib/tep/parser.rb CHANGED
@@ -55,8 +55,11 @@ module Tep
55
55
  end
56
56
 
57
57
  # Parse Cookie header into req.cookies. Format: "k=v; k2=v2; ...".
58
- # Whitespace around `;` is allowed and stripped.
58
+ # Whitespace around `;` is allowed and stripped. A request with no
59
+ # Cookie header reads as nil (don't call methods on it: spinel's
60
+ # unresolved-call gate raises NoMethodError, same as CRuby).
59
61
  cookie_blob = req.req_headers["cookie"]
62
+ cookie_blob = "" if cookie_blob.nil?
60
63
  if cookie_blob.length > 0
61
64
  cookie_blob.split(";").each do |pair|
62
65
  eq = Tep.str_find(pair, "=", 0)
data/lib/tep/pg.rb CHANGED
@@ -190,6 +190,16 @@ module PG
190
190
  @last_sqlstate = ""
191
191
  @last_error_message = ""
192
192
  @last_result_rh = -1
193
+ # Never-connected sentinel: PG::Connection.new("") is the
194
+ # type-seeding shape (see the _tep_seed_pg_* block). Exec on a
195
+ # seed conn returns the empty-Result sentinel instead of
196
+ # raising -- a REAL failed connect (non-empty conninfo) still
197
+ # raises PG::UnableToSend on first exec, per the documented
198
+ # non-raising-connect / raising-exec contract.
199
+ @seed_conn = false
200
+ if opts.is_a?(String) && opts.length == 0
201
+ @seed_conn = true
202
+ end
193
203
  if opts.is_a?(String)
194
204
  if Tep::Scheduler.scheduled_context?
195
205
  h = Connection.async_connect(opts)
@@ -197,34 +207,19 @@ module PG
197
207
  h = Pg.tep_pg_connect(opts)
198
208
  end
199
209
  else
200
- # ============ WORKAROUND -- REMOVE WHEN UPSTREAM LANDS ============
201
- # Hash-conninfo form. The `opts.each` below miscompiles at spinel
202
- # master: `opts` is a String|Hash param, but in this is_a?(String)
203
- # ELSE branch spinel types it String (the is_a?-else narrowing
204
- # gap, matz/spinel#1434) and rejects `opts.each` as String#each
205
- # -- the lone blocker to re-pinning tep onto master (tep#196).
206
- #
207
- # The Hash form is currently UNUSED + untested in tep and toy:
208
- # every PG::Connection.new / PG.connect caller passes a String
209
- # conninfo (AR connects with the String form too). So we stub
210
- # this dead branch to a failed connection to unblock the re-pin.
211
- #
212
- # RESTORE the original kv-pack loop (preserved below) once the
213
- # upstream narrowing fix lands, and re-add Hash-form test
214
- # coverage. Until then a Hash arg yields a failed Connection
215
- # (connected? == false) rather than a miscompile.
216
- #
217
- # keys = ""
218
- # vals = ""
219
- # n = 0
220
- # opts.each do |k, v|
221
- # keys = keys + k + "\0"
222
- # vals = vals + v + "\0"
223
- # n += 1
224
- # end
225
- # h = Pg.tep_pg_connect_kv(keys, vals, n)
226
- h = -1
227
- # =================================================================
210
+ # Hash-conninfo form: pack the key/value pairs into NUL-delimited
211
+ # buffers for the C shim. (`opts` narrows to Hash in this
212
+ # is_a?(String) ELSE branch -- the narrowing gap that blocked the
213
+ # re-pin, matz/spinel#1434, is fixed as of the SPINEL_PIN bump.)
214
+ keys = ""
215
+ vals = ""
216
+ n = 0
217
+ opts.each do |k, v|
218
+ keys = keys + k + "\0"
219
+ vals = vals + v + "\0"
220
+ n += 1
221
+ end
222
+ h = Pg.tep_pg_connect_kv(keys, vals, n)
228
223
  end
229
224
  if h < 0
230
225
  # Slot 0 holds the most recent connect-failure error message
@@ -397,6 +392,9 @@ module PG
397
392
  Pg.tep_pg_set_nonblocking(@pgh, 1)
398
393
  ok = Pg.tep_pg_send_query(@pgh, sql)
399
394
  if ok != 1
395
+ if @seed_conn
396
+ return PG::Result.new(-1)
397
+ end
400
398
  Connection.raise_send_failure(self)
401
399
  end
402
400
  Connection.drain_send(@pgh)
@@ -436,6 +434,9 @@ module PG
436
434
  Pg.tep_pg_set_nonblocking(@pgh, 1)
437
435
  ok = Pg.tep_pg_send_query_params(@pgh, sql)
438
436
  if ok != 1
437
+ if @seed_conn
438
+ return PG::Result.new(-1)
439
+ end
439
440
  Connection.raise_send_failure(self)
440
441
  end
441
442
  Connection.drain_send(@pgh)
data/lib/tep/proxy.rb CHANGED
@@ -62,7 +62,7 @@ module Tep
62
62
  # wins (whichever setter you called second).
63
63
  #
64
64
  # Default shape: max_attempts=1 (no retry, back-compat).
65
- class Proxy
65
+ class Proxy < Tep::Handler
66
66
  class RetryPolicy
67
67
  attr_accessor :max_attempts, :base_backoff_ms, :backoff_multiplier
68
68
  attr_accessor :retry_on_status
@@ -125,7 +125,7 @@ module Tep
125
125
  end
126
126
  end
127
127
 
128
- class Proxy < Tep::Handler
128
+ class Proxy
129
129
  attr_accessor :upstream, :timeout
130
130
  # Body size caps (chunk 6.6). max_request_body_bytes bounds the
131
131
  # inbound body the proxy will accept (over -> 413 Payload Too
data/lib/tep/request.rb CHANGED
@@ -51,11 +51,16 @@ module Tep
51
51
  def headers; @req_headers; end
52
52
  def body; @raw_body; end
53
53
 
54
- # Spinel's Hash[k] returns "" for missing string keys, not nil --
55
- # so an empty Connection header looks the same as no header at all.
56
- # We treat both as "use HTTP/1.1 default behaviour".
54
+ # A missing header reads as nil (CRuby Hash semantics; spinel's
55
+ # unresolved-call gate raises NoMethodError on nil receivers, same
56
+ # as CRuby would). Guard before calling String methods; an empty
57
+ # header and no header both mean "use HTTP/1.1 default behaviour".
58
+ # NOTE: guard with `x = "" if x.nil?`, not `x || ""` -- the ||-form
59
+ # mis-compiles at spinel pin f6d5eef (blanks the hit value).
57
60
  def keep_alive?
58
- lc = @req_headers["connection"].downcase
61
+ lc = @req_headers["connection"]
62
+ lc = "" if lc.nil?
63
+ lc = lc.downcase
59
64
  if lc == "close"
60
65
  return false
61
66
  end
@@ -70,7 +75,9 @@ module Tep
70
75
  end
71
76
 
72
77
  def form?
73
- @req_headers["content-type"].downcase.start_with?("application/x-www-form-urlencoded")
78
+ ct = @req_headers["content-type"]
79
+ ct = "" if ct.nil?
80
+ ct.downcase.start_with?("application/x-www-form-urlencoded")
74
81
  end
75
82
 
76
83
  # True when the request body is a multipart/form-data submission
@@ -78,7 +85,9 @@ module Tep
78
85
  # or carrying file inputs). Tep::Multipart.parse handles the
79
86
  # text fields; file-upload parts are skipped in v1.
80
87
  def multipart?
81
- @req_headers["content-type"].downcase.start_with?("multipart/form-data")
88
+ ct = @req_headers["content-type"]
89
+ ct = "" if ct.nil?
90
+ ct.downcase.start_with?("multipart/form-data")
82
91
  end
83
92
 
84
93
  # ---- Rack::Request-style accessors (reads only, no .ip yet) ----
@@ -99,6 +108,7 @@ module Tep
99
108
  # proxy sets.
100
109
  def scheme
101
110
  proto = @req_headers["x-forwarded-proto"]
111
+ proto = "" if proto.nil?
102
112
  if proto.length > 0
103
113
  return proto.downcase
104
114
  end
data/lib/tep/server.rb CHANGED
@@ -231,7 +231,11 @@ module Tep
231
231
  res.set_body("")
232
232
  end
233
233
 
234
- if res.body.length > 0 && !res.headers.key?("Content-Type")
234
+ # Default Content-Type even on empty bodies (redirects!) --
235
+ # sinatra/Rack parity, caught by the differential oracle
236
+ # (test/differential/runner.rb). 204/304 are the exception:
237
+ # Rack strips entity headers there, so we don't default one in.
238
+ if !res.headers.key?("Content-Type") && res.status != 204 && res.status != 304
235
239
  res.headers["Content-Type"] = "text/html; charset=utf-8"
236
240
  end
237
241
  res.headers["Content-Length"] = res.body.length.to_s
@@ -327,11 +327,14 @@ module Tep
327
327
  res.file_path = ""
328
328
  end
329
329
 
330
- # Default Content-Type for inline-body responses. Matches
331
- # Tep::Server#send; without it, the Security::Headers nosniff
332
- # default leaves the browser refusing to interpret an erb
333
- # response as HTML.
334
- if res.file_path.length == 0 && res.body.length > 0 && !res.headers.key?("Content-Type")
330
+ # Default Content-Type for inline-body responses -- including
331
+ # empty ones (redirects), for sinatra/Rack parity (differential
332
+ # oracle finding; matches Tep::Server). 204/304 excepted: Rack
333
+ # strips entity headers there. Without a Content-Type, the
334
+ # Security::Headers nosniff default leaves the browser refusing
335
+ # to interpret an erb response as HTML.
336
+ if res.file_path.length == 0 && !res.headers.key?("Content-Type") &&
337
+ res.status != 204 && res.status != 304
335
338
  res.headers["Content-Type"] = "text/html; charset=utf-8"
336
339
  end
337
340
  reason = Tep.reason(res.status)
data/lib/tep/session.rb CHANGED
@@ -24,7 +24,15 @@ module Tep
24
24
  # exposes only named methods; the translator rewrites
25
25
  # `session[k] = v` to `session.set(k, v)` and `session[k]` to
26
26
  # `session.get(k)` for source compatibility with Sinatra.
27
- def get(k); @data[k]; end
27
+ # A missing key reads as "" -- the documented `session[k]` DSL
28
+ # contract (test_sessions pins it; a deliberate divergence from
29
+ # sinatra's nil-on-miss, ledgered in docs/mirrors/sinatra.md).
30
+ # The raw @data hash reads nil for a miss.
31
+ def get(k)
32
+ v = @data[k]
33
+ v = "" if v.nil?
34
+ v
35
+ end
28
36
  def set(k, v); @data[k] = v; @dirty = true; end
29
37
  def has?(k); @data.key?(k); end
30
38
  def length; @data.length; end