tep 0.11.3 → 0.11.5

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +42 -2
  3. data/README.md +4 -4
  4. data/SINATRA_COMPAT.md +20 -20
  5. data/bin/tep +47 -10
  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/app.rb +4 -4
  12. data/examples/pg_hello.rb +11 -1
  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/broadcast.rb +18 -80
  21. data/lib/tep/events.rb +37 -37
  22. data/lib/tep/http.rb +3 -3
  23. data/lib/tep/job.rb +2 -2
  24. data/lib/tep/jwt.rb +4 -4
  25. data/lib/tep/live_view.rb +4 -4
  26. data/lib/tep/llm.rb +13 -45
  27. data/lib/tep/mcp.rb +12 -12
  28. data/lib/tep/multipart.rb +1 -1
  29. data/lib/tep/net.rb +8 -3
  30. data/lib/tep/openai_server.rb +102 -94
  31. data/lib/tep/parser.rb +2 -2
  32. data/lib/tep/pg.rb +468 -14
  33. data/lib/tep/presence.rb +33 -329
  34. data/lib/tep/proxy.rb +7 -7
  35. data/lib/tep/request.rb +1 -1
  36. data/lib/tep/response.rb +1 -1
  37. data/lib/tep/router.rb +1 -1
  38. data/lib/tep/session.rb +2 -2
  39. data/lib/tep/version.rb +1 -1
  40. data/lib/tep.rb +57 -137
  41. data/spinel-ext.json +6 -0
  42. data/test/helper.rb +95 -8
  43. data/test/run_parallel.rb +44 -7
  44. data/test/test_auth.rb +17 -17
  45. data/test/test_auth_oauth2.rb +5 -5
  46. data/test/test_broadcast_pg.rb +1 -0
  47. data/test/test_http_pool.rb +4 -4
  48. data/test/test_http_pool_send.rb +3 -3
  49. data/test/test_json.rb +12 -12
  50. data/test/test_jwt.rb +4 -4
  51. data/test/test_live_view.rb +3 -3
  52. data/test/test_llm.rb +12 -9
  53. data/test/test_llm_gateway.rb +2 -2
  54. data/test/test_logger.rb +2 -2
  55. data/test/test_openai_server.rb +10 -1
  56. data/test/test_password.rb +3 -3
  57. data/test/test_pg.rb +1 -0
  58. data/test/test_presence_pg.rb +1 -0
  59. data/test/test_real_world.rb +6 -1
  60. data/test/test_shutdown.rb +40 -0
  61. metadata +23 -8
  62. data/lib/tep/json.rb +0 -572
  63. 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"
@@ -70,10 +70,14 @@ require_relative "tep/live_view"
70
70
  require_relative "tep/server"
71
71
  require_relative "tep/server_scheduled"
72
72
  require_relative "tep/sqlite"
73
- require_relative "tep/pg"
74
- require_relative "tep/json"
73
+ # tep/pg is OPT-IN (#216): NOT required here. An app that needs
74
+ # PostgreSQL does `require "tep/pg"`, which bin/tep splices in at build
75
+ # time (and the test suite requires explicitly). Keeping it out of the
76
+ # core require tree is what lets a non-PG app DCE the libpq closure.
77
+ require_relative "spinel_kit/json"
78
+ require_relative "spinel_kit/json_decoder"
75
79
  require_relative "tep/mcp"
76
- require_relative "tep/logger"
80
+ require_relative "spinel_kit/log"
77
81
  require_relative "tep/jwt"
78
82
  require_relative "tep/password"
79
83
  require_relative "tep/security"
@@ -212,13 +216,7 @@ module Tep
212
216
  APP.set_after(Filter.new)
213
217
  APP.set_auth_filter(Filter.new)
214
218
  APP.set_auth_bearer_secret("")
215
- # Broadcast PG-backend setter seeds. enable_pg_backend reaches
216
- # these via set_broadcast_pg_conn / _channel / _enabled when a
217
- # connect succeeds; the empty-conninfo seed below short-circuits
218
- # before getting there, so we exercise the setters directly.
219
- APP.set_broadcast_pg_enabled(0)
220
- APP.set_broadcast_pg_channel("")
221
- APP.set_broadcast_pg_conn(PG::Connection.new(""))
219
+ # (broadcast_pg setter seeds relocated to lib/tep/pg.rb -- #216)
222
220
  APP.set_not_found(Handler.new)
223
221
  # Type-seeding: methods that may not be called by a given user app
224
222
  # would otherwise default their param C types to mrb_int and
@@ -274,19 +272,10 @@ module Tep
274
272
  Tep::Broadcast.subscriber_count
275
273
  Tep::Broadcast.clear
276
274
 
277
- # Broadcast PG-backend seeds. enable_pg_backend("", "") tries to
278
- # open a PG connection -- empty conninfo behaves the same as the
279
- # PG::Connection.new("") seed above: connect fails, returns -1.
280
- # The point is to pin parameter types on every cmeth.
281
- Tep::Broadcast.enable_pg_backend("", "")
282
- Tep::Broadcast.poll_pg_once(0)
283
- Tep::Broadcast.disable_pg_backend
284
- Tep::Broadcast.encode_wire("", "")
285
- Tep::Broadcast.deliver_wire_local("0:")
286
275
  Tep::Broadcast.publish_local_only("_seed", "")
287
- # The new PG::Connection LISTEN/NOTIFY method seeds live further
288
- # down with the rest of the PG seeds, where _tep_seed_pg_conn is
289
- # already defined.
276
+ # (Broadcast PG-backend seeds -- enable_pg_backend / poll_pg_once /
277
+ # disable_pg_backend / encode_wire / deliver_wire_local -- relocated
278
+ # to lib/tep/pg.rb, #216.)
290
279
 
291
280
  # Presence type-seeding. Same pattern as Broadcast: pin every
292
281
  # cmeth's param C types so compile units that don't otherwise
@@ -316,25 +305,10 @@ module Tep
316
305
  Tep::Presence.encode_diff("join", _tep_seed_presence_entry)
317
306
  Tep::Presence.publish_diff("join", _tep_seed_presence_entry)
318
307
  Tep::Presence.sweep_expired_status
319
- # PG mirror seeds (chunk 3.3). enable_pg_mirror("") fails the
320
- # connect cleanly (-1) but still pins param types.
321
- Tep::Presence.enable_pg_mirror("")
322
- Tep::Presence.schema_sql
323
- Tep::Presence.mirror_insert(_tep_seed_presence_entry)
324
- Tep::Presence.mirror_delete("_seed", -1)
325
- Tep::Presence.mirror_status("_seed", -1, :available, "", 0)
326
- Tep::Presence.list_global("_seed")
327
- Tep::Presence.count_global("_seed")
328
- Tep::Presence.worker_schema_sql
329
- Tep::Presence.heartbeat
330
- Tep::Presence.prune_stale_workers(90)
331
- Tep::Presence.disable_pg_mirror
332
- # Same APP-setter-via-constant pattern as the broadcast_pg_conn
333
- # seed: PG::Connection.new can't run inside App#initialize
334
- # (Tep::APP is mid-construction; sched_current read segfaults).
335
- APP.set_presence_pg_enabled(0)
336
- APP.set_presence_pg_worker_id("")
337
- APP.set_presence_pg_conn(PG::Connection.new(""))
308
+ # (Presence PG mirror seeds + presence_pg setter seeds relocated to
309
+ # lib/tep/pg.rb -- #216. The core mirror_insert/delete/status no-op
310
+ # stubs are already seeded by the track/set_status/untrack calls
311
+ # above.)
338
312
 
339
313
  # LiveView type-seeding (chunk 4.1). The render_page + dispatch_event
340
314
  # cmeths get pinned via top-level calls; the base-class mount /
@@ -386,94 +360,31 @@ module Tep
386
360
  _tep_seed_db.close
387
361
  end
388
362
 
