File size: 15,368 Bytes
c212805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
/**
 * Lock primitives retained for backwards-compatible imports. The auth client
 * coordinates refreshes itself (deduping in-instance callers onto a shared
 * in-flight promise) and lets the GoTrue server resolve cross-instance races,
 * so it does not invoke any primitive from this module. The functions still
 * work for direct callers that need a navigator.locks-backed or in-process
 * exclusive lock of their own.
 */

import { supportsLocalStorage } from './helpers'

/**
 * @deprecated Debug flag for `navigatorLock` / `processLock`. The auth
 * client ignores both, so this has no client-side effect.
 * @experimental
 */
export const internals = {
  /**
   * @experimental
   */
  debug: !!(
    globalThis &&
    supportsLocalStorage() &&
    globalThis.localStorage &&
    globalThis.localStorage.getItem('supabase.gotrue-js.locks.debug') === 'true'
  ),
}

/**
 * An error thrown when a lock cannot be acquired after some amount of time.
 *
 * @deprecated The auth client doesn't acquire locks around auth operations,
 * so this error never originates from `supabase.auth.*` calls. Direct callers
 * of `navigatorLock` / `processLock` still receive it on acquire timeout.
 */
export abstract class LockAcquireTimeoutError extends Error {
  public readonly isAcquireTimeout = true

  constructor(message: string) {
    super(message)
  }
}

/**
 * @deprecated The auth client doesn't call `navigator.locks`, so this error
 * never originates from `supabase.auth.*` calls. Direct callers of
 * `navigatorLock` still receive it on acquire timeout.
 */
export class NavigatorLockAcquireTimeoutError extends LockAcquireTimeoutError {}
/**
 * @deprecated The auth client doesn't run `processLock`, so this error
 * never originates from `supabase.auth.*` calls. Direct callers of
 * `processLock` still receive it on acquire timeout.
 */
export class ProcessLockAcquireTimeoutError extends LockAcquireTimeoutError {}

/**
 * Implements a global exclusive lock using the Navigator LockManager API. It
 * is available on all browsers released after 2022-03-15 with Safari being the
 * last one to release support. If the API is not available, this function will
 * throw. Make sure you check availablility before configuring {@link
 * GoTrueClient}.
 *
 * You can turn on debugging by setting the `supabase.gotrue-js.locks.debug`
 * local storage item to `true`.
 *
 * Internals:
 *
 * Since the LockManager API does not preserve stack traces for the async
 * function passed in the `request` method, a trick is used where acquiring the
 * lock releases a previously started promise to run the operation in the `fn`
 * function. The lock waits for that promise to finish (with or without error),
 * while the function will finally wait for the result anyway.
 *
 * @param name Name of the lock to be acquired.
 * @param acquireTimeout If negative, no timeout. If 0 an error is thrown if
 *                       the lock can't be acquired without waiting. If positive, the lock acquire
 *                       will time out after so many milliseconds. An error is
 *                       a timeout if it has `isAcquireTimeout` set to true.
 * @param fn The operation to run once the lock is acquired.
 *
 * @deprecated The auth client coordinates refreshes itself and the server
 * resolves concurrent refresh races, so passing `{ lock: navigatorLock }`
 * to it has no effect. You can safely drop the import from your client setup.
 */
export async function navigatorLock<R>(
  name: string,
  acquireTimeout: number,
  fn: () => Promise<R>
): Promise<R> {
  if (internals.debug) {
    console.log('@supabase/gotrue-js: navigatorLock: acquire lock', name, acquireTimeout)
  }

  const abortController = new globalThis.AbortController()

  let acquireTimeoutTimer: ReturnType<typeof setTimeout> | undefined

  if (acquireTimeout > 0) {
    acquireTimeoutTimer = setTimeout(() => {
      abortController.abort()
      if (internals.debug) {
        console.log('@supabase/gotrue-js: navigatorLock acquire timed out', name)
      }
    }, acquireTimeout)
  }

  // MDN article: https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request

  // Wrapping navigator.locks.request() with a plain Promise is done as some
  // libraries like zone.js patch the Promise object to track the execution
  // context. However, it appears that most browsers use an internal promise
  // implementation when using the navigator.locks.request() API causing them
  // to lose context and emit confusing log messages or break certain features.
  // This wrapping is believed to help zone.js track the execution context
  // better.
  await Promise.resolve()

  try {
    return await globalThis.navigator.locks.request(
      name,
      acquireTimeout === 0
        ? {
            mode: 'exclusive',
            ifAvailable: true,
          }
        : {
            mode: 'exclusive',
            signal: abortController.signal,
          },
      async (lock) => {
        if (lock) {
          // Lock acquired — cancel the acquire-timeout timer so it cannot fire
          // while fn() is running. Without this, a delayed timeout abort would
          // set signal.aborted = true even though we already hold the lock,
          // causing a subsequent steal to be misclassified as "our timeout
          // fired" and triggering a spurious steal-back cascade.
          clearTimeout(acquireTimeoutTimer)

          if (internals.debug) {
            console.log('@supabase/gotrue-js: navigatorLock: acquired', name, lock.name)
          }

          try {
            return await fn()
          } finally {
            if (internals.debug) {
              console.log('@supabase/gotrue-js: navigatorLock: released', name, lock.name)
            }
          }
        } else {
          if (acquireTimeout === 0) {
            if (internals.debug) {
              console.log('@supabase/gotrue-js: navigatorLock: not immediately available', name)
            }

            throw new NavigatorLockAcquireTimeoutError(
              `Acquiring an exclusive Navigator LockManager lock "${name}" immediately failed`
            )
          } else {
            if (internals.debug) {
              try {
                const result = await globalThis.navigator.locks.query()

                console.log(
                  '@supabase/gotrue-js: Navigator LockManager state',
                  JSON.stringify(result, null, '  ')
                )
              } catch (e) {
                console.warn(
                  '@supabase/gotrue-js: Error when querying Navigator LockManager state',
                  e
                )
              }
            }

            // Browser is not following the Navigator LockManager spec, it
            // returned a null lock when we didn't use ifAvailable. So we can
            // pretend the lock is acquired in the name of backward compatibility
            // and user experience and just run the function.
            console.warn(
              '@supabase/gotrue-js: Navigator LockManager returned a null lock when using #request without ifAvailable set to true, it appears this browser is not following the LockManager spec https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request'
            )

            clearTimeout(acquireTimeoutTimer)
            return await fn()
          }
        }
      }
    )
  } catch (e) {
    // Always clear the acquire timeout once the request settles, so it cannot
    // fire later and incorrectly abort/log after a rejection.
    if (acquireTimeout > 0) {
      clearTimeout(acquireTimeoutTimer)
    }

    // DOMException does not extend Error in Node.js, so use structural check
    if (
      e !== null &&
      typeof e === 'object' &&
      'name' in e &&
      e.name === 'AbortError' &&
      acquireTimeout > 0
    ) {
      if (abortController.signal.aborted) {
        // OUR timeout fired — the lock is genuinely orphaned. Steal it.
        //
        // The lock acquisition was aborted because the timeout fired while the
        // request was still pending. This typically means another lock holder is
        // not releasing the lock, possibly due to React Strict Mode's
        // double-mount/unmount behavior or a component unmounting mid-operation,
        // leaving an orphaned lock.
        //
        // Recovery: use { steal: true } to forcefully acquire the lock. Per the
        // Web Locks API spec, this releases any currently held lock with the same
        // name and grants the request immediately, preempting any queued requests.
        // The previous holder's callback continues running to completion but no
        // longer holds the lock for exclusion purposes.
        //
        // See: https://github.com/supabase/supabase/issues/42505
        if (internals.debug) {
          console.log(
            '@supabase/gotrue-js: navigatorLock: acquire timeout, recovering by stealing lock',
            name
          )
        }

        console.warn(
          `@supabase/gotrue-js: Lock "${name}" was not released within ${acquireTimeout}ms. ` +
            'This may indicate an orphaned lock from a component unmount (e.g., React Strict Mode). ' +
            'Forcefully acquiring the lock to recover.'
        )

        return await Promise.resolve().then(() =>
          globalThis.navigator.locks.request(
            name,
            {
              mode: 'exclusive',
              steal: true,
            },
            async (lock) => {
              if (lock) {
                if (internals.debug) {
                  console.log(
                    '@supabase/gotrue-js: navigatorLock: recovered (stolen)',
                    name,
                    lock.name
                  )
                }

                try {
                  return await fn()
                } finally {
                  if (internals.debug) {
                    console.log(
                      '@supabase/gotrue-js: navigatorLock: released (stolen)',
                      name,
                      lock.name
                    )
                  }
                }
              } else {
                // This should not happen with steal: true, but handle gracefully.
                console.warn(
                  '@supabase/gotrue-js: Navigator LockManager returned null lock even with steal: true'
                )
                return await fn()
              }
            }
          )
        )
      } else {
        // We HELD the lock but another request stole it from us.
        // Per the Web Locks spec, our fn() callback is still running as an
        // orphaned background task — do NOT steal back. Stealing back would
        // cause a cascade (A steals B, B steals A, ...) and run fn() a second
        // time concurrently, corrupting auth state.
        // Convert to a typed error so callers (e.g. _autoRefreshTokenTick)
        // can handle/filter it without it leaking to Sentry as a raw AbortError.
        if (internals.debug) {
          console.log(
            '@supabase/gotrue-js: navigatorLock: lock was stolen by another request',
            name
          )
        }

        throw new NavigatorLockAcquireTimeoutError(
          `Lock "${name}" was released because another request stole it`
        )
      }
    }

    throw e
  }
}

