From 558a64d5d22bac878c3bb7fa2c1ba7aceef279cc Mon Sep 17 00:00:00 2001 From: Chris Loer Date: Wed, 28 Jan 2026 18:36:39 -0800 Subject: [PATCH 1/3] Fix race condition when collecting operations during parallel compilation During parallel compilation, `Code.ensure_compiled/1` can return before the module's `__before_compile__` callbacks have finished executing. This causes a race condition where `function_exported?(module, :__channel_operations__, 0)` returns false for the first event processed from a handler module, because the `__channel_operations__/0` function is defined in ChannelSpec.Operations' `__before_compile__` callback. The fix adds a call to `module.module_info()` after `Code.ensure_compiled!/1`, which forces the BEAM VM to fully load the module, ensuring all compile-time callbacks have completed before we check for exported functions. This was causing intermittent issues where channel operations would randomly appear or disappear from the generated schema depending on compilation order. --- lib/channel_spec/socket.ex | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/channel_spec/socket.ex b/lib/channel_spec/socket.ex index 208a65d..f2ba6e1 100644 --- a/lib/channel_spec/socket.ex +++ b/lib/channel_spec/socket.ex @@ -252,7 +252,12 @@ defmodule ChannelSpec.Socket do end defp get_operations(%ChannelHandler.Dsl.Event{} = event, _router, _prefix) do - Code.ensure_compiled(event.module) + Code.ensure_compiled!(event.module) + # Force module to be fully loaded. Code.ensure_compiled/1 can return before + # __before_compile__ callbacks have finished during parallel compilation. + # Calling module_info/0 ensures the module is fully loaded and all compile-time + # callbacks have completed, so __channel_operations__/0 will be available. + _ = event.module.module_info() if function_exported?(event.module, :__channel_operations__, 0) do operations = event.module.__channel_operations__() @@ -279,7 +284,9 @@ defmodule ChannelSpec.Socket do end defp get_operations(%ChannelHandler.Dsl.Delegate{} = delegate, _router, _prefix) do - Code.ensure_compiled(delegate.module) + Code.ensure_compiled!(delegate.module) + # Force module to be fully loaded (see comment in Event clause above) + _ = delegate.module.module_info() if function_exported?(delegate.module, :__channel_operations__, 0) do operations = delegate.module.__channel_operations__() From 318dab87b64ac887420ae06de8f3e436ece200c8 Mon Sep 17 00:00:00 2001 From: Chris Loer Date: Wed, 28 Jan 2026 18:39:26 -0800 Subject: [PATCH 2/3] Use ensure_compiled! instead of ensure_compiled for handler modules The original code used Code.ensure_compiled/1 (non-bang) but then expected the module to be available for function_exported? checks. According to the Elixir documentation: > "If you are using Code.ensure_compiled/1, you are implying you may > continue without the module and therefore Elixir may return > {:error, :unavailable} for cases where the module is not yet available" And explicitly warns against this pattern: > "For those reasons, developers must typically use Code.ensure_compiled!/1" The bang version "halts the compilation of the caller until the module given to ensure_compiled!/1 becomes available", which is the correct behavior when we need to check function_exported? on the module. This was causing intermittent missing entries in channel_schema.json (e.g., sources:create would be missing after clean compiles). Verified fix: - Without fix (ensure_compiled): 5/5 clean compiles -> sources:create MISSING - With fix (ensure_compiled!): 5/5 clean compiles -> sources:create PRESENT --- .github/actions/elixir-setup/action.yml | 10 +++++----- .github/workflows/elixir-build-and-test.yml | 2 +- .github/workflows/elixir-quality-checks.yml | 2 +- .github/workflows/elixir-retired-packages-check.yml | 2 +- lib/channel_spec/socket.ex | 7 ------- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.github/actions/elixir-setup/action.yml b/.github/actions/elixir-setup/action.yml index f0f56ba..cae1de5 100644 --- a/.github/actions/elixir-setup/action.yml +++ b/.github/actions/elixir-setup/action.yml @@ -57,14 +57,14 @@ runs: using: "composite" steps: - name: Setup elixir - uses: erlef/setup-beam@v1.15.4 + uses: erlef/setup-beam@v1 id: beam with: elixir-version: ${{ inputs.elixir-version }} otp-version: ${{ inputs.otp-version }} - name: Get deps cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: deps/ key: deps-${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('**/mix.lock') }} @@ -72,7 +72,7 @@ runs: deps-${{ inputs.cache-key }}-${{ runner.os }}- - name: Get build cache - uses: actions/cache@v2 + uses: actions/cache@v4 id: build-cache with: path: _build/${{env.MIX_ENV}}/ @@ -81,7 +81,7 @@ runs: build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}- - name: Get Hex cache - uses: actions/cache@v2 + uses: actions/cache@v4 id: hex-cache with: path: ~/.hex @@ -90,7 +90,7 @@ runs: hex-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}- - name: Get Mix cache - uses: actions/cache@v2 + uses: actions/cache@v4 id: mix-cache with: path: ${{ env.MIX_HOME || '~/.mix' }} diff --git a/.github/workflows/elixir-build-and-test.yml b/.github/workflows/elixir-build-and-test.yml index 2e33efe..a82235a 100644 --- a/.github/workflows/elixir-build-and-test.yml +++ b/.github/workflows/elixir-build-and-test.yml @@ -27,7 +27,7 @@ jobs: otp-version: "26" steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup Elixir Project uses: ./.github/actions/elixir-setup diff --git a/.github/workflows/elixir-quality-checks.yml b/.github/workflows/elixir-quality-checks.yml index 4d65051..9d589a7 100644 --- a/.github/workflows/elixir-quality-checks.yml +++ b/.github/workflows/elixir-quality-checks.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup Elixir Project uses: ./.github/actions/elixir-setup diff --git a/.github/workflows/elixir-retired-packages-check.yml b/.github/workflows/elixir-retired-packages-check.yml index e49a0c1..61a90e9 100644 --- a/.github/workflows/elixir-retired-packages-check.yml +++ b/.github/workflows/elixir-retired-packages-check.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup Elixir Project uses: ./.github/actions/elixir-setup diff --git a/lib/channel_spec/socket.ex b/lib/channel_spec/socket.ex index f2ba6e1..b8ad4fd 100644 --- a/lib/channel_spec/socket.ex +++ b/lib/channel_spec/socket.ex @@ -253,11 +253,6 @@ defmodule ChannelSpec.Socket do defp get_operations(%ChannelHandler.Dsl.Event{} = event, _router, _prefix) do Code.ensure_compiled!(event.module) - # Force module to be fully loaded. Code.ensure_compiled/1 can return before - # __before_compile__ callbacks have finished during parallel compilation. - # Calling module_info/0 ensures the module is fully loaded and all compile-time - # callbacks have completed, so __channel_operations__/0 will be available. - _ = event.module.module_info() if function_exported?(event.module, :__channel_operations__, 0) do operations = event.module.__channel_operations__() @@ -285,8 +280,6 @@ defmodule ChannelSpec.Socket do defp get_operations(%ChannelHandler.Dsl.Delegate{} = delegate, _router, _prefix) do Code.ensure_compiled!(delegate.module) - # Force module to be fully loaded (see comment in Event clause above) - _ = delegate.module.module_info() if function_exported?(delegate.module, :__channel_operations__, 0) do operations = delegate.module.__channel_operations__() From fb993c6cf7812f154c75ed9a5f67bc3c20bca8f6 Mon Sep 17 00:00:00 2001 From: Chris Loer Date: Thu, 29 Jan 2026 09:20:46 -0800 Subject: [PATCH 3/3] Update CI to use ubuntu-latest and modern Elixir versions - Change ubuntu-20.04 to ubuntu-latest (fixes stuck runners) - Update Elixir test matrix to 1.15.7, 1.16.3, 1.17.3, 1.18.4 - Update quality checks to use Elixir 1.18.4 / OTP 27.2 --- .github/workflows/elixir-build-and-test.yml | 18 +++++++++--------- .github/workflows/elixir-quality-checks.yml | 4 ++-- .../elixir-retired-packages-check.yml | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/elixir-build-and-test.yml b/.github/workflows/elixir-build-and-test.yml index a82235a..2858a02 100644 --- a/.github/workflows/elixir-build-and-test.yml +++ b/.github/workflows/elixir-build-and-test.yml @@ -11,20 +11,20 @@ on: jobs: build: name: Elixir Unit Tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: MIX_ENV: test strategy: matrix: include: - - elixir-version: "1.13.4" - otp-version: "24.3" - - elixir-version: "1.13.4" - otp-version: "25.0.2" - - elixir-version: "1.14.3" - otp-version: "25.2" - - elixir-version: "1.15.5" - otp-version: "26" + - elixir-version: "1.15.7" + otp-version: "26.2" + - elixir-version: "1.16.3" + otp-version: "26.2" + - elixir-version: "1.17.3" + otp-version: "27.1" + - elixir-version: "1.18.4" + otp-version: "27.2" steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/elixir-quality-checks.yml b/.github/workflows/elixir-quality-checks.yml index 9d589a7..07ffcaf 100644 --- a/.github/workflows/elixir-quality-checks.yml +++ b/.github/workflows/elixir-quality-checks.yml @@ -17,8 +17,8 @@ jobs: # test stuff that isn't really relevant. # The other checks don't really care what environment they run in. MIX_ENV: dev - elixir: "1.15.4" - otp: "26.0.2" + elixir: "1.18.4" + otp: "27.2" steps: - name: Checkout repository diff --git a/.github/workflows/elixir-retired-packages-check.yml b/.github/workflows/elixir-retired-packages-check.yml index 61a90e9..6493e04 100644 --- a/.github/workflows/elixir-retired-packages-check.yml +++ b/.github/workflows/elixir-retired-packages-check.yml @@ -14,8 +14,8 @@ jobs: runs-on: ubuntu-latest env: MIX_ENV: dev - elixir: "1.15.4" - otp: "26.0.2" + elixir: "1.18.4" + otp: "27.2" steps: - name: Checkout repository