23 KiB
23 KiB
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.jamClientpresence,jamClient.IsNativeClient(), etc.)? - Where is
window.jamClientinitialized/replaced (real native bridge vs fake/intercepted client)? - Can
awaitbe introduced incrementally at callsites without breaking sync native behavior? - Which core session-flow call chains would require async propagation first?
Initial Plan
- Identify existing native/browser detection logic and init path(s)
- Find current jamClient interception/test hooks we can reuse for call-pattern recording
- Select one session-related call chain as the async spike candidate
- Document recommended compatibility pattern (native sync + web async via Promise-compatible calls)
Notes
- Key assumption to validate:
awaiton a synchronous native return value is safe (it should resolve immediately), allowing one code path if methods are called throughawaitin async functions. - Confirmed locally with Node runtime check:
awaiton plain values/objects resolves immediately.
Findings (2026-02-25)
- Native/browser mode detection already exists in multiple layers:
ClientHelper#is_native_client?setsgon.isNativeClient, including the forced mode via cookieact_as_native_client.homeScreen.jstogglesact_as_native_clientwith Ctrl+Shift+0 (keyCode == 48) whengon.allow_force_native_clientis enabled.- Runtime behavior also relies on
window.jamClientpresence andcontext.jamClient.IsNativeClient(). JK.initJamClient(app)inweb/app/assets/javascripts/utils.jsis the main installation point:- if no
window.jamClient, it installsJK.FakeJamClient. - this is the natural replacement point for a future
JK.WebJamClientin "pretend native/session-capable browser mode". - Existing interception hook for recording exists in
JK.initJamClient(app)(currently disabled underelse if(false)), which wrapswindow.jamClientmethods 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.coffeeand legacysession.jsboth callsessionUtils.SessionPageEnter()synchronously and immediately pass the result togearUtils.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/awaitsyntax in these files: - local compile checks with
coffee-scriptgem compileawaitas 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
awaitsyntax. - Confirmed from
origin/promised_based_api_interation: the team previously worked around this inSessionStore.js.coffeeby embedding raw JavaScript via CoffeeScript backticks (e.g.onJoinSession: `async function(sessionId) { ... }`andawait 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.coffeeproduced 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 existingsprockets-es6pipeline) rather than plain.js.
Changes Applied (2026-02-25)
- Migrated
web/app/assets/javascripts/react-components/stores/SessionStore.js.coffeetoweb/app/assets/javascripts/react-components/stores/SessionStore.es6usingdecaffeinate. - Removed the CoffeeScript source file for
SessionStore. - Basic syntax parse check passed by copying the
.es6file to a temporary.jsfilename and runningnode --check(Node does not natively parse.es6extension files directly). - Added parser-safe dual bundle delivery scaffolding for the
/clientlayout: web/app/assets/javascripts/client_legacy.jsweb/app/assets/javascripts/client_modern.jsweb/app/views/layouts/client.html.erbnow selects bundle server-side vialegacy_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.rbandweb/config/environments/test.rb - Split
SessionStoreloading 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.jsto loadSessionStoreModernexplicitly and avoidrequire_directory ./react-components/storespulling the legacy store - added
web/app/assets/javascripts/application_client_modern.js(copy ofapplication.jswithreact-components_modern) web/app/assets/javascripts/client_modern.jsnow requiresapplication_client_modern- Added
//= require babel/polyfilltoweb/app/assets/javascripts/application_client_modern.jsso Babel 5 async/await transpilation hasregeneratorRuntimeavailable in the modern client bundle. - Converted the modern-only session-entry path in
web/app/assets/javascripts/react-components/stores/SessionStoreModern.es6to nativeasync/await: onJoinSessiononJoinSessionDone(new helper extracted from the nested deferred chain)awaitnow wraps mixed sync/thenable results (rest, jQuery Deferreds, and syncSessionPageEnter()values) in the modern path only.- Continued the modern-only async conversion in
SessionStoreModern.es6: waitForSessionPageEnterDone->asyncjoinSession->async- Preserved
this.joinDeferredas the original jQuery Deferred/XHR object so existing.state()checks and.done()listeners still work. - Fixed
audio_latencyretrieval to awaitFTUEGetExpectedLatency()before reading.latency. - Added mode-aware browser jamClient installation seam in
web/app/assets/javascripts/utils.js: - if no native
window.jamClientandgon.isNativeClient && JK.WebJamClientexists, installWebJamClient - otherwise fall back to
FakeJamClient(current behavior) - Added initial
web/app/assets/javascripts/webJamClient.jsshim: - delegates to
FakeJamClient(method/property passthrough) so the seam is exercised without changing behavior yet - exposes
IsWebClient()marker and retains conservativeIsNativeClient() -> falsefor now - keeps
__delegatehandle to support incremental method replacement/testing - Loaded
webJamClient.jsfromweb/app/assets/javascripts/everywhere/everywhere.jsbeforeJK.initJamClient(...)runs. - Expanded
WebJamClientwith 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, orlocalStorage['jk.webClient.recording'] = '1', orgon.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.jsvia adapter wrapping (disabled by default): - installs
window.jamClientAdapter(does not swap/replace the special QtWebKitwindow.jamClientobject) - native adapter wraps and forwards to the real Qt bridge object during
JK.initJamClient(...) - exposes recorder controls on
JKfor 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, orlocalStorage['jk.nativeJamClient.recording'] = '1', orgon.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
jamClientAdapterinstead of directjamClientaccess so instrumentation does not require replacing the Qt bridge object. - Bulk-updated app frontend assets and inline views; remaining direct
jamClientreferences are intentionally limited toutils.jsbootstrap/adapter internals and object creation paths.
Recommended Direction (Initial)
- Treat
gon.isNativeClientas "session-capable client mode" (includes real native + forced browser emulation). - Distinguish actual bridge availability by checking native
window.jamClientpresence and/orjamClient.IsNativeClient(). - Replace
FakeJamClientinstallation inJK.initJamClient(app)with mode-aware behavior: - real native bridge present: use native
window.jamClient gon.isNativeClient == truebut no native bridge: installJK.WebJamClient- normal browser pages (
gon.isNativeClient == false): retain minimal fake/stub behavior as needed (or a reduced shim) - Introduce
awaitincrementally on selected jamClient call chains; native sync returns remain compatible underawait, but caller functions must becomeasync(or return Promises / deferreds) as propagation proceeds. - Split migration strategy by file/runtime:
- ES6 modules/wrappers can use
async/awaitdirectly. - 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 forWebJamClient - Alternative (proven but ugly): use backtick-embedded raw JS
async functionblocks inSessionStore.js.coffeefor 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.
SessionStoremigration is now done, so the next spike step should use nativeasyncinSessionStore.es6for the session-entry path.- Raw
async/awaitremains 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
asyncinSessionStoreModern.es6: confirm thesprockets-es6pipeline used here can parse/emit files containingasync/awaitsyntax (or at least pass through for modern browsers). - Validation results:
babel-transpiler(Babel 5.8 viasprockets-es6) successfully transpiles async/await and emitsregeneratorRuntime-based code.SessionStoreModern.es6passes syntax parse vianode --check(on a temporary.jscopy).- After additional edits,
SessionStoreModern.es6still passesnode --checkandBabel::Transpiler.transform(...)(BABEL_OK). - Rails/Sprockets asset lookup validation (serial run) succeeded for both client bundles:
MODERN_FOUNDLEGACY_FOUNDMODERN_HAS_REGENMODERN_HAS_SESSIONSTOREMODERN_CODEMODERN_NO_BAD_UNDEFINED_ACTIONSLEGACY_NO_SESSIONSTOREMODERN_REFLEGACY_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
6767and produce false-negative noise). - Local JS seam simulation confirmed
JK.initJamClient(...)selectsWebJamClientwhengon.isNativeClient == true, no nativewindow.jamClientis present, andJK.WebJamClientis defined. - Local JS runtime simulation validated
WebJamClientconstructor, recorder controls, session state updates, and debug-state reporting. utils.jsnative adapter instrumentation patch passes syntax parse (node --checkon a temp copy).- Full Rails asset compile/runtime validation is still pending; direct
rails runnerasset 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.jshas//= 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 useclient_bundles/client_legacyandclient_bundles/client_modern. - Also fixed decaffeinate strict-mode top-level references in
SessionStoreModern.es6(this->window/contextfor action bindings andSessionStoreexport) so Babel"use strict"does not produceundefined.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) usingfor...of(unsupported by old QtWebKit parser).- Initial selective skip in
everywhere.jscaused UI startup regressions because downstream code expectedfacebookHelper.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/resolvingloginStatusDeferredwith{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 beforeinitialize().- 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 duplicateparticipant_createrequests with identical payload/session id. - Likely cause in modern bundle: both
SessionStoreModernand legacySessionStorecan be loaded (viaapplication_client_modern.jsrequire_directory ..pulling top-levelreact-components.js), leading to duplicate RefluxSessionActions.joinSessionhandlers. - Mitigation added:
SessionStoreModern.es6sets markerwindow.__JK_USE_MODERN_SESSION_STORE__ = truebefore creating store.SessionStore.js.coffeenow 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 bysession_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
optionsobject (payload cloned before removingsession_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 multipleJK.Rest()instances exist.jam_rest.js: logs[join-dedupe] create|reuse|releasewithdedupe_key,rest_instance_id, andtrace_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.joinSessionwith call count + stack.CallbackStore.js.coffee: logs[join-source] generic-callback join_sessionwith payload + stack before dispatchingSessionActions.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.jsis 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.jssetswindow.__JK_SKIP_LEGACY_SESSION_STORE__ = true.application_client_modern.jsnow requires this flag before broad includes.SessionStore.js.coffeenow no-ops when either__JK_SKIP_LEGACY_SESSION_STORE__or__JK_USE_MODERN_SESSION_STORE__is set.- Implemented
debugging_console_spec.mddiversion: - Added env-gated gon flag:
gon.log_to_serverset fromENV['LOG_TO_SERVER'] == '1'inclient_helper.rb. - Added new API endpoint
POST /api/debug_console_logs(ApiDebugConsoleLogsController#create) that writes pretty JSON log dumps toweb/tmp/console-logs/YYYYMMDD-HHMMSS-<label>.log. - Label sanitization implemented per spec: trim + remove all whitespace/newlines; defaults to
logwhen blank. - Added client-side in-memory collector
debug_log_collector.js(enabled only whengon.log_to_server): - captures REST activity by wrapping
jQuery.ajax(rest.request,rest.response,rest.error). - exposes
JK.DebugLogCollector.push/getBuffer/clear/promptAndUpload. - prompts user on
EVENTS.SESSION_ENDEDfor label viaprompt(...), uploads to new API, and alerts result. - Integrated jam-bridge and websocket capture into collector:
utils.jsnative jamClient adapterpushRecord(...)now forwards events into collector.JamServer.jslogs websocket send/receive payloads into collector.- Added manifests so collector loads in both bundles:
application.jsclient_bundles/application_client_modern.js- Reduced debug UI noise per request:
- Removed post-upload
window.alertpopups fromdebug_log_collector.js(prompt remains; outcomes now recorded in buffer asdebug-log-collector.uploaded/debug-log-collector.upload-error). - Removed temporary native jamClient console mirroring in
utils.js; native bridge instrumentation remains buffer-based. - Removed
join-dedupeconsole/logger output fromjam_rest.js; join dedupe telemetry now goes to debug buffer only (join-dedupe.create|reuse|release). - Fixed debug log file double-encoding in
ApiDebugConsoleLogsController. logspayload is now normalized recursively (ActionController::Parameters/arrays/hashes) into plain JSON-friendly structures before writing.- Added safe best-effort JSON string parsing for string values that are valid JSON.
- Added REST request/response correlation IDs in debug collector.
rest.request,rest.response, andrest.errornow includerequest_id(rest-<seq>), and response/error includeduration_ms.- Continued root-cause instrumentation for duplicate join attempts, with all new signals routed into
DebugLogCollectorbuffer: SessionActions.joinSessionwrapper now captures both direct call and.trigger(...)invocations (join-source.session-action) with stack.session.js(legacy SessionScreen path) now logs:join-source.session-screen.afterShowjoin-source.session-screen.afterCurrentUserLoadedjoin-source.session-screen.sessionModel.joinSession
sessionModel.jsnow logs:join-source.session-model.joinSessionjoin-source.session-model.joinSessionRest
SessionStore.js.coffeenow logs to buffer:join-source.session-store.legacy-loadjoin-source.session-store.legacy-initjoin-source.session-store.legacy-onJoinSession
SessionStoreModern.es6now logs to buffer:join-source.session-store.modern-loadjoin-source.session-store.modern-init
CallbackStore.js.coffeenow records generic callback joins in buffer (join-source.generic-callback.join_session) and removed console spam.session_utils.jsjoin-source trace now writes to buffer (join-source.session-utils.joinSession) while downgrading console severity.- Added a hard gate in
SessionStoreModern.es6so the modern Reflux store only installs whenwindow.__JK_SKIP_LEGACY_SESSION_STORE__is true. - In legacy bundle loads, modern store now emits
join-source.session-store.modern-skippedand does not register listeners; this prevents hidden secondonJoinSessionlisteners from racing the legacy store. - Added targeted leave-source instrumentation to diagnose immediate post-join session departure:
SessionActions.leaveSessionnow emitsleave-source.session-action(call + trigger wrappers, with stack) inreact-components/actions/SessionActions.js.coffee.SessionStore.js.coffee#onLeaveSessionnow emitsleave-source.session-store.legacy-onLeaveSession.SessionStoreModern.es6#onLeaveSessionnow emitsleave-source.session-store.modern-onLeaveSession.- This is intended to identify the exact caller path behind immediate
DELETE /api/participants/:client_idafter successful join.