# Async Bridge Spike Progress ## Goal Determine whether frontend code can support `await context.jamClient.method()` for a browser/web-client implementation while preserving compatibility with existing synchronous native `window.jamClient` calls. ## Questions - What is the current source of truth for native-vs-browser mode (`gon.isNativeClient`, `window.jamClient` presence, `jamClient.IsNativeClient()`, etc.)? - Where is `window.jamClient` initialized/replaced (real native bridge vs fake/intercepted client)? - Can `await` be introduced incrementally at callsites without breaking sync native behavior? - Which core session-flow call chains would require async propagation first? ## Initial Plan - [x] Identify existing native/browser detection logic and init path(s) - [x] Find current jamClient interception/test hooks we can reuse for call-pattern recording - [x] Select one session-related call chain as the async spike candidate - [x] Document recommended compatibility pattern (native sync + web async via Promise-compatible calls) ## Notes - Key assumption to validate: `await` on a synchronous native return value is safe (it should resolve immediately), allowing one code path if methods are called through `await` in async functions. - Confirmed locally with Node runtime check: `await` on plain values/objects resolves immediately. ## Findings (2026-02-25) - Native/browser mode detection already exists in multiple layers: - `ClientHelper#is_native_client?` sets `gon.isNativeClient`, including the forced mode via cookie `act_as_native_client`. - `homeScreen.js` toggles `act_as_native_client` with Ctrl+Shift+0 (`keyCode == 48`) when `gon.allow_force_native_client` is enabled. - Runtime behavior also relies on `window.jamClient` presence and `context.jamClient.IsNativeClient()`. - `JK.initJamClient(app)` in `web/app/assets/javascripts/utils.js` is the main installation point: - if no `window.jamClient`, it installs `JK.FakeJamClient`. - this is the natural replacement point for a future `JK.WebJamClient` in "pretend native/session-capable browser mode". - Existing interception hook for recording exists in `JK.initJamClient(app)` (currently disabled under `else if(false)`), which wraps `window.jamClient` methods and logs call timing/returns. - Async spike candidate call chain selected: session entry (`SessionStore` / `session.js` -> `sessionUtils.SessionPageEnter()` -> `context.jamClient.SessionPageEnter()`). - This chain is a good spike because the return value is used immediately by `guardAgainstActiveProfileMissing`, so it exposes async propagation requirements early. - Current blast-radius observation (session entry path): - `SessionStore.js.coffee` and legacy `session.js` both call `sessionUtils.SessionPageEnter()` synchronously and immediately pass the result to `gearUtils.guardAgainstActiveProfileMissing(...)`. - `guardAgainstActiveProfileMissing(...)` expects a plain object (`backendInfo.error`, `backendInfo.reason`), not a Promise. - Therefore the first migration step cannot be "just make `SessionPageEnter()` async" without adapting both callers and guard handling. - CoffeeScript asset pipeline does not currently support real `async/await` syntax in these files: - local compile checks with `coffee-script` gem compile `await` as a normal identifier call (`await(y)`), producing invalid JS for native async/await semantics. - implication: for CoffeeScript-heavy paths, prefer Promise/jQuery Deferred adapters first, not direct `await` syntax. - Confirmed from `origin/promised_based_api_interation`: the team previously worked around this in `SessionStore.js.coffee` by embedding raw JavaScript via CoffeeScript backticks (e.g. ``onJoinSession: `async function(sessionId) { ... }` `` and `await context.jamClient...` inside). - That branch also converted the session-entry path (`onJoinSessionDone`, `joinSession`, `waitForSessionPageEnterDone`) to async this way, which directly validates the spike target and migration shape. - Confirmed local migration tool viability: - `npx decaffeinate --version` -> `v8.1.4` - Dry-run conversion of `SessionStore.js.coffee` produced structurally usable output (method/object style preserved closely enough for follow-on edits). - Decaffeinate outputs modern JS syntax, so the correct target in this repo is `.es6` (to stay on the existing `sprockets-es6` pipeline) rather than plain `.js`. ## Changes Applied (2026-02-25) - Migrated `web/app/assets/javascripts/react-components/stores/SessionStore.js.coffee` to `web/app/assets/javascripts/react-components/stores/SessionStore.es6` using `decaffeinate`. - Removed the CoffeeScript source file for `SessionStore`. - Basic syntax parse check passed by copying the `.es6` file to a temporary `.js` filename and running `node --check` (Node does not natively parse `.es6` extension files directly). - Added parser-safe dual bundle delivery scaffolding for the `/client` layout: - `web/app/assets/javascripts/client_legacy.js` - `web/app/assets/javascripts/client_modern.js` - `web/app/views/layouts/client.html.erb` now selects bundle server-side via `legacy_qt_webkit_client?` - `web/app/helpers/client_helper.rb#legacy_qt_webkit_client?` uses UA-only detection (ignores forced-native cookie) - Added asset precompile entries for both bundles in `web/config/application.rb` and `web/config/environments/test.rb` - Split `SessionStore` loading by bundle: - restored legacy `web/app/assets/javascripts/react-components/stores/SessionStore.js.coffee` (legacy/default path) - moved modern JS-converted store to `web/app/assets/javascripts/react-components/stores/SessionStoreModern.es6` - added `web/app/assets/javascripts/react-components_modern.js` to load `SessionStoreModern` explicitly and avoid `require_directory ./react-components/stores` pulling the legacy store - added `web/app/assets/javascripts/application_client_modern.js` (copy of `application.js` with `react-components_modern`) - `web/app/assets/javascripts/client_modern.js` now requires `application_client_modern` - Added `//= require babel/polyfill` to `web/app/assets/javascripts/application_client_modern.js` so Babel 5 async/await transpilation has `regeneratorRuntime` available in the modern client bundle. - Converted the modern-only session-entry path in `web/app/assets/javascripts/react-components/stores/SessionStoreModern.es6` to native `async/await`: - `onJoinSession` - `onJoinSessionDone` (new helper extracted from the nested deferred chain) - `await` now wraps mixed sync/thenable results (`rest`, jQuery Deferreds, and sync `SessionPageEnter()` values) in the modern path only. - Continued the modern-only async conversion in `SessionStoreModern.es6`: - `waitForSessionPageEnterDone` -> `async` - `joinSession` -> `async` - Preserved `this.joinDeferred` as the original jQuery Deferred/XHR object so existing `.state()` checks and `.done()` listeners still work. - Fixed `audio_latency` retrieval to await `FTUEGetExpectedLatency()` before reading `.latency`. - Added mode-aware browser jamClient installation seam in `web/app/assets/javascripts/utils.js`: - if no native `window.jamClient` and `gon.isNativeClient && JK.WebJamClient` exists, install `WebJamClient` - otherwise fall back to `FakeJamClient` (current behavior) - Added initial `web/app/assets/javascripts/webJamClient.js` shim: - delegates to `FakeJamClient` (method/property passthrough) so the seam is exercised without changing behavior yet - exposes `IsWebClient()` marker and retains conservative `IsNativeClient() -> false` for now - keeps `__delegate` handle to support incremental method replacement/testing - Loaded `webJamClient.js` from `web/app/assets/javascripts/everywhere/everywhere.js` before `JK.initJamClient(...)` runs. - Expanded `WebJamClient` with first real overrides and internal state bookkeeping: - callback registrations (`SessionRegisterCallback`, `RegisterRecordingCallbacks`, `RegisterSessionJoinLeaveRequestCallBack`) - session lifecycle (`SessionPageEnter`, `SessionPageLeave`, `JoinSession`, `LeaveSession`) - connection refresh rate tracking (`SessionSetConnectionStatusRefreshRate`) - `GetWebClientDebugState()` for inspection during manual testing - Added minimal local-media bootstrap placeholders in `WebJamClient` (disabled by default): - optional `getUserMedia({audio:true})` auto-start behind flags only - tracks local media status in debug state; stops tracks on page/session leave transitions - **Instrumentation hooks implemented (disabled by default, ready for manual recording sessions):** - records jamClient calls/returns/throws + thenable resolve/reject events + session state transitions inside `WebJamClient` - clearly gated by: - `window.JK_WEB_CLIENT_RECORDING_ENABLED = true`, or - `localStorage['jk.webClient.recording'] = '1'`, or - `gon.web_client_recording_enabled` - recorder control methods: - `jamClient.WebClientRecorderEnable()` - `jamClient.WebClientRecorderDisable()` - `jamClient.WebClientRecorderClear()` - `jamClient.WebClientRecorderGetLog()` - recorder is intended as TEMP/manual capture support and is easy to disable/remove later. - Implemented native QtWebKit bridge call instrumentation in `web/app/assets/javascripts/utils.js` via adapter wrapping (disabled by default): - installs `window.jamClientAdapter` (does **not** swap/replace the special QtWebKit `window.jamClient` object) - native adapter wraps and forwards to the real Qt bridge object during `JK.initJamClient(...)` - exposes recorder controls on `JK` for manual native capture sessions - recorder controls on `JK`: - `JK.enableNativeJamClientRecording()` - `JK.disableNativeJamClientRecording()` - `JK.clearNativeJamClientRecording()` - `JK.getNativeJamClientRecording()` - enable auto-recording with: - `window.JK_NATIVE_CLIENT_RECORDING_ENABLED = true`, or - `localStorage['jk.nativeJamClient.recording'] = '1'`, or - `gon.native_client_recording_enabled` - caveat: wrapping depends on `Object.keys(window.jamClient)` exposing Qt bridge method keys (the app already had a dormant interceptor using this same mechanism, which is why this approach is viable). - Pivoted callsite strategy (per native client constraint): migrated frontend callsites to use `jamClientAdapter` instead of direct `jamClient` access so instrumentation does not require replacing the Qt bridge object. - Bulk-updated app frontend assets and inline views; remaining direct `jamClient` references are intentionally limited to `utils.js` bootstrap/adapter internals and object creation paths. ## Recommended Direction (Initial) - Treat `gon.isNativeClient` as "session-capable client mode" (includes real native + forced browser emulation). - Distinguish actual bridge availability by checking native `window.jamClient` presence and/or `jamClient.IsNativeClient()`. - Replace `FakeJamClient` installation in `JK.initJamClient(app)` with mode-aware behavior: - real native bridge present: use native `window.jamClient` - `gon.isNativeClient == true` but no native bridge: install `JK.WebJamClient` - normal browser pages (`gon.isNativeClient == false`): retain minimal fake/stub behavior as needed (or a reduced shim) - Introduce `await` incrementally on selected jamClient call chains; native sync returns remain compatible under `await`, but caller functions must become `async` (or return Promises / deferreds) as propagation proceeds. - Split migration strategy by file/runtime: - ES6 modules/wrappers can use `async/await` directly. - CoffeeScript/jQuery chains should use a Promise/Deferred normalization helper (e.g., `JK.whenJamClientResult(result)` / `Promise.resolve(result)` + bridge to `$.Deferred`) until those paths are moved out of CoffeeScript. - First practical spike implementation should add a parallel method instead of changing existing sync method in place: - keep `sessionUtils.SessionPageEnter()` (sync contract) - add `sessionUtils.SessionPageEnterDeferred()` (or similar) that normalizes sync/async jamClient returns into a `$.Deferred` - update one caller path (prefer `SessionStore`) behind a feature flag or mode check for `WebJamClient` - Alternative (proven but ugly): use backtick-embedded raw JS `async function` blocks in `SessionStore.js.coffee` for the spike. - Preferred longer-term direction for maintainability: migrate high-churn CoffeeScript session/store files to JS/ES modules instead of expanding the backtick pattern. - `SessionStore` migration is now done, so the next spike step should use native `async` in `SessionStore.es6` for the session-entry path. - Raw `async/await` remains unsafe for legacy QtWebKit if delivered directly; the new dual bundle switch is the mechanism to keep parser-incompatible syntax out of the legacy client. - Next validation needed before introducing `async` in `SessionStoreModern.es6`: confirm the `sprockets-es6` pipeline used here can parse/emit files containing `async/await` syntax (or at least pass through for modern browsers). - Validation results: - `babel-transpiler` (Babel 5.8 via `sprockets-es6`) successfully transpiles async/await and emits `regeneratorRuntime`-based code. - `SessionStoreModern.es6` passes syntax parse via `node --check` (on a temporary `.js` copy). - After additional edits, `SessionStoreModern.es6` still passes `node --check` and `Babel::Transpiler.transform(...)` (`BABEL_OK`). - Rails/Sprockets asset lookup validation (serial run) succeeded for both client bundles: - `MODERN_FOUND` - `LEGACY_FOUND` - `MODERN_HAS_REGEN` - `MODERN_HAS_SESSIONSTOREMODERN_CODE` - `MODERN_NO_BAD_UNDEFINED_ACTIONS` - `LEGACY_NO_SESSIONSTOREMODERN_REF` - `LEGACY_NO_BAD_UNDEFINED_ACTIONS` - Practical note: Rails boot in this repo starts websocket/EventMachine side effects, so validation commands should be run serially (parallel boots can conflict on port `6767` and produce false-negative noise). - Local JS seam simulation confirmed `JK.initJamClient(...)` selects `WebJamClient` when `gon.isNativeClient == true`, no native `window.jamClient` is present, and `JK.WebJamClient` is defined. - Local JS runtime simulation validated `WebJamClient` constructor, recorder controls, session state updates, and debug-state reporting. - `utils.js` native adapter instrumentation patch passes syntax parse (`node --check` on a temp copy). - Full Rails asset compile/runtime validation is still pending; direct `rails runner` asset probing booted websocket/EventMachine side effects in this repo and was not a clean validation path. - Follow-up debugging from legacy client load (console log capture): - Legacy QtWebKit was still receiving modern manifest code because `application.js` has `//= require_directory .`, and the newly-added top-level manifests (`client_modern`, `application_client_modern`, `react-components_modern`) were accidentally included in the legacy/default bundle. - Fixed by moving all new manifests under `web/app/assets/javascripts/client_bundles/` and updating layout/precompile entries to use `client_bundles/client_legacy` and `client_bundles/client_modern`. - Also fixed decaffeinate strict-mode top-level references in `SessionStoreModern.es6` (`this` -> `window/context` for action bindings and `SessionStore` export) so Babel `"use strict"` does not produce `undefined.JamTrackActions`. - Legacy parser/runtime follow-ups from hosted `console.log`: - `fb-error` / `Expected token 'in'` traced to Facebook SDK script (`connect.facebook.net/.../all.js`) using `for...of` (unsupported by old QtWebKit parser). - Initial selective skip in `everywhere.js` caused UI startup regressions because downstream code expected `facebookHelper.deferredLoginStatus()` to always return a Deferred. - Stabilized by moving disable behavior into `facebook_helper.js`: - FB SDK is now hard-disabled via `shouldEnableSdk() -> false` (temporary), while still creating/resolving `loginStatusDeferred` with `{status: 'disabled'}` so call sites remain functional. - `promptLogin()` now resolves immediately to disabled response when SDK disabled. - `deferredLoginStatus()` now guarantees a non-null resolved Deferred even before `initialize()`. - Reverted special init branching in `everywhere.js`; initialization flow is again normal (`facebookHelper.initialize(...)`) but helper itself no-ops SDK load safely. - Investigated session join 409 (`PG::UniqueViolation`) and found duplicate `participant_create` requests with identical payload/session id. - Likely cause in modern bundle: both `SessionStoreModern` and legacy `SessionStore` can be loaded (via `application_client_modern.js` `require_directory ..` pulling top-level `react-components.js`), leading to duplicate Reflux `SessionActions.joinSession` handlers. - Mitigation added: - `SessionStoreModern.es6` sets marker `window.__JK_USE_MODERN_SESSION_STORE__ = true` before creating store. - `SessionStore.js.coffee` now aliases to existing store when marker is set (`@SessionStore = if context.__JK_USE_MODERN_SESSION_STORE__ then context.SessionStore else Reflux.createStore(...)`) so legacy store does not register duplicate listeners in modern bundle. - Kept `require_directory ..` in modern application manifest to avoid regressing other root-level script dependencies. - Added REST-level single-flight guard in `JK.Rest.joinSession` (`web/app/assets/javascripts/jam_rest.js`) keyed by `session_id|client_id`. - Duplicate in-flight join calls now reuse the same jqXHR instead of issuing a second POST. - Added cleanup on `always()` to release key after completion. - Hardened implementation to avoid mutating caller `options` object (payload cloned before removing `session_id`). - Added high-signal debug instrumentation for duplicate join investigation: - `jam_rest.js`: join dedupe map moved from per-instance to global shared map (`JK.__pendingJoinSessionRequests`) because multiple `JK.Rest()` instances exist. - `jam_rest.js`: logs `[join-dedupe] create|reuse|release` with `dedupe_key`, `rest_instance_id`, and `trace_token`. - `SessionStore.js.coffee`: logs legacy store load/init/onJoinSession (`[session-store] legacy-*`) with marker/count context. - `SessionStoreModern.es6`: logs modern store load/init/onJoinSession (`[session-store] modern-*`) with load-count context. - Added extra join-source tracing because duplicate attempt source is still unknown: - `session_utils.js`: logs `[join-source] SessionUtils.joinSession` with call count + stack. - `CallbackStore.js.coffee`: logs `[join-source] generic-callback join_session` with payload + stack before dispatching `SessionActions.joinSession`. - This should distinguish UI-initiated join from native/generic-callback initiated join. - Root-cause analysis from console logs: duplicate join came from two SessionStore pipelines running (legacy + modern), not from REST payload issues. - Why prior marker was insufficient: load-order race when `react-components.js` is included indirectly (`require_directory ..`), where legacy SessionStore may load before modern marker is set. - Added load-order-independent modern mode flag: - `client_bundles/session_store_modern_mode.js` sets `window.__JK_SKIP_LEGACY_SESSION_STORE__ = true`. - `application_client_modern.js` now requires this flag before broad includes. - `SessionStore.js.coffee` now no-ops when either `__JK_SKIP_LEGACY_SESSION_STORE__` or `__JK_USE_MODERN_SESSION_STORE__` is set. - Implemented `debugging_console_spec.md` diversion: - Added env-gated gon flag: `gon.log_to_server` set from `ENV['LOG_TO_SERVER'] == '1'` in `client_helper.rb`. - Added new API endpoint `POST /api/debug_console_logs` (`ApiDebugConsoleLogsController#create`) that writes pretty JSON log dumps to `web/tmp/console-logs/YYYYMMDD-HHMMSS-