Skip to content

Commit e5de482

Browse files
committed
Bound NYM mixnet request concurrency and timeout
Measured on a throttled Android emulator with the request logging from the previous commit: a single Avalanche wallet opens 12 concurrent mixnet requests during one sync. mix-fetch v1 has a history of serving one request per host at a time, which this module used to work around with a per-host queue removed in 5916d9d on the understanding that 1.4.2 had fixed it. Cap the mixnet at 6 concurrent requests overall and 2 per host, queueing the rest. Measured before and after on the same emulator against the live mixnet: peak in-flight drops from 12 to 6, per-request latency is unchanged (p50 2666ms vs 2669ms), and every request still settles. Also drop the per-request timeout from 300s to 60s. Healthy requests measure 2-4 seconds, so five minutes only ever meant that a request the mixnet never answered pinned its caller for five minutes. On the send screen that is the 'Calculating Fee' spinner that QA reports as an infinite hang. Neither change touches the request logging, which is still needed to attribute the remaining NYM-internal failure.
1 parent 4a12e40 commit e5de482

2 files changed

Lines changed: 101 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Unreleased
44

55
- added: Log the lifecycle of every NYM mixnet request (method, host, path, duration, HTTP status or error, and in-flight count), plus mixFetch setup timing. A request that logs a start with no terminal line identifies the host whose request never returned, which the previous logging could not attribute. These log at `warn` so they survive the default `warn` log level and appear in a log export without the user first enabling verbose logging.
6+
- fixed: Bound NYM mixnet requests to 6 concurrent overall and 2 per host. A single Avalanche wallet was measured opening 12 concurrent mixnet requests during one sync, and a full wallet list multiplies that; mix-fetch v1 has a history of serving one request per host at a time.
7+
- fixed: Reduce the NYM per-request timeout from 300 seconds to 60. At five minutes, a request the mixnet never answered held the send screen on "Calculating Fee" long enough to read as a permanent hang rather than an error.
68

79
## 2.47.1 (2026-07-17)
810

src/util/nym.ts

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,29 @@ export const mixFetchOptions: SetupMixFetchOps = {
1818
'5x6q9UfVHs5AohKMUqeivj7a556kVVy7QwoKige8xHxh.6CFoB3kJaDbYz6oafPJxNxNjzahpT2NtgtytcSyN9EvF@5rXcNe2a44vXisK3uqLHCzpzvEwcnsijDMU7hg4fcYk8',
1919
forceTls: true, // force WSS
2020
mixFetchOverride: {
21-
requestTimeoutMs: 300000
21+
// A healthy mixnet request measures 2-4 seconds. The previous 300000 (5
22+
// minutes) meant any request the mixnet never answered held its caller for
23+
// five minutes, which on the send screen reads as a permanently stuck
24+
// "Calculating Fee" spinner rather than an error.
25+
requestTimeoutMs: 60000
2226
}
2327
}
2428

29+
/**
30+
* Ceilings on how many requests are in the mixnet at once.
31+
*
32+
* mix-fetch v1 historically served one request per host at a time, which this
33+
* module used to work around with a per-host queue. That queue was removed in
34+
* 5916d9d2 on the understanding that 1.4.2 had fixed the limitation. Measured
35+
* on an Android emulator, a single Avalanche wallet still opens 12 concurrent
36+
* requests during one sync, and a wallet list the size of a real user's
37+
* multiplies that. Bounding it keeps the tunnel inside a regime we have
38+
* actually observed working, at a negligible cost given each request already
39+
* takes seconds.
40+
*/
41+
const MAX_IN_FLIGHT_TOTAL = 6
42+
const MAX_IN_FLIGHT_PER_HOST = 2
43+
2544
/**
2645
* Budget for `createMixFetch` itself (client start + gateway handshake).
2746
*
@@ -43,6 +62,64 @@ let inFlightCount = 0
4362
// Distinguishes concurrent requests to the same host in the log.
4463
let requestCounter = 0
4564

65+
// Requests waiting on a concurrency slot, oldest first.
66+
const waiting: Array<() => void> = []
67+
68+
// In-flight count per host key, for the per-host ceiling.
69+
const hostInFlight = new Map<string, number>()
70+
71+
/**
72+
* The `host:port` a request will actually open, used to key the per-host
73+
* ceiling. Falls back to the raw uri so an unparsable one still gets queued
74+
* rather than bypassing the limit.
75+
*/
76+
function getHostKey(uri: string): string {
77+
try {
78+
const url = new URL(uri)
79+
const port =
80+
url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'
81+
return `${url.hostname}:${port}`
82+
} catch (error: unknown) {
83+
return uri
84+
}
85+
}
86+
87+
function hasSlot(hostKey: string): boolean {
88+
return (
89+
inFlightCount < MAX_IN_FLIGHT_TOTAL &&
90+
(hostInFlight.get(hostKey) ?? 0) < MAX_IN_FLIGHT_PER_HOST
91+
)
92+
}
93+
94+
/**
95+
* Wait until this request is allowed into the mixnet, then reserve its slot.
96+
*/
97+
async function acquireSlot(hostKey: string): Promise<void> {
98+
while (!hasSlot(hostKey)) {
99+
await new Promise<void>(resolve => waiting.push(resolve))
100+
}
101+
inFlightCount += 1
102+
hostInFlight.set(hostKey, (hostInFlight.get(hostKey) ?? 0) + 1)
103+
}
104+
105+
/**
106+
* Release this request's slot and wake everyone waiting.
107+
*
108+
* Every waiter re-checks its own host ceiling in `acquireSlot`, so waking all
109+
* of them is correct: the ones still blocked simply queue again. Waking only
110+
* the head would stall the queue whenever the head is blocked on a busy host
111+
* while a slot for some other host just came free.
112+
*/
113+
function releaseSlot(hostKey: string): void {
114+
inFlightCount -= 1
115+
const remaining = (hostInFlight.get(hostKey) ?? 1) - 1
116+
if (remaining <= 0) hostInFlight.delete(hostKey)
117+
else hostInFlight.set(hostKey, remaining)
118+
119+
const woken = waiting.splice(0, waiting.length)
120+
for (const wake of woken) wake()
121+
}
122+
46123
/**
47124
* A path segment that is long and unbroken enough to be an identifier rather
48125
* than a route: an address, a txid, a public key, or an API key. Real route
@@ -176,8 +253,24 @@ export async function nymFetch(
176253
const method = opts.method ?? 'GET'
177254
const label = `mixFetch #${id} ${method} ${target}`
178255

256+
const hostKey = getHostKey(uri)
257+
const queuedAt = Date.now()
258+
await acquireSlot(hostKey)
259+
const queuedMs = Date.now() - queuedAt
260+
179261
const start = Date.now()
180-
log.warn(`${label} start (${++inFlightCount} in flight)`)
262+
log.warn(
263+
`${label} start (${inFlightCount} in flight${
264+
queuedMs > 0 ? `, queued ${queuedMs}ms` : ''
265+
})`
266+
)
267+
let released = false
268+
const release = (): void => {
269+
if (released) return
270+
released = true
271+
releaseSlot(hostKey)
272+
}
273+
181274
try {
182275
const response = await mixFetch(
183276
uri,
@@ -187,17 +280,19 @@ export async function nymFetch(
187280
},
188281
mixFetchOptions
189282
)
283+
release()
190284
log.warn(
191285
`${label} -> ${response.status} in ${
192286
Date.now() - start
193-
}ms (${--inFlightCount} in flight)`
287+
}ms (${inFlightCount} in flight)`
194288
)
195289
return response
196290
} catch (error: unknown) {
291+
release()
197292
log.error(
198293
`${label} failed after ${
199294
Date.now() - start
200-
}ms (${--inFlightCount} in flight): ${describeError(error)}`
295+
}ms (${inFlightCount} in flight): ${describeError(error)}`
201296
)
202297
throw error
203298
}

0 commit comments

Comments
 (0)