Skip to content

bug(stage-ui): consumer registration lost on first connect since the better-ws migration — external input:text is dropped until the stage reconnects #2305

Description

@ariksu

Describe the bug

useContextBridgeStore.initialize() registers the stage as the consumer of input:text,
input:text:voice and input:voice in the chat-ingestion consumer group. On a fresh page load
none of the three registrations reaches the server: Client.send() returns false because the
transport is still in its prepare phase, and neither call site checks that boolean. The store then
sets initialized = true and everything looks healthy.

Consequence: from a freshly loaded stage until its first reconnect, every input:text sent by an
external module is dropped by the runtime with no consumer registered for event delivery. The
character never answers, the browser console is clean, and on dispose() the store politely sends
three module:consumer:unregister for registrations the server never received.

How it is supposed to work

input:text, input:text:voice and input:voice are the only events whose protocol default is
consumer delivery rather than broadcast
(plugin-protocol/src/types/events.ts#L1242-L1265):
{ mode: 'consumer-group', group: 'chat-ingestion', selection: 'first' }.

That is deliberate, and the reason is written down in the code
(context-bridge.ts#L651-L672):
the SDK lives in every stage-web tab, each tab has its own chat orchestrator, so a broadcast
input:text would be ingested by every open tab and produce duplicated output:*. Web Locks guard
the in-browser side; the consumer group is the server-side half of the same coordination — the
runtime picks exactly one consumer instead of fanning out.

So the intended shape is:

  • producers — external modules that send input:*. Several exist in-tree:
    integrations/discord-bot/src/adapters/airi-adapter.ts (sends input:text with
    overrides.sessionId / messagePrefix), integrations/twitter-services/src/adapters/airi-adapter.ts,
    and anything else speaking the SDK.
  • consumer — the stage, which registers itself into chat-ingestion and ingests into the chat
    orchestrator. It is the only registrant in the tree; no integration registers as a consumer, and
    server-sdk exposes no consumer API at all (the word does not appear in it), so this one call
    site in the store is the entire registration surface.
  • runtime — keeps the consumer registry
    (server-runtime/src/index.ts#L817-L843)
    and routes each input:* to one registered consumer, or warns when there is none.

How it works now

The transport accepts messages only in state ready, but the store starts sending at
module:authenticated, which arrives while the transport is still preparing:

  1. App.vue#L99-L100
    calls serverChannelStore.initialize(), then contextBridgeStore.initialize().
  2. context-bridge.ts#L449-L451
    awaits ensureConnected(), then calls registerConsumers()
    (#L436-L447).
  3. ensureConnected() awaits initializing.value
    (channel-server.ts#L243-L248),
    which is resolved from the module:authenticated handler
    (#L221-L239) —
    the same handler that sets connected.value = true and calls flush().
  4. module:authenticated is delivered inside the prepare phase: the SDK performs
    authenticate/announce through the prepare channel
    (client.ts#L410-L457),
    and the runtime answers immediately — on peer open when it runs without a token
    (index.ts#L537-L544),
    or in reply to module:authenticate when it runs with one
    (#L620-L637).
  5. registerConsumers() therefore runs with connected.value === true while
    transport.state === 'preparing'. send() takes the direct branch
    (channel-server.ts#L301-L311),
    Client.send() returns false
    (client.ts#L343-L352)
    because better-ws requires state === 'ready'
    (better-ws/src/client/index.ts#L406-L417),
    and nobody looks at the result.

Same loss through the queued path: if the registrations reach pendingSend first,
flush()
sends them in the same preparing state, ignores the false, and clears the queue
unconditionally, so the later flush() from onReady has nothing left to send.

This is not a race: resolve() runs synchronously inside the message handler, so the continuation
after await ensureConnected() is a microtask — it always executes before the next websocket
message can move the transport to ready.

Client already has a non-silent variant next to send()
sendOrThrow()
which is not used here.

History of regression

The registration code has not changed since the day it was written; the contract underneath it did.

So the mechanism the March fix was built for — one consumer per input event instead of a broadcast —
is inoperative on a freshly loaded stage. In the browser the duplicate-response symptom of #1387 / #1223
is still held back by the Web Locks guard, but the server-side half of that protection is not
in place until the stage reconnects.

(That timeline is read from the code and the commit history, not measured — I did not run the
pre-better-ws SDK.)

Why this has probably gone unnoticed

Two independent reasons:

  1. Everything in-tree is a producer. Discord, Twitter and friends only send input:*; the
    stage is the only consumer, and its registration is a single call in a single place. The
    stage's own chat never travels through the bus, so a missing registration is invisible unless
    someone external is talking to a freshly loaded stage.
  2. The bug heals itself on the first reconnect. registerConsumers() is also registered as an
    onReconnected callback, and those callbacks run from onReady
    (channel-server.ts#L193-L218) —
    i.e. in state ready, where sending works. So any long-running deployment repairs itself at the
    first network blip or runtime restart, and only a freshly started pair (stage + runtime, no
    disconnects yet) stays broken. Measured below.

Not verified, but it follows from the same code path: a Discord message sent to a stage that has
just been loaded should hit exactly the same window.

Reproduction 1 — the drop, without a browser

The store's behaviour replayed against the real runtime with the real SDK: the same registration
payload sent twice, once from the module:authenticated handler (what the store does today), once
one tick after connect() resolves.

// repro-consumer-register.mjs
// Deps: "@proj-airi/server-sdk": "file:<airi>/packages/server-sdk"
import process from 'node:process'

import { Client } from '@proj-airi/server-sdk'

const URL = process.env.AIRI_WS_URL ?? 'ws://localhost:6121/ws'
const TOKEN = process.env.AIRI_WS_TOKEN || undefined
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const log = (...args) => console.info(new Date().toISOString().slice(11, 23), ...args)

const registration = event => ({
  type: 'module:consumer:register',
  data: { event, mode: 'consumer-group', group: 'chat-ingestion' },
})

const delivered = []
const stage = new Client({
  name: 'stage-web',
  url: URL,
  token: TOKEN,
  autoConnect: false,
  possibleEvents: ['input:text', 'input:text:voice', 'module:consumer:register', 'error'],
  onError: error => log('stage error:', error?.message ?? error),
})

// A — what channel-server.ts does today: send from the module:authenticated handler
stage.onEvent('module:authenticated', () => {
  log(`A: send(register input:text) during module:authenticated -> ${stage.send(registration('input:text'))}`)
})
stage.onEvent('input:text', event => delivered.push(['input:text', event.data.text]))
stage.onEvent('input:text:voice', event => delivered.push(['input:text:voice', event.data.text]))

await stage.connect()
// B — the same call after the transport reached `ready`
log(`B: send(register input:text:voice) after ready -> ${stage.send(registration('input:text:voice'))}`)

await sleep(500)
const core = new Client({ name: 'external-module', url: URL, token: TOKEN, autoConnect: false, possibleEvents: ['input:text', 'input:text:voice', 'error'] })
await core.connect()
// Both events default to delivery { mode: 'consumer-group', group: 'chat-ingestion' }
core.send({ type: 'input:text', data: { text: 'A: registered during authenticated' } })
core.send({ type: 'input:text:voice', data: { text: 'B: registered after ready' } })

await sleep(1500)
log('delivered to stage:', delivered.length ? JSON.stringify(delivered) : '(nothing)')
process.exit(0)

Run:

node packages/server-runtime/dist/bin/run.mjs      # in another shell
node repro-consumer-register.mjs

Output:

13:57:19.066 A: send(register input:text) during module:authenticated -> false
13:57:19.069 B: send(register input:text:voice) after ready -> true
13:57:21.090 delivered to stage: [["input:text:voice","B: registered after ready"]]

Same payload, same group, same connection — the only difference is the moment it is sent. Server
log for that run:

[log]  connected  { peer=d11ac7ee… activePeers=1 }
[log]  connected  { peer=40ecf9a4… activePeers=2 }
[warn] no consumer registered for event delivery  { peer=40ecf9a4… }

Identical result with AUTHENTICATION_TOKEN on the runtime and AIRI_WS_TOKEN on the clients
(A -> false, B -> true): only the trigger for module:authenticated changes, not the transport
state it arrives in.

Reproduction 2 — the reconnect heals it

Same setup, but the script starts the runtime itself, restarts it mid-way, and re-registers from
onReady exactly like the onReconnected callback does:

The part that matters:

const stage = new Client({
  name: 'stage-web',
  url: URL,
  autoConnect: false,
  possibleEvents: ['input:text', 'module:consumer:register', 'error'],
  onReady: () => {
    readyCount += 1
    if (readyCount > 1) // channel-server.ts runs the onReconnected callbacks here
      log(`reconnect: send(register input:text) from onReady -> ${stage.send(registration('input:text'))}`)
  },
})
stage.onEvent('module:authenticated', () => {
  if (readyCount === 0) // and this is where it registers on a first connect
    log(`first connect: send(register input:text) during module:authenticated -> ${stage.send(registration('input:text'))}`)
})
Full script (starts and restarts the runtime itself)
// repro-reconnect-heals.mjs
import { spawn } from 'node:child_process'
import process from 'node:process'

import { Client } from '@proj-airi/server-sdk'

const RUNTIME = process.env.AIRI_RUNTIME ?? '<airi>/packages/server-runtime/dist/bin/run.mjs'
const URL = process.env.AIRI_WS_URL ?? 'ws://localhost:6121/ws'
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const log = (...args) => console.info(new Date().toISOString().slice(11, 23), ...args)

const registration = event => ({
  type: 'module:consumer:register',
  data: { event, mode: 'consumer-group', group: 'chat-ingestion' },
})

function startRuntime() {
  const child = spawn(process.execPath, [RUNTIME], { stdio: ['ignore', 'pipe', 'pipe'] })
  return new Promise((resolve) => {
    const onData = (chunk) => {
      if (chunk.toString().includes('started on ws://')) {
        child.stdout.off('data', onData)
        resolve(child)
      }
    }
    child.stdout.on('data', onData)
    child.stderr.on('data', () => {})
  })
}

let runtime = await startRuntime()
log('runtime up')

const delivered = []
let readyCount = 0

const stage = new Client({
  name: 'stage-web',
  url: URL,
  autoConnect: false,
  possibleEvents: ['input:text', 'module:consumer:register', 'error'],
  onReady: () => {
    readyCount += 1
    if (readyCount > 1)
      log(`reconnect: send(register input:text) from onReady -> ${stage.send(registration('input:text'))}`)
  },
  onError: () => {},
})

stage.onEvent('module:authenticated', () => {
  if (readyCount === 0)
    log(`first connect: send(register input:text) during module:authenticated -> ${stage.send(registration('input:text'))}`)
})
stage.onEvent('input:text', event => delivered.push(event.data.text))

await stage.connect()

const core = new Client({ name: 'external-module', url: URL, autoConnect: false, possibleEvents: ['input:text', 'error'], onError: () => {} })
await core.connect()

core.send({ type: 'input:text', data: { text: 'before reconnect' } })
await sleep(1000)
log(`delivered after first connect: ${JSON.stringify(delivered)}`)

log('restarting runtime…')
runtime.kill()
await sleep(500)
runtime = await startRuntime()
await sleep(4000) // let both clients reconnect

core.send({ type: 'input:text', data: { text: 'after reconnect' } })
await sleep(1500)
log(`delivered after reconnect: ${JSON.stringify(delivered)}`)

core.close()
stage.close()
runtime.kill()
process.exit(0)

Output:

14:56:52.111 runtime up
14:56:52.157 first connect: send(register input:text) during module:authenticated -> false
14:56:53.179 delivered after first connect: []
14:56:53.179 restarting runtime…
14:56:54.228 reconnect: send(register input:text) from onReady -> true
14:56:59.497 delivered after reconnect: ["after reconnect"]

So the difference between "broken" and "working" is one reconnect, which is why this is easy to
miss in day-to-day use and painful for anyone bringing up a fresh stack.

Browser observation and workaround

Setup: runtime + stage-web (production build, single tab) + an external module that announces
itself and sends input:text. Result: runtime logs no consumer registered for event delivery,
character silent, browser console clean.

To see it on the wire, patch WebSocket.prototype.send before the app loads (I used CDP
Page.addScriptToEvaluateOnNewDocument; an inline <script> at the top of index.html works too):

const original = WebSocket.prototype.send
WebSocket.prototype.send = function (data) {
  console.info('[ws→]', typeof data === 'string' ? data.slice(0, 120) : data)
  return original.call(this, data)
}

Over a full page load there are exactly two outgoing frames — extension:module:announce and the
heartbeat. No module:consumer:register at all: zero registration frames in a 20-second window
after load, every time I looked. Calling dispose() + initialize() on the store makes all three
appear immediately in the same trace:

→ module:consumer:register {"event":"input:text","mode":"consumer-group","group":"chat-ingestion"}
→ module:consumer:register {"event":"input:text:voice",…}
→ module:consumer:register {"event":"input:voice",…}

Two workarounds, both confirmed in the browser: call dispose() then initialize() on the
context-bridge store after load, or simply restart the runtime once and let the stage reconnect.
The second one measured end-to-end on a live stack: right after page load the runtime logs
no consumer registered and the character stays silent; after a runtime restart the stage
reconnects in 0.4 s and the very same question comes back answered, out loud.

To be precise about what is measured and what is read: the mechanism (send() returning false
during preparing) is measured at SDK level in both reproductions above; in the browser I measured
the outcome — no registration frame ever reaches the socket — but did not instrument the store to
print that boolean.

Suggested fix

  1. Don't equate module:authenticated with a usable transport. Set connected.value = true
    and call flush() from onReady only. Authentication is one step of the handshake; the
    transport is writable after announce.
  2. Stop ignoring the boolean. In flush(), requeue on false instead of clearing
    pendingSend unconditionally; in send(), fall back to pendingSend when Client.send()
    returns false. Either one hides this bug; both together make future handshake changes fail
    loudly instead of silently.
  3. Optional, contract-level: make ensureConnected() await the actual connection (the SDK's
    connect() resolves on ready), so callers that await it can rely on being able to send. Today
    initialize() returns before the socket is open and await ensureConnected() is satisfied by
    authentication alone.

Happy to send a PR — say which shape you prefer (I would start with 1 + 2). A regression test fits
at the store level: authenticate, assert no registration is on the wire yet, reach ready, assert
all three module:consumer:register frames are there — and the same assertion after a reconnect,
which is the path that currently masks the bug.

Scope

packages/stage-ui is shared, so stage-web, stage-tamagotchi and stage-pocket are affected alike;
I verified on stage-web. All line references are permalinks to main @ d768f5a;
channel-server.ts and server-sdk/src/client.ts are byte-identical to that commit in the tree I
run locally, and context-bridge.ts differs only outside the registration path (my checkout is a
few days older).

Context for why I care: I am building an external Factorio bridge that talks to the runtime as a
module. Events routed by destination (spark:notify, context:update) arrive fine; everything
going through the chat-ingestion consumer group is dropped until the stage reconnects.

Possibly related

System Info

System:
    OS: Windows 11 10.0.26200
    CPU: (12) x64 Intel(R) Core(TM) i5-10400F CPU @ 2.90GHz
    Memory: 3.28 GB / 15.92 GB
  Binaries:
    Node: 22.17.0 - C:\Program Files\nodejs\node.EXE
    npm: 10.9.2 - C:\Program Files\nodejs\npm.CMD
  Browsers:
    Chrome: 151.0.7922.138
    Edge: Chromium (151.0.4129.86)
    Internet Explorer: 11.0.26100.8115

Validations

  • Follow our Code of Conduct
  • Read the Contributing Guide.
  • Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
  • Check that this is a concrete bug. For Q&A, please open a GitHub Discussion instead.

Contributions

  • I am willing to submit a PR to fix this issue
  • I am willing to submit a PR with failing tests (actually just go ahead and do it, thanks!)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpending triageNot yet confirmed.scope/engineeringScope related to toolchain, workflow, workspace, and CI/CD, deploy, packaging, etc.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions