Merged
Conversation
Replace the unmaintained Bacon test framework with Minitest::Test. Rename spec/ to test/ following Ruby conventions (*_test.rb suffix). Use prepend-based test mocking instead of class reopening to avoid method redefinition warnings. Drop RR and Bacon gem dependencies. Also: rewrite README with table of contents, add GitHub Actions CI workflow (Ruby 3.1–3.4), migrate from EventMachine to async I/O, and fix Ruby 4.0 frozen string warnings in Response.
- Replace ListenerTestMock send_data/initialize overrides with a MockConnection object passed to .new, so the real send_data writes to the mock connection instead of being monkey-patched - Split test files to one test class per file - Move connection logic into Inbound.start/Outbound.start class methods - Standardize on parentheses in all method definitions - Update examples to match current API
Remove Base.self.new/allocate/post_init pattern inherited from EventMachine. Each class now uses a standard initialize that accepts connection as the first argument.
async-io is deprecated. Replace with io-endpoint and io-stream which provide the same API. Remove the Librevox::Connection wrapper class since IO::Stream supports write(flush: true), read_partial, and close directly. Add logger as a gemspec dependency since it was removed from Ruby's stdlib.
Async already handles SIGINT/SIGTERM by cleanly stopping child tasks and raising Interrupt. Calling task.stop from a trap context crashes because async uses mutexes internally.
Docker Compose and other process managers send SIGTERM (not SIGINT) when stopping containers. Catch SignalException alongside Interrupt for graceful shutdown in all environments.
Add park helper to Applications. Update README dialplan section to show both calling styles: sequential (without blocks) and nested (with blocks for completion callbacks).
Without event-lock, FreeSWITCH returns the command reply immediately rather than waiting for the application to finish executing. This caused callbacks for bridge, playback, record etc. to fire prematurely.
require 'io/endpoint' does not load the tcp method; needs require 'io/endpoint/host_endpoint' instead.
Replace stored @task module state with a local Async::Barrier, giving proper group lifecycle (wait/stop) for multi-listener setups. Introduce Runner class to keep the block form of Librevox.start working without shared state. Move endpoint creation outside the inbound reconnect loop.
Separate wire-protocol parsing from listener business logic by introducing Protocol::Connection (wraps IO::Stream with read_message/ write/close), Server (accepts outbound connections), and Client (connects inbound with reconnect). Listeners become pure session logic with thin self.start methods that delegate to Server/Client. Also unifies CommandSocket to use Protocol::Connection, extracts Runner to its own file, and switches Librevox.start to kwargs.
endpoint.accept passes (socket, address) — we were only accepting one argument, causing ArgumentError in production.
… close update_session now explicitly sets @session from the uuid_dump response before calling the user's block, so variable() sees fresh channel data after slow applications like bridge or play_and_get_digits. Connection#close guards against double-close and rescues EPIPE/ECONNRESET when the remote end has already hung up.
…and/reply The application queue now waits for the application to actually finish on the channel before firing callbacks. This fixes the issue where all application callbacks would fire immediately on the sendmsg ack, before any application had executed. Session is updated directly from the CHANNEL_EXECUTE_COMPLETE event content, which carries all channel variables — eliminating the uuid_dump roundtrip after each application. Setup commands (connect/myevents/linger) use a separate reply queue that still advances on command/reply.
…tive command() is the universal one-response primitive (command/reply or api/response). sendmsg() handles two responses (ack + EXECUTE_COMPLETE). Eliminates @reply_queue and send_command helper.
Replace array-based callback queues with Async::Queue instances, enabling flat sequential code in session_initiated instead of nested callbacks. Two fibers per connection: a read fiber (read_loop) dispatches messages to queues, a session fiber (run_session) issues commands and blocks until replies arrive. Async::Semaphore(1) serializes command access so event hook tasks can't cause reply misdelivery. - Remove all &block params from commands and applications modules - Extract run_session for both inbound (auth/events/filters) and outbound (connect/myevents/linger/session_initiated) - Server and Client spawn session task alongside read_loop - Wrap tests in Sync via AsyncTest module, yield_to_fibers for cooperative scheduling in test helpers
Remove session_task.wait from client/server — when the connection drops, the session task may be blocked on a queue dequeue forever. The ensure block's session_task.stop already handles both finished and blocked tasks. Release command_mutex before waiting on app_complete_queue in sendmsg, allowing other fibers to call command() while an application is blocked waiting for CHANNEL_EXECUTE_COMPLETE.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Librevox was built on EventMachine, which is effectively unmaintained and incompatible with Ruby 4.0. This PR replaces the entire runtime with Ruby's fiber-based
asyncecosystem, making librevox work on modern Ruby while simplifying the concurrency model.What changed
Runtime: EventMachine → Async
The core dependency shifts from
eventmachineto three gems:async— fiber-based concurrency (replaces EM reactor loop)io-endpoint— TCP server/client (replacesEM.start_server/EM.connect)io-stream— buffered streaming (replacesEM::Connection#receive_data)The custom
Base.newallocator that mimickedEM::Connectionis replaced with plaininitializemethods. Signal handling usesAsync::Barrierfor clean shutdown instead ofEM.stop.Architecture: Extract connection management from listeners
Previously, listeners mixed protocol I/O with session logic. Now:
Protocol::Connection— wraps the socket stream, handles read/write framingServer— accepts inbound connections (outbound listener)Client— manages connection + reconnect loop (inbound listener)Concurrency: Callbacks → Queues
The old callback model (
application("playback", "file") { |r| ... }) is replaced with blocking calls that read naturally top-to-bottom:Under the hood, each connection runs two fibers:
session_initiated/ user logic, blocks on queuesThree synchronization primitives coordinate them:
@reply_queue(Async::Queue) — command/reply responses from FreeSWITCH@app_complete_queue(Async::Queue) — CHANNEL_EXECUTE_COMPLETE events@command_mutex(Async::Semaphore(1)) — serializes ESL command sendsThe mutex is held only for send + reply-ack, then released before waiting on
@app_complete_queue. This allowscommand()calls from other fibers (e.g. event hook pollers callingapi uuid_break) to proceed while anapplication()call waits for execution to complete.Disconnect handling
When FreeSWITCH drops the connection, the read loop exits. The session task may be blocked on a queue dequeue with nothing to wake it.
session_task.stopinensureterminates it cleanly — nosession_task.waitwhich would hang forever.Tests: Bacon → Minitest
MockConnectionthat capturessend_datacalls and drivesreceive_datadirectlyOther
event-lock: trueadded to allsendmsgcalls (prevents FreeSWITCH from executing apps out of order)parkandmultisetapplicationsupdate_sessionfixed to actually apply the uuid_dump response