Skip to content

Commit cd123cc

Browse files
fix: kill disposable nodes on stop and simplify started status (#554)
Fixes two problems: 1. If we use `ipfs-daemon` to connect to a running node, there's no `this.subprocess` only `this.api` so don't reference that property in the `this.stop()` method. 2. There's a delay between stopping the process and the process actually stopping - in CI we don't wait for this and start new tests which start new processes which can overwhelm small weak build machines so wait for the subprocess to stop before returning from `this.stop()` Changes behaviour: 1. Let the exiting of `this.subprocess` set `this.started = false` instead of when we stop the process 2. If a node is marked `disposable`, we don't really care about it exiting cleanly as we're going to delete it's repo shortly afterwards so just `SIGKILL` it immediately. Co-authored-by: Jacob Heun <[email protected]>
1 parent 57045f9 commit cd123cc

File tree

3 files changed

+130
-45
lines changed

3 files changed

+130
-45
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"lint": "aegir lint",
1212
"docs": "aegir docs",
1313
"build": "aegir build",
14-
"test": "aegir test -t node -t browser --timeout 10000",
14+
"test": "aegir test",
1515
"test:node": "aegir test -t node",
1616
"test:browser": "aegir test -t browser",
1717
"release": "aegir release --timeout 10000",
@@ -60,6 +60,7 @@
6060
"merge-options": "^3.0.1",
6161
"multiaddr": "^8.0.0",
6262
"nanoid": "^3.1.3",
63+
"p-wait-for": "^3.1.0",
6364
"temp-write": "^4.0.0"
6465
},
6566
"devDependencies": {

src/ipfsd-daemon.js

Lines changed: 75 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const path = require('path')
1010
const os = require('os')
1111
const tempWrite = require('temp-write')
1212
const { checkForRunningApi, repoExists, tmpDir, defaultRepo } = require('./utils')
13+
const waitFor = require('p-wait-for')
1314

1415
const daemonLog = {
1516
info: debug('ipfsd-ctl:daemon:stdout'),
@@ -157,33 +158,41 @@ class Daemon {
157158
* @returns {Promise<Daemon>}
158159
*/
159160
async start () {
160-
const args = ['daemon']
161-
const opts = this.opts.ipfsOptions
162-
// add custom args
163-
args.push(...this.opts.args)
164-
165-
if (opts.pass && this.opts.type === 'js') {
166-
args.push('--pass', '"' + opts.pass + '"')
167-
}
168-
if (opts.offline) {
169-
args.push('--offline')
170-
}
171-
if (opts.preload && this.opts.type === 'js') {
172-
args.push('--enable-preload', Boolean(opts.preload.enabled))
173-
}
174-
if (opts.EXPERIMENTAL && opts.EXPERIMENTAL.sharding && this.opts.type === 'js') {
175-
args.push('--enable-sharding-experiment')
176-
}
177-
if (opts.EXPERIMENTAL && opts.EXPERIMENTAL.ipnsPubsub) {
178-
args.push('--enable-namesys-pubsub')
179-
}
180-
181161
// Check if a daemon is already running
182162
const api = checkForRunningApi(this.path)
163+
183164
if (api) {
184165
this._setApi(api)
166+
} else if (!this.exec) {
167+
throw new Error('No executable specified')
185168
} else {
169+
const args = ['daemon']
170+
const opts = this.opts.ipfsOptions
171+
// add custom args
172+
args.push(...this.opts.args)
173+
174+
if (opts.pass && this.opts.type === 'js') {
175+
args.push('--pass', '"' + opts.pass + '"')
176+
}
177+
178+
if (opts.offline) {
179+
args.push('--offline')
180+
}
181+
182+
if (opts.preload && this.opts.type === 'js') {
183+
args.push('--enable-preload', Boolean(opts.preload.enabled))
184+
}
185+
186+
if (opts.EXPERIMENTAL && opts.EXPERIMENTAL.sharding && this.opts.type === 'js') {
187+
args.push('--enable-sharding-experiment')
188+
}
189+
190+
if (opts.EXPERIMENTAL && opts.EXPERIMENTAL.ipnsPubsub) {
191+
args.push('--enable-namesys-pubsub')
192+
}
193+
186194
let output = ''
195+
187196
const ready = new Promise((resolve, reject) => {
188197
this.subprocess = execa(this.exec, args, {
189198
env: this.env
@@ -213,7 +222,17 @@ class Daemon {
213222
}
214223
this.subprocess.stdout.on('data', readyHandler)
215224
this.subprocess.catch(err => reject(translateError(err)))
225+
this.subprocess.on('exit', () => {
226+
this.started = false
227+
this.subprocess.stderr.removeAllListeners()
228+
this.subprocess.stdout.removeAllListeners()
229+
230+
if (this.disposable) {
231+
this.cleanup().catch(() => {})
232+
}
233+
})
216234
})
235+
217236
await ready
218237
}
219238

@@ -228,43 +247,55 @@ class Daemon {
228247
/**
229248
* Stop the daemon.
230249
*
250+
* @param {object} [options]
251+
* @param {number} [options.timeout=60000] - How long to wait for the daemon to stop
231252
* @returns {Promise<Daemon>}
232253
*/
233-
async stop () {
254+
async stop (options = {}) {
255+
const timeout = options.timeout || 60000
256+
234257
if (!this.started) {
235258
return this
236259
}
237260

238-
let killTimeout
239-
let killed = false
240-
if (this.opts.forceKill !== false) {
241-
killTimeout = setTimeout(() => {
242-
// eslint-disable-next-line no-console
243-
console.error(new Error(`Timeout stopping ${this.opts.type} node. Process ${this.subprocess.pid} will be force killed now.`))
244-
killed = true
261+
if (this.subprocess) {
262+
let killTimeout
245263

264+
if (this.disposable) {
265+
// we're done with this node and will remove it's repo when we are done
266+
// so don't wait for graceful exit, just terminate the process
246267
this.subprocess.kill('SIGKILL')
247-
}, this.opts.forceKillTimeout)
248-
}
268+
} else {
269+
if (this.opts.forceKill !== false) {
270+
killTimeout = setTimeout(() => {
271+
// eslint-disable-next-line no-console
272+
console.error(new Error(`Timeout stopping ${this.opts.type} node after ${this.opts.forceKillTimeout}ms. Process ${this.subprocess.pid} will be force killed now.`))
273+
this.subprocess.kill('SIGKILL')
274+
}, this.opts.forceKillTimeout)
275+
}
249276

250-
try {
251-
await this.api.stop()
252-
} catch (err) {
253-
if (!killed) {
254-
throw err // if was killed then ignore error
277+
this.subprocess.cancel()
255278
}
256279

257-
daemonLog.info('Daemon was force killed')
258-
}
280+
// wait for the subprocess to exit and declare ourselves stopped
281+
await waitFor(() => !this.started, {
282+
timeout
283+
})
259284

260-
clearTimeout(killTimeout)
261-
this.subprocess.stderr.removeAllListeners()
262-
this.subprocess.stdout.removeAllListeners()
263-
this.started = false
285+
clearTimeout(killTimeout)
286+
287+
if (this.disposable) {
288+
// wait for the cleanup routine to run after the subprocess has exited
289+
await waitFor(() => this.clean, {
290+
timeout
291+
})
292+
}
293+
} else {
294+
await this.api.stop()
264295

265-
if (this.disposable) {
266-
await this.cleanup()
296+
this.started = false
267297
}
298+
268299
return this
269300
}
270301

test/controller.spec.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { createFactory, createController } = require('../src')
77
const { repoExists } = require('../src/utils')
88
const { isBrowser, isWebWorker, isNode } = require('ipfs-utils/src/env')
99
const pathJoin = require('ipfs-utils/src/path-join')
10+
const waitFor = require('p-wait-for')
1011

1112
/** @typedef {import("../src/index").ControllerOptions} ControllerOptions */
1213

@@ -189,6 +190,58 @@ describe('Controller API', function () {
189190
})
190191
}
191192
})
193+
194+
describe('should stop a running node that we have joined', () => {
195+
for (const opts of types) {
196+
it(`type: ${opts.type} remote: ${Boolean(opts.remote)}`, async function () {
197+
if (isBrowser || isWebWorker) {
198+
return this.skip() // browser can't attach to running node
199+
}
200+
201+
// have to use createController so we don't try to shut down
202+
// the node twice during test cleanup
203+
const ctl1 = await createController(merge(
204+
{
205+
type: 'go',
206+
ipfsHttpModule: require('ipfs-http-client'),
207+
ipfsBin: require('go-ipfs').path(),
208+
test: true,
209+
disposable: true,
210+
remote: false,
211+
ipfsOptions: {
212+
init: true,
213+
start: true
214+
}
215+
}))
216+
expect(ctl1.started).to.be.true()
217+
218+
const ctl2 = await createController(merge(
219+
opts, {
220+
ipfsHttpModule: require('ipfs-http-client'),
221+
ipfsModule: require('ipfs'),
222+
test: true,
223+
disposable: true,
224+
ipfsOptions: {
225+
repo: ctl1.path,
226+
start: true
227+
}
228+
}
229+
))
230+
expect(ctl2.started).to.be.true()
231+
232+
await ctl2.stop()
233+
expect(ctl2.started).to.be.false()
234+
235+
// wait for the other subprocess to exit
236+
await waitFor(() => !ctl1.started, { // eslint-disable-line max-nested-callbacks
237+
timeout: 10000,
238+
interval: 100
239+
})
240+
241+
expect(ctl1.started).to.be.false()
242+
})
243+
}
244+
})
192245
})
193246

194247
describe('cleanup', () => {

0 commit comments

Comments
 (0)