tep 0.11.2 → 0.11.4

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +31 -1
  3. data/README.md +4 -4
  4. data/SINATRA_COMPAT.md +20 -20
  5. data/bin/tep +8 -8
  6. data/examples/api_gateway/app.rb +1 -1
  7. data/examples/blog/app.rb +17 -17
  8. data/examples/chat/app.rb +12 -12
  9. data/examples/chatbot/README.md +2 -2
  10. data/examples/chatbot/app.rb +24 -24
  11. data/examples/llm_gateway/README.md +6 -5
  12. data/examples/llm_gateway/app.rb +4 -4
  13. data/lib/spinel_kit/hex.rb +65 -0
  14. data/lib/spinel_kit/json.rb +151 -0
  15. data/lib/spinel_kit/json_decoder.rb +396 -0
  16. data/lib/{tep/logger.rb → spinel_kit/log.rb} +25 -21
  17. data/lib/spinel_kit/url.rb +166 -0
  18. data/lib/tep/auth_bearer_token.rb +6 -6
  19. data/lib/tep/auth_oauth2.rb +4 -4
  20. data/lib/tep/events.rb +37 -37
  21. data/lib/tep/http.rb +3 -3
  22. data/lib/tep/job.rb +2 -2
  23. data/lib/tep/jwt.rb +4 -4
  24. data/lib/tep/live_view.rb +4 -4
  25. data/lib/tep/llm.rb +13 -45
  26. data/lib/tep/mcp.rb +12 -12
  27. data/lib/tep/multipart.rb +1 -1
  28. data/lib/tep/openai_server.rb +134 -93
  29. data/lib/tep/parser.rb +2 -2
  30. data/lib/tep/presence.rb +11 -11
  31. data/lib/tep/proxy.rb +7 -7
  32. data/lib/tep/request.rb +1 -1
  33. data/lib/tep/response.rb +1 -1
  34. data/lib/tep/router.rb +1 -1
  35. data/lib/tep/session.rb +2 -2
  36. data/lib/tep/version.rb +1 -1
  37. data/lib/tep.rb +30 -29
  38. data/test/helper.rb +95 -8
  39. data/test/run_parallel.rb +44 -7
  40. data/test/test_auth.rb +17 -17
  41. data/test/test_auth_oauth2.rb +5 -5
  42. data/test/test_http_pool.rb +4 -4
  43. data/test/test_http_pool_send.rb +3 -3
  44. data/test/test_json.rb +12 -12
  45. data/test/test_jwt.rb +4 -4
  46. data/test/test_live_view.rb +3 -3
  47. data/test/test_llm.rb +12 -9
  48. data/test/test_llm_gateway.rb +2 -2
  49. data/test/test_logger.rb +2 -2
  50. data/test/test_openai_server.rb +72 -1
  51. data/test/test_password.rb +3 -3
  52. data/test/test_real_world.rb +6 -1
  53. data/test/test_shutdown.rb +40 -0
  54. metadata +9 -8
  55. data/lib/tep/json.rb +0 -572
  56. data/lib/tep/url.rb +0 -161
data/lib/tep.rb CHANGED
@@ -36,7 +36,7 @@ if defined?(RUBY_ENGINE)
36
36
  end
37
37
 
38
38
  require_relative "tep/version"
39
- require_relative "tep/url"
39
+ require_relative "spinel_kit/url"
40
40
  require_relative "tep/multipart"
41
41
  require_relative "tep/net"
42
42
  require_relative "tep/agent_delegation"
@@ -59,7 +59,7 @@ require_relative "tep/router"
59
59
  require_relative "tep/app"
60
60
  # Auth provider classes land after App so Tep::AuthFilter < Tep::Filter
61
61
  # resolves and the install! helper can reach Tep::APP. References to
62
- # Tep::Jwt / Tep::Json inside their method bodies resolve at runtime.
62
+ # Tep::Jwt / SpinelKit::Json inside their method bodies resolve at runtime.
63
63
  require_relative "tep/auth_bearer_token"
64
64
  require_relative "tep/auth_session_cookie"
65
65
  require_relative "tep/auth_oauth2"
@@ -71,9 +71,10 @@ require_relative "tep/server"
71
71
  require_relative "tep/server_scheduled"
72
72
  require_relative "tep/sqlite"
73
73
  require_relative "tep/pg"
74
- require_relative "tep/json"
74
+ require_relative "spinel_kit/json"
75
+ require_relative "spinel_kit/json_decoder"
75
76
  require_relative "tep/mcp"
76
- require_relative "tep/logger"
77
+ require_relative "spinel_kit/log"
77
78
  require_relative "tep/jwt"
78
79
  require_relative "tep/password"
79
80
  require_relative "tep/security"
@@ -453,27 +454,27 @@ module Tep
453
454
  # NB: don't checkout/checkin against the size-0 seed pool; it'd
454
455
  # spin until timeout. The seed has @free.length=0 forever.
455
456
 
456
- # Tep::Json type-seeding. Pin every public method's parameter
457
+ # SpinelKit::Json type-seeding. Pin every public method's parameter
457
458
  # types so an app that uses one method but not another still
458
459
  # compiles cleanly. Calls have no side effects beyond producing
459
460
  # discardable strings.
460
- Tep::Json.escape("")
461
- Tep::Json.quote("")
462
- Tep::Json.encode_pair_str("", "")
463
- Tep::Json.encode_pair_int("", 0)
461
+ SpinelKit::Json.escape("")
462
+ SpinelKit::Json.quote("")
463
+ SpinelKit::Json.encode_pair_str("", "")
464
+ SpinelKit::Json.encode_pair_int("", 0)
464
465
  _tep_seed_str_h = Tep.str_hash
465
466
  _tep_seed_str_h["k"] = "v"
466
- Tep::Json.from_str_hash(_tep_seed_str_h)
467
+ SpinelKit::Json.from_str_hash(_tep_seed_str_h)
467
468
  _tep_seed_int_h = {"" => 0}
468
469
  _tep_seed_int_h.delete("")
469
470
  _tep_seed_int_h["k"] = 1
470
- Tep::Json.from_int_hash(_tep_seed_int_h)
471
+ SpinelKit::Json.from_int_hash(_tep_seed_int_h)
471
472
 
472
- # Tep::Logger seed -- pin parameter types for every method even
473
+ # SpinelKit::Log seed -- pin parameter types for every method even
473
474
  # when an app uses one but not another. The level-name string
474
475
  # ("info") and the messages ("") pin the :str shape; the file-
475
476
  # path setter pins to_file's :str arg.
476
- _tep_seed_logger = Tep::Logger.new
477
+ _tep_seed_logger = SpinelKit::Log.new
477
478
  _tep_seed_logger.set_level("info")
478
479
  _tep_seed_logger.to_file("")
479
480
  _tep_seed_logger.to_stderr
@@ -481,7 +482,7 @@ module Tep
481
482
  _tep_seed_logger.info("")
482
483
  _tep_seed_logger.warn("")
483
484
  _tep_seed_logger.error("")
484
- Tep::Logger.level_value("info")
485
+ SpinelKit::Log.level_value("info")
485
486
 
486
487
  # Tep::Jwt seed -- pin every method's :str arg types. The
487
488
  # secret + payload are blank but the call shapes pin the FFI
@@ -543,8 +544,8 @@ module Tep
543
544
  Tep::Shell.read("/etc/hostname")
544
545
  Tep::Shell.read_limited("/etc/hostname", 64)
545
546
 
546
- # Tep::Url seed -- the new split_url has to land at compile time.
547
- Tep::Url.split_url("http://x/")
547
+ # SpinelKit::Url seed -- the new split_url has to land at compile time.
548
+ SpinelKit::Url.split_url("http://x/")
548
549
 
549
550
  # Tep::Http seed -- every public method gets one canonical call so
550
551
  # spinel pins the param types. The URL "http://127.0.0.1:1/" won't
@@ -623,10 +624,10 @@ module Tep
623
624
  # Pin Sock.sphttp_sleep_ms's :int param so the backoff call site
624
625
  # resolves (called from Tep::Proxy#handle).
625
626
  Sock.sphttp_sleep_ms(0)
626
- # Tep::Json.get_float seed (#133). Pin the (String, String) -> Float
627
+ # SpinelKit::Json.get_float seed (#133). Pin the (String, String) -> Float
627
628
  # surface so callers (CompletionsHandler temperature/top_p,
628
629
  # backends that parse their own bodies) resolve cleanly.
629
- Tep::Json.get_float("{\"temperature\":0.7}", "temperature")
630
+ SpinelKit::Json.get_float("{\"temperature\":0.7}", "temperature")
630
631
  _tep_seed_retry_policy.backoff_for(0)
631
632
  _tep_seed_retry_policy.retriable?(502)
632
633
  _tep_seed_proxy.retry_policy(_tep_seed_proxy_req)
@@ -679,13 +680,13 @@ module Tep
679
680
  _tep_seed_oai_models = Tep::Llm::OpenAI::ModelsHandler.new
680
681
  _tep_seed_oai_models.handle(_tep_seed_proxy_req, _tep_seed_proxy_res)
681
682
  # 7.1b /v1/completions surface.
682
- Tep::Json.get_int_array("{}", "prompt")
683
+ SpinelKit::Json.get_int_array("{}", "prompt")
683
684
  _tep_seed_oai_sampling = Tep::Llm::OpenAI::Sampling.new
684
685
  _tep_seed_oai_sampling.max_tokens = 0
685
686
  _tep_seed_oai_sampling.temperature = 1.0
686
687
  _tep_seed_oai_sampling.top_p = 1.0
687
688
  _tep_seed_oai_comp = Tep::Llm::OpenAI::Completion.new
688
- _tep_seed_oai_backend.generate_from_tokens("m", Tep::Json.get_int_array("{}", "prompt"), _tep_seed_oai_sampling)
689
+ _tep_seed_oai_backend.generate_from_tokens("m", SpinelKit::Json.get_int_array("{}", "prompt"), _tep_seed_oai_sampling)
689
690
  _tep_seed_oai_completions = Tep::Llm::OpenAI::CompletionsHandler.new
