Skip to content

Commit abe761f

Browse files
committed
fix: harden container lifecycle and gRPC after cross-review
- stop() keeps the container handle until removal succeeds, tolerates 304/404, and shares one in-flight operation between concurrent callers. - start() removes the container on any failure after it was created, including the last port-conflict attempt and startup timeouts. - startGRPCServer resolves with the bound port, shuts down a previous server first, and every gRPC failure rejects instead of throwing. - Test teardown only sweeps containers created by the current run. - Bounded overrides: ws ^8.21.0 and uuid ^11.1.1 (uuid 14 is ESM-only). - test:watch builds first; gRPC tests exercise Exchange end to end; relaxed fail-fast timing bounds.
1 parent 9681f06 commit abe761f

11 files changed

Lines changed: 163 additions & 64 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"build": "rm -rf dist && tsc && cp src/grpc/*.proto dist/grpc/",
2626
"test:clean": "pnpm ts-node tests/pullImageKillOld.ts",
2727
"test": "pnpm test:clean && pnpm build && vitest run",
28-
"test:watch": "vitest",
28+
"test:watch": "pnpm build && vitest",
2929
"lint": "biome lint src/ tests/",
3030
"lint:fix": "biome check --write --unsafe .",
3131
"format": "biome format --write src/ tests/",
@@ -69,8 +69,8 @@
6969
"pnpm": {
7070
"overrides": {
7171
"axios": "^1.15.1",
72-
"ws": ">=8.21.0",
73-
"uuid": ">=11.1.1"
72+
"ws": "^8.21.0",
73+
"uuid": "^11.1.1"
7474
}
7575
}
7676
}

pnpm-lock.yaml

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Zemu.ts

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export default class Zemu {
7777
private readonly desiredSpeculosApiPort?: number
7878

7979
private readonly emuContainer: EmuContainer
80-
public readonly containerName: string
80+
public containerName: string
8181
private lastTransportError: Error | null = null
8282

8383
public readonly elfPath: string
@@ -128,14 +128,17 @@ export default class Zemu {
128128
await new Promise<void>((resolve) => setTimeout(resolve, timeInMs))
129129
}
130130

131-
/** Force-removes every zemu container, giving up after KILL_TIMEOUT. */
132-
static async stopAllEmuContainers(): Promise<void> {
131+
/**
132+
* Force-removes every zemu container, giving up after KILL_TIMEOUT.
133+
* Pass `createdAfter` (unix seconds) to leave containers of other sessions alone.
134+
*/
135+
static async stopAllEmuContainers(createdAfter?: number): Promise<void> {
133136
let timer: NodeJS.Timeout | undefined
134137
const timeout = new Promise<never>((_, reject) => {
135138
timer = setTimeout(() => reject(new Error(`Could not kill all containers within ${KILL_TIMEOUT}ms`)), KILL_TIMEOUT)
136139
})
137140
try {
138-
await Promise.race([EmuContainer.killContainerByName(BASE_NAME), timeout])
141+
await Promise.race([EmuContainer.killContainerByName(BASE_NAME, createdAfter), timeout])
139142
} finally {
140143
clearTimeout(timer)
141144
}
@@ -189,20 +192,17 @@ export default class Zemu {
189192
this.log('Checking ELF')
190193
Zemu.checkElf(this.startOptions.model, this.elfPath)
191194

192-
try {
193-
await this.runContainerWithFreePorts()
195+
await this.runContainerWithFreePorts()
194196

197+
// From here on a running container exists: never leave it behind on failure
198+
try {
195199
this.log('Connecting to container')
196-
await this.connect().catch(async (error) => {
197-
this.log(`${error}`)
198-
await this.close()
199-
throw error
200-
})
201-
200+
await this.connect()
202201
await this.finalizeStart()
203-
} catch (e) {
204-
this.log(`[ZEMU] ${e}`)
205-
throw e
202+
} catch (error) {
203+
this.log(`[ZEMU] ${error}`)
204+
await this.close().catch((closeError) => this.log(`[ZEMU] Cleanup after failed start: ${closeError}`))
205+
throw error
206206
}
207207
}
208208

@@ -296,12 +296,15 @@ export default class Zemu {
296296
})
297297
return
298298
} catch (error) {
299+
this.log(`[ZEMU] ${error}`)
300+
// Docker may have created the container without starting it; drop it so the
301+
// name is free again and nothing is left behind
302+
await this.emuContainer.stop().catch((stopError) => this.log(`[ZEMU] Cleanup after failed container start: ${stopError}`))
303+
299304
const portConflict = /port is already allocated|address already in use/i.test(String(error))
300305
if (!portConflict || attempt >= MAX_ATTEMPTS) throw error
301306

302-
this.log(`Port conflict, retrying with new ports: ${error}`)
303-
// Docker created the container but could not start it; drop it before retrying
304-
await this.emuContainer.stop().catch((stopError) => this.log(`Cleanup after port conflict failed: ${stopError}`))
307+
this.log('Port conflict, retrying with new ports')
305308
this.transportPort = undefined as unknown as number
306309
this.speculosApiPort = undefined as unknown as number
307310
}
@@ -315,10 +318,17 @@ export default class Zemu {
315318
}
316319
}
317320

318-
/** Resolves once the gRPC server is listening. Rejects if the address cannot be bound. */
319-
startGRPCServer(ip: string, port: number): Promise<void> {
320-
this.grpcManager = new GRPCRouter(ip, port, this.transport)
321-
return this.grpcManager.startServer()
321+
/**
322+
* Starts a gRPC server that forwards Exchange calls to the device transport.
323+
* Resolves with the bound port (useful when `port` is 0). Rejects if the address cannot be bound.
324+
* A previously started server is shut down first.
325+
*/
326+
async startGRPCServer(ip: string, port: number): Promise<number> {
327+
this.stopGRPCServer()
328+
const router = new GRPCRouter(ip, port, this.transport)
329+
const boundPort = await router.startServer()
330+
this.grpcManager = router
331+
return boundPort
322332
}
323333

324334
stopGRPCServer(): void {

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const DEFAULT_START_OPTIONS: IStartOptions = {
4141
startDelay: DEFAULT_START_DELAY,
4242
custom: '',
4343
model: DEFAULT_MODEL,
44+
sdk: '',
4445
startText: '',
4546
caseSensitive: false,
4647
startTimeout: DEFAULT_START_TIMEOUT,

src/emulator.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default class EmuContainer {
4040
private readonly image: string
4141
private readonly libElfs: Record<string, string>
4242
private currentContainer?: Container
43+
private stopping?: Promise<void>
4344

4445
constructor(elfLocalPath: string, libElfs: Record<string, string>, image: string, name: string) {
4546
this.image = image
@@ -55,11 +56,15 @@ export default class EmuContainer {
5556
}
5657
}
5758

58-
/** Force-removes every container whose name contains `name`. */
59-
static async killContainerByName(name: string): Promise<void> {
59+
/**
60+
* Force-removes every container whose name contains `name`.
61+
* `createdAfter` (unix seconds) limits the sweep to containers created after that time.
62+
*/
63+
static async killContainerByName(name: string, createdAfter?: number): Promise<void> {
6064
const docker = new Docker()
6165
const containers = await docker.listContainers({ all: true, filters: { name: [name] } })
62-
await Promise.all(containers.map((info) => docker.getContainer(info.Id).remove({ force: true })))
66+
const targets = createdAfter === undefined ? containers : containers.filter((info) => info.Created >= createdAfter)
67+
await Promise.all(targets.map((info) => docker.getContainer(info.Id).remove({ force: true })))
6368
}
6469

6570
static async checkAndPullImage(imageName: string): Promise<void> {
@@ -187,28 +192,44 @@ export default class EmuContainer {
187192
this.log(`[ZEMU] Started ${this.currentContainer.id}`)
188193
}
189194

190-
async stop(): Promise<void> {
191-
if (this.currentContainer == null) return
195+
/**
196+
* Stops and removes the container. The handle is only dropped once the
197+
* container is gone, so a failed attempt can be retried with another stop().
198+
* Concurrent calls share the same in-flight operation.
199+
*/
200+
stop(): Promise<void> {
201+
if (this.stopping == null) {
202+
this.stopping = this.doStop().finally(() => {
203+
this.stopping = undefined
204+
})
205+
}
206+
return this.stopping
207+
}
192208

209+
private async doStop(): Promise<void> {
193210
const container = this.currentContainer
194-
this.currentContainer = undefined
211+
if (container == null) return
212+
195213
this.log('[ZEMU] Stopping container')
196214
try {
197215
await container.stop({ t: 0 })
198216
} catch (e: any) {
199-
// 304: already stopped. Anything else is a real failure.
200-
if (e?.statusCode !== 304) {
217+
// 304: already stopped, 404: already gone. Anything else is a real failure.
218+
if (e?.statusCode !== 304 && e?.statusCode !== 404) {
201219
this.log(`[ZEMU] Stopping: ${e}`)
202220
throw e
203221
}
204222
}
205223
this.log('[ZEMU] Stopped')
206224
try {
207-
await container.remove()
208-
} catch (err) {
209-
this.log('[ZEMU] Unable to remove container')
210-
throw err
225+
await container.remove({ force: true })
226+
} catch (e: any) {
227+
if (e?.statusCode !== 404) {
228+
this.log(`[ZEMU] Unable to remove container: ${e}`)
229+
throw e
230+
}
211231
}
232+
this.currentContainer = undefined
212233
this.log('[ZEMU] Removed')
213234
}
214235
}

src/grpc/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ export default class GRPCRouter {
1818
this.server = new Server()
1919
}
2020

21-
/** Resolves once the server is bound and listening. */
22-
startServer(): Promise<void> {
21+
/** Resolves with the bound port once the server is listening. */
22+
async startServer(): Promise<number> {
2323
if (!existsSync(PROTO_PATH)) {
24-
return Promise.reject(new Error(`zemu.proto not found at ${PROTO_PATH}`))
24+
throw new Error(`zemu.proto not found at ${PROTO_PATH}`)
2525
}
2626

2727
const packageDefinition = loadSync(PROTO_PATH, {
@@ -44,14 +44,14 @@ export default class GRPCRouter {
4444
},
4545
})
4646

47-
return new Promise<void>((resolvePromise, reject) => {
47+
return await new Promise<number>((resolvePromise, reject) => {
4848
this.server.bindAsync(this.serverAddress, ServerCredentials.createInsecure(), (err, port) => {
4949
if (err != null) {
5050
reject(err)
5151
return
5252
}
5353
process.stdout.write(`gRPC listening on ${port}\n`)
54-
resolvePromise()
54+
resolvePromise(port)
5555
})
5656
})
5757
}

tests/basic.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { PolkadotGenericApp } from '@zondax/ledger-substrate'
1818
import { describe, expect, test } from 'vitest'
1919
import Zemu, { zondaxMainmenuNavigation } from '../src'
2020
import { defaultOptions, models, nanoModels, PATH, POLYMESH_SS58_PREFIX, SNAPSHOTS_DIR } from './common'
21+
import { exchangeViaGrpc } from './grpcClient'
2122

2223
// DER prefix for a raw Ed25519 public key (SubjectPublicKeyInfo)
2324
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
@@ -159,13 +160,22 @@ describe.each(nanoModels)('$name buttons', (m) => {
159160
})
160161
})
161162

162-
test('gRPC server start-stop', async () => {
163+
test('gRPC server forwards APDUs to the device', async () => {
163164
const m = nanoModels[0]
164165
const sim = new Zemu(m.path)
165166
try {
166167
await sim.start({ ...defaultOptions, model: m.name })
167-
await sim.startGRPCServer('127.0.0.1', 0)
168+
const port = await sim.startGRPCServer('127.0.0.1', 0)
169+
expect(port).toBeGreaterThan(0)
170+
171+
// GET_APP_INFO (handled by the OS): format id 1, then the app name
172+
const reply = await exchangeViaGrpc(port, Buffer.from([0xb0, 0x01, 0x00, 0x00, 0x00]))
173+
expect(reply.readUInt16BE(reply.length - 2)).toBe(0x9000)
174+
expect(reply[0]).toBe(1)
175+
expect(reply.subarray(2, 2 + reply[1]).toString('ascii')).toBe('Polymesh')
176+
168177
sim.stopGRPCServer()
178+
await expect(exchangeViaGrpc(port, Buffer.alloc(0))).rejects.toThrow()
169179
} finally {
170180
await sim.close()
171181
}

tests/error-handling.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ describe('APDU error handling', () => {
3737
await sim.start(options)
3838
const transport = sim.getTransport()
3939

40+
// Generous bound: the point is "not a wait-helper timeout", not raw latency
4041
const startTime = Date.now()
4142
await expect(transport.send(INVALID_CLA, 0x00, 0x00, 0x00)).rejects.toMatchObject({ statusCode: APDU_STATUS_CODES.CLA_NOT_SUPPORTED })
42-
expect(Date.now() - startTime).toBeLessThan(2000)
43+
expect(Date.now() - startTime).toBeLessThan(5000)
4344

4445
const recorded = sim.getLastTransportError() as any
4546
expect(recorded).not.toBeNull()
@@ -76,11 +77,12 @@ describe('APDU error handling', () => {
7677

7778
await expect(transport.send(INVALID_CLA, 0x00, 0x00, 0x00)).rejects.toThrow()
7879

80+
// Must fail well before the 20s wait timeout
7981
const startTime = Date.now()
80-
await expect(sim.waitUntilScreenIs(differentScreen(sim), 5000)).rejects.toBeInstanceOf(TransportError)
81-
expect(Date.now() - startTime).toBeLessThan(1500)
82+
await expect(sim.waitUntilScreenIs(differentScreen(sim), 20000)).rejects.toBeInstanceOf(TransportError)
83+
expect(Date.now() - startTime).toBeLessThan(5000)
8284

83-
await expect(sim.waitForText('never shown', 5000)).rejects.toBeInstanceOf(TransportError)
85+
await expect(sim.waitForText('never shown', 20000)).rejects.toBeInstanceOf(TransportError)
8486
await expect(sim.getEvents()).rejects.toMatchObject({ statusCode: APDU_STATUS_CODES.CLA_NOT_SUPPORTED })
8587
} finally {
8688
await sim.close()

tests/globalsetup.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@ import Zemu from '../src'
22

33
// Registered in vitest.config.ts as globalSetup. Runs once in the main vitest process.
44

5-
async function killContainers(reason: string): Promise<void> {
5+
// Only containers created by this run are swept on teardown, so a concurrent
6+
// zemu session on the same Docker daemon is left alone. Ctrl-C still sweeps everything.
7+
let runStartedAt = 0
8+
9+
async function killContainers(reason: string, createdAfter?: number): Promise<void> {
610
console.log(`[zemu] ${reason}: stopping dangling containers`)
711
try {
8-
await Zemu.stopAllEmuContainers()
12+
await Zemu.stopAllEmuContainers(createdAfter)
913
} catch (error) {
1014
console.error('[zemu] failed to stop containers:', error)
1115
}
1216
}
1317

1418
export function setup(): void {
19+
runStartedAt = Math.floor(Date.now() / 1000) - 1
1520
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
1621
process.once(signal, () => {
1722
void killContainers(signal).finally(() => process.exit(130))
@@ -20,5 +25,5 @@ export function setup(): void {
2025
}
2126

2227
export async function teardown(): Promise<void> {
23-
await killContainers('teardown')
28+
await killContainers('teardown', runStartedAt)
2429
}

0 commit comments

Comments
 (0)