389
- # PG type-seeding. PG::Connection.new("") returns a connection-
390
- # failed instance (@pgh=-1) rather than raising, so this is safe
391
- # at module load regardless of whether libpq has a reachable
392
- # server. The point is to pin parameter / return types on every
393
- # public Connection / Result method so apps that don't exercise
394
- # one method still compile cleanly.
395
- _tep_seed_pg_conn = PG::Connection.new("")
396
- _tep_seed_pg_conn.connected?
397
- _tep_seed_pg_conn.status
398
- _tep_seed_pg_conn.transaction_status
399
- _tep_seed_pg_conn.server_version
400
- _tep_seed_pg_conn.error_message
401
- _tep_seed_pg_conn.escape_string("")
402
- _tep_seed_pg_conn.escape_identifier("")
403
- _tep_seed_pg_conn.escape_literal("")
404
- _tep_seed_pg_conn.last_sqlstate = ""
405
- _tep_seed_pg_conn.last_error_message = ""
406
- _tep_seed_pg_conn.last_result_rh = -1
407
- # Async surface seed -- calling these on a failed-conn instance
408
- # is harmless (the C shim short-circuits on conn slot < 1).
409
- _tep_seed_pg_conn.async_exec("")
410
- _tep_seed_pg_seed_arr = [""]
411
- _tep_seed_pg_seed_arr.delete_at(0)
412
- _tep_seed_pg_conn.async_exec_params("", _tep_seed_pg_seed_arr)
413
- # Async connect cmeth. Returns -1 for empty conninfo from a
414
- # non-scheduled context (the shim's PQconnectStart-then-FAILED
415
- # path), which is type-equivalent to the success path.
416
- PG::Connection.async_connect("")
417
- # LISTEN / NOTIFY surface (Tep::Broadcast PG backend lands here).
418
- _tep_seed_pg_conn.listen("_seed")
419
- _tep_seed_pg_conn.unlisten("_seed")
420
- _tep_seed_pg_conn.notify("_seed", "")
421
- _tep_seed_pg_conn.poll_notification(0)
422
- _tep_seed_pg_conn.last_notify_channel
423
- _tep_seed_pg_conn.last_notify_payload
424
- _tep_seed_pg_res = PG::Result.new(-1)
425
- _tep_seed_pg_res.ntuples
426
- _tep_seed_pg_res.nfields
427
- _tep_seed_pg_res.fname(0)
428
- _tep_seed_pg_res.fnumber("")
429
- _tep_seed_pg_res.ftype(0)
430
- _tep_seed_pg_res.fformat(0)
431
- _tep_seed_pg_res.fmod(0)
432
- _tep_seed_pg_res.getvalue(0, 0)
433
- _tep_seed_pg_res.getisnull(0, 0)
434
- _tep_seed_pg_res.getlength(0, 0)
435
- _tep_seed_pg_res.value(0, 0)
436
- _tep_seed_pg_res.error_field(67)
437
- _tep_seed_pg_res.cmd_status
438
- _tep_seed_pg_res.cmd_tuples
439
- _tep_seed_pg_res.error_message
440
- _tep_seed_pg_res.sql_state
441
- _tep_seed_pg_res.fields
442
- _tep_seed_pg_res.values
443
- _tep_seed_pg_res.column_values(0)
444
- _tep_seed_pg_res.clear
445
- _tep_seed_pg_conn.close
446
- # Pool seed -- size 0 so we don't try to open real conns at load.
447
- _tep_seed_pg_pool = PG::Pool.new("", 0)
448
- _tep_seed_pg_pool.healthy?
449
- _tep_seed_pg_pool.available
450
- _tep_seed_pg_pool.size
451
- _tep_seed_pg_pool.set_checkout_timeout_ms(0)
452
- _tep_seed_pg_pool.close_all
453
- # NB: don't checkout/checkin against the size-0 seed pool; it'd
454
- # spin until timeout. The seed has @free.length=0 forever.
455
-
456
- # Tep::Json type-seeding. Pin every public method's parameter
363
+ # (PG::Connection / Result / Pool type-seeding relocated to
364
+ # lib/tep/pg.rb -- #216. PG.Connection.new("") is a failed-conn
365
+ # instance, not a raise, so the seeds stay safe at module load.)
366
+
367
+ # SpinelKit::Json type-seeding. Pin every public method's parameter
457
368
  # types so an app that uses one method but not another still
458
369
  # compiles cleanly. Calls have no side effects beyond producing
459
370
  # discardable strings.
460
- Tep::Json.escape("")
461
- Tep::Json.quote("")
462
- Tep::Json.encode_pair_str("", "")
463
- Tep::Json.encode_pair_int("", 0)
371
+ SpinelKit::Json.escape("")
372
+ SpinelKit::Json.quote("")
373
+ SpinelKit::Json.encode_pair_str("", "")
374
+ SpinelKit::Json.encode_pair_int("", 0)
464
375
  _tep_seed_str_h = Tep.str_hash
465
376
  _tep_seed_str_h["k"] = "v"
466
- Tep::Json.from_str_hash(_tep_seed_str_h)
377
+ SpinelKit::Json.from_str_hash(_tep_seed_str_h)
467
378
  _tep_seed_int_h = {"" => 0}
468
379
  _tep_seed_int_h.delete("")
469
380
  _tep_seed_int_h["k"] = 1
470
- Tep::Json.from_int_hash(_tep_seed_int_h)
381
+ SpinelKit::Json.from_int_hash(_tep_seed_int_h)
471
382
 
472
- # Tep::Logger seed -- pin parameter types for every method even
383
+ # SpinelKit::Log seed -- pin parameter types for every method even
473
384
  # when an app uses one but not another. The level-name string
