WebSocketHandler is a provider and a handler at two different heights #86

Open
opened 2026-08-02 16:15:21 +00:00 by aiko · 0 comments
Owner

WebSocketHandler is two things at once: it provides the socket, and it handles the messages. Handling needs the other contexts — it reads useVAD, useBgUrl, useLive2DConfig, useSubtitle, useChatHistory, useAiState — so it must be mounted below them. Providing needs to be above anything that talks to the socket. Those two heights are incompatible, and everything below came out of that one knot.

What it has already cost

  • 2026-08-01: the app would not start at all. df23c97 removed the context default that reported wsState: 'CLOSED' and upstream: null, on the entirely correct grounds that a component outside the provider should fail loudly rather than read a shut socket. But four consumers are outside it by constructionVADProvider via useInterrupt, useAudioTask and useSendAudio, and BgUrlProvider via baseUrl. The throw took the tree down on first render, which on a transparent undecorated window is a black rectangle and on Android an icon that does nothing. An APK was built and installed from it. The shut-socket default had been load-bearing: it was what let the cycle stand.
  • 2026-08-02: every utterance was silently dropped. The repair for the above made those reads optional. VADProvider is where Rust hands captured audio to the renderer, so useSendAudio there answered null and returned without sending. Voice input stopped working and nothing said so — the loud failure became a quiet one.

Both were the same missing seam, twice, in opposite directions.

What is in place now

useSendAudio talks to wsService directly, which is what the context was handing out anyway (sendMessage there is wsService.sendMessage bound, and wsService is a module singleton). useInterrupt and useAudioTask still read the context optionally and no-op when it is absent; BgUrlProvider falls back to defaultBaseUrl.

That is a patch per consumer, and the next one to be added above the provider will make the same mistake with no test to catch it.

What to build

Split the component in two:

  • A provider holding the socket, sendMessage, wsState, upstream, runFailure and the urls, mounted above everything that reads any of them.
  • A handler consuming the other contexts and routing incoming frames, mounted where it is now.

useWebSocket then throws only for genuine misuse, useWebSocketOptional and the per-consumer fallbacks disappear, and VADProvider reads a real socket instead of a stub.

Note what #37 deleted and why this is not a revert. There was a WebSocketProvider. It hard-coded wsState: 'CLOSED' and upstream: null, was exported, and was mounted nowhere — a component that lied. Deleting it was right; what is wanted here is the same shape actually wired to the socket.

Acceptance

  • Socket state and sendMessage come from a provider mounted above every consumer
  • useWebSocketOptional is gone, and so is every per-consumer fallback
  • VADProvider's audio path sends through the same socket every other caller uses, not a singleton reached around the side
  • useWebSocket throwing means a real mistake, and a test proves the tree mounts without one
  • The mount test (app-mounts.test.tsx) still passes, and a second one covers a consumer above the old boundary
  • Voice input verified by speaking, on desktop and on the Pixel — the failure this prevents is inaudible by nature

denpa#37 (deleted the lying provider), the black-window fix f148406, the dropped-audio fix, and app-mounts.test.tsx, which exists because none of this was catchable before.

`WebSocketHandler` is two things at once: it **provides** the socket, and it **handles** the messages. Handling needs the other contexts — it reads `useVAD`, `useBgUrl`, `useLive2DConfig`, `useSubtitle`, `useChatHistory`, `useAiState` — so it must be mounted below them. Providing needs to be *above* anything that talks to the socket. Those two heights are incompatible, and everything below came out of that one knot. ## What it has already cost - **2026-08-01: the app would not start at all.** `df23c97` removed the context default that reported `wsState: 'CLOSED'` and `upstream: null`, on the entirely correct grounds that a component outside the provider should fail loudly rather than read a shut socket. But four consumers are outside it *by construction* — `VADProvider` via `useInterrupt`, `useAudioTask` and `useSendAudio`, and `BgUrlProvider` via `baseUrl`. The throw took the tree down on first render, which on a transparent undecorated window is a black rectangle and on Android an icon that does nothing. An APK was built and installed from it. The shut-socket default had been load-bearing: it was what let the cycle stand. - **2026-08-02: every utterance was silently dropped.** The repair for the above made those reads optional. `VADProvider` is where Rust hands captured audio to the renderer, so `useSendAudio` there answered `null` and returned without sending. Voice input stopped working and nothing said so — the loud failure became a quiet one. Both were the same missing seam, twice, in opposite directions. ## What is in place now `useSendAudio` talks to `wsService` directly, which is what the context was handing out anyway (`sendMessage` there is `wsService.sendMessage` bound, and `wsService` is a module singleton). `useInterrupt` and `useAudioTask` still read the context optionally and no-op when it is absent; `BgUrlProvider` falls back to `defaultBaseUrl`. That is a patch per consumer, and the next one to be added above the provider will make the same mistake with no test to catch it. ## What to build Split the component in two: - A **provider** holding the socket, `sendMessage`, `wsState`, `upstream`, `runFailure` and the urls, mounted **above** everything that reads any of them. - A **handler** consuming the other contexts and routing incoming frames, mounted where it is now. `useWebSocket` then throws only for genuine misuse, `useWebSocketOptional` and the per-consumer fallbacks disappear, and `VADProvider` reads a real socket instead of a stub. **Note what `#37` deleted and why this is not a revert.** There *was* a `WebSocketProvider`. It hard-coded `wsState: 'CLOSED'` and `upstream: null`, was exported, and was mounted nowhere — a component that lied. Deleting it was right; what is wanted here is the same shape actually wired to the socket. ## Acceptance - [ ] Socket state and `sendMessage` come from a provider mounted above every consumer - [ ] `useWebSocketOptional` is gone, and so is every per-consumer fallback - [ ] `VADProvider`'s audio path sends through the same socket every other caller uses, not a singleton reached around the side - [ ] `useWebSocket` throwing means a real mistake, and a test proves the tree mounts without one - [ ] The mount test (`app-mounts.test.tsx`) still passes, and a second one covers a consumer above the old boundary - [ ] Voice input verified by speaking, on desktop and on the Pixel — the failure this prevents is inaudible by nature ## Related `denpa#37` (deleted the lying provider), the black-window fix `f148406`, the dropped-audio fix, and `app-mounts.test.tsx`, which exists because none of this was catchable before.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiko/denpa#86
No description provided.