Skip to content

Commit 101f6a3

Browse files
authored
fix: race requests to all URLs in parallel (#53)
* fix: race requests to all URLs in parallel * fix two requests resolving in same tick * fix race issues with OPTIONS probe * remove unused fn * fix remaining windows test failure * attempted fix 1 * add code comment about aborting downloads
1 parent 28d2718 commit 101f6a3

5 files changed

Lines changed: 523 additions & 79 deletions

File tree

src/lib/download-request.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { DownloadCreateParams } from '../routes/downloads.js'
55
import { type DownloadStateUpdate } from '../types.js'
66
import { StatusError } from './errors.js'
77
import { errors, jsonError } from './errors.js'
8-
import { secretStreamFetch } from './secret-stream-fetch.js'
8+
import { anyFetch } from './secret-stream-fetch.js'
99
import { StateUpdateEvent } from './state-update-event.js'
1010
import { throttle } from './throttle.js'
1111
import { addTrailingSlash, generateId, getErrorCode, noop } from './utils.js'
@@ -80,7 +80,8 @@ export class DownloadRequest extends TypedEventTarget<
8080
// namely whether it was canceled, or if a different error occurred on
8181
// the server side.
8282
try {
83-
const response = await secretStreamFetch(mapShareUrls, {
83+
// GET /:shareId is idempotent, so racing URLs directly is safe.
84+
const response = await anyFetch(mapShareUrls, {
8485
dispatcher: this.#dispatcher,
8586
signal: AbortSignal.timeout(2000),
8687
})
@@ -107,14 +108,42 @@ export class DownloadRequest extends TypedEventTarget<
107108
remotePublicKey: Uint8Array
108109
keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }
109110
}) {
111+
// anyFetch probes the URLs in parallel and only sends the real /download
112+
// request to the winner. Necessary because the sender only supports one
113+
// active download per share, so racing /download against a server
114+
// reachable on several URLs would be unsafe.
110115
const downloadUrls = mapShareUrls.map(
111116
(baseUrl) => new URL('download', addTrailingSlash(baseUrl)),
112-
) as unknown as [URL, ...URL[]] // grrrr TS
113-
const response = await secretStreamFetch(downloadUrls, {
114-
dispatcher: this.#dispatcher,
115-
})
117+
) as unknown as readonly [URL, ...URL[]]
118+
let response: Response
119+
try {
120+
// We deliberately do NOT pass this.#abortController.signal here. If we
121+
// did, a fast abort could fire while anyFetch is still in the probe
122+
// phase — before any real /download request has reached the sender —
123+
// leaving the sender's MapShare stuck in 'pending' because it never
124+
// saw a connection to cancel. Instead we let anyFetch run to
125+
// completion, then check the abort flag below (see the
126+
// `signal.aborted` check after this try/catch). That way the sender
127+
// always gets a real request, and cancelling the response body
128+
// closes the connection so the sender can transition its state.
129+
response = await anyFetch(downloadUrls, {
130+
dispatcher: this.#dispatcher,
131+
})
132+
} catch (error) {
133+
if (error instanceof DOMException && error.name === 'AbortError') {
134+
throw error // Handle abort in caller
135+
}
136+
throw new errors.DOWNLOAD_ERROR({
137+
message: 'Could not connect to map share sender',
138+
urls: mapShareUrls,
139+
cause: error,
140+
})
141+
}
116142
if (!response.body) {
117-
throw new errors.DOWNLOAD_ERROR('Could not connect to map share sender')
143+
throw new errors.DOWNLOAD_ERROR({
144+
message: 'Could not connect to map share sender',
145+
urls: mapShareUrls,
146+
})
118147
}
119148
if (!response.ok) {
120149
throw new StatusError(response.status, await response.json())

src/lib/secret-stream-fetch.ts

Lines changed: 135 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,150 @@
11
import { fetch as secretStreamFetchOrig } from 'secret-stream-http'
22

3-
import { errors } from './errors.js'
4-
import { isArrayReadonly } from './utils.js'
3+
import { noop } from './utils.js'
54

65
const CONNECTION_TIMEOUT_MS = 5000 // 5s
76

7+
type FetchFn = typeof secretStreamFetchOrig
8+
type FetchInput = Parameters<FetchFn>[0]
9+
type FetchInit = NonNullable<Parameters<FetchFn>[1]>
10+
11+
type AnyFetchInit = FetchInit & {
12+
/**
13+
* Underlying fetch implementation. Defaults to secret-stream-http's fetch.
14+
* Useful for overriding in tests.
15+
*/
16+
fetch?: FetchFn
17+
/**
18+
* Connection timeout in milliseconds. This is a timeout for establishing the
19+
* connection only, not for the entire request. If the connection is not
20+
* established within this time, the request will be aborted and treated as a
21+
* connection failure. Default is 5000ms (5s), which should be plenty of time
22+
* for a local network request
23+
*/
24+
timeoutMs?: number
25+
}
26+
827
/**
9-
* A wrapper around secret-stream-http's fetch that tries multiple URLs until one works.
10-
* This is useful when the server has multiple IPs for different network interfaces.
28+
* A fetch-compatible wrapper that accepts an array of URLs that are first
29+
* probed in parallel with OPTIONS requests. The real request is then sent to
30+
* each URL in series — starting with whichever probe responded first — until
31+
* one succeeds. The probe step is what makes this safe against stateful
32+
* endpoints: only one real request ever hits the server per call.
33+
*
34+
* Adds a per-request connection timeout, set by `init.timeoutMs` and defaults
35+
* to 5000ms (5s).
36+
*
37+
* The `init.fetch` option overrides the underlying fetch implementation
38+
* and defaults to secret-stream-http's fetch.
1139
*/
12-
export async function secretStreamFetch(
13-
urls: string | URL | readonly [string | URL, ...Array<string | URL>],
14-
options: Parameters<typeof secretStreamFetchOrig>[1],
15-
) {
16-
if (!isArrayReadonly(urls)) {
17-
urls = [urls]
40+
export async function anyFetch(
41+
inputs: readonly [FetchInput, ...FetchInput[]],
42+
init?: AnyFetchInit,
43+
): Promise<Response> {
44+
if (inputs.length === 1) {
45+
return await timeoutFetch(inputs[0], init)
1846
}
19-
let response: Response | undefined
20-
let error: unknown
2147

22-
// The server could have multiple IPs for different network interfaces, and
23-
// not all of them may be on the same network as us, so try each URL until
24-
// one works
25-
for (const url of urls) {
26-
const controller = new AbortController()
27-
const timeout = setTimeout(() => {
28-
controller.abort()
29-
}, CONNECTION_TIMEOUT_MS)
30-
const signal = options?.signal
31-
? AbortSignal.any([options.signal, controller.signal])
32-
: controller.signal
48+
let winningInput: FetchInput
49+
try {
50+
winningInput = await raceProbes(inputs, init)
51+
} catch (err) {
52+
// If the caller aborted mid-probe, propagate that as an AbortError
53+
// (via signal.reason) rather than the probe's AggregateError — the
54+
// caller needs to distinguish "I cancelled this" from "nothing was
55+
// reachable".
56+
init?.signal?.throwIfAborted()
57+
throw err
58+
}
59+
const orderedInputs = [
60+
winningInput,
61+
...inputs.filter((i) => i !== winningInput),
62+
]
63+
64+
const errors: unknown[] = []
65+
for (const input of orderedInputs) {
3366
try {
34-
response = (await secretStreamFetchOrig(url, {
35-
...options,
36-
signal,
37-
})) as unknown as Response // Subtle difference bewteen Undici fetch Response and whatwg Response
38-
break // Exit loop on successful fetch
67+
return await timeoutFetch(input, init)
3968
} catch (err) {
40-
error = err
41-
// Ignore errors and try the next URL
42-
} finally {
43-
clearTimeout(timeout)
69+
// If the caller aborted mid-fetch, propagate that rather than our
70+
// internal connection-timeout abort (or whatever lower-level error).
71+
init?.signal?.throwIfAborted()
72+
errors.push(err)
4473
}
4574
}
46-
if (!response) {
47-
throw new errors.DOWNLOAD_ERROR({
48-
message: 'Could not connect to map share sender',
49-
urls,
50-
cause: error,
51-
})
75+
throw new AggregateError(errors, 'All fetch attempts failed')
76+
}
77+
78+
/**
79+
* Fetch a single URL with a connection timeout (different than calling WHAT-WG
80+
* fetch with a timeout signal, which applies to the entire request, not just
81+
* connection establishment).
82+
*/
83+
async function timeoutFetch(
84+
url: FetchInput,
85+
{
86+
fetch: innerFetch = secretStreamFetchOrig,
87+
timeoutMs = CONNECTION_TIMEOUT_MS,
88+
...fetchInit
89+
}: AnyFetchInit = {},
90+
): Promise<Response> {
91+
const controller = new AbortController()
92+
const timeout = setTimeout(() => {
93+
controller.abort(new DOMException('Connection timed out', 'TimeoutError'))
94+
}, timeoutMs)
95+
const signal = fetchInit.signal
96+
? AbortSignal.any([fetchInit.signal, controller.signal])
97+
: controller.signal
98+
try {
99+
return (await innerFetch(url, {
100+
...fetchInit,
101+
signal,
102+
})) as unknown as Response // Subtle difference between Undici fetch Response and whatwg Response
103+
} finally {
104+
clearTimeout(timeout)
105+
}
106+
}
107+
108+
/**
109+
* Probe URLs in parallel with OPTIONS requests. Returns the "winning" URL.
110+
* Throws AggregateError if no probe responds before the connection timeout or
111+
* if all probes fail with a network error.
112+
*/
113+
async function raceProbes(
114+
inputs: readonly FetchInput[],
115+
fetchInit: FetchInit = {},
116+
): Promise<FetchInput> {
117+
const controllers: AbortController[] = []
118+
const probePromises: Promise<FetchInput>[] = []
119+
let hasWinner = false
120+
121+
for (const url of inputs) {
122+
const controller = new AbortController()
123+
const signal = fetchInit.signal
124+
? AbortSignal.any([fetchInit.signal, controller.signal])
125+
: controller.signal
126+
controllers.push(controller)
127+
const probePromise = (async () => {
128+
const response = await timeoutFetch(url, {
129+
...fetchInit,
130+
method: 'OPTIONS',
131+
body: undefined,
132+
signal,
133+
})
134+
response.body?.cancel().catch(noop) // We don't care about the response body, and we want to free resources as soon as possible
135+
if (hasWinner) {
136+
throw new DOMException('Aborted by winner', 'AbortError')
137+
}
138+
hasWinner = true
139+
for (const c of controllers) {
140+
if (c !== controller) {
141+
c.abort(new DOMException('Aborted by winner', 'AbortError'))
142+
}
143+
}
144+
return url
145+
})()
146+
probePromises.push(probePromise)
52147
}
53-
return response
148+
149+
return Promise.any(probePromises)
54150
}

src/lib/utils.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,3 @@ function isNonEmptyArray<T>(arr: T[]): arr is [T, ...T[]] {
107107
export function addTrailingSlash(url: string): string {
108108
return url.endsWith('/') ? url : url + '/'
109109
}
110-
111-
// Typescript's Array.isArray definition does not work as a type guard for
112-
// readonly arrays, so we need to define our own type guard for that
113-
export function isArrayReadonly<T, U>(
114-
value: U | readonly T[],
115-
): value is readonly T[] {
116-
return Array.isArray(value)
117-
}

src/routes/map-shares.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import os from 'node:os'
22

33
import { IRequestStrict, IttyRouter, type RequestHandler } from 'itty-router'
4-
import {
5-
fetch as secretStreamFetch,
6-
Agent as SecretStreamAgent,
7-
} from 'secret-stream-http'
4+
import { Agent as SecretStreamAgent } from 'secret-stream-http'
85
import { Type as T, type Static } from 'typebox'
96
import { Compile } from 'typebox/compile'
107

118
import type { Context } from '../context.js'
129
import { errors, StatusError } from '../lib/errors.js'
1310
import { createEventStreamResponse } from '../lib/event-stream-response.js'
1411
import { MapShare } from '../lib/map-share.js'
12+
import { anyFetch } from '../lib/secret-stream-fetch.js'
1513
import { SelfEvictingTimeoutMap } from '../lib/self-evicting-map.js'
1614
import { addTrailingSlash, timingSafeEqual } from '../lib/utils.js'
1715
import { localhostOnly } from '../middlewares/localhost-only.js'
@@ -127,6 +125,14 @@ export function MapSharesRouter(
127125
router.all('/:shareId', validateRemoteDeviceId)
128126
router.all('/:shareId/*', validateRemoteDeviceId)
129127

128+
// Lightweight non-mutating probe used by remote peers to pick a reachable
129+
// URL without triggering stateful handlers like /download or /decline.
130+
// Runs under validateRemoteDeviceId so it inherits the same authorization
131+
// as every other remote-accessible route.
132+
const probeHandler = () => new Response(null, { status: 204 })
133+
router.options('/:shareId', probeHandler)
134+
router.options('/:shareId/*', probeHandler)
135+
130136
router.get('/:shareId', async (request): Promise<MapShareState> => {
131137
return getMapShare(request.params.shareId).state
132138
})
@@ -152,28 +158,27 @@ export function MapSharesRouter(
152158
const { senderDeviceId, mapShareUrls, reason } = parsedBody
153159
const remotePublicKey = Buffer.from(senderDeviceId, 'hex')
154160
const keyPair = ctx.getKeyPair()
155-
let response: Response | undefined
156-
// The sharer could have multiple IPs for different network interfaces, and
157-
// not all of them may be on the same network as us, so try each URL until
158-
// one works
159-
for (const mapShareUrl of mapShareUrls) {
160-
const url = new URL('decline', addTrailingSlash(mapShareUrl))
161-
try {
162-
response = (await secretStreamFetch(url, {
163-
method: 'POST',
164-
body: JSON.stringify({ reason }),
165-
signal: request.signal,
166-
dispatcher: new SecretStreamAgent({ remotePublicKey, keyPair }),
167-
})) as unknown as Response // Subtle difference bewteen Undici fetch Response and whatwg Response
168-
break // Exit loop on successful fetch
169-
} catch (error) {
170-
if (error instanceof DOMException && error.name === 'AbortError') {
171-
throw error // Handle abort in caller
172-
}
173-
// Otherwise, try the next URL
161+
162+
// The sharer could have multiple IPs for different network interfaces
163+
// and not all of them may be on the same network as us. anyFetch picks
164+
// a reachable URL via an OPTIONS probe before POSTing /decline, so we
165+
// don't race a stateful endpoint against a single server reachable on
166+
// several URLs.
167+
const declineUrls = mapShareUrls.map(
168+
(mapShareUrl) => new URL('decline', addTrailingSlash(mapShareUrl)),
169+
) as unknown as readonly [URL, ...URL[]]
170+
let response: Response
171+
try {
172+
response = await anyFetch(declineUrls, {
173+
method: 'POST',
174+
body: JSON.stringify({ reason }),
175+
signal: request.signal,
176+
dispatcher: new SecretStreamAgent({ remotePublicKey, keyPair }),
177+
})
178+
} catch (error) {
179+
if (error instanceof DOMException && error.name === 'AbortError') {
180+
throw error // Handle abort in caller
174181
}
175-
}
176-
if (!response) {
177182
throw new errors.DECLINE_CANNOT_CONNECT()
178183
}
179184
if (!response.ok) {

0 commit comments

Comments
 (0)