474
385
  # ("info") and the messages ("") pin the :str shape; the file-
475
386
  # path setter pins to_file's :str arg.
476
- _tep_seed_logger = Tep::Logger.new
387
+ _tep_seed_logger = SpinelKit::Log.new
477
388
  _tep_seed_logger.set_level("info")
478
389
  _tep_seed_logger.to_file("")
479
390
  _tep_seed_logger.to_stderr
@@ -481,7 +392,7 @@ module Tep
481
392
  _tep_seed_logger.info("")
482
393
  _tep_seed_logger.warn("")
483
394
  _tep_seed_logger.error("")
484
- Tep::Logger.level_value("info")
395
+ SpinelKit::Log.level_value("info")
485
396
 
486
397
  # Tep::Jwt seed -- pin every method's :str arg types. The
487
398
  # secret + payload are blank but the call shapes pin the FFI
@@ -538,13 +449,22 @@ module Tep
538
449
  Tep::Scheduler.clear
539
450
 
540
451
  # Tep::Shell seed -- pin :str args at the FFI boundary.
452
+ #
453
+ # These run at MODULE LOAD, so the paths must be readable in EVERY
454
+ # deploy environment, not just gx10. `/etc/hostname` is absent in a
455
+ # bare container (e.g. Upsun); under the engine's now-correct
456
+ # ENOENT-raising File.read it threw at boot and 502'd the native
457
+ # serve_bin (tep#199 boot-hazard report). `/dev/null` exists on every
458
+ # POSIX target (Linux containers, macOS) and reads as empty, so it
459
+ # pins the same :str param type without the missing-file crash. The
460
+ # full fix -- no boot-time seed I/O at all -- is tep#199 (--rbs sig).
541
461
  Tep::Shell.run(":")
542
462
  Tep::Shell.run_limited(":", 1)
543
- Tep::Shell.read("/etc/hostname")
544
- Tep::Shell.read_limited("/etc/hostname", 64)
463
+ Tep::Shell.read("/dev/null")
464
+ Tep::Shell.read_limited("/dev/null", 64)
545
465
 
546
- # Tep::Url seed -- the new split_url has to land at compile time.
547
- Tep::Url.split_url("http://x/")
466
+ # SpinelKit::Url seed -- the new split_url has to land at compile time.
467
+ SpinelKit::Url.split_url("http://x/")
548
468
 
549
469
  # Tep::Http seed -- every public method gets one canonical call so
550
470
  # spinel pins the param types. The URL "http://127.0.0.1:1/" won't
@@ -623,10 +543,10 @@ module Tep
623
543
  # Pin Sock.sphttp_sleep_ms's :int param so the backoff call site
624
544
  # resolves (called from Tep::Proxy#handle).
625
545
  Sock.sphttp_sleep_ms(0)
626
- # Tep::Json.get_float seed (#133). Pin the (String, String) -> Float
546
+ # SpinelKit::Json.get_float seed (#133). Pin the (String, String) -> Float
627
547
  # surface so callers (CompletionsHandler temperature/top_p,
628
548
  # backends that parse their own bodies) resolve cleanly.
629
- Tep::Json.get_float("{\"temperature\":0.7}", "temperature")
549
+ SpinelKit::Json.get_float("{\"temperature\":0.7}", "temperature")
630
550
  _tep_seed_retry_policy.backoff_for(0)
631
551
  _tep_seed_retry_policy.retriable?(502)
632
552
  _tep_seed_proxy.retry_policy(_tep_seed_proxy_req)
@@ -679,13 +599,13 @@ module Tep
679
599
  _tep_seed_oai_models = Tep::Llm::OpenAI::ModelsHandler.new
680
600
  _tep_seed_oai_models.handle(_tep_seed_proxy_req, _tep_seed_proxy_res)
681
601
  # 7.1b /v1/completions surface.
682
- Tep::Json.get_int_array("{}", "prompt")
602
+ SpinelKit::Json.get_int_array("{}", "prompt")
683
603
  _tep_seed_oai_sampling = Tep::Llm::OpenAI::Sampling.new
684
604
  _tep_seed_oai_sampling.max_tokens = 0
685
605
  _tep_seed_oai_sampling.temperature = 1.0
686
606
  _tep_seed_oai_sampling.top_p = 1.0
687
607
  _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)
608
+ _tep_seed_oai_backend.generate_from_tokens("m", SpinelKit::Json.get_int_array("{}", "prompt"), _tep_seed_oai_sampling)
689
609
  _tep_seed_oai_completions = Tep::Llm::OpenAI::CompletionsHandler.new
