Skip to content

fix: splice a list-valued websocket reply into the command list - #400

Merged
Taure merged 1 commit into
masterfrom
fix/ws-reply-frame-list
Aug 4, 2026
Merged

fix: splice a list-valued websocket reply into the command list#400
Taure merged 1 commit into
masterfrom
fix/ws-reply-frame-list

Conversation

@Taure

@Taure Taure commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #399.

nova_websocket:call_result/0 documents

{reply, OutFrame :: cow_ws:frame() | [OutFrame :: cow_ws:frame()], State :: map()}

but the list form killed the connection process.

nova_basic_handler:handle_ws/2 consed the payload onto commands as a single element. nova_ws_handler:handle_ws/4 hands that list to cowboy untouched, and cowboy treats every top-level element as one command — so a list-valued reply reached cow_ws:frame/2 whole:

{function_clause,[{cow_ws,frame,[[{text,<<...>>},{text,<<...>>}],#{}],
   [{file,"cow_ws.erl"},{line,875}]},
 {cowboy_websocket,commands,3,...},
 {cowboy_websocket,handler_call_result,5,...}]}

Ranch logs the connection process exiting and the client's socket drops.

The fix

prepend_frames(Frames, Commands) when is_list(Frames) -> Frames ++ Commands;
prepend_frames(Frame, Commands) -> [Frame|Commands].

Order matters and is not reversed: handle_ws/4 returns the accumulator to cowboy as-is, so its order is the order frames reach the wire. Splicing in place is therefore correct and lists:reverse/1 would be wrong.

No cow_ws:frame() is itself a list — they are atoms (close, ping, pong) and tuples — so is_list/1 separates the two shapes unambiguously. An empty list contributes nothing.

Tests

Both levels, because the defect is only visible at the lower one:

  • nova_basic_handler_tests — the command list built for a single frame, a list, an empty list, the hibernate variant, order preservation, earlier commands kept, and bare atom frames.
  • nova_ws_handler_tests — what cowboy actually receives from websocket_info/2 and websocket_handle/2, plus an assertion that every element survives cow_ws:frame/2. That last one is what would have caught the original bug: the controller's return value is correct in isolation, so a test one level up passes while the connection dies.

Reverting the source change fails 8 of them.

Checks

xref, dialyzer (no warnings in the changed files), eunit 356/356, ct, ex_doc. elp lint reports one pre-existing L0002 on nova_basic_handler.erl that is also present on master (an ELP internal crash, not a code defect).

How it was found

An application emitted two frames from one websocket_info/2 clause. EUnit passed — the return value is right — and Common Test failed, because the defect only exists once Nova translates the return into cowboy commands.

nova_websocket:call_result/0 documents that a controller may reply with
{reply, cow_ws:frame() | [cow_ws:frame()], State}, but the list form killed
the connection process.

nova_basic_handler:handle_ws/2 consed the payload onto `commands' as a
single element. nova_ws_handler passes that list to cowboy untouched and
cowboy treats every top-level element as one command, so a list-valued
reply arrived at cow_ws:frame/2 whole:

  {function_clause,[{cow_ws,frame,[[{text,<<...>>},{text,<<...>>}],#{}],...

Ranch then logged the connection process exiting and the client's socket
dropped.

Splice the list in instead, preserving order - the accumulator is handed to
cowboy without a reverse, so its order is the order frames reach the wire.
No cow_ws:frame() is itself a list, so is_list/1 separates the two shapes
unambiguously, and an empty list contributes nothing.

Tests cover both handler levels: handle_ws/2 for the command list it
builds, and nova_ws_handler for what cowboy actually receives - including
that every element survives cow_ws:frame/2, which is the assertion that
would have caught this. The defect was invisible one level up, because the
controller's return value is correct in isolation.

Closes #399
@Taure
Taure merged commit a334a6d into master Aug 4, 2026
14 checks passed
@Taure
Taure deleted the fix/ws-reply-frame-list branch August 4, 2026 04:52
Taure added a commit to widgrensit/asobi that referenced this pull request Aug 4, 2026
…eleases

Hex nova 0.15.1 crashes the connection process when a websocket controller
replies with a list of frames, which nova_websocket:call_result/0 documents
as valid: nova_basic_handler:handle_ws/2 consed the payload onto the command
list as one element, cowboy reads one command per element, so the whole list
reached cow_ws:frame/2 as a single frame and died with function_clause.

That is why asobi_ws_handler emits exactly one frame per websocket_info/2
return, and why #330 put the producing extension in the payload rather than
dual-emitting game.message and module.message as two frames.

Fixed upstream in novaframework/nova#400, merged and tagged v0.15.3 but not
yet on Hex. Pinned to the merge commit rather than {branch, "master"} so the
ref cannot drift underneath a build. The delta from hex 0.15.1 is exactly two
commits - that fix and a cowboy bump which changes nothing here, since
cowboy/cowlib/ranch resolve to the same 2.18.0/2.19.0/2.2.1 either way.

Verified through the real handler chain: a {reply, [F1, F2], State} now
yields two commands, both encodable by cow_ws:frame/2.

Revert to plain hex nova once it releases - asobi#347.
Taure added a commit to widgrensit/asobi that referenced this pull request Aug 4, 2026
…eleases (#349)

Hex nova 0.15.1 crashes the connection process when a websocket controller
replies with a list of frames, which nova_websocket:call_result/0 documents
as valid: nova_basic_handler:handle_ws/2 consed the payload onto the command
list as one element, cowboy reads one command per element, so the whole list
reached cow_ws:frame/2 as a single frame and died with function_clause.

That is why asobi_ws_handler emits exactly one frame per websocket_info/2
return, and why #330 put the producing extension in the payload rather than
dual-emitting game.message and module.message as two frames.

Fixed upstream in novaframework/nova#400, merged and tagged v0.15.3 but not
yet on Hex. Pinned to the merge commit rather than {branch, "master"} so the
ref cannot drift underneath a build. The delta from hex 0.15.1 is exactly two
commits - that fix and a cowboy bump which changes nothing here, since
cowboy/cowlib/ranch resolve to the same 2.18.0/2.19.0/2.2.1 either way.

Verified through the real handler chain: a {reply, [F1, F2], State} now
yields two commands, both encodable by cow_ws:frame/2.

Revert to plain hex nova once it releases - asobi#347.
Taure added a commit to widgrensit/asobi that referenced this pull request Aug 4, 2026
…error (#354)

* feat(ws): complete the S6 frame-type rename to module.message/module.error

S6 put one extension (Lua) in the wire type, where no second extension
could ever reuse it. #330 shipped the mechanism - the producing extension
travels in the payload's `module` key - but not the rename, because nova
0.15.1 crashed the connection process on a list-valued reply, so
dual-emitting old and new was impossible.

novaframework/nova#400 fixed that and asobi carries the fixed nova by git
ref (#349), so both frames can now go out on one reply.

Extension pushes are `module.message` and `module.error`. `game.message`
and `game.error` are emitted alongside them with identical payloads, so
every SDK built before the rename keeps working, and are removed at the
1.0 wire break.

`asobi.ws_legacy_game_frames` (default true) drops the legacy pair.
`game.message` is asobi_lua's `game.send/2`, which a script may call per
player per tick, so the compat frame doubles asobi's hottest
extension-produced egress path. An operator whose clients all dispatch
`module.*` gets that back without waiting for 1.0.

Fixtures for both new types; the old two keep theirs.
asobi_protocol_coverage_tests learns to read extension_frames/3, which is
now the only emit site for either pair.

* style: ASCII hyphen in the S6 test comment

* docs: pin the wire-history note to v0.54.0
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.

nova_basic_handler:handle_ws/2 crashes the connection when a controller replies with a list of frames

1 participant