Skip to content

Commit f615690

Browse files
committed
Add deterministic engine-scheduler tests
The fake plugin reports each makeCurrencyEngine call through an onEngineCreate hook, so tests can observe creation order. Cases cover concurrency-limited draining, waitForCurrencyWallet bumping a queued wallet to the front, cold wallets bypassing the queue, a deleted queued wallet giving up its place, and balanceMap identity across unchanged balance reports.
1 parent 79f8321 commit f615690

3 files changed

Lines changed: 250 additions & 1 deletion

File tree

CHANGELOG.md

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

55
- added: Per-wallet `walletCache.json` with each wallet's name, fiat code, enabled token IDs, and last-known balances. Currency wallets now emit their API objects as soon as this cache loads, before their engines exist, so the GUI can render the wallet list immediately at login. Engine-backed methods wait for the engine internally and reject if it fails or the wallet is deleted. First login (no cache) behaves exactly as before.
6+
- added: Cached wallets' engine startup is staggered through a limited-concurrency queue (8 at a time) instead of all racing at login. Asking for a wallet via `waitForCurrencyWallet`, calling an engine- or storage-backed method, or un-pausing it moves it to the front of the queue. Wallets without a cache skip the queue, so first login is unaffected.
67
- changed: `waitForCurrencyWallet` and `waitForAllWallets` now resolve when the wallet object exists, which can be before its engine loads.
78
- changed: `wallet.otherMethods` is `{}` until the wallet's engine loads, then switches to the engine's methods.
9+
- changed: `EdgeCurrencyWallet.balanceMap` keeps its object identity when an engine re-reports an unchanged balance.
810

911
## 2.47.1 (2026-07-17)
1012

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { expect } from 'chai'
2+
import { afterEach, beforeEach, describe, it } from 'mocha'
3+
4+
import { engineSchedulerConfig } from '../../../../src/core/currency/wallet/engine-scheduler'
5+
import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file'
6+
import { EdgeContext, makeFakeEdgeWorld } from '../../../../src/index'
7+
import { snooze } from '../../../../src/util/snooze'
8+
import {
9+
createEngineGate,
10+
fakePluginTestConfig
11+
} from '../../../fake/fake-currency-plugin'
12+
import { fakeUser } from '../../../fake/fake-user'
13+
14+
const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' }
15+
const quiet = { onLog() {} }
16+
17+
// Generous wait for the throttled cache saver (50ms in tests) to write:
18+
const SAVE_WAIT_MS = 300
19+
20+
// Short wait to prove something has *not* happened:
21+
const RACE_WAIT_MS = 150
22+
23+
interface MultiWalletWorld {
24+
context: EdgeContext
25+
walletIds: string[]
26+
}
27+
28+
/**
29+
* Logs in once, pads the account out to at least `count` fakecoin
30+
* wallets, waits for their cache files to persist, and logs out.
31+
* The next login on the returned context is a warm start where every
32+
* wallet's engine startup enters the scheduler queue. The returned
33+
* ids cover EVERY wallet in the account (the fake user starts with
34+
* two), so length comparisons against engine-creation counts hold.
35+
*/
36+
async function makeMultiWalletWorld(count: number): Promise<MultiWalletWorld> {
37+
const world = await makeFakeEdgeWorld([fakeUser], quiet)
38+
const context = await world.makeEdgeContext({
39+
...contextOptions,
40+
plugins: { fakecoin: true }
41+
})
42+
43+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
44+
const walletIds = account.activeWalletIds.filter(
45+
walletId => account.getWalletInfo(walletId)?.type === 'wallet:fakecoin'
46+
)
47+
if (walletIds.length !== account.activeWalletIds.length) {
48+
throw new Error('Broken test account: unexpected non-fakecoin wallets')
49+
}
50+
while (walletIds.length < count) {
51+
const wallet = await account.createCurrencyWallet('wallet:fakecoin', {
52+
fiatCurrencyCode: 'iso:USD',
53+
name: `Wallet ${walletIds.length}`
54+
})
55+
walletIds.push(wallet.id)
56+
}
57+
58+
// Let the throttled saver persist each wallet's cache file:
59+
await snooze(SAVE_WAIT_MS)
60+
await account.logout()
61+
62+
return { context, walletIds }
63+
}
64+
65+
/** Returns a sorted copy, so order-insensitive comparisons read cleanly. */
66+
function sorted(list: string[]): string[] {
67+
return [...list].sort((a, b) => a.localeCompare(b))
68+
}
69+
70+
/** Polls until `condition` holds, or fails the test after ~5s. */
71+
async function pollUntil(condition: () => boolean): Promise<void> {
72+
for (let i = 0; i < 100; ++i) {
73+
if (condition()) return
74+
await snooze(50)
75+
}
76+
throw new Error('Timed out waiting for a test condition')
77+
}
78+
79+
describe('engine scheduler', function () {
80+
beforeEach(function () {
81+
fakePluginTestConfig.engineGate = undefined
82+
fakePluginTestConfig.onEngineCreate = undefined
83+
walletCacheSaverConfig.throttleMs = 50
84+
})
85+
86+
afterEach(function () {
87+
fakePluginTestConfig.engineGate = undefined
88+
fakePluginTestConfig.onEngineCreate = undefined
89+
walletCacheSaverConfig.throttleMs = 5000
90+
engineSchedulerConfig.concurrency = 8
91+
})
92+
93+
it('runs cached startup work at the configured concurrency', async function () {
94+
this.timeout(15000)
95+
const { context, walletIds } = await makeMultiWalletWorld(3)
96+
97+
engineSchedulerConfig.concurrency = 1
98+
const created: string[] = []
99+
fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId)
100+
const { gate, release } = createEngineGate()
101+
fakePluginTestConfig.engineGate = gate
102+
103+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
104+
105+
// Every wallet emits from its cache while the queue is stuck:
106+
await pollUntil(() =>
107+
walletIds.every(walletId => account.currencyWallets[walletId] != null)
108+
)
109+
110+
// The gate blocks the slot holder inside `makeCurrencyEngine`,
111+
// so with one slot, exactly one creation can have begun:
112+
await snooze(RACE_WAIT_MS)
113+
expect(created.length).equals(1)
114+
115+
// Releasing the gate drains the queue one wallet at a time,
116+
// and every engine still starts:
117+
release()
118+
await pollUntil(() => created.length === walletIds.length)
119+
expect(sorted(created)).deep.equals(sorted(walletIds))
120+
await account.logout()
121+
})
122+
123+
it('waitForCurrencyWallet moves a queued wallet to the front', async function () {
124+
this.timeout(15000)
125+
const { context, walletIds } = await makeMultiWalletWorld(4)
126+
127+
engineSchedulerConfig.concurrency = 1
128+
const created: string[] = []
129+
fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId)
130+
const { gate, release } = createEngineGate()
131+
fakePluginTestConfig.engineGate = gate
132+
133+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
134+
await pollUntil(() =>
135+
walletIds.every(walletId => account.currencyWallets[walletId] != null)
136+
)
137+
await pollUntil(() => created.length === 1)
138+
139+
// Pick the wallet at the back of the line and ask for it:
140+
const queued = walletIds.filter(walletId => !created.includes(walletId))
141+
const target = queued[queued.length - 1]
142+
await account.waitForCurrencyWallet(target)
143+
144+
// Once the slot frees up, the bumped wallet goes next:
145+
release()
146+
await pollUntil(() => created.length === walletIds.length)
147+
expect(created[1]).equals(target)
148+
await account.logout()
149+
})
150+
151+
it('cold wallets skip the queue entirely', async function () {
152+
this.timeout(15000)
153+
const { context, walletIds } = await makeMultiWalletWorld(3)
154+
155+
// Erase the cache files, making the next login a cold start.
156+
// Let the login's own cache save settle first, so nothing
157+
// rewrites the files after the deletes:
158+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
159+
await account.waitForAllWallets()
160+
await snooze(SAVE_WAIT_MS)
161+
for (const walletId of walletIds) {
162+
const wallet = await account.waitForCurrencyWallet(walletId)
163+
await wallet.localDisklet.delete('walletCache.json')
164+
}
165+
await account.logout()
166+
167+
engineSchedulerConfig.concurrency = 1
168+
const created: string[] = []
169+
fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId)
170+
const { gate, release } = createEngineGate()
171+
fakePluginTestConfig.engineGate = gate
172+
173+
// Cold wallets cannot emit until their startup work runs,
174+
// so they bypass the queue: every creation begins even though
175+
// the single queue slot never opens:
176+
const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
177+
await pollUntil(() => created.length === walletIds.length)
178+
179+
release()
180+
await account2.waitForAllWallets()
181+
await account2.logout()
182+
})
183+
184+
it('a wallet deleted while queued gives up its place in line', async function () {
185+
this.timeout(15000)
186+
const { context, walletIds } = await makeMultiWalletWorld(3)
187+
188+
engineSchedulerConfig.concurrency = 1
189+
const created: string[] = []
190+
fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId)
191+
const { gate, release } = createEngineGate()
192+
fakePluginTestConfig.engineGate = gate
193+
194+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
195+
await pollUntil(() =>
196+
walletIds.every(walletId => account.currencyWallets[walletId] != null)
197+
)
198+
await pollUntil(() => created.length === 1)
199+
200+
// Delete a wallet that is still waiting in the queue:
201+
const queued = walletIds.filter(walletId => !created.includes(walletId))
202+
const deleted = queued[0]
203+
await account.changeWalletStates({ [deleted]: { deleted: true } })
204+
205+
// The queue keeps moving: the deleted wallet never creates an
206+
// engine, and the remaining wallets still get theirs:
207+
release()
208+
const survivors = walletIds.filter(walletId => walletId !== deleted)
209+
await pollUntil(() => created.length === survivors.length)
210+
expect(sorted(created)).deep.equals(sorted(survivors))
211+
await snooze(RACE_WAIT_MS)
212+
expect(created.includes(deleted)).equals(false)
213+
await account.logout()
214+
})
215+
216+
it('unchanged balances keep the same balanceMap object', async function () {
217+
this.timeout(15000)
218+
const { context, walletIds } = await makeMultiWalletWorld(1)
219+
220+
const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin)
221+
const wallet = await account.waitForCurrencyWallet(walletIds[0])
222+
await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 })
223+
await pollUntil(() => wallet.balanceMap.get(null) === '20')
224+
225+
// Re-reporting the same balance must not produce a new map:
226+
const before = wallet.balanceMap
227+
await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 })
228+
await snooze(RACE_WAIT_MS)
229+
expect(wallet.balanceMap).equals(before)
230+
231+
// A real change still lands:
232+
await account.currencyConfig.fakecoin.changeUserSettings({ balance: 21 })
233+
await pollUntil(() => wallet.balanceMap.get(null) === '21')
234+
expect(wallet.balanceMap).not.equals(before)
235+
await account.logout()
236+
})
237+
})

test/fake/fake-currency-plugin.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,17 @@ export interface FakePluginTestConfig {
3737
* so "before the engine exists" is a deterministic state in tests.
3838
*/
3939
engineGate?: Promise<void>
40+
41+
/**
42+
* If set, receives each wallet id as its `makeCurrencyEngine` call
43+
* begins (before any gate), so tests can observe creation order.
44+
*/
45+
onEngineCreate?: (walletId: string) => void
4046
}
4147

4248
export const fakePluginTestConfig: FakePluginTestConfig = {
43-
engineGate: undefined
49+
engineGate: undefined,
50+
onEngineCreate: undefined
4451
}
4552

4653
/**
@@ -444,6 +451,9 @@ export function makeFakeCurrencyPlugin(
444451
walletInfo: EdgeWalletInfo,
445452
opts: EdgeCurrencyEngineOptions
446453
): Promise<EdgeCurrencyEngine> {
454+
if (fakePluginTestConfig.onEngineCreate != null) {
455+
fakePluginTestConfig.onEngineCreate(walletInfo.id)
456+
}
447457
if (fakePluginTestConfig.engineGate != null) {
448458
await fakePluginTestConfig.engineGate
449459
}

0 commit comments

Comments
 (0)