const PROCESS_LOCKS: { [name: string]: Promise<any> } = {}

/**
 * Implements a global exclusive lock that works only in the current process.
 * Useful for environments like React Native or other non-browser
 * single-process (i.e. no concept of "tabs") environments.
 *
 * Use {@link navigatorLock} in browser environments.
 *
 * @param name Name of the lock to be acquired.
 * @param acquireTimeout If negative, no timeout. If 0 an error is thrown if
 *                       the lock can't be acquired without waiting. If positive, the lock acquire
 *                       will time out after so many milliseconds. An error is
 *                       a timeout if it has `isAcquireTimeout` set to true.
 * @param fn The operation to run once the lock is acquired.
 *
 * @deprecated The auth client coordinates refreshes itself and the server
 * resolves concurrent refresh races, so passing `{ lock: processLock }`
 * to it has no effect. You can safely drop the import from your client setup.
 *
 * @example
 * ```ts
 * await processLock('migrate', 5000, async () => {
 *   await runMigration()
 * })
 * ```
 */
export async function processLock<R>(
  name: string,
  acquireTimeout: number,
  fn: () => Promise<R>
): Promise<R> {
  const previousOperation = PROCESS_LOCKS[name] ?? Promise.resolve()

  // Wrap previousOperation to handle errors without using .catch()
  // This avoids Firefox content script security errors
  const previousOperationHandled = (async () => {
    try {
      await previousOperation
      return null
    } catch (e) {
      // ignore error of previous operation that we're waiting to finish
      return null
    }
  })()

  const currentOperation = (async () => {
    let timeoutId: ReturnType<typeof setTimeout> | null = null

    try {
      // Wait for either previous operation or timeout
      const timeoutPromise =
        acquireTimeout >= 0
          ? new Promise((_, reject) => {
              timeoutId = setTimeout(() => {
                console.warn(
                  `@supabase/gotrue-js: Lock "${name}" acquisition timed out after ${acquireTimeout}ms. ` +
                    'This may be caused by another operation holding the lock. ' +
                    'Consider increasing lockAcquireTimeout or checking for stuck operations.'
                )

                reject(
                  new ProcessLockAcquireTimeoutError(
                    `Acquiring process lock with name "${name}" timed out`
                  )
                )
              }, acquireTimeout)
            })
          : null

      await Promise.race([previousOperationHandled, timeoutPromise].filter((x) => x))

      // If we reach here, previousOperationHandled won the race
      // Clear the timeout to prevent false warnings
      if (timeoutId !== null) {
        clearTimeout(timeoutId)
      }
    } catch (e) {
      // Clear the timeout on error path as well
      if (timeoutId !== null) {
        clearTimeout(timeoutId)
      }

      // Re-throw timeout errors, ignore others
      if (e instanceof LockAcquireTimeoutError) {
        throw e
      }
      // Fall through to run fn() - previous operation finished with error
    }

    // Previous operations finished and we didn't get a race on the acquire
    // timeout, so the current operation can finally start
    return await fn()
  })()

  PROCESS_LOCKS[name] = (async () => {
    try {
      return await currentOperation
    } catch (e) {
      if (e instanceof LockAcquireTimeoutError) {
        // if the current operation timed out, it doesn't mean that the previous
        // operation finished, so we need continue waiting for it to finish
        try {
          await previousOperation
        } catch (prevError) {
          // Ignore previous operation errors
        }
        return null
      }

      throw e
    }
  })()

  // finally wait for the current operation to finish successfully, with an
  // error or with an acquire timeout error
  return await currentOperation
}