Skip to content

Commit 0f654ce

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 0f654ce

2 files changed

Lines changed: 150 additions & 9 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+
- changed: 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+
- changed: 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: 148 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,40 @@ 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+
44+
/**
45+
* How long a request will wait for a slot before going anyway.
46+
*
47+
* The ceilings above are smoothing, not an invariant worth stalling on. If
48+
* every slot is held by a request that will not answer, an unbounded queue
49+
* would add its own multi-minute delay on top of the per-request timeout and
50+
* recreate exactly the stuck "Calculating Fee" this change exists to prevent.
51+
* Past this deadline the request proceeds and the log says it did.
52+
*/
53+
const MAX_QUEUE_WAIT_MS = 10000
54+
2555
/**
2656
* Budget for `createMixFetch` itself (client start + gateway handshake).
2757
*
@@ -43,13 +73,102 @@ let inFlightCount = 0
4373
// Distinguishes concurrent requests to the same host in the log.
4474
let requestCounter = 0
4575

76+
// Requests waiting on a concurrency slot, oldest first.
77+
const waiting: Array<() => void> = []
78+
79+
// In-flight count per host key, for the per-host ceiling.
80+
const hostInFlight = new Map<string, number>()
81+
4682
/**
47-
* A path segment that is long and unbroken enough to be an identifier rather
48-
* than a route: an address, a txid, a public key, or an API key. Real route
49-
* segments in the endpoints we call (`ext`, `bc`, `C`, `rpc`, `v2`, `api`,
50-
* `get_address_info`) are short, or contain separators, or both.
83+
* The `host:port` a request will actually open, used to key the per-host
84+
* ceiling. Falls back to the raw uri so an unparsable one still gets queued
85+
* rather than bypassing the limit.
5186
*/
52-
const IDENTIFIER_SEGMENT = /^(0x)?[0-9a-zA-Z]{20,}$/
87+
function getHostKey(uri: string): string {
88+
try {
89+
const url = new URL(uri)
90+
const port =
91+
url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'
92+
return `${url.hostname}:${port}`
93+
} catch (error: unknown) {
94+
return uri
95+
}
96+
}
97+
98+
function hasSlot(hostKey: string): boolean {
99+
return (
100+
inFlightCount < MAX_IN_FLIGHT_TOTAL &&
101+
(hostInFlight.get(hostKey) ?? 0) < MAX_IN_FLIGHT_PER_HOST
102+
)
103+
}
104+
105+
/**
106+
* Wait until this request is allowed into the mixnet, then reserve its slot.
107+
*
108+
* Returns true when the slot was granted by the ceilings, false when the
109+
* deadline elapsed first and the request is proceeding regardless. The slot is
110+
* reserved either way, so the counters stay honest.
111+
*/
112+
async function acquireSlot(hostKey: string): Promise<boolean> {
113+
const deadline = Date.now() + MAX_QUEUE_WAIT_MS
114+
let granted = true
115+
116+
while (!hasSlot(hostKey)) {
117+
const remaining = deadline - Date.now()
118+
if (remaining <= 0) {
119+
granted = false
120+
break
121+
}
122+
let timer: ReturnType<typeof setTimeout> | undefined
123+
let wake: () => void = () => {}
124+
await new Promise<void>(resolve => {
125+
wake = resolve
126+
waiting.push(resolve)
127+
timer = setTimeout(resolve, remaining)
128+
})
129+
clearTimeout(timer)
130+
// Drop our own resolver. `releaseSlot` drains the whole array, so this
131+
// only matters when the deadline fired instead: without it, a stretch
132+
// where nothing settles (exactly the case being diagnosed) would grow
133+
// `waiting` without bound, since nothing else ever clears it.
134+
const index = waiting.indexOf(wake)
135+
if (index !== -1) waiting.splice(index, 1)
136+
}
137+
138+
inFlightCount += 1
139+
hostInFlight.set(hostKey, (hostInFlight.get(hostKey) ?? 0) + 1)
140+
return granted
141+
}
142+
143+
/**
144+
* Release this request's slot and wake everyone waiting.
145+
*
146+
* Every waiter re-checks its own host ceiling in `acquireSlot`, so waking all
147+
* of them is correct: the ones still blocked simply queue again. Waking only
148+
* the head would stall the queue whenever the head is blocked on a busy host
149+
* while a slot for some other host just came free.
150+
*/
151+
function releaseSlot(hostKey: string): void {
152+
inFlightCount -= 1
153+
const remaining = (hostInFlight.get(hostKey) ?? 1) - 1
154+
if (remaining <= 0) hostInFlight.delete(hostKey)
155+
else hostInFlight.set(hostKey, remaining)
156+
157+
const woken = waiting.splice(0, waiting.length)
158+
for (const wake of woken) wake()
159+
}
160+
161+
/**
162+
* A path segment long enough to be an identifier rather than a route: an
163+
* address, a txid, a public key, or an API key.
164+
*
165+
* Length is what separates the two, not character set. Route segments in the
166+
* endpoints we call are short (`ext`, `bc`, `C`, `rpc`, `v2`, `api`, the
167+
* 16-character `get_address_info`), while identifiers run 26 characters and up.
168+
* The separator classes matter: hyphens for UUIDs, a colon for cashaddr-style
169+
* `prefix:address`, underscores and dots for the rest.
170+
*/
171+
const IDENTIFIER_SEGMENT = /^[0-9a-zA-Z][0-9a-zA-Z._:-]{19,}$/
53172

54173
/**
55174
* Reduce a request URI to the part that is safe to write to a user's log.
@@ -176,8 +295,26 @@ export async function nymFetch(
176295
const method = opts.method ?? 'GET'
177296
const label = `mixFetch #${id} ${method} ${target}`
178297

298+
const hostKey = getHostKey(uri)
299+
const queuedAt = Date.now()
300+
const granted = await acquireSlot(hostKey)
301+
const queuedMs = Date.now() - queuedAt
302+
179303
const start = Date.now()
180-
log.warn(`${label} start (${++inFlightCount} in flight)`)
304+
const queueNote =
305+
queuedMs === 0
306+
? ''
307+
: granted
308+
? `, queued ${queuedMs}ms`
309+
: `, queued ${queuedMs}ms then went over the limit`
310+
log.warn(`${label} start (${inFlightCount} in flight${queueNote})`)
311+
let released = false
312+
const release = (): void => {
313+
if (released) return
314+
released = true
315+
releaseSlot(hostKey)
316+
}
317+
181318
try {
182319
const response = await mixFetch(
183320
uri,
@@ -187,17 +324,19 @@ export async function nymFetch(
187324
},
188325
mixFetchOptions
189326
)
327+
release()
190328
log.warn(
191329
`${label} -> ${response.status} in ${
192330
Date.now() - start
193-
}ms (${--inFlightCount} in flight)`
331+
}ms (${inFlightCount} in flight)`
194332
)
195333
return response
196334
} catch (error: unknown) {
335+
release()
197336
log.error(
198337
`${label} failed after ${
199338
Date.now() - start
200-
}ms (${--inFlightCount} in flight): ${describeError(error)}`
339+
}ms (${inFlightCount} in flight): ${describeError(error)}`
201340
)
202341
throw error
203342
}

0 commit comments

Comments
 (0)