Skip to content

Commit 5b4e383

Browse files
fix: macOS download modal hangs 10s on fast connections (#452)
* fix: stop trusting navigator.connection.downlink for macOS download modal hold * fix: test * chore: remove PR.md not needed
1 parent 3bf8d30 commit 5b4e383

2 files changed

Lines changed: 61 additions & 71 deletions

File tree

src/modules/streamOrFallback.test.ts

Lines changed: 40 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const buildHeadResponse = (contentLength: string | null, ok = true): Response =>
2121
headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? contentLength : null) }
2222
}) as unknown as Response
2323

24+
// We expose this helper to prove the new implementation *ignores* the API
25+
// even when Chrome reports a value. Pre-fix, this dial drove the estimate;
26+
// post-fix, it must have zero effect on the result.
2427
const setNavigatorConnection = (downlink: number | undefined): void => {
2528
Object.defineProperty(window.navigator, 'connection', {
2629
configurable: true,
@@ -45,45 +48,63 @@ describe('estimateDownloadHoldMs', () => {
4548
clearNavigatorConnection()
4649
})
4750

48-
describe('when the HEAD request returns a Content-Length and the browser exposes downlink', () => {
51+
describe('when the HEAD request returns a Content-Length', () => {
4952
// ~4.4 MB DMG — the actual size of the current Decentraland macOS
5053
// installer, verified via curl HEAD on the gateway. Tauri reuses the
5154
// system WebView so the bundle stays small.
5255
const DMG_SIZE_BYTES = 4_618_782
5356

54-
beforeEach(() => {
57+
it('should land between MIN and MAX for the real DMG size at the assumed broadband rate', async () => {
58+
// 4.4 MB at 100 Mbps ≈ 350ms transfer + 500ms safety margin → ~850ms,
59+
// just above the 800ms minimum.
5560
mockFetch.mockResolvedValue(buildHeadResponse(String(DMG_SIZE_BYTES)))
56-
})
5761

58-
it('should compute a sub-second hold for fiber-class connections on the real DMG size', async () => {
59-
// 4.4 MB at 100 Mbps ≈ 350ms transfer + 500ms safety margin → ~850ms,
60-
// which clears the 800ms minimum so the math result is what we expect.
61-
setNavigatorConnection(100)
6262
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
63-
expect(ms).toBeGreaterThanOrEqual(800)
63+
64+
expect(ms).toBeGreaterThanOrEqual(ESTIMATE_MIN_HOLD_MS)
6465
expect(ms).toBeLessThanOrEqual(1100)
6566
})
6667

67-
it('should produce a longer hold on a slow connection but never beyond MAX', async () => {
68-
// 4.4 MB at 1 Mbps ≈ 35s pure transfer, way over ESTIMATE_MAX_HOLD_MS.
69-
setNavigatorConnection(1)
68+
it('should clamp to MAX for files large enough that the pure transfer exceeds the cap', async () => {
69+
// 200 MB at 100 Mbps ≈ 16s of pure transfer — well past MAX.
70+
mockFetch.mockResolvedValue(buildHeadResponse(String(200 * 1024 * 1024)))
71+
7072
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
73+
7174
expect(ms).toBe(ESTIMATE_MAX_HOLD_MS)
7275
})
7376

74-
it('should produce a mid-range hold on typical broadband', async () => {
75-
// 4.4 MB at 25 Mbps ≈ 1.4s + 500ms margin → ~1.9s. Well within MIN/MAX.
76-
setNavigatorConnection(25)
77+
it('should clamp to MIN for tiny files so the modal does not blink and vanish', async () => {
78+
mockFetch.mockResolvedValue(buildHeadResponse('512')) // 512 bytes
79+
7780
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
78-
expect(ms).toBeGreaterThanOrEqual(1500)
79-
expect(ms).toBeLessThanOrEqual(2500)
81+
82+
expect(ms).toBe(ESTIMATE_MIN_HOLD_MS)
83+
})
84+
85+
it('should ignore navigator.connection.downlink (Chrome caps it at ~10 Mbps, breaking the estimate)', async () => {
86+
// Pre-fix this would have driven the estimate up to MAX. Post-fix it
87+
// must have zero effect: the result for a fixed file size is identical
88+
// regardless of what the (mis)reported downlink claims.
89+
mockFetch.mockResolvedValue(buildHeadResponse(String(DMG_SIZE_BYTES)))
90+
91+
setNavigatorConnection(1)
92+
const slow = await estimateDownloadHoldMs('https://example.com/file.dmg')
93+
94+
setNavigatorConnection(100)
95+
const fast = await estimateDownloadHoldMs('https://example.com/file.dmg')
96+
97+
clearNavigatorConnection()
98+
const missing = await estimateDownloadHoldMs('https://example.com/file.dmg')
99+
100+
expect(slow).toBe(fast)
101+
expect(fast).toBe(missing)
80102
})
81103
})
82104

83105
describe('when the HEAD request fails', () => {
84106
it('should return the minimum hold instead of guessing', async () => {
85107
mockFetch.mockRejectedValue(new TypeError('CORS blocked'))
86-
setNavigatorConnection(50)
87108

88109
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
89110

@@ -94,7 +115,6 @@ describe('estimateDownloadHoldMs', () => {
94115
describe('when the HEAD response is non-2xx (403, 405, etc.)', () => {
95116
it('should treat it like a missing Content-Length and return the minimum hold', async () => {
96117
mockFetch.mockResolvedValue(buildHeadResponse(String(80 * 1024 * 1024), false))
97-
setNavigatorConnection(50)
98118

99119
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
100120

@@ -105,33 +125,6 @@ describe('estimateDownloadHoldMs', () => {
105125
describe('when the HEAD response omits Content-Length', () => {
106126
it('should return the minimum hold', async () => {
107127
mockFetch.mockResolvedValue(buildHeadResponse(null))
108-
setNavigatorConnection(50)
109-
110-
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
111-
112-
expect(ms).toBe(ESTIMATE_MIN_HOLD_MS)
113-
})
114-
})
115-
116-
describe('when the browser does not expose navigator.connection (Safari, Firefox)', () => {
117-
it('should fall back to the default downlink and still produce an adaptive hold', async () => {
118-
// 4.4 MB DMG at the default 50 Mbps ≈ 700ms + 500ms margin ≈ 1.2s.
119-
// Lands between MIN and MAX so the assertion exercises the adaptive
120-
// arithmetic rather than the clamps.
121-
mockFetch.mockResolvedValue(buildHeadResponse(String(4_618_782)))
122-
clearNavigatorConnection()
123-
124-
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
125-
126-
expect(ms).toBeGreaterThan(ESTIMATE_MIN_HOLD_MS)
127-
expect(ms).toBeLessThan(ESTIMATE_MAX_HOLD_MS)
128-
})
129-
})
130-
131-
describe('when the file is tiny', () => {
132-
it('should clamp to the minimum hold so the modal does not vanish instantly', async () => {
133-
mockFetch.mockResolvedValue(buildHeadResponse('512')) // 512 bytes
134-
setNavigatorConnection(100)
135128

136129
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
137130

@@ -216,9 +209,8 @@ describe('streamOrFallback', () => {
216209

217210
it('should resolve promptly when the signal aborts mid-hold', async () => {
218211
// Long estimated hold so the test exercises a real wait window:
219-
// 80 MB at 5 Mbps caps at MAX_HOLD_MS.
220-
mockFetch.mockResolvedValue(buildHeadResponse(String(80 * 1024 * 1024)))
221-
setNavigatorConnection(5)
212+
// 200 MB at the assumed 100 Mbps caps at MAX_HOLD_MS.
213+
mockFetch.mockResolvedValue(buildHeadResponse(String(200 * 1024 * 1024)))
222214

223215
let resolved = false
224216
streamOrFallback({

src/modules/streamOrFallback.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,24 @@ import type { StreamOrFallbackArgs } from './streamOrFallback.types'
1010
// progress signal.
1111
const FALLBACK_LOADER_HOLD_MS = 4000
1212

13-
// Bandwidth assumption when the browser doesn't expose `navigator.connection`
14-
// (Safari, Firefox). Tuned to modern broadband — the alternative for fast
15-
// users is the modal hanging long after the file actually landed. Slow users
16-
// fall through to the browser's own download bar as the real progress signal.
17-
const DEFAULT_DOWNLINK_MBPS = 50
13+
// Static bandwidth assumption used as the basis for the modal hold estimate.
14+
//
15+
// We intentionally do NOT read `navigator.connection.downlink`: per the
16+
// Network Information API W3C spec, Chrome caps `downlink` at ~10 Mbps for
17+
// privacy, regardless of the user's real bandwidth (verified empirically —
18+
// a user on 100 Mbps reads `downlink === 9.3`). Trusting that value
19+
// produces a wildly pessimistic estimate that clamps to the max hold for
20+
// every user, making the modal hang ~10 s on near-instant downloads.
21+
// Safari and Firefox don't expose the API at all.
22+
//
23+
// A static assumption of modern broadband is more accurate in practice than
24+
// the API value, and lets us reason about the worst-case hold time
25+
// deterministically.
26+
const ASSUMED_DOWNLINK_MBPS = 100
1827

1928
// Wall-clock margin added on top of the pure transfer estimate to absorb
2029
// connection setup, server-side TTFB, and the browser's write-to-disk after
21-
// the bytes arrive. Sized for the current ~4.4 MB DMG — a flat 1.5s margin
22-
// dominates total hold time on a small file.
30+
// the bytes arrive.
2331
const ESTIMATE_SAFETY_MARGIN_MS = 500
2432

2533
// Always hold for at least this long so the modal doesn't blink-and-vanish
@@ -29,11 +37,7 @@ const ESTIMATE_MIN_HOLD_MS = 800
2937
// Never hang a static (no progress bar) modal beyond this. Past this point
3038
// the browser's own download manager already shows as the user-facing
3139
// progress signal — keeping our modal up longer would just look stuck.
32-
const ESTIMATE_MAX_HOLD_MS = 10000
33-
34-
interface NavigatorConnectionLike {
35-
downlink?: number
36-
}
40+
const ESTIMATE_MAX_HOLD_MS = 4000
3741

3842
/**
3943
* Sleeps for `ms` milliseconds, resolving early if `signal` aborts. Resolves
@@ -76,26 +80,20 @@ const fetchContentLength = async (url: string, signal?: AbortSignal): Promise<nu
7680
}
7781

7882
/**
79-
* Estimates how long the modal should stay open given the file size and
80-
* the browser's reported connection speed. Adaptive per-user instead of a
81-
* single hardcoded constant. Returns the clamped hold in milliseconds.
83+
* Estimates how long the modal should stay open given the file size and a
84+
* static broadband assumption. Returns the clamped hold in milliseconds.
8285
*
8386
* - Reads `Content-Length` from a HEAD request to the gateway.
84-
* - Reads `navigator.connection.downlink` (Mbps) when available; falls back
85-
* to a conservative default on Safari and Firefox where the API doesn't
86-
* exist.
87+
* - Uses `ASSUMED_DOWNLINK_MBPS` (NOT `navigator.connection.downlink`, which
88+
* Chrome caps and Safari/Firefox don't expose — see constant docs above).
8789
* - Adds a margin for handshake / write-to-disk variance and clamps the
8890
* result so the modal can't disappear instantly nor hang forever.
8991
*/
9092
const estimateDownloadHoldMs = async (url: string, signal?: AbortSignal): Promise<number> => {
9193
const sizeBytes = await fetchContentLength(url, signal)
9294
if (sizeBytes === null) return ESTIMATE_MIN_HOLD_MS
9395

94-
const connection =
95-
typeof navigator !== 'undefined' ? (navigator as Navigator & { connection?: NavigatorConnectionLike }).connection : undefined
96-
const downlinkMbps = connection?.downlink && connection.downlink > 0 ? connection.downlink : DEFAULT_DOWNLINK_MBPS
97-
98-
const downlinkBytesPerSec = (downlinkMbps * 1024 * 1024) / 8
96+
const downlinkBytesPerSec = (ASSUMED_DOWNLINK_MBPS * 1024 * 1024) / 8
9997
const transferMs = (sizeBytes / downlinkBytesPerSec) * 1000
10098
const total = transferMs + ESTIMATE_SAFETY_MARGIN_MS
10199
return Math.min(ESTIMATE_MAX_HOLD_MS, Math.max(ESTIMATE_MIN_HOLD_MS, total))

0 commit comments

Comments
 (0)