690
691
  _tep_seed_oai_completions.handle(_tep_seed_proxy_req, _tep_seed_proxy_res)
691
692
  # Chat completions skeleton (POST /v1/chat/completions). Default
@@ -717,10 +718,10 @@ module Tep
717
718
  # makes the underlying sphttp_write_chunk a harmless EBADF at boot.
718
719
  _tep_seed_oai_sink.emit_token("seed")
719
720
  _tep_seed_oai_backend.generate_stream_from_tokens(
720
- "m", Tep::Json.get_int_array("{}", "prompt"), _tep_seed_oai_sampling, _tep_seed_oai_sink)
721
+ "m", SpinelKit::Json.get_int_array("{}", "prompt"), _tep_seed_oai_sampling, _tep_seed_oai_sink)
721
722
  _tep_seed_oai_cstreamer = Tep::Llm::OpenAI::CompletionsStreamer.new
722
723
  _tep_seed_oai_cstreamer.model = "m"
723
- _tep_seed_oai_cstreamer.token_ids = Tep::Json.get_int_array("{}", "prompt")
724
+ _tep_seed_oai_cstreamer.token_ids = SpinelKit::Json.get_int_array("{}", "prompt")
724
725
  _tep_seed_oai_cstreamer.sampling = _tep_seed_oai_sampling
725
726
  _tep_seed_oai_cstreamer.prompt_tokens = 0
726
727
  _tep_seed_oai_cstreamer.t0 = 0
@@ -774,13 +775,13 @@ module Tep
774
775
  Tep::Job.mark_failed(":memory:", 0)
775
776
  _tep_seed_str_arr = [""]
776
777
  _tep_seed_str_arr.delete_at(0)
777
- Tep::Json.from_str_array(_tep_seed_str_arr)
778
+ SpinelKit::Json.from_str_array(_tep_seed_str_arr)
778
779
  _tep_seed_int_arr = [0]
779
780
  _tep_seed_int_arr.delete_at(0)
780
- Tep::Json.from_int_array(_tep_seed_int_arr)
781
- Tep::Json.get_str("{}", "")
782
- Tep::Json.get_int("{}", "")
783
- Tep::Json.has_key?("{}", "")
781
+ SpinelKit::Json.from_int_array(_tep_seed_int_arr)
782
+ SpinelKit::Json.get_str("{}", "")
783
+ SpinelKit::Json.get_int("{}", "")
784
+ SpinelKit::Json.has_key?("{}", "")
784
785
 
785
786
  # Tep::MCP seeds (chunk 5.1). Tools register at compile time via
786
787
  # bin/tep's mcp_tool DSL; the runtime helpers below are the
@@ -810,7 +811,7 @@ module Tep
810
811
 
811
812
  # Tep::Llm seeds. attr_accessor return types default to mrb_int
812
813
  # if spinel sees no concrete callsite -- and Tep::Llm.build_request_body
813
- # passes msg.role / msg.content into Tep::Json.quote(String) which
814
+ # passes msg.role / msg.content into SpinelKit::Json.quote(String) which
814
815
  # then mismatches. Pin Message + Response attrs to String, and
815
816
  # run one full encode + parse round-trip so the static analyzer
816
817
  # sees every public method called with concrete types.
@@ -846,7 +847,7 @@ module Tep
846
847
  Tep::Llm.dechunk_leftover("")
847
848
  Tep::Llm.dechunk_pass("")
848
849
  Tep::Llm.drain_sse_buf("", _tep_seed_llm_stream, "")
849
- Tep::Llm.hex_to_int("")
850
+ SpinelKit::Hex.to_int("")
850
851
 
851
852
  # Tep::WebSocket seeds. Pins frame/handshake/driver/connection
852
853
  # surfaces to concrete typed callsites so the analyzer doesn't
data/test/helper.rb CHANGED
@@ -30,10 +30,51 @@ module TepHarness
30
30
  attr_reader :port
31
31
  end
32
32
 
33
+ # Hand out the next port that's actually bindable, skipping any that's
34
+ # already held -- a squatter (stray server from an earlier run) or an orphan
35
+ # the reap missed. Without this, a class boots on a squatted port, its own
36
+ # bind fails, and wait_for_port connects to the SQUATTER instead -> requests
37
+ # routed to the wrong app (cross-talk). Probing closes the TOCTOU window to
38
+ # nearly nothing: under the parallel runner each worker owns a DISJOINT port
39
+ # range (PORT_BASE_START + idx*STEP), so no two workers probe the same port,
40
+ # and the range sits below the OS ephemeral range so client source ports
41
+ # can't race it either. Stays well within the per-file headroom (STEP=50,
42
+ # <=9 classes).
33
43
  def self.next_port
34
- p = @port
35
- @port += 1
36
- p
44
+ loop do
45
+ p = @port
46
+ @port += 1
47
+ begin
48
+ TCPServer.new("127.0.0.1", p).close
49
+ return p
50
+ rescue Errno::EADDRINUSE, Errno::EACCES
51
+ next
52
+ end
53
+ end
54
+ end
55
+
56
+ # Run a compile, retrying once on a non-success exit that produced NO
57
+ # compiler diagnostic -- i.e. the toolchain was killed/crashed rather than
58
+ # rejecting the source. Under the parallel runner, N concurrent Spinel
59
+ # compiles can transiently exhaust a resource and one gets reaped mid-build
60
+ # (empty error, nonzero exit); a real codegen error is deterministic and
61
+ # surfaces on the retry too, so this never hides genuine breakage. Also
62
+ # helps the serial suite shrug off a one-off hiccup.
63
+ def self.build_with_retry(cmd, what, tries: 2)
64
+ out = ""
65
+ tries.times do |i|
66
+ out = `#{cmd} 2>&1`
67
+ return out if $?.success?
68
+ # Only retry a diagnostic-free (killed/crashed) failure. A genuine
69
+ # rejection carries an "error:" (cc) or "undefined reference" (link).
70
+ # The inlined lib's "cannot resolve ... (emitting 0)" lines are
71
+ # "warning:" only, so they don't trip this -- otherwise every build
72
+ # (they all emit those) would look like a real error and never retry.
73
+ real_error = out.match?(/\berror:|fatal error|undefined reference/i)
74
+ break if real_error || i == tries - 1
75
+ sleep 0.5
76
+ end
77
+ raise "#{what} failed:\n#{out}"
37
78
  end
38
79
 
39
80
  # Compile `source` (Sinatra-classic by default) and return the bound
@@ -47,11 +88,9 @@ module TepHarness
47
88
  bin = File.join(tmp, "app")
48
89
  case mode
49
90
  when :sinatra
50
- out = `#{TEP_BIN} build #{src} -o #{bin} 2>&1`
51
- raise "tep build failed:\n#{out}" unless $?.success?
91
+ build_with_retry("#{TEP_BIN} build #{src} -o #{bin}", "tep build")
52
92
  when :direct
53
- out = `#{SPINEL} #{src} -o #{bin} 2>&1`
54
- raise "spinel failed:\n#{out}" unless $?.success?
93
+ build_with_retry("#{SPINEL} #{src} -o #{bin}", "spinel")
55
94
  else
56
95
  raise "unknown mode: #{mode}"
57
96
  end
@@ -87,6 +126,39 @@ module TepHarness
87
126
  @running.delete(s)
88
127
  end
89
128
 
129
+ # Like terminate, but RETURNS the Process::Status of the SIGTERM'd
130
+ # process (or nil if not found) so a test can assert on how it exited
131
+ # -- e.g. that a no-events server doesn't SIGSEGV on shutdown (the #186
132
+ # regression: termsig == SEGV => exit 139). Bounded: escalates to
133
+ # SIGKILL after `timeout` so a wedged server can't hang the suite.
134
+ def self.terminate_status(port, timeout: 5.0)
135
+ s = find_by_port(port)
136
+ return nil unless s
137
+ pid = s[:pid]
138
+ signal_group(pid, "TERM")
139
+ deadline = Time.now + timeout
140
+ status = nil
141
+ loop do
142
+ begin
143
+ got, st = Process.waitpid2(pid, Process::WNOHANG)
144
+ if got
145
+ status = st
146
+ break
147
+ end
148
+ rescue Errno::ECHILD
149
+ break
150
+ end
151
+ if Time.now > deadline
152
+ signal_group(pid, "KILL")
153
+ _, status = (Process.waitpid2(pid) rescue [nil, nil])
154
+ break
155
+ end
156
+ sleep 0.02
157
+ end
158
+ @running.delete(s)
159
+ status
160
+ end
161
+
90
162
  def self.kill_all
91
163
  @running.each do |s|
92
164
  reap(s[:pid])
@@ -174,7 +246,12 @@ Minitest.after_run { TepHarness.kill_all }
174
246
 
175
247
  # Kill any zombie tep test processes leaking from previous runs.
176
248
  # Skip on hosts that don't ship `pgrep` (some slim containers don't).
177
- if system("which pgrep >/dev/null 2>&1")
249
+ # Skip under the parallel runner (TEP_PARALLEL): sibling workers each run
250
+ # their own tep-test binaries concurrently, so a global pgrep-kill here
251
+ # would tear down another worker's live servers. The parallel runner does
252
+ # one stale-proc sweep up front instead, and each worker reaps only its
253
+ # own via Minitest.after_run.
254
+ if !ENV["TEP_PARALLEL"] && system("which pgrep >/dev/null 2>&1")
178
255
  `pgrep -f tep-test 2>/dev/null`.split.each do |pid|
179
256
  Process.kill("TERM", pid.to_i) rescue nil
180
257
  end
@@ -242,6 +319,16 @@ class TepTest < Minitest::Test
242
319
  r = klass.new(uri)
243
320
  r.body = body if body && %i[post put patch].include?(method)
244
321
  headers.each { |k, v| r[k] = v }
