Skip to content

Comments

Fix race condition when collecting operations during parallel compilation#25

Merged
axelson merged 3 commits intomainfrom
fix/ensure-module-fully-loaded-before-checking-exports
Jan 29, 2026
Merged

Fix race condition when collecting operations during parallel compilation#25
axelson merged 3 commits intomainfrom
fix/ensure-module-fully-loaded-before-checking-exports

Conversation

@ChrisLoer
Copy link
Contributor

@ChrisLoer ChrisLoer commented Jan 29, 2026

Summary

Fixes intermittent missing entries in channel_schema.json (e.g., sources:create would sometimes be missing after clean compiles).

The Fix

Change Code.ensure_compiled(module) to Code.ensure_compiled!(module) (add the bang).

From the Elixir 1.18.4 Code 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 (but may be available later on).

For those reasons, developers must typically use Code.ensure_compiled!/1. In particular, do not do this:

case Code.ensure_compiled(module) do
  {:module, _} -> module
  {:error, _} -> raise ...
end

And from ensure_compiled!/1:

If the module was not compiled yet, ensure_compiled!/1 halts the compilation of the caller until the module given to ensure_compiled!/1 becomes available

The original code used ensure_compiled/1 (non-bang) but then expected the module to be available for function_exported? checks. When ensure_compiled/1 returned {:error, :unavailable}, the code silently skipped the module. The bang version properly blocks until the module is ready.

Why the Original Commit Worked

The original PR accidentally changed ensure_compiled to ensure_compiled! (with bang) while also adding module_info/0 calls. The ensure_compiled! change was the actual fix - the module_info/0 calls were unnecessary. The explanation about __before_compile__ timing was incorrect.

Verification

Tested in the Felt codebase by modifying deps/channel_spec/lib/channel_spec/socket.ex:

Without fix (Code.ensure_compiled):

$ for i in 1 2 3 4 5; do rm -rf _build/dev/lib/felt_server _build/dev/lib/channel_spec; mix compile; grep -c '"sources:create"' ../../shared/json-schemas/channel_schema.json; done
0
0
0
0
0

With fix (Code.ensure_compiled!):

$ for i in 1 2 3 4 5; do rm -rf _build/dev/lib/felt_server _build/dev/lib/channel_spec; mix compile; grep -c '"sources:create"' ../../shared/json-schemas/channel_schema.json; done
1
1
1
1
1

All 5 clean compiles consistently show sources:create present with the fix.

…tion

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.
@ChrisLoer ChrisLoer force-pushed the fix/ensure-module-fully-loaded-before-checking-exports branch from a4e04a9 to 4f276ad Compare January 29, 2026 03:43
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
@ChrisLoer ChrisLoer force-pushed the fix/ensure-module-fully-loaded-before-checking-exports branch from 4f276ad to 318dab8 Compare January 29, 2026 06:39
@ChrisLoer ChrisLoer requested a review from axelson January 29, 2026 17:16
- 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
Copy link
Contributor

@axelson axelson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this on the main repo with {:channel_spec, git: "https://github.com/felt/channel_spec.git", branch: "fix/ensure-module-fully-loaded-before-checking-exports"}

and ran mix clean and partial compiles and the json was successfully regenerated! Thanks for looking into this!

We'll want to follow this up with a version bump

@axelson axelson merged commit 728875e into main Jan 29, 2026
6 checks passed
@axelson axelson deleted the fix/ensure-module-fully-loaded-before-checking-exports branch January 29, 2026 22:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants