Skip to content

Fix missing join_ref in PushMessage for Phoenix V2 protocol - #84

Merged
the-mikedavis merged 1 commit into
CuatroElixir:mainfrom
evan-hines-js:main
Dec 15, 2025
Merged

the-mikedavis merged 1 commit into
CuatroElixir:mainfrom
evan-hines-js:main

Conversation

@evan-hines-js

@evan-hines-js evan-hines-js commented Dec 13, 2025

Copy link
Copy Markdown
Contributor

The join_ref is currently nil when receiving messages in a client -> server flow. Without the join_ref, the server cannot properly route the message to the correct channel process, causing client->server messages to be silently dropped while server->client messages work fine.

The fix adds join_ref from state.joins[topic], matching the pattern already used in the LeaveTopic handler.

This randomly started happening in several of my projects in the last week without explicit code changes so seems to have been added in 1.2.1 during the struct refactor although I have not investigated deeply.

When pushing messages to a Phoenix channel, the V2 JSON serializer
expects a join_ref to be included in the message array:
[join_ref, ref, topic, event, payload]

Without the join_ref, the server cannot properly route the message
to the correct channel process, causing client->server messages to
be silently dropped while server->client messages work fine.

The fix adds join_ref from state.joins[topic], matching the pattern
already used in the LeaveTopic handler.
@the-mikedavis

Copy link
Copy Markdown
Collaborator

It looks like #82 didn't touch this block so I'm not sure it's related. Do you have a reproduction case for the dropped messages? Ideally we can add a test case for this bug

@evan-hines-js

evan-hines-js commented Dec 14, 2025

Copy link
Copy Markdown
Contributor Author

Maybe I am using the library incorrectly but here is a reproduction:

https://github.com/evan-hines-js/slipstream_repro

main branch uses my fork, no_fork branch uses the official release

Run the docker compose for the postgres db and then run the server. On the main branch I see

[info] [PingClient] Sending ping with timestamp: 1765734364541
[info] [PingChannel] Received ping: %{"timestamp" => 1765734364541}
[debug] HANDLED ping INCOMING ON ping:lobby (SlipstreamReproWeb.PingChannel) in 143µs
  Parameters: %{"timestamp" => 1765734364541}
[info] [PingClient] Got reply for ref "2": {:ok, %{"message" => "pong", "timestamp" => 1765734364542}}
[info] [PingClient] Sending ping with timestamp: 1765734365541
[info] [PingChannel] Received ping: %{"timestamp" => 1765734365541}
[debug] HANDLED ping INCOMING ON ping:lobby (SlipstreamReproWeb.PingChannel) in 139µs
  Parameters: %{"timestamp" => 1765734365541}
[info] [PingClient] Got reply for ref "3": {:ok, %{"message" => "pong", "timestamp" => 1765734365542}}
[info] [PingClient] Sending ping with timestamp: 1765734366542
[info] [PingChannel] Received ping: %{"timestamp" => 1765734366542}
[debug] HANDLED ping INCOMING ON ping:lobby (SlipstreamReproWeb.PingChannel) in 75µs
  Parameters: %{"timestamp" => 1765734366542}
[info] [PingClient] Got reply for ref "4": {:ok, %{"message" => "pong", "timestamp" => 1765734366543}}
[info] [PingClient] Sending ping with timestamp: 1765734367543
[info] [PingChannel] Received ping: %{"timestamp" => 1765734367543}
[debug] HANDLED ping INCOMING ON ping:lobby (SlipstreamReproWeb.PingChannel) in 107µs
  Parameters: %{"timestamp" => 1765734367543}
[info] [PingClient] Got reply for ref "5": {:ok, %{"message" => "pong", "timestamp" => 1765734367544}}
[info] [PingClient] Sending ping with timestamp: 1765734368545
[info] [PingChannel] Received ping: %{"timestamp" => 1765734368545}
[debug] HANDLED ping INCOMING ON ping:lobby (SlipstreamReproWeb.PingChannel) in 157µs
  Parameters: %{"timestamp" => 1765734368545}
[info] [PingClient] Got reply for ref "6": {:ok, %{"message" => "pong", "timestamp" => 1765734368546}}
[info] [PingClient] Sending ping with timestamp: 1765734369545
[info] [PingChannel] Received ping: %{"timestamp" => 1765734369545}
[debug] HANDLED ping INCOMING ON ping:lobby (SlipstreamReproWeb.PingChannel) in 36µs
  Parameters: %{"timestamp" => 1765734369545}
[info] [PingClient] Got reply for ref "7": {:ok, %{"message" => "pong", "timestamp" => 1765734369546}}

On the no_fork branch, all the messages are lost:

[info] [PingClient] Sending ping with timestamp: 1765734482018
[info] [PingClient] Sending ping with timestamp: 1765734483019
[info] [PingClient] Sending ping with timestamp: 1765734484020
[info] [PingClient] Sending ping with timestamp: 1765734485021
[info] [PingClient] Sending ping with timestamp: 1765734486022

@evan-hines-js

evan-hines-js commented Dec 14, 2025

Copy link
Copy Markdown
Contributor Author

With this reproduction repo, I went back through the Slipstream versions and none of them seem to work. Which is highly unusual because my projects just randomly stopped working without this fork even when I went back 10-20 commits (multiple days) in my own repo.

Edit: Found it. Added a new branch to the repro repo no_fork_1.8.2. The issue only occurs with Phoenix 1.8.3 which, according to the changelog has a bug fix that affected how stale join_refs are handled. Now they are dropped.

https://hexdocs.pm/phoenix/changelog.html

So I'm not sure if this change is needed as-is or something that needs to be investigated further. For now I'll pin Phoenix or use my fork.

@the-mikedavis

Copy link
Copy Markdown
Collaborator

Ah ok, that makes sense that it was a change in Phoenix instead. If I mix deps.update phoenix locally the test suite has many failures, and then with this change everything is passing again.

@the-mikedavis
the-mikedavis merged commit 63d30b8 into CuatroElixir:main Dec 15, 2025
5 checks passed
joshk added a commit to nerves-hub/nerves_hub_web that referenced this pull request Mar 3, 2026
Because of phoenixframework/phoenix@c73bbfc, and that we don't control when devices update their Slipstream so that they work with this Phoenix change
(CuatroElixir/slipstream#84), this commit fills in the `join_ref` when its missing.
joshk added a commit to nerves-hub/nerves_hub_web that referenced this pull request Mar 3, 2026
Because of phoenixframework/phoenix@c73bbfc, and that we don't control when devices update their Slipstream so that they work with this Phoenix change
(CuatroElixir/slipstream#84), this commit fills in the `join_ref` when its missing.
nshoes pushed a commit to nerves-hub/nerves_hub_web that referenced this pull request Mar 3, 2026
Because of
phoenixframework/phoenix@c73bbfc,
and that we don't control when devices update their Slipstream so that
they work with this Phoenix change
(CuatroElixir/slipstream#84), this commit fills
in the `join_ref` when its missing.
@SteffenDE

Copy link
Copy Markdown

This was a bug on the Phoenix side. It is fixed in 1.8.12. We do recommend to send the join ref on each message, so this is good!

phoenixframework/phoenix#6800

joshk added a commit to nerves-hub/nerves_hub_web that referenced this pull request Aug 21, 2026
Bumps Phoenix to 1.8.12 and removes the `join_ref` workaround from
`DeviceSocket`.

## Background

Older Slipstream releases only send a `join_ref` on `phx_join`, leaving
it `nil` on every message after that
([CuatroElixir/slipstream#84](CuatroElixir/slipstream#84)).
Phoenix tightened its staleness check in
[c73bbfc](phoenixframework/phoenix@c73bbfc)
and started discarding those messages. Since we can't force deployed
devices onto a newer Slipstream, `DeviceSocket` decoded each frame,
patched the `join_ref` back in, and re-encoded it before handing it to
the channel.


[phoenixframework/phoenix#6800](phoenixframework/phoenix#6800)
relaxed the check. The guard used to only tolerate a nil ref on the
stored side:

```elixir
# 1.8.11 - matches when the server's stored ref is nil (v1 protocol)
%{^pid => {^topic, existing_join_ref}} when existing_join_ref in [join_ref, nil] ->
```

Slipstream joins *with* a `join_ref`, so the stored one is non-nil, and
subsequent messages fell through to the "stale, ignore" clause. 1.8.12
tolerates nil on the incoming side, which is exactly our case:

```elixir
# 1.8.12
%{^pid => {^topic, existing_join_ref}}
when is_nil(join_ref) or join_ref === existing_join_ref ->
```

Stale non-nil refs are still dropped. The workaround never affected
those anyway. It only ever filled in refs that were nil.

## Changes

- `mix.lock`: phoenix 1.8.11 → 1.8.12, and bandit 1.12.4 → 1.12.5 pulled
in transitively. No `mix.exs` change needed, `~> 1.8.4` already allowed
it.
- `DeviceSocket`: dropped `maybe_fix_join_ref/2`. The `handle_in/2`
override stays because `heartbeat/1` still needs it.
- Added a test covering a legacy client, plus the two client serializers
behind it.

## On the test

The suite pins Slipstream 1.2.2, the fixed client, so nothing in it was
exercising the path the workaround existed for. Removing the code
without a legacy client on the wire would have been unguarded.

`SocketClient.LegacyJoinRefSerializer` and its msgpack counterpart send
a `join_ref` on `phx_join` and `nil` on everything after, the way old
Slipstream does. The new test joins over one of them, pushes
`connection_types`, and asserts the device's connection metadata gets
updated.

I pinned back to 1.8.11 to check the test actually fails without the
fix:

- **1.8.11**: fails on both wire formats, metadata is `nil`, message
silently dropped
- **1.8.12**: passes on both

## A bug that went with it

The removed code had a latent bug. It decoded with `socket.serializer`
but re-encoded as JSON unconditionally, so a msgpack device sending a
nil `join_ref` would have had JSON handed to the msgpack decoder.
Probably unreachable in practice, since msgpack devices are on newer
Slipstream, but it's gone now either way.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants