Skip to content

Commit d50a491

Browse files
authored
fix: reject a stalled image pull instead of hanging (#646)
checkAndPullImage now rejects when the pull stream produces no progress for DEFAULT_PULL_INACTIVITY_TIMEOUT (120 s) and destroys the stream. The same value is set as the Docker client socket timeout, so a daemon that accepts the request but never answers is covered as well. The timeout is an optional second argument for callers that want a different bound. Add regression tests for #564 that drive the real followProgress with a fake pull stream, so they run without a Docker daemon: request failure, stream error after progress, stall, slow-but-alive pull, and success. Follow-up to #643 and #565 (thanks @turbocrime for the report).
1 parent 9a35e95 commit d50a491

3 files changed

Lines changed: 160 additions & 2 deletions

File tree

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const DEFAULT_START_TIMEOUT = 30000
2929
export const KILL_TIMEOUT = 5000
3030
export const DEFAULT_METHOD_TIMEOUT = 15000
3131
export const DEFAULT_WAIT_TIMEOUT = 45000
32+
// Reject an image pull that produces no progress events for this long
33+
export const DEFAULT_PULL_INACTIVITY_TIMEOUT = 120000
3234

3335
export const DEFAULT_NANO_APPROVE_KEYWORD = 'APPROVE'
3436
export const DEFAULT_NANO_REJECT_KEYWORD = 'REJECT'

src/emulator.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import path from 'node:path'
1818
import { Transform } from 'node:stream'
1919
import Docker, { type Container } from 'dockerode'
20+
import { DEFAULT_PULL_INACTIVITY_TIMEOUT } from './constants'
2021

2122
// Development certificate key for emulator testing only - NOT FOR PRODUCTION USE
2223
// This is a well-known test key used by the Ledger emulator for development purposes
@@ -67,18 +68,46 @@ export default class EmuContainer {
6768
await Promise.all(targets.map((info) => docker.getContainer(info.Id).remove({ force: true })))
6869
}
6970

70-
static async checkAndPullImage(imageName: string): Promise<void> {
71-
const docker = new Docker()
71+
/**
72+
* Pulls `imageName` if it is not present locally.
73+
*
74+
* Rejects instead of stalling when the daemon is unreachable, the pull fails, or the pull stream
75+
* stops producing progress events for `inactivityTimeout` ms. The same value is applied as the
76+
* socket timeout for the initial request, so a daemon that accepts the connection but never
77+
* answers is covered too.
78+
*/
79+
static async checkAndPullImage(imageName: string, inactivityTimeout: number = DEFAULT_PULL_INACTIVITY_TIMEOUT): Promise<void> {
80+
const docker = new Docker({ timeout: inactivityTimeout })
7281
const stream = await docker.pull(imageName)
7382

7483
await new Promise<void>((resolve, reject) => {
84+
let watchdog: NodeJS.Timeout | undefined
85+
86+
const stopWatchdog = (): void => {
87+
if (watchdog !== undefined) clearTimeout(watchdog)
88+
}
89+
90+
const armWatchdog = (): void => {
91+
stopWatchdog()
92+
watchdog = setTimeout(() => {
93+
const err = new Error(`[DOCKER] pull of ${imageName} produced no progress for ${inactivityTimeout} ms`)
94+
process.stdout.write(`${err.message}\n`)
95+
reject(err)
96+
// Tear down the request so the daemon side is not left hanging
97+
const destroyable = stream as { destroy?: (error?: Error) => void }
98+
if (typeof destroyable.destroy === 'function') destroyable.destroy(err)
99+
}, inactivityTimeout)
100+
}
101+
75102
const onProgress = (event: any): void => {
103+
armWatchdog()
76104
const progress = event?.progress ?? ''
77105
const status = event?.status ?? ''
78106
process.stdout.write(`[DOCKER] ${status}: ${progress}\n`)
79107
}
80108

81109
const onFinished = (err: Error | null): void => {
110+
stopWatchdog()
82111
if (err != null) {
83112
process.stdout.write(`[DOCKER] ${err}\n`)
84113
reject(err)
@@ -88,6 +117,7 @@ export default class EmuContainer {
88117
}
89118

90119
docker.modem.followProgress(stream, onFinished, onProgress)
120+
armWatchdog()
91121
})
92122
}
93123

tests/check-and-pull-image.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/** ******************************************************************************
2+
* (c) 2018 - 2024 Zondax AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
******************************************************************************* */
16+
import { PassThrough } from 'node:stream'
17+
import Docker from 'dockerode'
18+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
19+
import Zemu from '../src'
20+
import { DEFAULT_EMU_IMG } from '../src/constants'
21+
import EmuContainer from '../src/emulator'
22+
23+
// Regression tests for #564. None of these talk to a Docker daemon: `Docker.prototype.pull` is
24+
// replaced with a fake stream, while `docker.modem.followProgress` stays real so the tests drive the
25+
// same code path that used to stall when the pull failed.
26+
27+
const IMAGE = 'zondax/does-not-matter:test'
28+
29+
function fakePull(): PassThrough {
30+
const stream = new PassThrough()
31+
vi.spyOn(Docker.prototype, 'pull').mockImplementation(() => Promise.resolve(stream as any))
32+
return stream
33+
}
34+
35+
describe('EmuContainer.checkAndPullImage', () => {
36+
beforeEach(() => {
37+
// Keep pull progress out of the test output
38+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
39+
})
40+
41+
afterEach(() => {
42+
vi.restoreAllMocks()
43+
})
44+
45+
test('rejects when the pull request itself fails', async () => {
46+
const error = new Error('connect ENOENT /var/run/docker.sock')
47+
vi.spyOn(Docker.prototype, 'pull').mockImplementation(() => Promise.reject(error))
48+
49+
await expect(EmuContainer.checkAndPullImage(IMAGE)).rejects.toBe(error)
50+
})
51+
52+
test('rejects when the pull stream errors after progress started', async () => {
53+
const stream = fakePull()
54+
const error = new Error('unexpected EOF')
55+
56+
const pending = EmuContainer.checkAndPullImage(IMAGE)
57+
stream.write('{"status":"Pulling from zondax/does-not-matter"}\n')
58+
setImmediate(() => stream.emit('error', error))
59+
60+
await expect(pending).rejects.toBe(error)
61+
})
62+
63+
test('rejects when the pull stream produces no progress for longer than the inactivity timeout', async () => {
64+
const stream = fakePull()
65+
const destroy = vi.spyOn(stream, 'destroy')
66+
67+
const pending = EmuContainer.checkAndPullImage(IMAGE, 50)
68+
stream.write('{"status":"Waiting"}\n')
69+
70+
await expect(pending).rejects.toThrow(/no progress for 50 ms/)
71+
expect(destroy).toHaveBeenCalledTimes(1)
72+
})
73+
74+
test('keeps waiting while progress events keep arriving', async () => {
75+
const stream = fakePull()
76+
77+
const pending = EmuContainer.checkAndPullImage(IMAGE, 80)
78+
// Each event lands inside the timeout window but the whole pull takes longer than one window
79+
for (let i = 0; i < 5; i++) {
80+
await new Promise((r) => setTimeout(r, 40))
81+
stream.write(`{"status":"Downloading","progress":"${i * 20}%"}\n`)
82+
}
83+
stream.end()
84+
85+
await expect(pending).resolves.toBeUndefined()
86+
})
87+
88+
test('resolves when the pull completes', async () => {
89+
const stream = fakePull()
90+
91+
const pending = EmuContainer.checkAndPullImage(IMAGE)
92+
stream.write('{"status":"Pull complete"}\n')
93+
stream.end()
94+
95+
await expect(pending).resolves.toBeUndefined()
96+
})
97+
98+
test('passes the inactivity timeout to the Docker client as socket timeout', async () => {
99+
const stream = fakePull()
100+
let clientTimeout: number | undefined
101+
vi.spyOn(Docker.prototype, 'pull').mockImplementation(function (this: Docker) {
102+
clientTimeout = (this.modem as { timeout?: number }).timeout
103+
return Promise.resolve(stream as any)
104+
})
105+
106+
const pending = EmuContainer.checkAndPullImage(IMAGE, 1234)
107+
stream.end()
108+
await pending
109+
110+
expect(clientTimeout).toBe(1234)
111+
})
112+
})
113+
114+
describe('Zemu.checkAndPullImage', () => {
115+
afterEach(() => {
116+
vi.restoreAllMocks()
117+
})
118+
119+
test('pulls the default emulator image and propagates failures', async () => {
120+
const error = new Error('pull failed')
121+
const spy = vi.spyOn(EmuContainer, 'checkAndPullImage').mockRejectedValue(error)
122+
123+
await expect(Zemu.checkAndPullImage()).rejects.toBe(error)
124+
expect(spy).toHaveBeenCalledWith(DEFAULT_EMU_IMG)
125+
})
126+
})

0 commit comments

Comments
 (0)