Bug: /signin page stuck in loading state due to localStorage lock deadlock in refresh-token.ts #21570

Closed
opened 2026-02-21 20:13:15 -05:00 by yindo · 3 comments
Owner

Originally created by @lyzno1 on GitHub (Jan 11, 2026).

Description

The /signin page occasionally gets stuck in a loading state, with the refresh-token API never being triggered. This is an intermittent bug that is difficult to reproduce consistently.

Observed Behavior

  • The /signin page displays a loading spinner indefinitely
  • Network panel shows /account/profile returning 400/401, but refresh-token API is never called
  • The issue persists even after page refresh

Root Cause Analysis

After deep code analysis, the root cause is a deadlock mechanism in web/service/refresh-token.ts:

Problem 1: Conditional check in releaseRefreshLock (line 74-81)

function releaseRefreshLock() {
  if (isRefreshing) {  // ⚠️ This condition is the problem
    isRefreshing = false
    globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
    globalThis.localStorage.removeItem('last_refresh_time')
    globalThis.removeEventListener('beforeunload', releaseRefreshLock)
  }
}

The lock is only released when isRefreshing === true. However, when a tab enters the waitUntilTokenRefreshed branch (waiting for another tab), its isRefreshing remains false, so the lock is never cleaned up after timeout.

Problem 2: waitUntilTokenRefreshed has no built-in timeout (line 7-22)

function waitUntilTokenRefreshed() {
  return new Promise<void>((resolve) => {
    function _check() {
      const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
      if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
        setTimeout(() => { _check() }, 1000)  // Infinite loop if lock persists
      } else {
        resolve()
      }
    }
    _check()
  })
}

Deadlock Scenario

  1. Tab A starts refresh token, sets localStorage['is_other_tab_refreshing'] = '1'
  2. Tab A crashes/closes/navigates away before completing (beforeunload may not fire)
  3. Lock remains in localStorage
  4. User opens Tab B, visits /signin
  5. Tab B detects lock exists and is within 100s timeout window
  6. Tab B enters waitUntilTokenRefreshed() - refresh-token API is never called
  7. After 100s timeout, releaseRefreshLock() is called
  8. But isRefreshing === false, so lock is not cleaned up
  9. User refreshes page → enters waiting again → vicious cycle

Steps to Reproduce

  1. Open /signin in Tab A
  2. Trigger a 401 response (e.g., expired token)
  3. Close Tab A before refresh-token completes
  4. Open /signin in Tab B within 100 seconds
  5. Page gets stuck in loading state

Verification

Check localStorage in browser console:

console.log('Lock:', localStorage.getItem('is_other_tab_refreshing'))
console.log('Last refresh time:', localStorage.getItem('last_refresh_time'))

If lock is '1', manually clear it:

localStorage.removeItem('is_other_tab_refreshing')
localStorage.removeItem('last_refresh_time')

Suggested Fix

Option 1: Remove conditional check in releaseRefreshLock (Recommended)

function releaseRefreshLock() {
  // Remove the conditional check - always clean up
  isRefreshing = false
  globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
  globalThis.localStorage.removeItem('last_refresh_time')
  globalThis.removeEventListener('beforeunload', releaseRefreshLock)
}

Option 2: Add timeout to waitUntilTokenRefreshed

function waitUntilTokenRefreshed(maxWaitTime: number = 30000) {
  return new Promise<void>((resolve) => {
    const startTime = Date.now()
    function _check() {
      if (Date.now() - startTime > maxWaitTime) {
        // Force release lock on timeout
        globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
        globalThis.localStorage.removeItem('last_refresh_time')
        resolve()
        return
      }
      // ... existing logic ...
    }
    _check()
  })
}

Option 3: Use BroadcastChannel instead of localStorage

Replace the localStorage lock mechanism with BroadcastChannel API for more reliable cross-tab communication.

Environment

  • Edition: Cloud (SaaS)
  • Affected file: web/service/refresh-token.ts

Related Code

  • web/service/refresh-token.ts - Lock mechanism
  • web/service/base.ts - 401 handling and refresh trigger (line 599)
  • web/app/signin/normal-form.tsx - Loading state control
  • web/service/use-common.ts - useIsLogin hook
Originally created by @lyzno1 on GitHub (Jan 11, 2026). ## Description The `/signin` page occasionally gets stuck in a loading state, with the `refresh-token` API never being triggered. This is an intermittent bug that is difficult to reproduce consistently. ## Observed Behavior - The `/signin` page displays a loading spinner indefinitely - Network panel shows `/account/profile` returning 400/401, but `refresh-token` API is **never called** - The issue persists even after page refresh ## Root Cause Analysis After deep code analysis, the root cause is a **deadlock mechanism** in `web/service/refresh-token.ts`: ### Problem 1: Conditional check in `releaseRefreshLock` (line 74-81) ```typescript function releaseRefreshLock() { if (isRefreshing) { // ⚠️ This condition is the problem isRefreshing = false globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) globalThis.localStorage.removeItem('last_refresh_time') globalThis.removeEventListener('beforeunload', releaseRefreshLock) } } ``` The lock is **only released when `isRefreshing === true`**. However, when a tab enters the `waitUntilTokenRefreshed` branch (waiting for another tab), its `isRefreshing` remains `false`, so the lock is never cleaned up after timeout. ### Problem 2: `waitUntilTokenRefreshed` has no built-in timeout (line 7-22) ```typescript function waitUntilTokenRefreshed() { return new Promise<void>((resolve) => { function _check() { const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY) if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) { setTimeout(() => { _check() }, 1000) // Infinite loop if lock persists } else { resolve() } } _check() }) } ``` ### Deadlock Scenario 1. **Tab A** starts refresh token, sets `localStorage['is_other_tab_refreshing'] = '1'` 2. **Tab A** crashes/closes/navigates away before completing (beforeunload may not fire) 3. Lock remains in localStorage 4. User opens **Tab B**, visits `/signin` 5. **Tab B** detects lock exists and is within 100s timeout window 6. **Tab B** enters `waitUntilTokenRefreshed()` - **refresh-token API is never called** 7. After 100s timeout, `releaseRefreshLock()` is called 8. But `isRefreshing === false`, so **lock is not cleaned up** 9. User refreshes page → enters waiting again → **vicious cycle** ## Steps to Reproduce 1. Open `/signin` in Tab A 2. Trigger a 401 response (e.g., expired token) 3. Close Tab A before refresh-token completes 4. Open `/signin` in Tab B within 100 seconds 5. Page gets stuck in loading state ### Verification Check localStorage in browser console: ```javascript console.log('Lock:', localStorage.getItem('is_other_tab_refreshing')) console.log('Last refresh time:', localStorage.getItem('last_refresh_time')) ``` If lock is `'1'`, manually clear it: ```javascript localStorage.removeItem('is_other_tab_refreshing') localStorage.removeItem('last_refresh_time') ``` ## Suggested Fix ### Option 1: Remove conditional check in `releaseRefreshLock` (Recommended) ```typescript function releaseRefreshLock() { // Remove the conditional check - always clean up isRefreshing = false globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) globalThis.localStorage.removeItem('last_refresh_time') globalThis.removeEventListener('beforeunload', releaseRefreshLock) } ``` ### Option 2: Add timeout to `waitUntilTokenRefreshed` ```typescript function waitUntilTokenRefreshed(maxWaitTime: number = 30000) { return new Promise<void>((resolve) => { const startTime = Date.now() function _check() { if (Date.now() - startTime > maxWaitTime) { // Force release lock on timeout globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) globalThis.localStorage.removeItem('last_refresh_time') resolve() return } // ... existing logic ... } _check() }) } ``` ### Option 3: Use BroadcastChannel instead of localStorage Replace the localStorage lock mechanism with `BroadcastChannel` API for more reliable cross-tab communication. ## Environment - Edition: Cloud (SaaS) - Affected file: `web/service/refresh-token.ts` ## Related Code - `web/service/refresh-token.ts` - Lock mechanism - `web/service/base.ts` - 401 handling and refresh trigger (line 599) - `web/app/signin/normal-form.tsx` - Loading state control - `web/service/use-common.ts` - `useIsLogin` hook
yindo added the 🐞 bugcloud labels 2026-02-21 20:13:15 -05:00
yindo closed this issue 2026-02-21 20:13:15 -05:00
Author
Owner

@lyzno1 commented on GitHub (Jan 11, 2026):

@dosu give some context.

@lyzno1 commented on GitHub (Jan 11, 2026): @dosu give some context.
Author
Owner

@dosubot[bot] commented on GitHub (Jan 11, 2026):

Here's the context: The /signin page can get stuck in a loading state due to a deadlock in the refresh-token lock mechanism. This happens when a tab starts a token refresh, sets a lock in localStorage, and then closes or crashes before finishing. The lock isn't cleaned up because the cleanup function only runs if a flag is set, which isn't the case for tabs just waiting for the refresh. As a result, new tabs see the lock, wait forever, and the refresh-token API never gets called—leading to an infinite loading spinner even after refreshing the page.

