Skip to content

fix(e2e/oauth): gate Redux store exposure and fix loopback listener timeout race#2670

Merged
graycyrus merged 6 commits into
tinyhumansai:mainfrom
YellowSnnowmann:fix/oauth-listener-timeout-race-and-e2e-store-gating
May 27, 2026
Merged

fix(e2e/oauth): gate Redux store exposure and fix loopback listener timeout race#2670
graycyrus merged 6 commits into
tinyhumansai:mainfrom
YellowSnnowmann:fix/oauth-listener-timeout-race-and-e2e-store-gating

Conversation

@YellowSnnowmann
Copy link
Copy Markdown
Contributor

@YellowSnnowmann YellowSnnowmann commented May 26, 2026

Summary

  • Fixes a timeout-vs-listener-registration race in startLoopbackOauthListener() that could leave a stale listener active after promise rejection.
  • Ensures late listener registration is immediately torn down if timeout already fired, and clears stale activeUnlisten references safely.
  • Gates window.OPENHUMAN_STORE exposure to dev/E2E-only builds instead of exposing it unconditionally in renderer runtime.
  • Uses centralized config (E2E_RESTART_APP_AS_RELOAD) instead of direct import.meta.env checks in loopback OAuth listener code.

Problem

  • The Redux store handle was being attached to window whenever window existed, which created unnecessary production attack surface.
  • Loopback OAuth callback setup had an async race: timeout could reject before listen() resolved, then the returned unlisten function could still be stored globally, leaving a leaked/armed listener.
  • These issues affect security posture (store exposure) and correctness/stability (stale OAuth listener lifecycle).

Solution

  • Added IS_DEV || E2E_RESTART_APP_AS_RELOAD gating around global store exposure so production bundles do not publish the Redux store handle.

  • Replaced direct env-flag checks with E2E_RESTART_APP_AS_RELOAD from config.ts to follow centralized config access conventions.

  • Introduced a timedOut guard in loopback OAuth listener setup:timeout marks state and rejects once;

  • callback path no-ops after timeout;

  • post-timeout listen().then(fn) path immediately calls fn() instead of storing it;

  • activeUnlisten is cleared only when the same handle is removed.

  • Tradeoff: slightly more listener lifecycle code, but deterministic cleanup and safer behavior under timing edge cases.

Submission Checklist

  • If a section does not apply to this change, mark the item as N/A with a one-line reason. Do not delete items.
  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80% — changed lines (Vitest + cargo-llvm-cov merged via diff-cover) meet the gate enforced by .github/workflows/coverage.yml. Run pnpm test:coverage and pnpm test:rust locally; PRs below 80% on changed lines will not merge.
  • Coverage matrix updated — added/removed/renamed feature rows in docs/TEST-COVERAGE-MATRIX.md reflect this change (or N/A: behaviour-only change)
  • All affected feature IDs from the matrix are listed in the PR description under ## Related
  • No new external network dependencies introduced (mock backend used per Testing Strategy)
  • Manual smoke checklist updated if this touches release-cut surfaces (docs/RELEASE-MANUAL-SMOKE.md)
  • Linked issue closed via Closes #NNN in the ## Related section

Impact

  • Runtime/platform: frontend renderer behavior used in desktop Tauri app; impacts OAuth loopback flow handling and E2E/dev test hooks.
  • Security: reduces production exposure of global Redux store handle.
  • Compatibility/migration: no config migration required; no API contract changes.
  • Performance: negligible; extra branching only in listener lifecycle paths.

Related

  • N/A: No existing tracking issue; this PR hardens OAuth listener lifecycle and E2E/dev store exposure.

Summary by CodeRabbit

  • Bug Fixes

    • Improved OAuth listener timeout handling to prevent resource leaks, avoid double-cleanup, and ignore callbacks arriving after timeouts.
  • Chores

    • Strengthened unit and E2E tests for authentication edge cases and teardown behavior.
    • Reduced debug surface in production by restricting when internal runtime handles are exposed in the browser.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 473c8fa1-af10-4f2a-aaa3-4adf4d5a3420

📥 Commits

Reviewing files that changed from the base of the PR and between 7ed1f23 and ed546a4.

📒 Files selected for processing (1)
  • app/src/utils/__tests__/loopbackOauthListener.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/utils/tests/loopbackOauthListener.test.ts

📝 Walkthrough

Walkthrough

This PR centralizes an E2E restart flag, uses it to gate Redux store window exposure, fixes a race in the loopback OAuth listener timeout/registration flow, and strengthens related unit and E2E tests.

Changes

E2E Gating and OAuth Timeout Fix

Layer / File(s) Summary
E2E Flag Extraction and Import
app/src/store/index.ts, app/src/utils/loopbackOauthListener.ts
E2E_RESTART_APP_AS_RELOAD is imported from ./config in both store and loopback OAuth listener to centralize feature flag sourcing.
Store Runtime Exposure Gating
app/src/store/index.ts
Redux store window exposure guard now requires IS_DEV OR E2E_RESTART_APP_AS_RELOAD, restricting production access.
Loopback OAuth Timeout Race Handling
app/src/utils/loopbackOauthListener.ts
Race condition in awaitCallback is fixed by tracking timedOut, guarding listener teardown/arming so late-registered unlisten handles are immediately disposed, and updating the E2E hook gate to use the imported flag.
Timeout Behavior Test Strengthening
app/src/utils/__tests__/loopbackOauthListener.test.ts
Timeout tests updated with additional mock sequencing; new tests verify late listen() registration is torn down immediately and late callback events after timeout are ignored without extra unlisten calls.
E2E Build-Hook Test Refactoring
app/src/utils/__tests__/loopbackOauthListener.test.ts
Switch from env stubbing to direct config module mocking (vi.doMock / vi.doUnmock) for E2E_RESTART_APP_AS_RELOAD, with updated teardown and assertions for both flag states.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • tinyhumansai/openhuman#2511: Both PRs directly modify the loopback OAuth listener implementation in app/src/utils/loopbackOauthListener.ts; this PR refines timeout/race cleanup logic and E2E gating.

Suggested labels

bug, working

Suggested reviewers

  • graycyrus

Poem

🐰 A timedOut flag hops in with grace,
Late listeners cleaned without a trace,
E2E gates gathered in a single place,
Tests now guard the timeout race,
Hooray — no double-unlisten chase! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures both main changes: gating Redux store exposure and fixing the loopback listener timeout race, matching the core fixes described in the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@YellowSnnowmann YellowSnnowmann marked this pull request as ready for review May 26, 2026 10:05
@YellowSnnowmann YellowSnnowmann requested a review from a team May 26, 2026 10:05
@YellowSnnowmann
Copy link
Copy Markdown
Contributor Author

@coderabbitai please review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch PR details
gh pr view 2670 --repo tinyhumansai/openhuman --json title,body,files,additions,deletions,commits

echo "=== PR DIFF ==="
gh pr diff 2670 --repo tinyhumansai/openhuman

Length of output: 12517



@YellowSnnowmann: Here is the review.


Review: fix(e2e/oauth): gate Redux store exposure and fix loopback listener timeout race

Summary

The PR correctly addresses two distinct problems: unconditional Redux-store window exposure and the timeout-vs-registration race in the loopback OAuth listener. The core logic is sound. A few items worth attention are noted below.


app/src/utils/loopbackOauthListener.ts — Race-fix analysis

The four paths through the new timedOut guard are all handled correctly:

Scenario Behaviour
Timer fires before listen() resolves (unlisten === null) timedOut = true, promise rejected; .then(fn) immediately calls fn() without storing — no leak ✅
Timer fires after listen() resolves (unlisten !== null) unlisten() called; activeUnlisten cleared only when identity matches ✅
Callback arrives before timeout if (timedOut) return guards a double-resolve ✅
listen() registration throws if (timedOut) return in .catch() prevents a double-rejection ✅