322
+ # Ruby <= 3.x's Net::HTTP auto-set Content-Type:
323
+ # application/x-www-form-urlencoded whenever request.body was
324
+ # assigned; Ruby 4.0 dropped that default, so a bodied POST goes
325
+ # out with no Content-Type and tep (correctly) doesn't parse it as
326
+ # form params. Restore the historical default for bodied requests
327
+ # unless a test set its own Content-Type -- matches what real form
328
+ # clients send and keeps the suite Ruby-version-agnostic.
329
+ if r.body && !r["content-type"]
330
+ r["Content-Type"] = "application/x-www-form-urlencoded"
331
+ end
245
332
  http.request(r)
246
333
  end
247
334
  end
data/test/run_parallel.rb CHANGED
@@ -22,18 +22,51 @@ require "shellwords"
22
22
 
23
23
  ROOT = File.expand_path("..", __dir__)
24
24
  TESTS = Dir[File.join(ROOT, "test", "test_*.rb")].sort
25
- PROCS = (ENV["TEP_TEST_PROCS"] || Etc.nprocessors.to_s).to_i
25
+ # Default below core count: each worker's Spinel compile is CPU-bound, and the
26
+ # booted test servers + their HTTP clients also need cycles -- saturating every
27
+ # core just trades compile parallelism for boot-timeout/build-kill flakes (the
28
+ # build retry absorbs those, but at the cost of a re-compile). ~60% of cores is
29
+ # the sweet spot here (12 on a 20-core box: 0F/0E, ~7.6min vs ~30min serial).
30
+ PROCS = (ENV["TEP_TEST_PROCS"] || [Etc.nprocessors * 3 / 5, 2].max.to_s).to_i
26
31
 
27
- # Each test class boots its own app on a port from the harness's
28
- # next_port counter; spacing 100 per file is comfy headroom (test
29
- # files have at most a couple of classes).
30
- PORT_BASE_STEP = 100
31
- PORT_BASE_START = (ENV["TEP_TEST_PORT_BASE"] || "4900").to_i
32
+ # Each test class boots its own app on a port = PORT_BASE_START + idx*STEP
33
+ # (+1 per extra class in the file). Files have at most ~9 classes, so STEP=50
34
+ # is ample disjoint headroom per file. With 64 files the window spans
35
+ # 10000..13200 -- chosen to sit BELOW the OS ephemeral range
36
+ # (/proc/sys/net/ipv4/ip_local_port_range, 32768+ here): a server bound in the
37
+ # ephemeral range collides with another test's *outbound* HTTP client source
38
+ # port (EADDRINUSE bind failures). It's also clear of the legacy 4900 base.
39
+ #
40
+ # Cross-run safety (a server can outlive its worker -- tep #188 leaks one under
41
+ # load, and tep binds prefork listeners SO_REUSEPORT, so a surviving orphan on
42
+ # a reused port would be silently shared / connected-to instead of this run's
43
+ # server -> cross-talk) is handled by SIGKILL reaping (reap_tep_test_procs),
44
+ # up-front and at end, not by moving the window: SIGKILL can't be ignored, so
45
+ # the prior run's orphans are gone before this run binds.
46
+ PORT_BASE_STEP = 50
47
+ PORT_BASE_START = (ENV["TEP_TEST_PORT_BASE"] || "10000").to_i
32
48
 
33
49
  queue = TESTS.each_with_index.to_a
34
50
  mutex = Mutex.new
35
51
  results = []
36
52
 
53
+ # Reap stray tep-test servers by PID with SIGKILL (SIGTERM is unreliable here,
54
+ # tep #188). pgrep/Process.kill, not `pkill -f tep-test` -- the latter spawns a
55
+ # shell whose own cmdline contains the pattern and self-matches. run_parallel's
56
+ # cmdline ("ruby test/run_parallel.rb") doesn't contain "tep-test", so it's
57
+ # never a target.
58
+ def reap_tep_test_procs
59
+ `pgrep -f tep-test 2>/dev/null`.split.map(&:to_i).each do |pid|
60
+ Process.kill("KILL", pid) rescue nil
61
+ end
62
+ end
63
+
64
+ # Up-front: clear orphans from a previous run. Workers set TEP_PARALLEL so their
65
+ # helper.rb skips its own global pgrep-kill (which would TERM sibling workers'
66
+ # live servers). Per-worker cleanup still runs via Minitest.after_run; this
67
+ # reap is the backstop for what #188 leaks.
68
+ reap_tep_test_procs
69
+
37
70
  workers = PROCS.times.map do
38
71
  Thread.new do
39
72
  loop do
@@ -41,7 +74,7 @@ workers = PROCS.times.map do
41
74
  break unless job
42
75
  path, idx = job
43
76
  port_base = PORT_BASE_START + idx * PORT_BASE_STEP
44
- env = { "TEP_TEST_PORT_BASE" => port_base.to_s }
77
+ env = { "TEP_TEST_PORT_BASE" => port_base.to_s, "TEP_PARALLEL" => "1" }
45
78
  # Honor TEP_SKIP_SPINEL_FRESH if the caller set it (Makefile
46
79
  # handles the freshness check once; per-process would be wasteful).
47
80
  ["TEP_SKIP_SPINEL_FRESH", "SPINEL", "TEP_KEEP_TMP"].each do |k|
@@ -60,6 +93,10 @@ workers = PROCS.times.map do
60
93
  end
61
94
  workers.each(&:join)
62
95
 
96
+ # Reap any servers a worker leaked (tep #188) so they don't accumulate across
97
+ # runs or hold the random window's ports.
98
+ reap_tep_test_procs
99
+
63
100
  # Sort so the printed order is deterministic (queue order), not
64
101
  # completion order.
65
102
  results.sort_by! { |r| TESTS.index(r[:path]) }
data/test/test_auth.rb CHANGED
@@ -14,46 +14,46 @@ class TestAuth < TepTest
14
14
  Tep::Auth.install!
15
15
 
16
16
  # ---- mint endpoints (test harness uses these to get tokens) ----
17
- # The payload is Tep::Json-friendly flat JSON: sub, exp, caps
17
+ # The payload is SpinelKit::Json-friendly flat JSON: sub, exp, caps
18
18
  # (comma-separated), and optionally delegate (pipe-encoded).
19
19
 
20
20
  post '/mint_human' do
21
21
  res.headers["Content-Type"] = "text/plain"
22
- sub = Tep::Json.get_str(req.raw_body, "sub")
23
- caps = Tep::Json.get_str(req.raw_body, "caps")
22
+ sub = SpinelKit::Json.get_str(req.raw_body, "sub")
23
+ caps = SpinelKit::Json.get_str(req.raw_body, "caps")
24
24
  exp = Time.now.to_i + 600
25
25
  payload = "{" +
26
- Tep::Json.encode_pair_str("sub", sub) + "," +
27
- Tep::Json.encode_pair_int("exp", exp) + "," +
28
- Tep::Json.encode_pair_str("caps", caps) +
26
+ SpinelKit::Json.encode_pair_str("sub", sub) + "," +
27
+ SpinelKit::Json.encode_pair_int("exp", exp) + "," +
28
+ SpinelKit::Json.encode_pair_str("caps", caps) +
29
29
  "}"
30
30
  Tep::Jwt.encode_hs256(payload, SECRET)
31
31
  end
32
32
 
33
33
  post '/mint_agent' do
34
34
  res.headers["Content-Type"] = "text/plain"
35
- sub = Tep::Json.get_str(req.raw_body, "sub")
36
- caps = Tep::Json.get_str(req.raw_body, "caps")
37
- delegate = Tep::Json.get_str(req.raw_body, "delegate")
35
+ sub = SpinelKit::Json.get_str(req.raw_body, "sub")
36
+ caps = SpinelKit::Json.get_str(req.raw_body, "caps")
37
+ delegate = SpinelKit::Json.get_str(req.raw_body, "delegate")
38
38
  exp = Time.now.to_i + 600
39
39
  payload = "{" +
40
- Tep::Json.encode_pair_str("sub", sub) + "," +
41
- Tep::Json.encode_pair_int("exp", exp) + "," +
42
- Tep::Json.encode_pair_str("caps", caps) + "," +
43
- Tep::Json.encode_pair_str("delegate", delegate) +
40
+ SpinelKit::Json.encode_pair_str("sub", sub) + "," +
41
+ SpinelKit::Json.encode_pair_int("exp", exp) + "," +
42
+ SpinelKit::Json.encode_pair_str("caps", caps) + "," +
43
+ SpinelKit::Json.encode_pair_str("delegate", delegate) +
44
44
  "}"
45
45
  Tep::Jwt.encode_hs256(payload, SECRET)
46
46
  end
47
47
 
48
48
  post '/mint_expired' do
49
49
  res.headers["Content-Type"] = "text/plain"
50
- sub = Tep::Json.get_str(req.raw_body, "sub")
50
+ sub = SpinelKit::Json.get_str(req.raw_body, "sub")
51
51
  # Issued in the past, expired in the past.
52
52
  exp = Time.now.to_i - 60
53
53
  payload = "{" +
54
- Tep::Json.encode_pair_str("sub", sub) + "," +
55
- Tep::Json.encode_pair_int("exp", exp) + "," +
56
- Tep::Json.encode_pair_str("caps", "read") +
54
+ SpinelKit::Json.encode_pair_str("sub", sub) + "," +
55
+ SpinelKit::Json.encode_pair_int("exp", exp) + "," +
56
+ SpinelKit::Json.encode_pair_str("caps", "read") +
57
57
  "}"
58
58
  Tep::Jwt.encode_hs256(payload, SECRET)
59
59
  end
@@ -30,17 +30,17 @@ class TestAuthOAuth2 < TepTest
30
30
  # only reach here on user-approve. The test stub skips the UI.
31
31
 
32
32
  post '/consent' do
33
- principal_id = Tep::Json.get_str(req.raw_body, "principal_id")
34
- client_id = Tep::Json.get_str(req.raw_body, "client_id")
35
- caps_str = Tep::Json.get_str(req.raw_body, "caps")
33
+ principal_id = SpinelKit::Json.get_str(req.raw_body, "principal_id")
34
+ client_id = SpinelKit::Json.get_str(req.raw_body, "client_id")
35
+ caps_str = SpinelKit::Json.get_str(req.raw_body, "caps")
36
36
  Tep::AuthOAuth2.issue_code(principal_id, client_id, caps_str, 0)
37
37
  end
38
38
 
39
39
  # ---- token-exchange endpoint: bot redeems code for JWT.
40
40
 
41
41
  post '/token' do
42
- code = Tep::Json.get_str(req.raw_body, "code")
43
- client_id = Tep::Json.get_str(req.raw_body, "client_id")
42
+ code = SpinelKit::Json.get_str(req.raw_body, "code")
43
+ client_id = SpinelKit::Json.get_str(req.raw_body, "client_id")
44
44
  Tep::AuthOAuth2.exchange_code(code, client_id, 0)
45
45
  end
46
46
 
@@ -51,10 +51,10 @@ class TestHttpPool < TepTest
51
51
  res.headers["Content-Type"] = "application/json"
52
52
  s = Tep::Http::Pool.stats
53
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) +
54
+ SpinelKit::Json.encode_pair_int("checkouts", s["checkouts"].to_i) + "," +
55
+ SpinelKit::Json.encode_pair_int("checkins", s["checkins"].to_i) + "," +
56
+ SpinelKit::Json.encode_pair_int("hits", s["hits"].to_i) + "," +
57
+ SpinelKit::Json.encode_pair_int("misses", s["misses"].to_i) +
58
58
  "}"
59
59
  end
60
60
 
@@ -33,9 +33,9 @@ class TestHttpPoolSend < TepTest
33
33
  r2 = Tep::Http.get(base + "/ping")
34
34
  h1 = Tep::Http::Pool.stats["hits"].to_i
35
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) +
36
+ SpinelKit::Json.encode_pair_int("hits_delta", h1 - h0) + "," +
37
+ SpinelKit::Json.encode_pair_str("b1", r1.body) + "," +
38
+ SpinelKit::Json.encode_pair_str("b2", r2.body) +
39
39
  "}"
40
40
  end
41
41
  RB
data/test/test_json.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require_relative "helper"
2
2
 
3
- # Tep::Json -- pure-Ruby JSON encode primitives + flat-key decode.
3
+ # SpinelKit::Json -- pure-Ruby JSON encode primitives + flat-key decode.
4
4
  class TestJson < TepTest
5
5
  app_source <<~RB
6
6
  require 'sinatra'
@@ -8,56 +8,56 @@ class TestJson < TepTest
8
8
  # ---- encode side ----
9
9
  get '/escape' do
10
10
  res.headers["Content-Type"] = "application/json"
11
- Tep::Json.quote("a\\"b\\nc")
11
+ SpinelKit::Json.quote("a\\"b\\nc")
12
12
  end
13
13
 
14
14
  get '/object' do
15
15
  res.headers["Content-Type"] = "application/json"
16
- "{" + Tep::Json.encode_pair_str("name", "alice") + "," +
17
- Tep::Json.encode_pair_int("age", 30) + "}"
16
+ "{" + SpinelKit::Json.encode_pair_str("name", "alice") + "," +
17
+ SpinelKit::Json.encode_pair_int("age", 30) + "}"
18
18
  end
19
19
 
20
20
  get '/array' do
21
21
  res.headers["Content-Type"] = "application/json"
22
- Tep::Json.from_str_array(["a", "b", "c"])
22
+ SpinelKit::Json.from_str_array(["a", "b", "c"])
23
23
  end
24
24
 
25
25
  get '/int_array' do
26
26
  res.headers["Content-Type"] = "application/json"
27
- Tep::Json.from_int_array([1, 2, 3])
27
+ SpinelKit::Json.from_int_array([1, 2, 3])
28
28
  end
29
29
 
30
30
  get '/echo_html' do
31
31
  res.headers["Content-Type"] = "application/json"
32
- "{" + Tep::Json.encode_pair_str("payload", "<script>alert(1)</script>") + "}"
32
+ "{" + SpinelKit::Json.encode_pair_str("payload", "<script>alert(1)</script>") + "}"
33
33
  end
34
34
 
35
35
  # ---- decode side ----
36
36
  post '/parse_str' do
37
37
  res.headers["Content-Type"] = "text/plain"
38
- Tep::Json.get_str(req.raw_body, "name")
38
+ SpinelKit::Json.get_str(req.raw_body, "name")
39
39
  end
40
40
 
41
41
  post '/parse_int' do
42
42
  res.headers["Content-Type"] = "text/plain"
43
- Tep::Json.get_int(req.raw_body, "n").to_s
43
+ SpinelKit::Json.get_int(req.raw_body, "n").to_s
44
44
  end
45
45
 
46
46
  post '/has_key' do
47
47
  res.headers["Content-Type"] = "text/plain"
48
- Tep::Json.has_key?(req.raw_body, "x") ? "yes" : "no"
48
+ SpinelKit::Json.has_key?(req.raw_body, "x") ? "yes" : "no"
49
49
  end
50
50
 
51
51
  post '/skip_nested' do
52
52
  # Read a top-level key past a nested object (skip_value should
53
53
  # walk the nested object correctly).
54
54
  res.headers["Content-Type"] = "text/plain"
55
- Tep::Json.get_str(req.raw_body, "after")
55
+ SpinelKit::Json.get_str(req.raw_body, "after")
56
56
  end
57
57
 
58
58
  post '/parse_float' do
59
59
  res.headers["Content-Type"] = "text/plain"
60
- Tep::Json.get_float(req.raw_body, "x").to_s
60
+ SpinelKit::Json.get_float(req.raw_body, "x").to_s
61
61
  end
62
62
  RB
63
63
 
data/test/test_jwt.rb CHANGED
@@ -10,8 +10,8 @@ class TestJwt < TepTest
10
10
 
11
11
  post '/issue' do
12
12
  res.headers["Content-Type"] = "text/plain"
13
- user = Tep::Json.get_str(req.raw_body, "user")
14
- payload = "{" + Tep::Json.encode_pair_str("sub", user) + "}"
13
+ user = SpinelKit::Json.get_str(req.raw_body, "user")
14
+ payload = "{" + SpinelKit::Json.encode_pair_str("sub", user) + "}"
15
15
  Tep::Jwt.encode_hs256(payload, SECRET)
16
16
  end
17
17
 
@@ -42,8 +42,8 @@ class TestJwt < TepTest
42
42
 
43
43
  post '/timing_eq' do
44
44
  res.headers["Content-Type"] = "text/plain"
45
- a = Tep::Json.get_str(req.raw_body, "a")
46
- b = Tep::Json.get_str(req.raw_body, "b")
45
+ a = SpinelKit::Json.get_str(req.raw_body, "a")
46
+ b = SpinelKit::Json.get_str(req.raw_body, "b")
47
47
  Tep::Jwt.timing_safe_eq(a, b) ? "yes" : "no"
48
48
  end
49
49
  RB
@@ -150,9 +150,9 @@ class TestLiveView < TepTest
150
150
  "<div id='tep-live-root'>" + @last_principal + ":" + @last_state + "</div>"
151
151
  end
152
152
  def handle_presence_diff(diff_json)
153
- @last_principal = Tep::Json.get_str(diff_json, "principal")
154
- @last_kind = Tep::Json.get_str(diff_json, "kind")
155
- @last_state = Tep::Json.get_str(diff_json, "state")
153
+ @last_principal = SpinelKit::Json.get_str(diff_json, "principal")
154
+ @last_kind = SpinelKit::Json.get_str(diff_json, "kind")
155
+ @last_state = SpinelKit::Json.get_str(diff_json, "state")
156
156
  0
157
157
  end
158
158
  end
data/test/test_llm.rb CHANGED
@@ -82,14 +82,15 @@ class TestLlm < TepTest
82
82
  # --- Phase B: chunked decode + SSE event consume ---
83
83
 
84
84
  get "/hex_to_int_valid" do
85
- Tep::Llm.hex_to_int("ff").to_s + "|" +
86
- Tep::Llm.hex_to_int("a").to_s + "|" +
87
- Tep::Llm.hex_to_int("100").to_s
85
+ SpinelKit::Hex.to_int("ff").to_s + "|" +
86
+ SpinelKit::Hex.to_int("a").to_s + "|" +
87
+ SpinelKit::Hex.to_int("100").to_s
88
88
  end
89
89
 
90
- get "/hex_to_int_invalid" do
91
- Tep::Llm.hex_to_int("zz").to_s + "|" +
92
- Tep::Llm.hex_to_int("").to_s
90
+ get "/hex_to_int_leading" do
91
+ SpinelKit::Hex.to_int("zz").to_s + "|" + # no leading hex digit -> 0
92
+ SpinelKit::Hex.to_int("").to_s + "|" + # empty -> 0
93
+ SpinelKit::Hex.to_int("ff;ext").to_s # leading hex, stops at ext -> 255
93
94
  end
94
95
 
95
96
  # One chunked body: 5 bytes "Hello", then last-chunk 0.
@@ -212,9 +213,11 @@ class TestLlm < TepTest
212
213
  assert_equal "255|10|256", res.body
213
214
  end
214
215
 
215
- def test_hex_to_int_malformed_returns_neg_one
216
- res = get("/hex_to_int_invalid")
217
- assert_equal "-1|-1", res.body
216
+ # SpinelKit::Hex.to_int parses LEADING hex (>= 0), unlike the old strict
217
+ # Tep::Llm.hex_to_int that returned -1 on any malformation. See dechunk_*.
218
+ def test_hex_to_int_leading_parse
219
+ res = get("/hex_to_int_leading")
220
+ assert_equal "0|0|255", res.body
218
221
  end
219
222
 
220
223
  def test_dechunk_complete_single_chunk