690
610
  _tep_seed_oai_completions.handle(_tep_seed_proxy_req, _tep_seed_proxy_res)
691
611
  # Chat completions skeleton (POST /v1/chat/completions). Default
@@ -717,10 +637,10 @@ module Tep
717
637
  # makes the underlying sphttp_write_chunk a harmless EBADF at boot.
718
638
  _tep_seed_oai_sink.emit_token("seed")
719
639
  _tep_seed_oai_backend.generate_stream_from_tokens(
720
- "m", Tep::Json.get_int_array("{}", "prompt"), _tep_seed_oai_sampling, _tep_seed_oai_sink)
640
+ "m", SpinelKit::Json.get_int_array("{}", "prompt"), _tep_seed_oai_sampling, _tep_seed_oai_sink)
721
641
  _tep_seed_oai_cstreamer = Tep::Llm::OpenAI::CompletionsStreamer.new
722
642
  _tep_seed_oai_cstreamer.model = "m"
723
- _tep_seed_oai_cstreamer.token_ids = Tep::Json.get_int_array("{}", "prompt")
643
+ _tep_seed_oai_cstreamer.token_ids = SpinelKit::Json.get_int_array("{}", "prompt")
724
644
  _tep_seed_oai_cstreamer.sampling = _tep_seed_oai_sampling
725
645
  _tep_seed_oai_cstreamer.prompt_tokens = 0
726
646
  _tep_seed_oai_cstreamer.t0 = 0
@@ -774,13 +694,13 @@ module Tep
774
694
  Tep::Job.mark_failed(":memory:", 0)
775
695
  _tep_seed_str_arr = [""]
776
696
  _tep_seed_str_arr.delete_at(0)
777
- Tep::Json.from_str_array(_tep_seed_str_arr)
697
+ SpinelKit::Json.from_str_array(_tep_seed_str_arr)
778
698
  _tep_seed_int_arr = [0]
779
699
  _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?("{}", "")
700
+ SpinelKit::Json.from_int_array(_tep_seed_int_arr)
701
+ SpinelKit::Json.get_str("{}", "")
702
+ SpinelKit::Json.get_int("{}", "")
703
+ SpinelKit::Json.has_key?("{}", "")
784
704
 
785
705
  # Tep::MCP seeds (chunk 5.1). Tools register at compile time via
786
706
  # bin/tep's mcp_tool DSL; the runtime helpers below are the
@@ -810,7 +730,7 @@ module Tep
810
730
 
811
731
  # Tep::Llm seeds. attr_accessor return types default to mrb_int
812
732
  # if spinel sees no concrete callsite -- and Tep::Llm.build_request_body
813
- # passes msg.role / msg.content into Tep::Json.quote(String) which
733
+ # passes msg.role / msg.content into SpinelKit::Json.quote(String) which
814
734
  # then mismatches. Pin Message + Response attrs to String, and
815
735
  # run one full encode + parse round-trip so the static analyzer
816
736
  # sees every public method called with concrete types.
@@ -846,7 +766,7 @@ module Tep
846
766
  Tep::Llm.dechunk_leftover("")
847
767
  Tep::Llm.dechunk_pass("")
848
768
  Tep::Llm.drain_sse_buf("", _tep_seed_llm_stream, "")
849
- Tep::Llm.hex_to_int("")
769
+ SpinelKit::Hex.to_int("")
850
770
 
851
771
  # Tep::WebSocket seeds. Pins frame/handshake/driver/connection
852
772
  # surfaces to concrete typed callsites so the analyzer doesn't
data/spinel-ext.json CHANGED
@@ -5,6 +5,12 @@
5
5
  "source": "lib/tep/sphttp.c",
6
6
  "cflags": ["-O2"]
7
7
  },
8
+ {
9
+ "name": "sphttp",
10
+ "placeholder": "@TEP_SPHTTP_CFLAGS@",
11
+ "pkg_config": "openssl",
12
+ "pkg_config_fallback": "-lssl -lcrypto"
13
+ },
8
14
  {
9
15
  "name": "sqlite",
10
16
  "placeholder": "@TEP_SQLITE_O@",
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
 
@@ -18,6 +18,7 @@ class TestBroadcastPg < TepTest
18
18
 
19
19
  app_source <<~RB
20
20
  require 'sinatra'
21
+ require "tep/pg" # opt-in PG backend (#216)
21
22
 
22
23
  PG_URL = "#{PG_URL}"
23
24
  CHANNEL = "#{CHANNEL}"