Manual cleanup of localStorage can temporarily fix it. The recommended fixes are to always release the lock regardless of the flag, add a timeout to the waiting logic, or switch to using BroadcastChannel for cross-tab communication.

There are also related signin page bugs where the loading spinner gets stuck due to how loading state and error handling are implemented. Recent changes addressed these by removing the logged_in flag from the loading condition and treating any error as "not logged in" to prevent infinite loading states (PR 30675, Issue 30674).

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

@dosubot[bot] commented on GitHub (Jan 11, 2026): <!-- Answer --> Here's the context: The /signin page can get stuck in a loading state due to a deadlock in the refresh-token lock mechanism. This happens when a tab starts a token refresh, sets a lock in localStorage, and then closes or crashes before finishing. The lock isn't cleaned up because the cleanup function only runs if a flag is set, which isn't the case for tabs just waiting for the refresh. As a result, new tabs see the lock, wait forever, and the refresh-token API never gets called—leading to an infinite loading spinner even after refreshing the page. Manual cleanup of localStorage can temporarily fix it. The recommended fixes are to always release the lock regardless of the flag, add a timeout to the waiting logic, or switch to using BroadcastChannel for cross-tab communication. There are also related signin page bugs where the loading spinner gets stuck due to how loading state and error handling are implemented. Recent changes addressed these by removing the logged_in flag from the loading condition and treating any error as "not logged in" to prevent infinite loading states ([PR 30675](https://github.com/langgenius/dify/pull/30675), [Issue 30674](https://github.com/langgenius/dify/issues/30674)). <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/6dfcae81-14ce-4c4c-9c84-b8a33bfe16f8?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/6dfcae81-14ce-4c4c-9c84-b8a33bfe16f8?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/6dfcae81-14ce-4c4c-9c84-b8a33bfe16f8?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/6dfcae81-14ce-4c4c-9c84-b8a33bfe16f8?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/6dfcae81-14ce-4c4c-9c84-b8a33bfe16f8?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/6dfcae81-14ce-4c4c-9c84-b8a33bfe16f8?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/6dfcae81-14ce-4c4c-9c84-b8a33bfe16f8?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/6088a4f4-073a-43f3-b3d4-4f06fb0c05a0/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Here%27s%20the%20context%3A%20The%20/signin%20page%20can%20get%20stuck%20in%20a%20loading%20state%20due%20to%20a%20deadlock%20in%20the%20refresh-token%20lock%20mechanism.%20This%20happens%20when%20a%20tab%20starts%20a%20token%20refresh%2C%20sets%20a%20lock%20in%20localStorage%2C%20and%20then%20closes%20or%20crashes%20before%20finishing.%20The%20lock%20isn%27t%20cleaned%20up%20because%20the%20cleanup%20function%20only%20runs%20if%20a%20flag%20is%20set%2C%20which%20isn%27t%20the%20case%20for%20tabs%20just%20waiting%20for%20the%20refresh.%20As%20a%20result%2C%20new%20tabs%20see%20the%20lock%2C%20wait%20forever%2C%20and%20the%20refresh-token%20API%20never%20gets%20called%E2%80%94leading%20to%20an%20infinite%20loading%20spinner%20even%20after%20refreshing%20the%20page.%0A%0AManual%20cleanup%20of%20localStorage%20can%20temporarily%20fix%20it.%20The%20recommended%20fixes%20are%20to%20always%20release%20the%20lock%20regardless%20of%20the%20flag%2C%20add%20a%20timeout%20to%20the%20waiting%20logic%2C%20or%20switch%20to%20using%20BroadcastChannel%20for%20cross-tab%20communication.%0A%0AThere%20are%20also%20related%20signin%20page%20bugs%20where%20the%20loading%20spinner%20gets%20stuck%20due%20to%20how%20loading%20state%20and%20error%20handling%20are%20implemented.%20Recent%20changes%20addressed%20these%20by%20removing%20the%20logged_in%20flag%20from%20the%20loading%20condition%20and%20treating%20any%20error%20as%20%22not%20logged%20in%22%20to%20prevent%20infinite%20loading%20states%20%28%5BPR%2030675%5D%28https%3A//github.com/langgenius/dify/pull/30675%29%2C%20%5BIssue%2030674%5D%28https%3A//github.com/langgenius/dify/issues/30674%29%29.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/langgenius/dify/issues/30827)
Author
Owner
@lyzno1 commented on GitHub (Jan 11, 2026): https://github.com/user-attachments/assets/e679bdc6-29ac-4d3e-ab84-a1eb77b0d31e
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langgenius/dify#21570