Skip to content

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

Merged
Taure merged 3 commits into
mainfrom
feat/s6-module-frame-rename
Aug 4, 2026
Merged

feat(ws): complete the S6 frame-type rename to module.message/module.error#354
Taure merged 3 commits into
mainfrom
feat/s6-module-frame-rename

Conversation

@Taure

@Taure Taure commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Plan item S6, the half of it that #330 could not ship.

What S6 was for

game.message / game.error put ONE extension (Lua) in the wire type.
No second extension could reuse a type named after the first. #330 shipped
the mechanism - the producing extension travels in the payload's module
key - and deferred the type rename. The plan gives S6 a hard clock: before
the wire freeze.

The constraint that deferred it is gone

nova 0.15.1's nova_basic_handler:handle_ws/2 consed a list-valued reply
onto cowboy's command list as a single command, so {reply, [F1, F2], S}
reached cow_ws:frame/2 as one frame and killed the connection process.
Dual-emit was therefore impossible, and a bare rename would have broken
every shipped SDK.

novaframework/nova#400 replaced the cons with a splice, and asobi carries
the fixed nova by git ref (#349). Verified before writing any of this:

> nova_basic_handler:handle_ws({reply, [{text, <<"a">>}, {text, <<"b">>}], st}, #{commands => []}).
#{commands => [{text,<<"a">>},{text,<<"b">>}], controller_data => st}

Two commands, order preserved, and nova_ws_handler hands commands
to cowboy untouched. asobi_ws_SUITE proves it end to end over a real
socket, which is the only place the splice is actually exercised.

What this PR does

  • Extension pushes are module.message and module.error.
  • game.message and game.error are emitted alongside them, identical
    payload, same reply, legacy frame first. No shipped SDK breaks.
  • Both new types get fixtures; both old ones keep theirs.
  • The guide marks the game.* pair deprecated, says new SDK code
    dispatches on module.*, and says the pair is removed at the 1.0 wire
    break.

The hot path, honestly

You asked for the reasoning rather than a default, so:

error is free. It is dev-mode only (ASOBI_DEV_ERRORS=true), rate
limited to one per second per match, and never emitted in production.
Dual-emitting it costs nothing an operator will ever measure.

message is not free. game.message is asobi_lua's game.send/2.
A script may call it per player per tick; at 30Hz with 8 players that is
240 frames/s per match becoming 480, each with its own json:encode/1
and its own cowboy frame. It is the hottest extension-produced egress
path asobi has, and dual-emit doubles it.

But the alternative is worse. Five of seven SDKs cannot dispatch an
unknown frame type at runtime, and Godot/LOVE users vendor by copying
source, so emitting only module.message makes per-player messages
vanish silently in every shipped build - the exact silent dev-facing
failure class we treat as a defect.

So the split is not "dual-emit error, rename message". It is: dual-emit
both, and give the hot path an exit that does not require waiting for
1.0.
asobi.ws_legacy_game_frames, default true:

  • Default: today's wire plus the new types. Nothing breaks, anywhere.
  • false: only module.*. An operator whose clients all dispatch the
    new types stops paying the doubling immediately.

One flag, not two, and it governs error as well - not for the cost, but
because an SDK author verifying their new dispatch path wants the old
frame out of the way while they do it. At 1.0 the legacy pair goes and
the flag becomes a no-op.

If you would rather not carry a flag at all, the fallback is unconditional
dual-emit and a note in the performance-tuning guide. I did not pick that
because it leaves a real per-tick doubling with no operator remedy until
a major version.

Ordering

Legacy frame first, module.* second, so a shipped client sees
byte-identical frame ordering to before this PR. The guide warns not to
dispatch on both - a client handling game.message and module.message
processes every message twice.

The commit-message defect on main

Commit a6bc2eb ("refactor: generalise the two Lua-specific WebSocket
frames", #330) is a squash whose message says:

Both are now also emitted as module.message and module.error

That is not what merged. The second commit inside the same squash
("fix: keep the extension frames to one wire frame per message") removed
the dual-emit because of the nova bug above, and its text is further down
the same message - so the message contradicts itself and the top half is
the part people read. No release before this
change emits module.* - not v0.54.0, and not v0.53.0 where a6bc2eb
landed.

A merged commit cannot be rewritten, so the correction lives where
somebody would actually look for it:
guides/websocket-protocol.md now has a "Wire history" paragraph naming
a6bc2eb and stating what each release actually emits.

Coverage gate

asobi_protocol_coverage_tests scans encode_reply(_, ~"type", _)
literals in the handler. Both pairs now go out through
extension_frames/3, where the encode takes a variable, so the scanner
learns extension_frames(~"new", ~"legacy", _) too. Without that the
four fixtures read as stale (which is how I found it).

Checks

fmt --check, xref, dialyzer clean. eunit 1236/1236. ct --suite=asobi_ws_SUITE 8/8.

Revert verification. Reduced extension_frames/3 to the pre-PR
single legacy frame: 6 eunit failures (the four payload tests, the
frame-count test, the flag test) and 1 CT failure
(ws_script_error_rendered_as_extension_error). Restored, all green.

One caveat I will not paper over: no_stale_fixtures_test did not
fail under that revert, because the ~"module.message" literal is still
present as an argument in the source the scanner reads. The fixture gate
proves a type name appears at an emit site, not that the frame reaches a
socket. The frame-count eunit test and the CT case are what cover that.

nova_splices_a_list_reply_into_separate_commands_test is a pin guard,
not a test of asobi code - it fails if the nova pin is reverted to a
build without #400, which would otherwise only show up as a dead
connection at runtime.

Taure added 3 commits August 4, 2026 08:42
…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.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🟡 Code Coverage — 73.4%

5774 of 7863 lines covered.

@Taure
Taure merged commit b16e76e into main Aug 4, 2026
15 checks passed
@Taure
Taure deleted the feat/s6-module-frame-rename branch August 4, 2026 06:57
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.

1 participant