One subtle but non-blocking observation: in the post-timeout .then(fn) branch, fn() is correctly called to avoid a leak, but activeUnlisten is not explicitly nulled. Because fn was never assigned to activeUnlisten in this branch, the identity check activeUnlisten === fn would always be false and nulling it would be meaningless. However, if activeUnlisten still holds a stale reference from a prior call at this point, that stale handle is not affected. This is a pre-existing condition rather than a regression introduced here.


app/src/store/index.ts — Store gating

The IS_DEV || E2E_RESTART_APP_AS_RELOAD guard is the right approach. The updated comment is accurate and clear.


Missing: unit tests for the race condition

This is the main concern. The checklist states "Tests added or updated (happy path + at least one failure / edge case)", but the test diff only refactors the existing E2E-build-hook assertions (swapping vi.stubEnvvi.doMock). There are no new test cases covering the timedOut paths, specifically:

  • Timer fires while listen() is still pending → listener should not leak into activeUnlisten.
  • Timer fires after listen() resolves → activeUnlisten should be null afterward.
  • Callback arrives after timeout fires → promise must not resolve/reject a second time.

Without these, the race-fix coverage delta is likely below the 80 % gate enforced by .github/workflows/coverage.yml.


app/src/utils/__tests__/loopbackOauthListener.test.ts

The switch from vi.stubEnv + module reset to vi.doMock('../config', …) is the correct pattern here — since E2E_RESTART_APP_AS_RELOAD is a pre-evaluated constant, stubbing the env after the module graph is loaded has no effect. The mock-based approach is more robust.

One small observation: vi.doUnmock('../config') in the finally blocks removes the mock for future imports, but because vi.resetModules() is called at the top of each test before each dynamic import(), isolation between tests is maintained correctly.


## Related / linked issue

The PR description does not include a ## Related section or a Closes #NNN`` reference. Per the submission checklist, this should either reference a linked issue or be explicitly marked N/A.


Checklist items to address before merge

  1. Add unit tests for the three timedOut race scenarios described above to satisfy the 80 % diff-coverage requirement.
  2. Add or mark N/A the Closes #NNN`` / ## Related checklist item in the PR description.

@coderabbitai coderabbitai Bot added working A PR that is being worked on by the team. bug labels May 26, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes May 26, 2026
Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean PR, nice work.

What changed

File Area Change
app/src/store/index.ts Frontend Gates window.__OPENHUMAN_STORE__ behind IS_DEV || E2E_RESTART_APP_AS_RELOAD — no longer exposed in production bundles
app/src/utils/loopbackOauthListener.ts Frontend Introduces timedOut guard to close the timeout-vs-listen-registration race: late listen() resolutions are torn down immediately, late callbacks are no-oped, activeUnlisten is only cleared when the reference matches
app/src/utils/__tests__/loopbackOauthListener.test.ts Tests Covers all three race scenarios (late listen resolution, late callback, stale activeUnlisten after timeout) and migrates E2E build hook tests from vi.stubEnv to vi.doMock to match the centralized config pattern

Notes

  • The timedOut closure neatly handles all orderings of timeout-vs-listen-resolution-vs-callback, and the activeUnlisten === unlisten guard prevents a timed-out call from accidentally clearing a newer call's listener. Well-reasoned.
  • Config centralization (E2E_RESTART_APP_AS_RELOAD from config.ts instead of raw import.meta.env) follows the project convention — good.
  • Test coverage is thorough for the edge cases being fixed.

No issues found. Marking for manual approval.

graycyrus
graycyrus previously approved these changes May 27, 2026
@graycyrus graycyrus merged commit ee63a9d into tinyhumansai:main May 27, 2026
39 of 41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants